Skip to content
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 71 additions & 21 deletions src/plugin/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,28 +134,78 @@ export function createEventHandler(args: {
const lastHandledRetryStatusKey = new Map<string, string>();
const lastKnownModelBySession = new Map<string, { providerID: string; modelID: string }>();

// Event subscriptions: which event types each hook cares about.
// Hooks mapped to string[] only fire for those event types; "*" fires for all.
// Unlisted hooks default to "*" (backward compat).
const SESSION_LIFECYCLE = ["session.idle", "session.created", "session.deleted", "session.status", "session.error", "session.updated"];
const MESSAGE_EVENTS = ["message.updated", "message.part.updated"];
const HOOK_SUBSCRIPTIONS: Record<string, string[] | "*"> = {
// ALL events including deltas (transcript tracking, streaming output monitoring)
claudeCodeHooks: "*",
interactiveBashSession: "*",
// Session lifecycle only
sessionNotification: SESSION_LIFECYCLE,
unstableAgentBabysitter: SESSION_LIFECYCLE,
runtimeFallback: SESSION_LIFECYCLE,
agentUsageReminder: SESSION_LIFECYCLE,
categorySkillReminder: SESSION_LIFECYCLE,
ralphLoop: SESSION_LIFECYCLE,
stopContinuationGuard: SESSION_LIFECYCLE,
backgroundNotificationHook: SESSION_LIFECYCLE,
autoUpdateChecker: SESSION_LIFECYCLE,
// Message events (no deltas)
contextWindowMonitor: [...MESSAGE_EVENTS, "session.status"],
anthropicContextWindowLimitRecovery: MESSAGE_EVENTS,
compactionTodoPreserver: MESSAGE_EVENTS,
writeExistingFileGuard: MESSAGE_EVENTS,
todoContinuationEnforcer: MESSAGE_EVENTS,
atlasHook: MESSAGE_EVENTS,
// Chat-level events
directoryAgentsInjector: ["session.created", "message.updated"],
directoryReadmeInjector: ["session.created", "message.updated"],
rulesInjector: ["session.created", "message.updated"],
thinkMode: ["session.created", "message.updated"],
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

4. Broken hook subscriptions 🐞 Bug ✓ Correctness

HOOK_SUBSCRIPTIONS is used to skip hook invocations, but multiple hooks are subscribed to
too-narrow event sets that omit event types their own handlers explicitly depend on. This will
prevent core behaviors (todo continuation, auto-compact recovery, atlas continuation,
runtime/background fallback, notifications) and some cleanups from ever running.
Agent Prompt
## Issue description
`src/plugin/event.ts` now filters hook invocations via `HOOK_SUBSCRIPTIONS`, but many hooks are subscribed to event sets that omit event types their own handlers explicitly process (e.g. `session.idle`, `session.error`, `session.compacted`, `session.stop`, `tool.execute.*`). This makes key hook logic unreachable and breaks functionality/cleanup.

## Issue Context
The dispatcher enforces the subscription list (`if (subs !== "*" && !subs.includes(eventType)) continue;`). Several hook implementations contain branches for events that are not in their configured subscription arrays.

## Fix Focus Areas
- src/plugin/event.ts[140-168]
- src/plugin/event.ts[199-208]
- src/hooks/todo-continuation-enforcer/handler.ts[32-68]
- src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts[39-140]
- src/hooks/atlas/event-handler.ts[26-43]
- src/hooks/atlas/event-handler.ts[178-204]
- src/hooks/runtime-fallback/event-handler.ts[190-195]
- src/hooks/session-notification.ts[136-185]
- src/features/background-agent/manager.ts[693-755]

## Suggested direction
1. Expand `SESSION_LIFECYCLE` to include at least `session.compacted` and `session.stop` (and any other session.* events used by hooks).
2. Fix individual subscriptions, e.g.:
   - `todoContinuationEnforcer`: include `session.idle`, `session.error`, `session.deleted`, and tool events it watches.
   - `anthropicContextWindowLimitRecovery`: include `session.error`, `session.idle`, `session.deleted`.
   - `atlasHook`: include `session.idle`, `session.error`, `session.deleted`, `session.compacted`, and `tool.execute.*`.
   - `runtimeFallback`: include `message.updated` and `session.stop`.
   - `sessionNotification`: include `message.updated` and `tool.execute.*`.
   - `backgroundNotificationHook`: likely `"*"` (it routes all events to `BackgroundManager`).
3. Alternatively (often simpler/safer): only special-case filtering for `message.part.delta` by maintaining a small allowlist of hooks for that single high-frequency event, and let all hooks receive other event types as before.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


// Hooks that MUST be awaited (order-dependent or mutate state read by later hooks)
const AWAITED_HOOKS = new Set(["claudeCodeHooks", "stopContinuationGuard", "writeExistingFileGuard"]);

// Build dispatch entries once: [name, invokeFn, subscriptions]
type HookInvoker = (input: EventInput) => unknown;
const hookEntries: Array<[string, HookInvoker, string[] | "*"]> = ([
["autoUpdateChecker", (i: EventInput) => hooks.autoUpdateChecker?.event?.(i)] as const,
["claudeCodeHooks", (i: EventInput) => hooks.claudeCodeHooks?.event?.(i)] as const,
["backgroundNotificationHook", (i: EventInput) => hooks.backgroundNotificationHook?.event?.(i)] as const,
["sessionNotification", (i: EventInput) => hooks.sessionNotification?.(i)] as const,
["todoContinuationEnforcer", (i: EventInput) => hooks.todoContinuationEnforcer?.handler?.(i)] as const,
["unstableAgentBabysitter", (i: EventInput) => hooks.unstableAgentBabysitter?.event?.(i)] as const,
["contextWindowMonitor", (i: EventInput) => hooks.contextWindowMonitor?.event?.(i)] as const,
["directoryAgentsInjector", (i: EventInput) => hooks.directoryAgentsInjector?.event?.(i)] as const,
["directoryReadmeInjector", (i: EventInput) => hooks.directoryReadmeInjector?.event?.(i)] as const,
["rulesInjector", (i: EventInput) => hooks.rulesInjector?.event?.(i)] as const,
["thinkMode", (i: EventInput) => hooks.thinkMode?.event?.(i)] as const,
["anthropicContextWindowLimitRecovery", (i: EventInput) => hooks.anthropicContextWindowLimitRecovery?.event?.(i)] as const,
["runtimeFallback", (i: EventInput) => hooks.runtimeFallback?.event?.(i)] as const,
["agentUsageReminder", (i: EventInput) => hooks.agentUsageReminder?.event?.(i)] as const,
["categorySkillReminder", (i: EventInput) => hooks.categorySkillReminder?.event?.(i)] as const,
["interactiveBashSession", (i: EventInput) => hooks.interactiveBashSession?.event?.(i as EventInput)] as const,
["ralphLoop", (i: EventInput) => hooks.ralphLoop?.event?.(i)] as const,
["stopContinuationGuard", (i: EventInput) => hooks.stopContinuationGuard?.event?.(i)] as const,
["compactionTodoPreserver", (i: EventInput) => hooks.compactionTodoPreserver?.event?.(i)] as const,
["writeExistingFileGuard", (i: EventInput) => hooks.writeExistingFileGuard?.event?.(i)] as const,
["atlasHook", (i: EventInput) => hooks.atlasHook?.handler?.(i)] as const,
] as [string, HookInvoker][]).map(([name, fn]) => [name, fn, HOOK_SUBSCRIPTIONS[name] ?? "*"] as [string, HookInvoker, string[] | "*"]);

const dispatchToHooks = async (input: EventInput): Promise<void> => {
await Promise.resolve(hooks.autoUpdateChecker?.event?.(input));
await Promise.resolve(hooks.claudeCodeHooks?.event?.(input));
await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input));
await Promise.resolve(hooks.sessionNotification?.(input));
await Promise.resolve(hooks.todoContinuationEnforcer?.handler?.(input));
await Promise.resolve(hooks.unstableAgentBabysitter?.event?.(input));
await Promise.resolve(hooks.contextWindowMonitor?.event?.(input));
await Promise.resolve(hooks.directoryAgentsInjector?.event?.(input));
await Promise.resolve(hooks.directoryReadmeInjector?.event?.(input));
await Promise.resolve(hooks.rulesInjector?.event?.(input));
await Promise.resolve(hooks.thinkMode?.event?.(input));
await Promise.resolve(hooks.anthropicContextWindowLimitRecovery?.event?.(input));
await Promise.resolve(hooks.runtimeFallback?.event?.(input));
await Promise.resolve(hooks.agentUsageReminder?.event?.(input));
await Promise.resolve(hooks.categorySkillReminder?.event?.(input));
await Promise.resolve(hooks.interactiveBashSession?.event?.(input as EventInput));
await Promise.resolve(hooks.ralphLoop?.event?.(input));
await Promise.resolve(hooks.stopContinuationGuard?.event?.(input));
await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input));
await Promise.resolve(hooks.writeExistingFileGuard?.event?.(input));
await Promise.resolve(hooks.atlasHook?.handler?.(input));
const eventType = input.event.type;
for (const [name, invoke, subs] of hookEntries) {
if (subs !== "*" && !subs.includes(eventType)) continue;
if (AWAITED_HOOKS.has(name)) {
await Promise.resolve(invoke(input));
} else {
Promise.resolve(invoke(input)).catch((err) => log("[hook] error:", { hook: name, error: err }));
}
}
};

const recentSyntheticIdles = new Map<string, number>();
Expand Down