Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
189 lines
6.1 KiB
TypeScript
189 lines
6.1 KiB
TypeScript
import type { PluginInput } from "@opencode-ai/plugin"
|
|
import type { Platform } from "./session-notification-sender"
|
|
|
|
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
|
|
platform: Platform
|
|
config: SessionNotificationConfig
|
|
hasIncompleteTodos: (ctx: PluginInput, sessionID: string) => Promise<boolean>
|
|
send: (ctx: PluginInput, platform: Platform, sessionID: string) => Promise<void>
|
|
playSound: (ctx: PluginInput, platform: Platform, 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, options.platform, sessionID)
|
|
|
|
if (options.config.playSound && options.config.soundPath) {
|
|
await options.playSound(options.ctx, options.platform, 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,
|
|
}
|
|
}
|