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.
|
- **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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user