384bc53bfd
The idle notification scheduler was initialized with platform 'unsupported' before platform detection had run. This stale value was passed to send/playSound callbacks, causing sendSessionNotification to silently no-op (no switch case for 'unsupported'). Session-idle notifications never fired as a result. Fix: remove platform from scheduler options entirely. Callbacks now resolve platform via ensureNotificationPlatform() which is sync, cached, and already called by each event handler before scheduling.
187 lines
6.0 KiB
TypeScript
187 lines
6.0 KiB
TypeScript
import type { PluginInput } from "@opencode-ai/plugin"
|
|
|
|
type SessionNotificationConfig = {
|
|
playSound: boolean
|
|
soundPath: string
|
|
idleConfirmationDelay: number
|
|
skipIfIncompleteTodos: boolean
|
|
maxTrackedSessions: number
|
|
/** Grace period in ms to ignore late-arriving activity events after scheduling (default: 100) */
|
|
activityGracePeriodMs?: number
|
|
}
|
|
|
|
export function createIdleNotificationScheduler(options: {
|
|
ctx: PluginInput
|
|
config: SessionNotificationConfig
|
|
hasIncompleteTodos: (ctx: PluginInput, sessionID: string) => Promise<boolean>
|
|
send: (ctx: PluginInput, sessionID: string) => Promise<void>
|
|
playSound: (ctx: PluginInput, soundPath: string) => Promise<void>
|
|
}) {
|
|
const notifiedSessions = new Set<string>()
|
|
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
const sessionActivitySinceIdle = new Set<string>()
|
|
const notificationVersions = new Map<string, number>()
|
|
const executingNotifications = new Set<string>()
|
|
const scheduledAt = new Map<string, number>()
|
|
|
|
const activityGracePeriodMs = options.config.activityGracePeriodMs ?? 100
|
|
|
|
function cleanupOldSessions(): void {
|
|
const maxSessions = options.config.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)
|
|
})
|
|
}
|
|
if (executingNotifications.size > maxSessions) {
|
|
const sessionsToRemove = Array.from(executingNotifications).slice(0, executingNotifications.size - maxSessions)
|
|
sessionsToRemove.forEach((id) => {
|
|
executingNotifications.delete(id)
|
|
})
|
|
}
|
|
if (scheduledAt.size > maxSessions) {
|
|
const sessionsToRemove = Array.from(scheduledAt.keys()).slice(0, scheduledAt.size - maxSessions)
|
|
sessionsToRemove.forEach((id) => {
|
|
scheduledAt.delete(id)
|
|
})
|
|
}
|
|
}
|
|
|
|
function cancelPendingNotification(sessionID: string): void {
|
|
const timer = pendingTimers.get(sessionID)
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
pendingTimers.delete(sessionID)
|
|
}
|
|
scheduledAt.delete(sessionID)
|
|
sessionActivitySinceIdle.add(sessionID)
|
|
notificationVersions.set(sessionID, (notificationVersions.get(sessionID) ?? 0) + 1)
|
|
}
|
|
|
|
function markSessionActivity(sessionID: string): void {
|
|
const scheduledTime = scheduledAt.get(sessionID)
|
|
if (
|
|
activityGracePeriodMs > 0 &&
|
|
scheduledTime !== undefined &&
|
|
Date.now() - scheduledTime <= activityGracePeriodMs
|
|
) {
|
|
return
|
|
}
|
|
|
|
cancelPendingNotification(sessionID)
|
|
if (!executingNotifications.has(sessionID)) {
|
|
notifiedSessions.delete(sessionID)
|
|
}
|
|
}
|
|
|
|
async function executeNotification(sessionID: string, version: number): Promise<void> {
|
|
if (executingNotifications.has(sessionID)) {
|
|
pendingTimers.delete(sessionID)
|
|
scheduledAt.delete(sessionID)
|
|
return
|
|
}
|
|
|
|
if (notificationVersions.get(sessionID) !== version) {
|
|
pendingTimers.delete(sessionID)
|
|
scheduledAt.delete(sessionID)
|
|
return
|
|
}
|
|
|
|
if (sessionActivitySinceIdle.has(sessionID)) {
|
|
sessionActivitySinceIdle.delete(sessionID)
|
|
pendingTimers.delete(sessionID)
|
|
scheduledAt.delete(sessionID)
|
|
return
|
|
}
|
|
|
|
if (notifiedSessions.has(sessionID)) {
|
|
pendingTimers.delete(sessionID)
|
|
scheduledAt.delete(sessionID)
|
|
return
|
|
}
|
|
|
|
executingNotifications.add(sessionID)
|
|
try {
|
|
if (options.config.skipIfIncompleteTodos) {
|
|
const hasPendingWork = await options.hasIncompleteTodos(options.ctx, sessionID)
|
|
if (notificationVersions.get(sessionID) !== version) {
|
|
return
|
|
}
|
|
if (hasPendingWork) return
|
|
}
|
|
|
|
if (notificationVersions.get(sessionID) !== version) {
|
|
return
|
|
}
|
|
|
|
if (sessionActivitySinceIdle.has(sessionID)) {
|
|
sessionActivitySinceIdle.delete(sessionID)
|
|
return
|
|
}
|
|
|
|
notifiedSessions.add(sessionID)
|
|
|
|
await options.send(options.ctx, sessionID)
|
|
|
|
if (options.config.playSound && options.config.soundPath) {
|
|
await options.playSound(options.ctx, options.config.soundPath)
|
|
}
|
|
} finally {
|
|
executingNotifications.delete(sessionID)
|
|
pendingTimers.delete(sessionID)
|
|
scheduledAt.delete(sessionID)
|
|
if (sessionActivitySinceIdle.has(sessionID)) {
|
|
notifiedSessions.delete(sessionID)
|
|
sessionActivitySinceIdle.delete(sessionID)
|
|
}
|
|
}
|
|
}
|
|
|
|
function scheduleIdleNotification(sessionID: string): void {
|
|
if (notifiedSessions.has(sessionID)) return
|
|
if (pendingTimers.has(sessionID)) return
|
|
if (executingNotifications.has(sessionID)) return
|
|
|
|
sessionActivitySinceIdle.delete(sessionID)
|
|
scheduledAt.set(sessionID, Date.now())
|
|
|
|
const currentVersion = (notificationVersions.get(sessionID) ?? 0) + 1
|
|
notificationVersions.set(sessionID, currentVersion)
|
|
|
|
const timer = setTimeout(() => {
|
|
executeNotification(sessionID, currentVersion)
|
|
}, options.config.idleConfirmationDelay)
|
|
|
|
pendingTimers.set(sessionID, timer)
|
|
cleanupOldSessions()
|
|
}
|
|
|
|
function deleteSession(sessionID: string): void {
|
|
cancelPendingNotification(sessionID)
|
|
notifiedSessions.delete(sessionID)
|
|
sessionActivitySinceIdle.delete(sessionID)
|
|
notificationVersions.delete(sessionID)
|
|
executingNotifications.delete(sessionID)
|
|
scheduledAt.delete(sessionID)
|
|
}
|
|
|
|
return {
|
|
markSessionActivity,
|
|
scheduleIdleNotification,
|
|
deleteSession,
|
|
}
|
|
}
|