@@ -234,6 +234,93 @@ interface NotificationRuntime {
234234 preferCmux : boolean
235235}
236236
237+ const QUESTION_DEDUPE_WINDOW_MS = 1500
238+ const READY_DEDUPE_WINDOW_MS = 1500
239+ const PERMISSION_DEDUPE_WINDOW_MS = 1500
240+
241+ type RecentNotifications = Map < string , number >
242+
243+ function toNonEmptyString ( value : unknown ) : string | null {
244+ if ( typeof value !== "string" ) return null
245+
246+ const normalized = value . trim ( )
247+ if ( ! normalized ) return null
248+
249+ return normalized
250+ }
251+
252+ function shouldSendDedupedNotification (
253+ recentNotifications : RecentNotifications ,
254+ dedupeKey : string ,
255+ windowMs : number ,
256+ nowMs = Date . now ( ) ,
257+ ) : boolean {
258+ for ( const [ key , timestamp ] of recentNotifications ) {
259+ if ( nowMs - timestamp >= windowMs ) {
260+ recentNotifications . delete ( key )
261+ }
262+ }
263+
264+ const lastSentAt = recentNotifications . get ( dedupeKey )
265+ if ( lastSentAt !== undefined && nowMs - lastSentAt < windowMs ) {
266+ return false
267+ }
268+
269+ recentNotifications . set ( dedupeKey , nowMs )
270+ return true
271+ }
272+
273+ function buildQuestionToolDedupeKey ( sessionID : unknown , callID : unknown ) : string | null {
274+ const normalizedSessionID = toNonEmptyString ( sessionID )
275+ if ( ! normalizedSessionID ) return null
276+
277+ const normalizedCallID = toNonEmptyString ( callID )
278+ if ( ! normalizedCallID ) return null
279+
280+ return `question:${ normalizedSessionID } :${ normalizedCallID } `
281+ }
282+
283+ function buildQuestionEventDedupeKey ( properties : unknown ) : string | null {
284+ if ( ! properties || typeof properties !== "object" ) return null
285+
286+ const record = properties as Record < string , unknown >
287+ const normalizedSessionID = toNonEmptyString ( record . sessionID )
288+ if ( ! normalizedSessionID ) return null
289+
290+ const toolInfo =
291+ record . tool && typeof record . tool === "object"
292+ ? ( record . tool as Record < string , unknown > )
293+ : undefined
294+ const normalizedCallID = toNonEmptyString ( toolInfo ?. callID )
295+ if ( normalizedCallID ) {
296+ return `question:${ normalizedSessionID } :${ normalizedCallID } `
297+ }
298+
299+ const normalizedRequestID = toNonEmptyString ( record . id )
300+ if ( normalizedRequestID ) {
301+ return `question:${ normalizedSessionID } :request:${ normalizedRequestID } `
302+ }
303+
304+ return null
305+ }
306+
307+ function buildSessionReadyDedupeKey ( sessionID : unknown ) : string | null {
308+ const normalizedSessionID = toNonEmptyString ( sessionID )
309+ if ( ! normalizedSessionID ) return null
310+
311+ return `session-ready:${ normalizedSessionID } `
312+ }
313+
314+ function buildPermissionEventDedupeKey ( properties : unknown ) : string | null {
315+ if ( ! properties || typeof properties !== "object" ) return null
316+
317+ const record = properties as Record < string , unknown >
318+ const normalizedRequestID = toNonEmptyString ( record . id )
319+ if ( ! normalizedRequestID ) return null
320+
321+ return `permission:request:${ normalizedRequestID } `
322+ }
323+
237324function sendNodeNotification ( options : NotificationOptions ) : void {
238325 const { title, message, sound, terminalInfo } = options
239326
@@ -409,31 +496,93 @@ export const NotifyPlugin: Plugin = async (ctx) => {
409496 const notificationRuntime : NotificationRuntime = {
410497 preferCmux : canUseCmuxNotification ( ) ,
411498 }
499+ const recentQuestionNotifications : RecentNotifications = new Map ( )
500+ const recentReadyNotifications : RecentNotifications = new Map ( )
501+ const recentPermissionNotifications : RecentNotifications = new Map ( )
502+
503+ const notifyQuestionIfNeeded = async ( dedupeKey : string | null ) : Promise < void > => {
504+ if (
505+ dedupeKey &&
506+ ! shouldSendDedupedNotification (
507+ recentQuestionNotifications ,
508+ dedupeKey ,
509+ QUESTION_DEDUPE_WINDOW_MS ,
510+ )
511+ ) {
512+ return
513+ }
514+
515+ await handleQuestionAsked ( config , terminalInfo , notificationRuntime )
516+ }
517+
518+ const notifySessionReadyIfNeeded = async ( sessionID : unknown ) : Promise < void > => {
519+ const normalizedSessionID = toNonEmptyString ( sessionID )
520+ if ( ! normalizedSessionID ) return
521+
522+ const dedupeKey = buildSessionReadyDedupeKey ( normalizedSessionID )
523+ if ( ! dedupeKey ) return
524+
525+ if (
526+ ! shouldSendDedupedNotification ( recentReadyNotifications , dedupeKey , READY_DEDUPE_WINDOW_MS )
527+ ) {
528+ return
529+ }
530+
531+ await handleSessionIdle (
532+ client as OpencodeClient ,
533+ normalizedSessionID ,
534+ config ,
535+ terminalInfo ,
536+ notificationRuntime ,
537+ )
538+ }
539+
540+ const notifyPermissionIfNeeded = async ( properties : unknown ) : Promise < void > => {
541+ const dedupeKey = buildPermissionEventDedupeKey ( properties )
542+
543+ if (
544+ dedupeKey &&
545+ ! shouldSendDedupedNotification (
546+ recentPermissionNotifications ,
547+ dedupeKey ,
548+ PERMISSION_DEDUPE_WINDOW_MS ,
549+ )
550+ ) {
551+ return
552+ }
553+
554+ await handlePermissionUpdated ( config , terminalInfo , notificationRuntime )
555+ }
412556
413557 return {
414558 "tool.execute.before" : async ( input : { tool : string ; sessionID : string ; callID : string } ) => {
415559 if ( input . tool === "question" ) {
416- await handleQuestionAsked ( config , terminalInfo , notificationRuntime )
560+ await notifyQuestionIfNeeded ( buildQuestionToolDedupeKey ( input . sessionID , input . callID ) )
417561 }
418562 } ,
419563 event : async ( { event } : { event : Event } ) : Promise < void > => {
420- switch ( event . type ) {
421- case "session.idle" : {
422- const sessionID = event . properties . sessionID
423- if ( sessionID ) {
424- await handleSessionIdle (
425- client as OpencodeClient ,
426- sessionID ,
427- config ,
428- terminalInfo ,
429- notificationRuntime ,
430- )
564+ const runtimeEvent = event as { type : string ; properties : Record < string , unknown > }
565+
566+ switch ( runtimeEvent . type ) {
567+ case "session.status" : {
568+ const sessionID = runtimeEvent . properties . sessionID
569+ const statusType =
570+ runtimeEvent . properties . status && typeof runtimeEvent . properties . status === "object"
571+ ? ( ( runtimeEvent . properties . status as { type ?: string } ) . type ?? undefined )
572+ : undefined
573+
574+ if ( sessionID && statusType === "idle" ) {
575+ await notifySessionReadyIfNeeded ( sessionID )
431576 }
432577 break
433578 }
579+ case "session.idle" : {
580+ await notifySessionReadyIfNeeded ( runtimeEvent . properties . sessionID )
581+ break
582+ }
434583 case "session.error" : {
435- const sessionID = event . properties . sessionID
436- const error = event . properties . error
584+ const sessionID = toNonEmptyString ( runtimeEvent . properties . sessionID )
585+ const error = runtimeEvent . properties . error
437586 const errorMessage = typeof error === "string" ? error : error ? String ( error ) : undefined
438587 if ( sessionID ) {
439588 await handleSessionError (
@@ -448,8 +597,14 @@ export const NotifyPlugin: Plugin = async (ctx) => {
448597 break
449598 }
450599
451- case "permission.updated" : {
452- await handlePermissionUpdated ( config , terminalInfo , notificationRuntime )
600+ case "permission.updated" :
601+ case "permission.asked" : {
602+ await notifyPermissionIfNeeded ( runtimeEvent . properties )
603+ break
604+ }
605+ case "question.asked" : {
606+ const dedupeKey = buildQuestionEventDedupeKey ( runtimeEvent . properties )
607+ await notifyQuestionIfNeeded ( dedupeKey )
453608 break
454609 }
455610 }
0 commit comments