From 7859f0dd2d79c95ee4cbd646245dbe8c4293e41a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 13 Dec 2025 13:02:55 +0900 Subject: [PATCH] fix(hooks): add session-notification to disabled_hooks with race/memory fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add session-notification to HookNameSchema and schema.json - Integrate session-notification into disabled_hooks conditional creation - Fix race condition with version-based invalidation - Fix memory leak with maxTrackedSessions cleanup - Add missing activity event types (message.created, tool.execute.*) - Document disabled_hooks configuration in README 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) --- README.md | 12 +++++++ assets/oh-my-opencode.schema.json | 1 + src/config/schema.ts | 1 + src/hooks/session-notification.ts | 57 +++++++++++++++++++++++++++++-- src/index.ts | 5 +++ 5 files changed, 73 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e9dc0d0..934d8f9 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,18 @@ Example workflow: - **Empty Task Response Detector**: Detects when subagent tasks return empty or meaningless responses and handles gracefully. - **Grep Output Truncator**: Prevents grep output from overwhelming the context by truncating excessively long results. +You can disable specific built-in hooks using `disabled_hooks` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`: + +```json +{ + "disabled_hooks": ["session-notification", "comment-checker"] +} +``` + +Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker` + +> **Note**: `disabled_hooks` controls Oh My OpenCode's built-in hooks. To disable Claude Code's `settings.json` hooks, use `claude_code.hooks: false` instead (see [Compatibility Toggles](#compatibility-toggles)). + ### Claude Code Compatibility Oh My OpenCode provides seamless Claude Code configuration compatibility. If you've been using Claude Code, your existing setup works out of the box. diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index a9d9387..22acbcf 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -40,6 +40,7 @@ "todo-continuation-enforcer", "context-window-monitor", "session-recovery", + "session-notification", "comment-checker", "grep-output-truncator", "directory-agents-injector", diff --git a/src/config/schema.ts b/src/config/schema.ts index 3445bf5..5692b65 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -28,6 +28,7 @@ export const HookNameSchema = z.enum([ "todo-continuation-enforcer", "context-window-monitor", "session-recovery", + "session-notification", "comment-checker", "grep-output-truncator", "directory-agents-injector", diff --git a/src/hooks/session-notification.ts b/src/hooks/session-notification.ts index 9a092f2..d3147d8 100644 --- a/src/hooks/session-notification.ts +++ b/src/hooks/session-notification.ts @@ -17,6 +17,8 @@ interface SessionNotificationConfig { idleConfirmationDelay?: number /** Skip notification if there are incomplete todos (default: true) */ skipIfIncompleteTodos?: boolean + /** Maximum number of sessions to track before cleanup (default: 100) */ + maxTrackedSessions?: number } type Platform = "darwin" | "linux" | "win32" | "unsupported" @@ -103,12 +105,31 @@ export function createSessionNotification( soundPath: defaultSoundPath, idleConfirmationDelay: 1500, skipIfIncompleteTodos: true, + maxTrackedSessions: 100, ...config, } const notifiedSessions = new Set() const pendingTimers = new Map>() const sessionActivitySinceIdle = new Set() + // Track notification execution version to handle race conditions + const notificationVersions = new Map() + + function cleanupOldSessions() { + const maxSessions = mergedConfig.maxTrackedSessions + if (notifiedSessions.size > maxSessions) { + const sessionsToRemove = Array.from(notifiedSessions).slice(0, notifiedSessions.size - maxSessions) + sessionsToRemove.forEach(id => notifiedSessions.delete(id)) + } + if (sessionActivitySinceIdle.size > maxSessions) { + const sessionsToRemove = Array.from(sessionActivitySinceIdle).slice(0, sessionActivitySinceIdle.size - maxSessions) + sessionsToRemove.forEach(id => sessionActivitySinceIdle.delete(id)) + } + if (notificationVersions.size > maxSessions) { + const sessionsToRemove = Array.from(notificationVersions.keys()).slice(0, notificationVersions.size - maxSessions) + sessionsToRemove.forEach(id => notificationVersions.delete(id)) + } + } function cancelPendingNotification(sessionID: string) { const timer = pendingTimers.get(sessionID) @@ -117,6 +138,8 @@ export function createSessionNotification( pendingTimers.delete(sessionID) } sessionActivitySinceIdle.add(sessionID) + // Increment version to invalidate any in-flight notifications + notificationVersions.set(sessionID, (notificationVersions.get(sessionID) ?? 0) + 1) } function markSessionActivity(sessionID: string) { @@ -124,9 +147,14 @@ export function createSessionNotification( notifiedSessions.delete(sessionID) } - async function executeNotification(sessionID: string) { + async function executeNotification(sessionID: string, version: number) { pendingTimers.delete(sessionID) + // Race condition fix: check if version matches (activity happened during async wait) + if (notificationVersions.get(sessionID) !== version) { + return + } + if (sessionActivitySinceIdle.has(sessionID)) { sessionActivitySinceIdle.delete(sessionID) return @@ -136,9 +164,17 @@ export function createSessionNotification( if (mergedConfig.skipIfIncompleteTodos) { const hasPendingWork = await hasIncompleteTodos(ctx, sessionID) + // Re-check version after async call (race condition fix) + if (notificationVersions.get(sessionID) !== version) { + return + } if (hasPendingWork) return } + if (notificationVersions.get(sessionID) !== version) { + return + } + notifiedSessions.add(sessionID) try { @@ -172,20 +208,34 @@ export function createSessionNotification( if (pendingTimers.has(sessionID)) return sessionActivitySinceIdle.delete(sessionID) + + const currentVersion = (notificationVersions.get(sessionID) ?? 0) + 1 + notificationVersions.set(sessionID, currentVersion) const timer = setTimeout(() => { - executeNotification(sessionID) + executeNotification(sessionID, currentVersion) }, mergedConfig.idleConfirmationDelay) pendingTimers.set(sessionID, timer) + cleanupOldSessions() + return } - if (event.type === "message.updated") { + if (event.type === "message.updated" || event.type === "message.created") { const info = props?.info as Record | undefined const sessionID = info?.sessionID as string | undefined if (sessionID) { markSessionActivity(sessionID) } + return + } + + if (event.type === "tool.execute.before" || event.type === "tool.execute.after") { + const sessionID = props?.sessionID as string | undefined + if (sessionID) { + markSessionActivity(sessionID) + } + return } if (event.type === "session.deleted") { @@ -194,6 +244,7 @@ export function createSessionNotification( cancelPendingNotification(sessionInfo.id) notifiedSessions.delete(sessionInfo.id) sessionActivitySinceIdle.delete(sessionInfo.id) + notificationVersions.delete(sessionInfo.id) } } } diff --git a/src/index.ts b/src/index.ts index a448da7..ba4dca5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { createTodoContinuationEnforcer, createContextWindowMonitorHook, createSessionRecoveryHook, + createSessionNotification, createCommentCheckerHooks, createGrepOutputTruncatorHook, createDirectoryAgentsInjectorHook, @@ -161,6 +162,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const sessionRecovery = isHookEnabled("session-recovery") ? createSessionRecoveryHook(ctx) : null; + const sessionNotification = isHookEnabled("session-notification") + ? createSessionNotification(ctx) + : null; // Wire up recovery state tracking between session-recovery and todo-continuation-enforcer // This prevents the continuation enforcer from injecting prompts during active recovery @@ -292,6 +296,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { await autoUpdateChecker?.event(input); await claudeCodeHooks.event(input); await backgroundNotificationHook?.event(input); + await sessionNotification?.(input); await todoContinuationEnforcer?.handler(input); await contextWindowMonitor?.event(input); await directoryAgentsInjector?.event(input);