60a23a557e
Gate idle ready notifications on the existing background-task continuation marker so cmux does not receive premature ready alerts while delegated work is still active. Constraint: Reuse marker state from background task lifecycle without adding new notification config Rejected: Patch cmux directly | notification readiness belongs upstream in OMO Confidence: high Scope-risk: narrow
177 lines
6.8 KiB
TypeScript
177 lines
6.8 KiB
TypeScript
import type { PluginInput } from "@opencode-ai/plugin"
|
|
import { subagentSessions, getMainSessionID } from "../features/claude-code-session-state"
|
|
import { buildReadyNotificationContent } from "./session-notification-content"
|
|
import { type Platform } from "./session-notification-sender"
|
|
import * as sessionNotificationSender from "./session-notification-sender"
|
|
import { getEventToolName, getQuestionText, getSessionID } from "./session-notification-event-properties"
|
|
import { hasPendingSessionWork } from "./session-todo-status"
|
|
import { createIdleNotificationScheduler } from "./session-notification-scheduler"
|
|
import { createSessionNotificationInit } from "./session-notification-init"
|
|
import { resolveSessionEventID } from "../shared/event-session-id"
|
|
|
|
interface SessionNotificationConfig {
|
|
title?: string
|
|
message?: string
|
|
questionMessage?: string
|
|
permissionMessage?: string
|
|
playSound?: boolean
|
|
soundPath?: string
|
|
/** Delay in ms before sending notification to confirm session is still idle (default: 1500) */
|
|
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
|
|
enforceMainSessionFilter?: boolean
|
|
/** Grace period in ms to ignore late-arriving activity events after scheduling (default: 100) */
|
|
activityGracePeriodMs?: number
|
|
}
|
|
|
|
export function createSessionNotification(ctx: PluginInput, config: SessionNotificationConfig = {}) {
|
|
const mergedConfig = {
|
|
title: "OpenCode",
|
|
message: "Agent is ready for input",
|
|
questionMessage: "Agent is asking a question",
|
|
permissionMessage: "Agent needs permission to continue",
|
|
playSound: false,
|
|
soundPath: "",
|
|
idleConfirmationDelay: 1500,
|
|
skipIfIncompleteTodos: true,
|
|
maxTrackedSessions: 100,
|
|
enforceMainSessionFilter: true,
|
|
...config,
|
|
}
|
|
|
|
const sessionNotificationInit = createSessionNotificationInit()
|
|
let currentPlatform: Platform | null = null
|
|
let defaultSoundPath = mergedConfig.soundPath
|
|
|
|
const scheduler = createIdleNotificationScheduler({
|
|
ctx,
|
|
config: mergedConfig,
|
|
hasIncompleteTodos: hasPendingSessionWork,
|
|
send: async (hookCtx, sessionID) => {
|
|
const platform = ensureNotificationPlatform()
|
|
if (typeof hookCtx.client.session.get !== "function" && typeof hookCtx.client.session.messages !== "function") {
|
|
await sessionNotificationSender.sendSessionNotification(hookCtx, platform, mergedConfig.title, mergedConfig.message)
|
|
return
|
|
}
|
|
|
|
const content = await buildReadyNotificationContent(hookCtx, {
|
|
sessionID,
|
|
baseTitle: mergedConfig.title,
|
|
baseMessage: mergedConfig.message,
|
|
})
|
|
|
|
await sessionNotificationSender.sendSessionNotification(hookCtx, platform, content.title, content.message)
|
|
},
|
|
playSound: async (hookCtx, soundPath) => {
|
|
const platform = ensureNotificationPlatform()
|
|
await sessionNotificationSender.playSessionNotificationSound(hookCtx, platform, soundPath)
|
|
},
|
|
})
|
|
|
|
const QUESTION_TOOLS = new Set(["question", "ask_user_question", "askuserquestion"])
|
|
const PERMISSION_EVENTS = new Set(["permission.ask", "permission.asked", "permission.updated", "permission.requested"])
|
|
const PERMISSION_HINT_PATTERN = /\b(permission|approve|approval|allow|deny|consent)\b/i
|
|
|
|
const ensureNotificationPlatform = (): Platform => {
|
|
if (currentPlatform) return currentPlatform
|
|
|
|
const initialized = sessionNotificationInit.initialize()
|
|
currentPlatform = initialized.platform
|
|
defaultSoundPath = initialized.defaultSoundPath || mergedConfig.soundPath
|
|
return currentPlatform
|
|
}
|
|
|
|
const shouldNotifyForSession = (sessionID: string): boolean => {
|
|
if (subagentSessions.has(sessionID)) return false
|
|
|
|
if (mergedConfig.enforceMainSessionFilter) {
|
|
const mainSessionID = getMainSessionID()
|
|
if (mainSessionID && sessionID !== mainSessionID) return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
|
const props = event.properties as Record<string, unknown> | undefined
|
|
|
|
if (event.type === "session.created") {
|
|
const sessionID = resolveSessionEventID(props)
|
|
if (sessionID) scheduler.markSessionActivity(sessionID)
|
|
return
|
|
}
|
|
|
|
if (event.type === "session.idle") {
|
|
const sessionID = getSessionID(props)
|
|
if (!sessionID) return
|
|
|
|
const platform = ensureNotificationPlatform()
|
|
if (platform === "unsupported") return
|
|
if (!shouldNotifyForSession(sessionID)) return
|
|
|
|
scheduler.scheduleIdleNotification(sessionID)
|
|
return
|
|
}
|
|
|
|
if (
|
|
event.type === "message.updated" ||
|
|
event.type === "message.part.updated" ||
|
|
event.type === "message.part.delta"
|
|
) {
|
|
const info = props?.info as Record<string, unknown> | undefined
|
|
const sessionID = getSessionID({ ...props, info })
|
|
if (sessionID) scheduler.markSessionActivity(sessionID)
|
|
return
|
|
}
|
|
|
|
if (PERMISSION_EVENTS.has(event.type)) {
|
|
const sessionID = getSessionID(props)
|
|
if (!sessionID) return
|
|
|
|
const platform = ensureNotificationPlatform()
|
|
if (platform === "unsupported") return
|
|
if (!shouldNotifyForSession(sessionID)) return
|
|
|
|
scheduler.markSessionActivity(sessionID)
|
|
await sessionNotificationSender.sendSessionNotification(ctx, platform, mergedConfig.title, mergedConfig.permissionMessage)
|
|
if (mergedConfig.playSound && defaultSoundPath) {
|
|
await sessionNotificationSender.playSessionNotificationSound(ctx, platform, defaultSoundPath)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
|
|
const sessionID = getSessionID(props)
|
|
if (sessionID) {
|
|
scheduler.markSessionActivity(sessionID)
|
|
|
|
if (event.type === "tool.execute.before") {
|
|
const toolName = getEventToolName(props)?.toLowerCase()
|
|
if (toolName && QUESTION_TOOLS.has(toolName)) {
|
|
const platform = ensureNotificationPlatform()
|
|
if (platform === "unsupported") return
|
|
if (!shouldNotifyForSession(sessionID)) return
|
|
|
|
const questionText = getQuestionText(props)
|
|
const message = PERMISSION_HINT_PATTERN.test(questionText) ? mergedConfig.permissionMessage : mergedConfig.questionMessage
|
|
|
|
await sessionNotificationSender.sendSessionNotification(ctx, platform, mergedConfig.title, message)
|
|
if (mergedConfig.playSound && defaultSoundPath) {
|
|
await sessionNotificationSender.playSessionNotificationSound(ctx, platform, defaultSoundPath)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if (event.type === "session.deleted") {
|
|
const sessionID = resolveSessionEventID(props)
|
|
if (sessionID) scheduler.deleteSession(sessionID)
|
|
}
|
|
}
|
|
}
|