Skip to content

Commit 52f905d

Browse files
committed
sync: synced file(s) with kdcokenny/ocx
1 parent 2263973 commit 52f905d

File tree

1 file changed

+171
-16
lines changed

1 file changed

+171
-16
lines changed

src/notify.ts

Lines changed: 171 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
237324
function 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

Comments
 (0)