fix(hooks): add session-notification to disabled_hooks with race/memory fixes

- 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)
This commit is contained in:
YeonGyu-Kim
2025-12-13 13:02:55 +09:00
parent e131491db4
commit 7859f0dd2d
5 changed files with 73 additions and 3 deletions

View File

@@ -332,6 +332,18 @@ Example workflow:
- **Empty Task Response Detector**: Detects when subagent tasks return empty or meaningless responses and handles gracefully. - **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. - **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 ### 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. Oh My OpenCode provides seamless Claude Code configuration compatibility. If you've been using Claude Code, your existing setup works out of the box.

View File

@@ -40,6 +40,7 @@
"todo-continuation-enforcer", "todo-continuation-enforcer",
"context-window-monitor", "context-window-monitor",
"session-recovery", "session-recovery",
"session-notification",
"comment-checker", "comment-checker",
"grep-output-truncator", "grep-output-truncator",
"directory-agents-injector", "directory-agents-injector",

View File

@@ -28,6 +28,7 @@ export const HookNameSchema = z.enum([
"todo-continuation-enforcer", "todo-continuation-enforcer",
"context-window-monitor", "context-window-monitor",
"session-recovery", "session-recovery",
"session-notification",
"comment-checker", "comment-checker",
"grep-output-truncator", "grep-output-truncator",
"directory-agents-injector", "directory-agents-injector",

View File

@@ -17,6 +17,8 @@ interface SessionNotificationConfig {
idleConfirmationDelay?: number idleConfirmationDelay?: number
/** Skip notification if there are incomplete todos (default: true) */ /** Skip notification if there are incomplete todos (default: true) */
skipIfIncompleteTodos?: boolean skipIfIncompleteTodos?: boolean
/** Maximum number of sessions to track before cleanup (default: 100) */
maxTrackedSessions?: number
} }
type Platform = "darwin" | "linux" | "win32" | "unsupported" type Platform = "darwin" | "linux" | "win32" | "unsupported"
@@ -103,12 +105,31 @@ export function createSessionNotification(
soundPath: defaultSoundPath, soundPath: defaultSoundPath,
idleConfirmationDelay: 1500, idleConfirmationDelay: 1500,
skipIfIncompleteTodos: true, skipIfIncompleteTodos: true,
maxTrackedSessions: 100,
...config, ...config,
} }
const notifiedSessions = new Set<string>() const notifiedSessions = new Set<string>()
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>() const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
const sessionActivitySinceIdle = new Set<string>() const sessionActivitySinceIdle = new Set<string>()
// Track notification execution version to handle race conditions
const notificationVersions = new Map<string, number>()
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) { function cancelPendingNotification(sessionID: string) {
const timer = pendingTimers.get(sessionID) const timer = pendingTimers.get(sessionID)
@@ -117,6 +138,8 @@ export function createSessionNotification(
pendingTimers.delete(sessionID) pendingTimers.delete(sessionID)
} }
sessionActivitySinceIdle.add(sessionID) sessionActivitySinceIdle.add(sessionID)
// Increment version to invalidate any in-flight notifications
notificationVersions.set(sessionID, (notificationVersions.get(sessionID) ?? 0) + 1)
} }
function markSessionActivity(sessionID: string) { function markSessionActivity(sessionID: string) {
@@ -124,9 +147,14 @@ export function createSessionNotification(
notifiedSessions.delete(sessionID) notifiedSessions.delete(sessionID)
} }
async function executeNotification(sessionID: string) { async function executeNotification(sessionID: string, version: number) {
pendingTimers.delete(sessionID) 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)) { if (sessionActivitySinceIdle.has(sessionID)) {
sessionActivitySinceIdle.delete(sessionID) sessionActivitySinceIdle.delete(sessionID)
return return
@@ -136,9 +164,17 @@ export function createSessionNotification(
if (mergedConfig.skipIfIncompleteTodos) { if (mergedConfig.skipIfIncompleteTodos) {
const hasPendingWork = await hasIncompleteTodos(ctx, sessionID) 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 (hasPendingWork) return
} }
if (notificationVersions.get(sessionID) !== version) {
return
}
notifiedSessions.add(sessionID) notifiedSessions.add(sessionID)
try { try {
@@ -173,19 +209,33 @@ export function createSessionNotification(
sessionActivitySinceIdle.delete(sessionID) sessionActivitySinceIdle.delete(sessionID)
const currentVersion = (notificationVersions.get(sessionID) ?? 0) + 1
notificationVersions.set(sessionID, currentVersion)
const timer = setTimeout(() => { const timer = setTimeout(() => {
executeNotification(sessionID) executeNotification(sessionID, currentVersion)
}, mergedConfig.idleConfirmationDelay) }, mergedConfig.idleConfirmationDelay)
pendingTimers.set(sessionID, timer) 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<string, unknown> | undefined const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined const sessionID = info?.sessionID as string | undefined
if (sessionID) { if (sessionID) {
markSessionActivity(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") { if (event.type === "session.deleted") {
@@ -194,6 +244,7 @@ export function createSessionNotification(
cancelPendingNotification(sessionInfo.id) cancelPendingNotification(sessionInfo.id)
notifiedSessions.delete(sessionInfo.id) notifiedSessions.delete(sessionInfo.id)
sessionActivitySinceIdle.delete(sessionInfo.id) sessionActivitySinceIdle.delete(sessionInfo.id)
notificationVersions.delete(sessionInfo.id)
} }
} }
} }

View File

@@ -4,6 +4,7 @@ import {
createTodoContinuationEnforcer, createTodoContinuationEnforcer,
createContextWindowMonitorHook, createContextWindowMonitorHook,
createSessionRecoveryHook, createSessionRecoveryHook,
createSessionNotification,
createCommentCheckerHooks, createCommentCheckerHooks,
createGrepOutputTruncatorHook, createGrepOutputTruncatorHook,
createDirectoryAgentsInjectorHook, createDirectoryAgentsInjectorHook,
@@ -161,6 +162,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const sessionRecovery = isHookEnabled("session-recovery") const sessionRecovery = isHookEnabled("session-recovery")
? createSessionRecoveryHook(ctx) ? createSessionRecoveryHook(ctx)
: null; : null;
const sessionNotification = isHookEnabled("session-notification")
? createSessionNotification(ctx)
: null;
// Wire up recovery state tracking between session-recovery and todo-continuation-enforcer // Wire up recovery state tracking between session-recovery and todo-continuation-enforcer
// This prevents the continuation enforcer from injecting prompts during active recovery // 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 autoUpdateChecker?.event(input);
await claudeCodeHooks.event(input); await claudeCodeHooks.event(input);
await backgroundNotificationHook?.event(input); await backgroundNotificationHook?.event(input);
await sessionNotification?.(input);
await todoContinuationEnforcer?.handler(input); await todoContinuationEnforcer?.handler(input);
await contextWindowMonitor?.event(input); await contextWindowMonitor?.event(input);
await directoryAgentsInjector?.event(input); await directoryAgentsInjector?.event(input);