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:
12
README.md
12
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.
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"todo-continuation-enforcer",
|
||||
"context-window-monitor",
|
||||
"session-recovery",
|
||||
"session-notification",
|
||||
"comment-checker",
|
||||
"grep-output-truncator",
|
||||
"directory-agents-injector",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string>()
|
||||
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
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) {
|
||||
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 {
|
||||
@@ -173,19 +209,33 @@ export function createSessionNotification(
|
||||
|
||||
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<string, unknown> | 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user