opencode-smart-voice-notify 1.2.2 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,978 +1,978 @@
1
- import fs from 'fs';
2
- import os from 'os';
3
- import path from 'path';
4
- import { createTTS, getTTSConfig } from './util/tts.js';
5
- import { getSmartMessage } from './util/ai-messages.js';
6
-
7
- /**
8
- * OpenCode Smart Voice Notify Plugin
9
- *
10
- * A smart notification plugin with multiple TTS engines (auto-fallback):
11
- * 1. ElevenLabs (Online, High Quality, Anime-like voices)
12
- * 2. Edge TTS (Free, Neural voices)
13
- * 3. Windows SAPI (Offline, Built-in)
14
- * 4. Local Sound Files (Fallback)
15
- *
16
- * Features:
17
- * - Smart notification mode (sound-first, tts-first, both, sound-only)
18
- * - Delayed TTS reminders if user doesn't respond
19
- * - Follow-up reminders with exponential backoff
20
- * - Monitor wake and volume boost
21
- * - Cross-platform support (Windows, macOS, Linux)
22
- *
23
- * @type {import("@opencode-ai/plugin").Plugin}
24
- */
25
- export default async function SmartVoiceNotifyPlugin({ project, client, $, directory, worktree }) {
26
- const config = getTTSConfig();
27
-
28
- // Master switch: if plugin is disabled, return empty handlers immediately
29
- if (config.enabled === false) {
30
- const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
31
- const logsDir = path.join(configDir, 'logs');
32
- const logFile = path.join(logsDir, 'smart-voice-notify-debug.log');
33
- if (config.debugLog) {
34
- try {
35
- if (!fs.existsSync(logsDir)) {
36
- fs.mkdirSync(logsDir, { recursive: true });
37
- }
38
- const timestamp = new Date().toISOString();
39
- fs.appendFileSync(logFile, `[${timestamp}] Plugin disabled via config (enabled: false) - no event handlers registered\n`);
40
- } catch (e) {}
41
- }
42
- return {};
43
- }
44
-
45
- const tts = createTTS({ $, client });
46
-
47
- const platform = os.platform();
48
-
49
- const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
50
- const logsDir = path.join(configDir, 'logs');
51
- const logFile = path.join(logsDir, 'smart-voice-notify-debug.log');
52
-
53
- // Ensure logs directory exists if debug logging is enabled
54
- if (config.debugLog && !fs.existsSync(logsDir)) {
55
- try {
56
- fs.mkdirSync(logsDir, { recursive: true });
57
- } catch (e) {
58
- // Silently fail - logging is optional
59
- }
60
- }
61
-
62
- // Track pending TTS reminders (can be cancelled if user responds)
63
- const pendingReminders = new Map();
64
-
65
- // Track last user activity time
66
- let lastUserActivityTime = Date.now();
67
-
68
- // Track seen user message IDs to avoid treating message UPDATES as new user activity
69
- // Key insight: message.updated fires for EVERY modification to a message, not just new messages
70
- // We only want to treat the FIRST occurrence of each user message as "user activity"
71
- const seenUserMessageIds = new Set();
72
-
73
- // Track the timestamp of when session went idle, to detect post-idle user messages
74
- let lastSessionIdleTime = 0;
75
-
76
- // Track active permission request to prevent race condition where user responds
77
- // before async notification code runs. Set on permission.updated, cleared on permission.replied.
78
- let activePermissionId = null;
79
-
80
- // ========================================
81
- // PERMISSION BATCHING STATE
82
- // Batches multiple simultaneous permission requests into a single notification
83
- // ========================================
84
-
85
- // Array of permission IDs waiting to be notified (collected during batch window)
86
- let pendingPermissionBatch = [];
87
-
88
- // Timeout ID for the batch window (debounce timer)
89
- let permissionBatchTimeout = null;
90
-
91
- // Batch window duration in milliseconds (how long to wait for more permissions)
92
- const PERMISSION_BATCH_WINDOW_MS = config.permissionBatchWindowMs || 800;
93
-
94
- // ========================================
95
- // QUESTION BATCHING STATE (SDK v1.1.7+)
96
- // Batches multiple simultaneous question requests into a single notification
97
- // ========================================
98
-
99
- // Array of question request objects waiting to be notified (collected during batch window)
100
- // Each object contains { id: string, questionCount: number } to track actual question count
101
- let pendingQuestionBatch = [];
102
-
103
- // Timeout ID for the question batch window (debounce timer)
104
- let questionBatchTimeout = null;
105
-
106
- // Batch window duration in milliseconds (how long to wait for more questions)
107
- const QUESTION_BATCH_WINDOW_MS = config.questionBatchWindowMs || 800;
108
-
109
- // Track active question request to prevent race condition where user responds
110
- // before async notification code runs. Set on question.asked, cleared on question.replied/rejected.
111
- let activeQuestionId = null;
112
-
113
- /**
114
- * Write debug message to log file
115
- */
116
- const debugLog = (message) => {
117
- if (!config.debugLog) return;
118
- try {
119
- const timestamp = new Date().toISOString();
120
- fs.appendFileSync(logFile, `[${timestamp}] ${message}\n`);
121
- } catch (e) {}
122
- };
123
-
124
- /**
125
- * Get a random message from an array of messages
126
- */
127
- const getRandomMessage = (messages) => {
128
- if (!Array.isArray(messages) || messages.length === 0) {
129
- return 'Notification';
130
- }
131
- return messages[Math.floor(Math.random() * messages.length)];
132
- };
133
-
134
- /**
135
- * Show a TUI toast notification
136
- */
137
- const showToast = async (message, variant = 'info', duration = 5000) => {
138
- if (!config.enableToast) return;
139
- try {
140
- if (typeof client?.tui?.showToast === 'function') {
141
- await client.tui.showToast({
142
- body: {
143
- message: message,
144
- variant: variant,
145
- duration: duration
146
- }
147
- });
148
- }
149
- } catch (e) {}
150
- };
151
-
152
- /**
153
- * Play a sound file from assets
154
- */
155
- const playSound = async (soundFile, loops = 1) => {
156
- if (!config.enableSound) return;
157
- try {
158
- const soundPath = path.isAbsolute(soundFile)
159
- ? soundFile
160
- : path.join(configDir, soundFile);
161
-
162
- if (!fs.existsSync(soundPath)) {
163
- debugLog(`playSound: file not found: ${soundPath}`);
164
- return;
165
- }
166
-
167
- await tts.wakeMonitor();
168
- await tts.forceVolume();
169
- await tts.playAudioFile(soundPath, loops);
170
- debugLog(`playSound: played ${soundPath} (${loops}x)`);
171
- } catch (e) {
172
- debugLog(`playSound error: ${e.message}`);
173
- }
174
- };
175
-
176
- /**
177
- * Cancel any pending TTS reminder for a given type
178
- */
179
- const cancelPendingReminder = (type) => {
180
- const existing = pendingReminders.get(type);
181
- if (existing) {
182
- clearTimeout(existing.timeoutId);
183
- pendingReminders.delete(type);
184
- debugLog(`cancelPendingReminder: cancelled ${type}`);
185
- }
186
- };
187
-
188
- /**
189
- * Cancel all pending TTS reminders (called on user activity)
190
- */
191
- const cancelAllPendingReminders = () => {
192
- for (const [type, reminder] of pendingReminders.entries()) {
193
- clearTimeout(reminder.timeoutId);
194
- debugLog(`cancelAllPendingReminders: cancelled ${type}`);
195
- }
196
- pendingReminders.clear();
197
- };
198
-
199
- /**
200
- * Schedule a TTS reminder if user doesn't respond within configured delay.
201
- * The reminder uses a personalized TTS message.
202
- * @param {string} type - 'idle', 'permission', or 'question'
203
- * @param {string} message - The TTS message to speak (used directly, supports count-aware messages)
204
- * @param {object} options - Additional options (fallbackSound, permissionCount, questionCount)
205
- */
206
- const scheduleTTSReminder = (type, message, options = {}) => {
207
- // Check if TTS reminders are enabled
208
- if (!config.enableTTSReminder) {
209
- debugLog(`scheduleTTSReminder: TTS reminders disabled`);
210
- return;
211
- }
212
-
213
- // Get delay from config (in seconds, convert to ms)
214
- let delaySeconds;
215
- if (type === 'permission') {
216
- delaySeconds = config.permissionReminderDelaySeconds || config.ttsReminderDelaySeconds || 30;
217
- } else if (type === 'question') {
218
- delaySeconds = config.questionReminderDelaySeconds || config.ttsReminderDelaySeconds || 25;
219
- } else {
220
- delaySeconds = config.idleReminderDelaySeconds || config.ttsReminderDelaySeconds || 30;
221
- }
222
- const delayMs = delaySeconds * 1000;
223
-
224
- // Cancel any existing reminder of this type
225
- cancelPendingReminder(type);
226
-
227
- // Store count for generating count-aware messages in reminders
228
- const itemCount = options.permissionCount || options.questionCount || 1;
229
-
230
- debugLog(`scheduleTTSReminder: scheduling ${type} TTS in ${delaySeconds}s (count=${itemCount})`);
231
-
232
- const timeoutId = setTimeout(async () => {
233
- try {
234
- // Check if reminder was cancelled (user responded)
235
- if (!pendingReminders.has(type)) {
236
- debugLog(`scheduleTTSReminder: ${type} was cancelled before firing`);
237
- return;
238
- }
239
-
240
- // Check if user has been active since notification
241
- const reminder = pendingReminders.get(type);
242
- if (reminder && lastUserActivityTime > reminder.scheduledAt) {
243
- debugLog(`scheduleTTSReminder: ${type} skipped - user active since notification`);
244
- pendingReminders.delete(type);
245
- return;
246
- }
247
-
248
- debugLog(`scheduleTTSReminder: firing ${type} TTS reminder (count=${reminder?.itemCount || 1})`);
249
-
250
- // Get the appropriate reminder message
251
- // For permissions/questions with count > 1, use the count-aware message generator
252
- const storedCount = reminder?.itemCount || 1;
253
- let reminderMessage;
254
- if (type === 'permission') {
255
- reminderMessage = await getPermissionMessage(storedCount, true);
256
- } else if (type === 'question') {
257
- reminderMessage = await getQuestionMessage(storedCount, true);
258
- } else {
259
- reminderMessage = await getSmartMessage('idle', true, config.idleReminderTTSMessages);
260
- }
261
-
262
- // Check for ElevenLabs API key configuration issues
263
- // If user hasn't responded (reminder firing) and config is missing, warn about fallback
264
- if (config.ttsEngine === 'elevenlabs' && (!config.elevenLabsApiKey || config.elevenLabsApiKey.trim() === '')) {
265
- debugLog('ElevenLabs API key missing during reminder - showing fallback toast');
266
- await showToast("⚠️ ElevenLabs API Key missing! Falling back to Edge TTS.", "warning", 6000);
267
- }
268
-
269
- // Speak the reminder using TTS
270
- await tts.wakeMonitor();
271
- await tts.forceVolume();
272
- await tts.speak(reminderMessage, {
273
- enableTTS: true,
274
- fallbackSound: options.fallbackSound
275
- });
276
-
277
- // CRITICAL FIX: Check if cancelled during playback (user responded while TTS was speaking)
278
- if (!pendingReminders.has(type)) {
279
- debugLog(`scheduleTTSReminder: ${type} cancelled during playback - aborting follow-up`);
280
- return;
281
- }
282
-
283
- // Clean up
284
- pendingReminders.delete(type);
285
-
286
- // Schedule follow-up reminder if configured (exponential backoff or fixed)
287
- if (config.enableFollowUpReminders) {
288
- const followUpCount = (reminder?.followUpCount || 0) + 1;
289
- const maxFollowUps = config.maxFollowUpReminders || 3;
290
-
291
- if (followUpCount < maxFollowUps) {
292
- // Schedule another reminder with optional backoff
293
- const backoffMultiplier = config.reminderBackoffMultiplier || 1.5;
294
- const nextDelay = delaySeconds * Math.pow(backoffMultiplier, followUpCount);
295
-
296
- debugLog(`scheduleTTSReminder: scheduling follow-up ${followUpCount + 1}/${maxFollowUps} in ${nextDelay}s`);
297
-
298
- const followUpTimeoutId = setTimeout(async () => {
299
- const followUpReminder = pendingReminders.get(type);
300
- if (!followUpReminder || lastUserActivityTime > followUpReminder.scheduledAt) {
301
- pendingReminders.delete(type);
302
- return;
303
- }
304
-
305
- // Use count-aware message for follow-ups too
306
- const followUpStoredCount = followUpReminder?.itemCount || 1;
307
- let followUpMessage;
308
- if (type === 'permission') {
309
- followUpMessage = await getPermissionMessage(followUpStoredCount, true);
310
- } else if (type === 'question') {
311
- followUpMessage = await getQuestionMessage(followUpStoredCount, true);
312
- } else {
313
- followUpMessage = await getSmartMessage('idle', true, config.idleReminderTTSMessages);
314
- }
315
-
316
- await tts.wakeMonitor();
317
- await tts.forceVolume();
318
- await tts.speak(followUpMessage, {
319
- enableTTS: true,
320
- fallbackSound: options.fallbackSound
321
- });
322
-
323
- pendingReminders.delete(type);
324
- }, nextDelay * 1000);
325
-
326
- pendingReminders.set(type, {
327
- timeoutId: followUpTimeoutId,
328
- scheduledAt: Date.now(),
329
- followUpCount,
330
- itemCount: storedCount // Preserve the count for follow-ups
331
- });
332
- }
333
- }
334
- } catch (e) {
335
- debugLog(`scheduleTTSReminder error: ${e.message}`);
336
- pendingReminders.delete(type);
337
- }
338
- }, delayMs);
339
-
340
- // Store the pending reminder with item count
341
- pendingReminders.set(type, {
342
- timeoutId,
343
- scheduledAt: Date.now(),
344
- followUpCount: 0,
345
- itemCount // Store count for later use
346
- });
347
- };
348
-
349
- /**
350
- * Smart notification: play sound first, then schedule TTS reminder
351
- * @param {string} type - 'idle', 'permission', or 'question'
352
- * @param {object} options - Notification options
353
- */
354
- const smartNotify = async (type, options = {}) => {
355
- const {
356
- soundFile,
357
- soundLoops = 1,
358
- ttsMessage,
359
- fallbackSound,
360
- permissionCount, // Support permission count for batched notifications
361
- questionCount // Support question count for batched notifications
362
- } = options;
363
-
364
- // Step 1: Play the immediate sound notification
365
- if (soundFile) {
366
- await playSound(soundFile, soundLoops);
367
- }
368
-
369
- // CRITICAL FIX: Check if user responded during sound playback
370
- // For idle notifications: check if there was new activity after the idle start
371
- if (type === 'idle' && lastUserActivityTime > lastSessionIdleTime) {
372
- debugLog(`smartNotify: user active during sound - aborting idle reminder`);
373
- return;
374
- }
375
- // For permission notifications: check if the permission was already handled
376
- if (type === 'permission' && !activePermissionId) {
377
- debugLog(`smartNotify: permission handled during sound - aborting reminder`);
378
- return;
379
- }
380
- // For question notifications: check if the question was already answered/rejected
381
- if (type === 'question' && !activeQuestionId) {
382
- debugLog(`smartNotify: question handled during sound - aborting reminder`);
383
- return;
384
- }
385
-
386
- // Step 2: Schedule TTS reminder if user doesn't respond
387
- if (config.enableTTSReminder && ttsMessage) {
388
- scheduleTTSReminder(type, ttsMessage, { fallbackSound, permissionCount, questionCount });
389
- }
390
-
391
- // Step 3: If TTS-first mode is enabled, also speak immediately
392
- if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
393
- let immediateMessage;
394
- if (type === 'permission') {
395
- immediateMessage = await getSmartMessage('permission', false, config.permissionTTSMessages);
396
- } else if (type === 'question') {
397
- immediateMessage = await getSmartMessage('question', false, config.questionTTSMessages);
398
- } else {
399
- immediateMessage = await getSmartMessage('idle', false, config.idleTTSMessages);
400
- }
401
-
402
- await tts.speak(immediateMessage, {
403
- enableTTS: true,
404
- fallbackSound
405
- });
406
- }
407
- };
408
-
409
- /**
410
- * Get a count-aware TTS message for permission requests
411
- * Uses AI generation when enabled, falls back to static messages
412
- * @param {number} count - Number of permission requests
413
- * @param {boolean} isReminder - Whether this is a reminder message
414
- * @returns {Promise<string>} The formatted message
415
- */
416
- const getPermissionMessage = async (count, isReminder = false) => {
417
- const messages = isReminder
418
- ? config.permissionReminderTTSMessages
419
- : config.permissionTTSMessages;
420
-
421
- // If AI messages are enabled, ALWAYS try AI first (regardless of count)
422
- if (config.enableAIMessages) {
423
- const aiMessage = await getSmartMessage('permission', isReminder, messages, { count, type: 'permission' });
424
- // getSmartMessage returns static message as fallback, so if AI was attempted
425
- // and succeeded, we'll get the AI message. If it failed, we get static.
426
- // Check if we got a valid message (not the generic fallback)
427
- if (aiMessage && aiMessage !== 'Notification') {
428
- return aiMessage;
429
- }
430
- }
431
-
432
- // Fallback to static messages (AI disabled or failed with generic fallback)
433
- if (count === 1) {
434
- return getRandomMessage(messages);
435
- } else {
436
- const countMessages = isReminder
437
- ? config.permissionReminderTTSMessagesMultiple
438
- : config.permissionTTSMessagesMultiple;
439
-
440
- if (countMessages && countMessages.length > 0) {
441
- const template = getRandomMessage(countMessages);
442
- return template.replace('{count}', count.toString());
443
- }
444
- return `Attention! There are ${count} permission requests waiting for your approval.`;
445
- }
446
- };
447
-
448
- /**
449
- * Get a count-aware TTS message for question requests (SDK v1.1.7+)
450
- * Uses AI generation when enabled, falls back to static messages
451
- * @param {number} count - Number of question requests
452
- * @param {boolean} isReminder - Whether this is a reminder message
453
- * @returns {Promise<string>} The formatted message
454
- */
455
- const getQuestionMessage = async (count, isReminder = false) => {
456
- const messages = isReminder
457
- ? config.questionReminderTTSMessages
458
- : config.questionTTSMessages;
459
-
460
- // If AI messages are enabled, ALWAYS try AI first (regardless of count)
461
- if (config.enableAIMessages) {
462
- const aiMessage = await getSmartMessage('question', isReminder, messages, { count, type: 'question' });
463
- // getSmartMessage returns static message as fallback, so if AI was attempted
464
- // and succeeded, we'll get the AI message. If it failed, we get static.
465
- // Check if we got a valid message (not the generic fallback)
466
- if (aiMessage && aiMessage !== 'Notification') {
467
- return aiMessage;
468
- }
469
- }
470
-
471
- // Fallback to static messages (AI disabled or failed with generic fallback)
472
- if (count === 1) {
473
- return getRandomMessage(messages);
474
- } else {
475
- const countMessages = isReminder
476
- ? config.questionReminderTTSMessagesMultiple
477
- : config.questionTTSMessagesMultiple;
478
-
479
- if (countMessages && countMessages.length > 0) {
480
- const template = getRandomMessage(countMessages);
481
- return template.replace('{count}', count.toString());
482
- }
483
- return `Hey! I have ${count} questions for you. Please check your screen.`;
484
- }
485
- };
486
-
487
- /**
488
- * Process the batched permission requests as a single notification
489
- * Called after the batch window expires
490
- *
491
- * FIX: Play sound IMMEDIATELY before any AI generation to avoid delay.
492
- * AI message generation can take 3-15+ seconds, which was delaying sound playback.
493
- */
494
- const processPermissionBatch = async () => {
495
- // Capture and clear the batch
496
- const batch = [...pendingPermissionBatch];
497
- const batchCount = batch.length;
498
- pendingPermissionBatch = [];
499
- permissionBatchTimeout = null;
500
-
501
- if (batchCount === 0) {
502
- debugLog('processPermissionBatch: empty batch, skipping');
503
- return;
504
- }
505
-
506
- debugLog(`processPermissionBatch: processing ${batchCount} permission(s)`);
507
-
508
- // Set activePermissionId to the first one (for race condition checks)
509
- // We track all IDs in the batch for proper cleanup
510
- activePermissionId = batch[0];
511
-
512
- // Step 1: Show toast IMMEDIATELY (fire and forget - no await)
513
- const toastMessage = batchCount === 1
514
- ? "⚠️ Permission request requires your attention"
515
- : `⚠️ ${batchCount} permission requests require your attention`;
516
- showToast(toastMessage, "warning", 8000); // No await - instant display
517
-
518
- // Step 2: Play sound (after toast is triggered)
519
- const soundLoops = batchCount === 1 ? 2 : Math.min(3, batchCount);
520
- await playSound(config.permissionSound, soundLoops);
521
-
522
- // CHECK: Did user already respond while sound was playing?
523
- if (pendingPermissionBatch.length > 0) {
524
- // New permissions arrived during sound - they'll be handled in next batch
525
- debugLog('processPermissionBatch: new permissions arrived during sound');
526
- }
527
-
528
- // Step 3: Check race condition - did user respond during sound?
529
- if (activePermissionId === null) {
530
- debugLog('processPermissionBatch: user responded during sound - aborting');
531
- return;
532
- }
533
-
534
- // Step 4: Generate AI message for reminder AFTER sound played
535
- const reminderMessage = await getPermissionMessage(batchCount, true);
536
-
537
- // Step 5: Schedule TTS reminder if enabled
538
- if (config.enableTTSReminder && reminderMessage) {
539
- scheduleTTSReminder('permission', reminderMessage, {
540
- fallbackSound: config.permissionSound,
541
- permissionCount: batchCount
542
- });
543
- }
544
-
545
- // Step 6: If TTS-first or both mode, generate and speak immediate message
546
- if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
547
- const ttsMessage = await getPermissionMessage(batchCount, false);
548
- await tts.wakeMonitor();
549
- await tts.forceVolume();
550
- await tts.speak(ttsMessage, {
551
- enableTTS: true,
552
- fallbackSound: config.permissionSound
553
- });
554
- }
555
-
556
- // Final check: if user responded during notification, cancel scheduled reminder
557
- if (activePermissionId === null) {
558
- debugLog('processPermissionBatch: user responded during notification - cancelling reminder');
559
- cancelPendingReminder('permission');
560
- }
561
- };
562
-
563
- /**
564
- * Process the batched question requests as a single notification (SDK v1.1.7+)
565
- * Called after the batch window expires
566
- *
567
- * FIX: Play sound IMMEDIATELY before any AI generation to avoid delay.
568
- * AI message generation can take 3-15+ seconds, which was delaying sound playback.
569
- */
570
- const processQuestionBatch = async () => {
571
- // Capture and clear the batch
572
- const batch = [...pendingQuestionBatch];
573
- pendingQuestionBatch = [];
574
- questionBatchTimeout = null;
575
-
576
- if (batch.length === 0) {
577
- debugLog('processQuestionBatch: empty batch, skipping');
578
- return;
579
- }
580
-
581
- // Calculate total number of questions across all batched requests
582
- // Each batch item is { id, questionCount } where questionCount is the number of questions in that request
583
- const totalQuestionCount = batch.reduce((sum, item) => sum + (item.questionCount || 1), 0);
584
-
585
- debugLog(`processQuestionBatch: processing ${batch.length} request(s) with ${totalQuestionCount} total question(s)`);
586
-
587
- // Set activeQuestionId to the first one (for race condition checks)
588
- // We track all IDs in the batch for proper cleanup
589
- activeQuestionId = batch[0]?.id;
590
-
591
- // Step 1: Show toast IMMEDIATELY (fire and forget - no await)
592
- const toastMessage = totalQuestionCount === 1
593
- ? "❓ The agent has a question for you"
594
- : `❓ The agent has ${totalQuestionCount} questions for you`;
595
- showToast(toastMessage, "info", 8000); // No await - instant display
596
-
597
- // Step 2: Play sound (after toast is triggered)
598
- await playSound(config.questionSound, 2);
599
-
600
- // CHECK: Did user already respond while sound was playing?
601
- if (pendingQuestionBatch.length > 0) {
602
- // New questions arrived during sound - they'll be handled in next batch
603
- debugLog('processQuestionBatch: new questions arrived during sound');
604
- }
605
-
606
- // Step 3: Check race condition - did user respond during sound?
607
- if (activeQuestionId === null) {
608
- debugLog('processQuestionBatch: user responded during sound - aborting');
609
- return;
610
- }
611
-
612
- // Step 4: Generate AI message for reminder AFTER sound played
613
- const reminderMessage = await getQuestionMessage(totalQuestionCount, true);
614
-
615
- // Step 5: Schedule TTS reminder if enabled
616
- if (config.enableTTSReminder && reminderMessage) {
617
- scheduleTTSReminder('question', reminderMessage, {
618
- fallbackSound: config.questionSound,
619
- questionCount: totalQuestionCount
620
- });
621
- }
622
-
623
- // Step 6: If TTS-first or both mode, generate and speak immediate message
624
- if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
625
- const ttsMessage = await getQuestionMessage(totalQuestionCount, false);
626
- await tts.wakeMonitor();
627
- await tts.forceVolume();
628
- await tts.speak(ttsMessage, {
629
- enableTTS: true,
630
- fallbackSound: config.questionSound
631
- });
632
- }
633
-
634
- // Final check: if user responded during notification, cancel scheduled reminder
635
- if (activeQuestionId === null) {
636
- debugLog('processQuestionBatch: user responded during notification - cancelling reminder');
637
- cancelPendingReminder('question');
638
- }
639
- };
640
-
641
- return {
642
- event: async ({ event }) => {
643
- try {
644
- // ========================================
645
- // USER ACTIVITY DETECTION
646
- // Cancels pending TTS reminders when user responds
647
- // ========================================
648
- // NOTE: OpenCode event types (supporting SDK v1.0.x, v1.1.x, and v1.1.7+):
649
- // - message.updated: fires when a message is added/updated (use properties.info.role to check user vs assistant)
650
- // - permission.updated (SDK v1.0.x): fires when a permission request is created
651
- // - permission.asked (SDK v1.1.1+): fires when a permission request is created (replaces permission.updated)
652
- // - permission.replied: fires when user responds to a permission request
653
- // - SDK v1.0.x: uses permissionID, response
654
- // - SDK v1.1.1+: uses requestID, reply
655
- // - question.asked (SDK v1.1.7+): fires when agent asks user a question
656
- // - question.replied (SDK v1.1.7+): fires when user answers a question
657
- // - question.rejected (SDK v1.1.7+): fires when user dismisses a question
658
- // - session.created: fires when a new session starts
659
- //
660
- // CRITICAL: message.updated fires for EVERY modification to a message (not just creation).
661
- // Context-injector and other plugins can trigger multiple updates for the same message.
662
- // We must only treat NEW user messages (after session.idle) as actual user activity.
663
-
664
- if (event.type === "message.updated") {
665
- const messageInfo = event.properties?.info;
666
- const messageId = messageInfo?.id;
667
- const isUserMessage = messageInfo?.role === 'user';
668
-
669
- if (isUserMessage && messageId) {
670
- // Check if this is a NEW user message we haven't seen before
671
- const isNewMessage = !seenUserMessageIds.has(messageId);
672
-
673
- // Check if this message arrived AFTER the last session.idle
674
- // This is the key: only a message sent AFTER idle indicates user responded
675
- const messageTime = messageInfo?.time?.created;
676
- const isAfterIdle = lastSessionIdleTime > 0 && messageTime && (messageTime * 1000) > lastSessionIdleTime;
677
-
678
- if (isNewMessage) {
679
- seenUserMessageIds.add(messageId);
680
-
681
- // Only cancel reminders if this is a NEW message AFTER session went idle
682
- // OR if there are no pending reminders (initial message before any notifications)
683
- if (isAfterIdle || pendingReminders.size === 0) {
684
- if (isAfterIdle) {
685
- lastUserActivityTime = Date.now();
686
- cancelAllPendingReminders();
687
- debugLog(`NEW user message AFTER idle: ${messageId} - cancelled pending reminders`);
688
- } else {
689
- debugLog(`Initial user message (before any idle): ${messageId} - no reminders to cancel`);
690
- }
691
- } else {
692
- debugLog(`Ignored: user message ${messageId} created BEFORE session.idle (time=${messageTime}, idleTime=${lastSessionIdleTime})`);
693
- }
694
- } else {
695
- // This is an UPDATE to an existing message (e.g., context injection)
696
- debugLog(`Ignored: update to existing user message ${messageId} (not new activity)`);
697
- }
698
- }
699
- }
700
-
701
- if (event.type === "permission.replied") {
702
- // User responded to a permission request (granted or denied)
703
- // Structure varies by SDK version:
704
- // - Old SDK: event.properties.{ sessionID, permissionID, response }
705
- // - New SDK (v1.1.1+): event.properties.{ sessionID, requestID, reply }
706
- // CRITICAL: Clear activePermissionId FIRST to prevent race condition
707
- // where permission.updated/asked handler is still running async operations
708
- const repliedPermissionId = event.properties?.permissionID || event.properties?.requestID;
709
- const response = event.properties?.response || event.properties?.reply;
710
-
711
- // Remove this permission from the pending batch (if still waiting)
712
- if (repliedPermissionId && pendingPermissionBatch.includes(repliedPermissionId)) {
713
- pendingPermissionBatch = pendingPermissionBatch.filter(id => id !== repliedPermissionId);
714
- debugLog(`Permission replied: removed ${repliedPermissionId} from pending batch (${pendingPermissionBatch.length} remaining)`);
715
- }
716
-
717
- // If batch is now empty and we have a pending batch timeout, we can cancel it
718
- // (user responded to all permissions before batch window expired)
719
- if (pendingPermissionBatch.length === 0 && permissionBatchTimeout) {
720
- clearTimeout(permissionBatchTimeout);
721
- permissionBatchTimeout = null;
722
- debugLog('Permission replied: cancelled batch timeout (all permissions handled)');
723
- }
724
-
725
- // Match if IDs are equal, or if we have an active permission with unknown ID (undefined)
726
- // (This happens if permission.updated/asked received an event without permissionID)
727
- if (activePermissionId === repliedPermissionId || activePermissionId === undefined) {
728
- activePermissionId = null;
729
- debugLog(`Permission replied: cleared activePermissionId ${repliedPermissionId || '(unknown)'}`);
730
- }
731
- lastUserActivityTime = Date.now();
732
- cancelPendingReminder('permission'); // Cancel permission-specific reminder
733
- debugLog(`Permission replied: ${event.type} (response=${response}) - cancelled permission reminder`);
734
- }
735
-
736
- if (event.type === "session.created") {
737
- // New session started - reset tracking state
738
- lastUserActivityTime = Date.now();
739
- lastSessionIdleTime = 0;
740
- activePermissionId = null;
741
- activeQuestionId = null;
742
- seenUserMessageIds.clear();
743
- cancelAllPendingReminders();
744
-
745
- // Reset permission batch state
746
- pendingPermissionBatch = [];
747
- if (permissionBatchTimeout) {
748
- clearTimeout(permissionBatchTimeout);
749
- permissionBatchTimeout = null;
750
- }
751
-
752
- // Reset question batch state
753
- pendingQuestionBatch = [];
754
- if (questionBatchTimeout) {
755
- clearTimeout(questionBatchTimeout);
756
- questionBatchTimeout = null;
757
- }
758
-
759
- debugLog(`Session created: ${event.type} - reset all tracking state`);
760
- }
761
-
762
- // ========================================
763
- // NOTIFICATION 1: Session Idle (Agent Finished)
764
- //
765
- // FIX: Play sound IMMEDIATELY before any AI generation to avoid delay.
766
- // AI message generation can take 3-15+ seconds, which was delaying sound playback.
767
- // ========================================
768
- if (event.type === "session.idle") {
769
- const sessionID = event.properties?.sessionID;
770
- if (!sessionID) return;
771
-
772
- try {
773
- const session = await client.session.get({ path: { id: sessionID } });
774
- if (session?.data?.parentID) {
775
- debugLog(`session.idle: skipped (sub-session ${sessionID})`);
776
- return;
777
- }
778
- } catch (e) {}
779
-
780
- // Record the time session went idle - used to filter out pre-idle messages
781
- lastSessionIdleTime = Date.now();
782
-
783
- debugLog(`session.idle: notifying for session ${sessionID} (idleTime=${lastSessionIdleTime})`);
784
-
785
- // Step 1: Show toast IMMEDIATELY (fire and forget - no await)
786
- showToast("✅ Agent has finished working", "success", 5000); // No await - instant display
787
-
788
- // Step 2: Play sound (after toast is triggered)
789
- // Only play sound in sound-first, sound-only, or both mode
790
- if (config.notificationMode !== 'tts-first') {
791
- await playSound(config.idleSound, 1);
792
- }
793
-
794
- // Step 3: Check race condition - did user respond during sound?
795
- if (lastUserActivityTime > lastSessionIdleTime) {
796
- debugLog(`session.idle: user active during sound - aborting`);
797
- return;
798
- }
799
-
800
- // Step 4: Generate AI message for reminder AFTER sound played
801
- const reminderMessage = await getSmartMessage('idle', true, config.idleReminderTTSMessages);
802
-
803
- // Step 5: Schedule TTS reminder if enabled
804
- if (config.enableTTSReminder && reminderMessage) {
805
- scheduleTTSReminder('idle', reminderMessage, {
806
- fallbackSound: config.idleSound
807
- });
808
- }
809
-
810
- // Step 6: If TTS-first or both mode, generate and speak immediate message
811
- if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
812
- const ttsMessage = await getSmartMessage('idle', false, config.idleTTSMessages);
813
- await tts.wakeMonitor();
814
- await tts.forceVolume();
815
- await tts.speak(ttsMessage, {
816
- enableTTS: true,
817
- fallbackSound: config.idleSound
818
- });
819
- }
820
- }
821
-
822
- // ========================================
823
- // NOTIFICATION 2: Permission Request (BATCHED)
824
- // ========================================
825
- // NOTE: OpenCode SDK v1.1.1+ changed permission events:
826
- // - Old: "permission.updated" with properties.id
827
- // - New: "permission.asked" with properties.id
828
- // We support both for backward compatibility.
829
- //
830
- // BATCHING: When multiple permissions arrive simultaneously (e.g., 5 at once),
831
- // we batch them into a single notification instead of playing 5 overlapping sounds.
832
- if (event.type === "permission.updated" || event.type === "permission.asked") {
833
- // Capture permissionID
834
- const permissionId = event.properties?.id;
835
-
836
- if (!permissionId) {
837
- debugLog(`${event.type}: permission ID missing. properties keys: ` + Object.keys(event.properties || {}).join(', '));
838
- }
839
-
840
- // Add to the pending batch (avoid duplicates)
841
- if (permissionId && !pendingPermissionBatch.includes(permissionId)) {
842
- pendingPermissionBatch.push(permissionId);
843
- debugLog(`${event.type}: added ${permissionId} to batch (now ${pendingPermissionBatch.length} pending)`);
844
- } else if (!permissionId) {
845
- // If no ID, still count it (use a placeholder)
846
- pendingPermissionBatch.push(`unknown-${Date.now()}`);
847
- debugLog(`${event.type}: added unknown permission to batch (now ${pendingPermissionBatch.length} pending)`);
848
- }
849
-
850
- // Reset the batch window timer (debounce)
851
- // This gives more permissions a chance to arrive before we notify
852
- if (permissionBatchTimeout) {
853
- clearTimeout(permissionBatchTimeout);
854
- }
855
-
856
- permissionBatchTimeout = setTimeout(async () => {
857
- try {
858
- await processPermissionBatch();
859
- } catch (e) {
860
- debugLog(`processPermissionBatch error: ${e.message}`);
861
- }
862
- }, PERMISSION_BATCH_WINDOW_MS);
863
-
864
- debugLog(`${event.type}: batch window reset (will process in ${PERMISSION_BATCH_WINDOW_MS}ms if no more arrive)`);
865
- }
866
-
867
- // ========================================
868
- // NOTIFICATION 3: Question Request (BATCHED) - SDK v1.1.7+
869
- // ========================================
870
- // The "question" tool allows the LLM to ask users questions during execution.
871
- // Events: question.asked, question.replied, question.rejected
872
- //
873
- // BATCHING: When multiple question requests arrive simultaneously,
874
- // we batch them into a single notification instead of playing overlapping sounds.
875
- // NOTE: Each question.asked event can contain multiple questions in its questions array.
876
- if (event.type === "question.asked") {
877
- // Capture question request ID and count of questions in this request
878
- const questionId = event.properties?.id;
879
- const questionsArray = event.properties?.questions;
880
- const questionCount = Array.isArray(questionsArray) ? questionsArray.length : 1;
881
-
882
- if (!questionId) {
883
- debugLog(`${event.type}: question ID missing. properties keys: ` + Object.keys(event.properties || {}).join(', '));
884
- }
885
-
886
- // Add to the pending batch (avoid duplicates by checking ID)
887
- // Store as object with id and questionCount for proper counting
888
- const existingIndex = pendingQuestionBatch.findIndex(item => item.id === questionId);
889
- if (questionId && existingIndex === -1) {
890
- pendingQuestionBatch.push({ id: questionId, questionCount });
891
- debugLog(`${event.type}: added ${questionId} with ${questionCount} question(s) to batch (now ${pendingQuestionBatch.length} request(s) pending)`);
892
- } else if (!questionId) {
893
- // If no ID, still count it (use a placeholder)
894
- pendingQuestionBatch.push({ id: `unknown-${Date.now()}`, questionCount });
895
- debugLog(`${event.type}: added unknown question request with ${questionCount} question(s) to batch (now ${pendingQuestionBatch.length} request(s) pending)`);
896
- }
897
-
898
- // Reset the batch window timer (debounce)
899
- // This gives more questions a chance to arrive before we notify
900
- if (questionBatchTimeout) {
901
- clearTimeout(questionBatchTimeout);
902
- }
903
-
904
- questionBatchTimeout = setTimeout(async () => {
905
- try {
906
- await processQuestionBatch();
907
- } catch (e) {
908
- debugLog(`processQuestionBatch error: ${e.message}`);
909
- }
910
- }, QUESTION_BATCH_WINDOW_MS);
911
-
912
- debugLog(`${event.type}: batch window reset (will process in ${QUESTION_BATCH_WINDOW_MS}ms if no more arrive)`);
913
- }
914
-
915
- // Handle question.replied - user answered the question(s)
916
- if (event.type === "question.replied") {
917
- const repliedQuestionId = event.properties?.requestID;
918
- const answers = event.properties?.answers;
919
-
920
- // Remove this question from the pending batch (if still waiting)
921
- // pendingQuestionBatch is now an array of { id, questionCount } objects
922
- const existingIndex = pendingQuestionBatch.findIndex(item => item.id === repliedQuestionId);
923
- if (repliedQuestionId && existingIndex !== -1) {
924
- pendingQuestionBatch.splice(existingIndex, 1);
925
- debugLog(`Question replied: removed ${repliedQuestionId} from pending batch (${pendingQuestionBatch.length} remaining)`);
926
- }
927
-
928
- // If batch is now empty and we have a pending batch timeout, we can cancel it
929
- if (pendingQuestionBatch.length === 0 && questionBatchTimeout) {
930
- clearTimeout(questionBatchTimeout);
931
- questionBatchTimeout = null;
932
- debugLog('Question replied: cancelled batch timeout (all questions handled)');
933
- }
934
-
935
- // Clear active question ID
936
- if (activeQuestionId === repliedQuestionId || activeQuestionId === undefined) {
937
- activeQuestionId = null;
938
- debugLog(`Question replied: cleared activeQuestionId ${repliedQuestionId || '(unknown)'}`);
939
- }
940
- lastUserActivityTime = Date.now();
941
- cancelPendingReminder('question'); // Cancel question-specific reminder
942
- debugLog(`Question replied: ${event.type} (answers=${JSON.stringify(answers)}) - cancelled question reminder`);
943
- }
944
-
945
- // Handle question.rejected - user dismissed the question
946
- if (event.type === "question.rejected") {
947
- const rejectedQuestionId = event.properties?.requestID;
948
-
949
- // Remove this question from the pending batch (if still waiting)
950
- // pendingQuestionBatch is now an array of { id, questionCount } objects
951
- const existingIndex = pendingQuestionBatch.findIndex(item => item.id === rejectedQuestionId);
952
- if (rejectedQuestionId && existingIndex !== -1) {
953
- pendingQuestionBatch.splice(existingIndex, 1);
954
- debugLog(`Question rejected: removed ${rejectedQuestionId} from pending batch (${pendingQuestionBatch.length} remaining)`);
955
- }
956
-
957
- // If batch is now empty and we have a pending batch timeout, we can cancel it
958
- if (pendingQuestionBatch.length === 0 && questionBatchTimeout) {
959
- clearTimeout(questionBatchTimeout);
960
- questionBatchTimeout = null;
961
- debugLog('Question rejected: cancelled batch timeout (all questions handled)');
962
- }
963
-
964
- // Clear active question ID
965
- if (activeQuestionId === rejectedQuestionId || activeQuestionId === undefined) {
966
- activeQuestionId = null;
967
- debugLog(`Question rejected: cleared activeQuestionId ${rejectedQuestionId || '(unknown)'}`);
968
- }
969
- lastUserActivityTime = Date.now();
970
- cancelPendingReminder('question'); // Cancel question-specific reminder
971
- debugLog(`Question rejected: ${event.type} - cancelled question reminder`);
972
- }
973
- } catch (e) {
974
- debugLog(`event handler error: ${e.message}`);
975
- }
976
- },
977
- };
978
- }
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { createTTS, getTTSConfig } from './util/tts.js';
5
+ import { getSmartMessage } from './util/ai-messages.js';
6
+
7
+ /**
8
+ * OpenCode Smart Voice Notify Plugin
9
+ *
10
+ * A smart notification plugin with multiple TTS engines (auto-fallback):
11
+ * 1. ElevenLabs (Online, High Quality, Anime-like voices)
12
+ * 2. Edge TTS (Free, Neural voices)
13
+ * 3. Windows SAPI (Offline, Built-in)
14
+ * 4. Local Sound Files (Fallback)
15
+ *
16
+ * Features:
17
+ * - Smart notification mode (sound-first, tts-first, both, sound-only)
18
+ * - Delayed TTS reminders if user doesn't respond
19
+ * - Follow-up reminders with exponential backoff
20
+ * - Monitor wake and volume boost
21
+ * - Cross-platform support (Windows, macOS, Linux)
22
+ *
23
+ * @type {import("@opencode-ai/plugin").Plugin}
24
+ */
25
+ export default async function SmartVoiceNotifyPlugin({ project, client, $, directory, worktree }) {
26
+ const config = getTTSConfig();
27
+
28
+ // Master switch: if plugin is disabled, return empty handlers immediately
29
+ if (config.enabled === false) {
30
+ const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
31
+ const logsDir = path.join(configDir, 'logs');
32
+ const logFile = path.join(logsDir, 'smart-voice-notify-debug.log');
33
+ if (config.debugLog) {
34
+ try {
35
+ if (!fs.existsSync(logsDir)) {
36
+ fs.mkdirSync(logsDir, { recursive: true });
37
+ }
38
+ const timestamp = new Date().toISOString();
39
+ fs.appendFileSync(logFile, `[${timestamp}] Plugin disabled via config (enabled: false) - no event handlers registered\n`);
40
+ } catch (e) {}
41
+ }
42
+ return {};
43
+ }
44
+
45
+ const tts = createTTS({ $, client });
46
+
47
+ const platform = os.platform();
48
+
49
+ const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
50
+ const logsDir = path.join(configDir, 'logs');
51
+ const logFile = path.join(logsDir, 'smart-voice-notify-debug.log');
52
+
53
+ // Ensure logs directory exists if debug logging is enabled
54
+ if (config.debugLog && !fs.existsSync(logsDir)) {
55
+ try {
56
+ fs.mkdirSync(logsDir, { recursive: true });
57
+ } catch (e) {
58
+ // Silently fail - logging is optional
59
+ }
60
+ }
61
+
62
+ // Track pending TTS reminders (can be cancelled if user responds)
63
+ const pendingReminders = new Map();
64
+
65
+ // Track last user activity time
66
+ let lastUserActivityTime = Date.now();
67
+
68
+ // Track seen user message IDs to avoid treating message UPDATES as new user activity
69
+ // Key insight: message.updated fires for EVERY modification to a message, not just new messages
70
+ // We only want to treat the FIRST occurrence of each user message as "user activity"
71
+ const seenUserMessageIds = new Set();
72
+
73
+ // Track the timestamp of when session went idle, to detect post-idle user messages
74
+ let lastSessionIdleTime = 0;
75
+
76
+ // Track active permission request to prevent race condition where user responds
77
+ // before async notification code runs. Set on permission.updated, cleared on permission.replied.
78
+ let activePermissionId = null;
79
+
80
+ // ========================================
81
+ // PERMISSION BATCHING STATE
82
+ // Batches multiple simultaneous permission requests into a single notification
83
+ // ========================================
84
+
85
+ // Array of permission IDs waiting to be notified (collected during batch window)
86
+ let pendingPermissionBatch = [];
87
+
88
+ // Timeout ID for the batch window (debounce timer)
89
+ let permissionBatchTimeout = null;
90
+
91
+ // Batch window duration in milliseconds (how long to wait for more permissions)
92
+ const PERMISSION_BATCH_WINDOW_MS = config.permissionBatchWindowMs || 800;
93
+
94
+ // ========================================
95
+ // QUESTION BATCHING STATE (SDK v1.1.7+)
96
+ // Batches multiple simultaneous question requests into a single notification
97
+ // ========================================
98
+
99
+ // Array of question request objects waiting to be notified (collected during batch window)
100
+ // Each object contains { id: string, questionCount: number } to track actual question count
101
+ let pendingQuestionBatch = [];
102
+
103
+ // Timeout ID for the question batch window (debounce timer)
104
+ let questionBatchTimeout = null;
105
+
106
+ // Batch window duration in milliseconds (how long to wait for more questions)
107
+ const QUESTION_BATCH_WINDOW_MS = config.questionBatchWindowMs || 800;
108
+
109
+ // Track active question request to prevent race condition where user responds
110
+ // before async notification code runs. Set on question.asked, cleared on question.replied/rejected.
111
+ let activeQuestionId = null;
112
+
113
+ /**
114
+ * Write debug message to log file
115
+ */
116
+ const debugLog = (message) => {
117
+ if (!config.debugLog) return;
118
+ try {
119
+ const timestamp = new Date().toISOString();
120
+ fs.appendFileSync(logFile, `[${timestamp}] ${message}\n`);
121
+ } catch (e) {}
122
+ };
123
+
124
+ /**
125
+ * Get a random message from an array of messages
126
+ */
127
+ const getRandomMessage = (messages) => {
128
+ if (!Array.isArray(messages) || messages.length === 0) {
129
+ return 'Notification';
130
+ }
131
+ return messages[Math.floor(Math.random() * messages.length)];
132
+ };
133
+
134
+ /**
135
+ * Show a TUI toast notification
136
+ */
137
+ const showToast = async (message, variant = 'info', duration = 5000) => {
138
+ if (!config.enableToast) return;
139
+ try {
140
+ if (typeof client?.tui?.showToast === 'function') {
141
+ await client.tui.showToast({
142
+ body: {
143
+ message: message,
144
+ variant: variant,
145
+ duration: duration
146
+ }
147
+ });
148
+ }
149
+ } catch (e) {}
150
+ };
151
+
152
+ /**
153
+ * Play a sound file from assets
154
+ */
155
+ const playSound = async (soundFile, loops = 1) => {
156
+ if (!config.enableSound) return;
157
+ try {
158
+ const soundPath = path.isAbsolute(soundFile)
159
+ ? soundFile
160
+ : path.join(configDir, soundFile);
161
+
162
+ if (!fs.existsSync(soundPath)) {
163
+ debugLog(`playSound: file not found: ${soundPath}`);
164
+ return;
165
+ }
166
+
167
+ await tts.wakeMonitor();
168
+ await tts.forceVolume();
169
+ await tts.playAudioFile(soundPath, loops);
170
+ debugLog(`playSound: played ${soundPath} (${loops}x)`);
171
+ } catch (e) {
172
+ debugLog(`playSound error: ${e.message}`);
173
+ }
174
+ };
175
+
176
+ /**
177
+ * Cancel any pending TTS reminder for a given type
178
+ */
179
+ const cancelPendingReminder = (type) => {
180
+ const existing = pendingReminders.get(type);
181
+ if (existing) {
182
+ clearTimeout(existing.timeoutId);
183
+ pendingReminders.delete(type);
184
+ debugLog(`cancelPendingReminder: cancelled ${type}`);
185
+ }
186
+ };
187
+
188
+ /**
189
+ * Cancel all pending TTS reminders (called on user activity)
190
+ */
191
+ const cancelAllPendingReminders = () => {
192
+ for (const [type, reminder] of pendingReminders.entries()) {
193
+ clearTimeout(reminder.timeoutId);
194
+ debugLog(`cancelAllPendingReminders: cancelled ${type}`);
195
+ }
196
+ pendingReminders.clear();
197
+ };
198
+
199
+ /**
200
+ * Schedule a TTS reminder if user doesn't respond within configured delay.
201
+ * The reminder uses a personalized TTS message.
202
+ * @param {string} type - 'idle', 'permission', or 'question'
203
+ * @param {string} message - The TTS message to speak (used directly, supports count-aware messages)
204
+ * @param {object} options - Additional options (fallbackSound, permissionCount, questionCount)
205
+ */
206
+ const scheduleTTSReminder = (type, message, options = {}) => {
207
+ // Check if TTS reminders are enabled
208
+ if (!config.enableTTSReminder) {
209
+ debugLog(`scheduleTTSReminder: TTS reminders disabled`);
210
+ return;
211
+ }
212
+
213
+ // Get delay from config (in seconds, convert to ms)
214
+ let delaySeconds;
215
+ if (type === 'permission') {
216
+ delaySeconds = config.permissionReminderDelaySeconds || config.ttsReminderDelaySeconds || 30;
217
+ } else if (type === 'question') {
218
+ delaySeconds = config.questionReminderDelaySeconds || config.ttsReminderDelaySeconds || 25;
219
+ } else {
220
+ delaySeconds = config.idleReminderDelaySeconds || config.ttsReminderDelaySeconds || 30;
221
+ }
222
+ const delayMs = delaySeconds * 1000;
223
+
224
+ // Cancel any existing reminder of this type
225
+ cancelPendingReminder(type);
226
+
227
+ // Store count for generating count-aware messages in reminders
228
+ const itemCount = options.permissionCount || options.questionCount || 1;
229
+
230
+ debugLog(`scheduleTTSReminder: scheduling ${type} TTS in ${delaySeconds}s (count=${itemCount})`);
231
+
232
+ const timeoutId = setTimeout(async () => {
233
+ try {
234
+ // Check if reminder was cancelled (user responded)
235
+ if (!pendingReminders.has(type)) {
236
+ debugLog(`scheduleTTSReminder: ${type} was cancelled before firing`);
237
+ return;
238
+ }
239
+
240
+ // Check if user has been active since notification
241
+ const reminder = pendingReminders.get(type);
242
+ if (reminder && lastUserActivityTime > reminder.scheduledAt) {
243
+ debugLog(`scheduleTTSReminder: ${type} skipped - user active since notification`);
244
+ pendingReminders.delete(type);
245
+ return;
246
+ }
247
+
248
+ debugLog(`scheduleTTSReminder: firing ${type} TTS reminder (count=${reminder?.itemCount || 1})`);
249
+
250
+ // Get the appropriate reminder message
251
+ // For permissions/questions with count > 1, use the count-aware message generator
252
+ const storedCount = reminder?.itemCount || 1;
253
+ let reminderMessage;
254
+ if (type === 'permission') {
255
+ reminderMessage = await getPermissionMessage(storedCount, true);
256
+ } else if (type === 'question') {
257
+ reminderMessage = await getQuestionMessage(storedCount, true);
258
+ } else {
259
+ reminderMessage = await getSmartMessage('idle', true, config.idleReminderTTSMessages);
260
+ }
261
+
262
+ // Check for ElevenLabs API key configuration issues
263
+ // If user hasn't responded (reminder firing) and config is missing, warn about fallback
264
+ if (config.ttsEngine === 'elevenlabs' && (!config.elevenLabsApiKey || config.elevenLabsApiKey.trim() === '')) {
265
+ debugLog('ElevenLabs API key missing during reminder - showing fallback toast');
266
+ await showToast("⚠️ ElevenLabs API Key missing! Falling back to Edge TTS.", "warning", 6000);
267
+ }
268
+
269
+ // Speak the reminder using TTS
270
+ await tts.wakeMonitor();
271
+ await tts.forceVolume();
272
+ await tts.speak(reminderMessage, {
273
+ enableTTS: true,
274
+ fallbackSound: options.fallbackSound
275
+ });
276
+
277
+ // CRITICAL FIX: Check if cancelled during playback (user responded while TTS was speaking)
278
+ if (!pendingReminders.has(type)) {
279
+ debugLog(`scheduleTTSReminder: ${type} cancelled during playback - aborting follow-up`);
280
+ return;
281
+ }
282
+
283
+ // Clean up
284
+ pendingReminders.delete(type);
285
+
286
+ // Schedule follow-up reminder if configured (exponential backoff or fixed)
287
+ if (config.enableFollowUpReminders) {
288
+ const followUpCount = (reminder?.followUpCount || 0) + 1;
289
+ const maxFollowUps = config.maxFollowUpReminders || 3;
290
+
291
+ if (followUpCount < maxFollowUps) {
292
+ // Schedule another reminder with optional backoff
293
+ const backoffMultiplier = config.reminderBackoffMultiplier || 1.5;
294
+ const nextDelay = delaySeconds * Math.pow(backoffMultiplier, followUpCount);
295
+
296
+ debugLog(`scheduleTTSReminder: scheduling follow-up ${followUpCount + 1}/${maxFollowUps} in ${nextDelay}s`);
297
+
298
+ const followUpTimeoutId = setTimeout(async () => {
299
+ const followUpReminder = pendingReminders.get(type);
300
+ if (!followUpReminder || lastUserActivityTime > followUpReminder.scheduledAt) {
301
+ pendingReminders.delete(type);
302
+ return;
303
+ }
304
+
305
+ // Use count-aware message for follow-ups too
306
+ const followUpStoredCount = followUpReminder?.itemCount || 1;
307
+ let followUpMessage;
308
+ if (type === 'permission') {
309
+ followUpMessage = await getPermissionMessage(followUpStoredCount, true);
310
+ } else if (type === 'question') {
311
+ followUpMessage = await getQuestionMessage(followUpStoredCount, true);
312
+ } else {
313
+ followUpMessage = await getSmartMessage('idle', true, config.idleReminderTTSMessages);
314
+ }
315
+
316
+ await tts.wakeMonitor();
317
+ await tts.forceVolume();
318
+ await tts.speak(followUpMessage, {
319
+ enableTTS: true,
320
+ fallbackSound: options.fallbackSound
321
+ });
322
+
323
+ pendingReminders.delete(type);
324
+ }, nextDelay * 1000);
325
+
326
+ pendingReminders.set(type, {
327
+ timeoutId: followUpTimeoutId,
328
+ scheduledAt: Date.now(),
329
+ followUpCount,
330
+ itemCount: storedCount // Preserve the count for follow-ups
331
+ });
332
+ }
333
+ }
334
+ } catch (e) {
335
+ debugLog(`scheduleTTSReminder error: ${e.message}`);
336
+ pendingReminders.delete(type);
337
+ }
338
+ }, delayMs);
339
+
340
+ // Store the pending reminder with item count
341
+ pendingReminders.set(type, {
342
+ timeoutId,
343
+ scheduledAt: Date.now(),
344
+ followUpCount: 0,
345
+ itemCount // Store count for later use
346
+ });
347
+ };
348
+
349
+ /**
350
+ * Smart notification: play sound first, then schedule TTS reminder
351
+ * @param {string} type - 'idle', 'permission', or 'question'
352
+ * @param {object} options - Notification options
353
+ */
354
+ const smartNotify = async (type, options = {}) => {
355
+ const {
356
+ soundFile,
357
+ soundLoops = 1,
358
+ ttsMessage,
359
+ fallbackSound,
360
+ permissionCount, // Support permission count for batched notifications
361
+ questionCount // Support question count for batched notifications
362
+ } = options;
363
+
364
+ // Step 1: Play the immediate sound notification
365
+ if (soundFile) {
366
+ await playSound(soundFile, soundLoops);
367
+ }
368
+
369
+ // CRITICAL FIX: Check if user responded during sound playback
370
+ // For idle notifications: check if there was new activity after the idle start
371
+ if (type === 'idle' && lastUserActivityTime > lastSessionIdleTime) {
372
+ debugLog(`smartNotify: user active during sound - aborting idle reminder`);
373
+ return;
374
+ }
375
+ // For permission notifications: check if the permission was already handled
376
+ if (type === 'permission' && !activePermissionId) {
377
+ debugLog(`smartNotify: permission handled during sound - aborting reminder`);
378
+ return;
379
+ }
380
+ // For question notifications: check if the question was already answered/rejected
381
+ if (type === 'question' && !activeQuestionId) {
382
+ debugLog(`smartNotify: question handled during sound - aborting reminder`);
383
+ return;
384
+ }
385
+
386
+ // Step 2: Schedule TTS reminder if user doesn't respond
387
+ if (config.enableTTSReminder && ttsMessage) {
388
+ scheduleTTSReminder(type, ttsMessage, { fallbackSound, permissionCount, questionCount });
389
+ }
390
+
391
+ // Step 3: If TTS-first mode is enabled, also speak immediately
392
+ if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
393
+ let immediateMessage;
394
+ if (type === 'permission') {
395
+ immediateMessage = await getSmartMessage('permission', false, config.permissionTTSMessages);
396
+ } else if (type === 'question') {
397
+ immediateMessage = await getSmartMessage('question', false, config.questionTTSMessages);
398
+ } else {
399
+ immediateMessage = await getSmartMessage('idle', false, config.idleTTSMessages);
400
+ }
401
+
402
+ await tts.speak(immediateMessage, {
403
+ enableTTS: true,
404
+ fallbackSound
405
+ });
406
+ }
407
+ };
408
+
409
+ /**
410
+ * Get a count-aware TTS message for permission requests
411
+ * Uses AI generation when enabled, falls back to static messages
412
+ * @param {number} count - Number of permission requests
413
+ * @param {boolean} isReminder - Whether this is a reminder message
414
+ * @returns {Promise<string>} The formatted message
415
+ */
416
+ const getPermissionMessage = async (count, isReminder = false) => {
417
+ const messages = isReminder
418
+ ? config.permissionReminderTTSMessages
419
+ : config.permissionTTSMessages;
420
+
421
+ // If AI messages are enabled, ALWAYS try AI first (regardless of count)
422
+ if (config.enableAIMessages) {
423
+ const aiMessage = await getSmartMessage('permission', isReminder, messages, { count, type: 'permission' });
424
+ // getSmartMessage returns static message as fallback, so if AI was attempted
425
+ // and succeeded, we'll get the AI message. If it failed, we get static.
426
+ // Check if we got a valid message (not the generic fallback)
427
+ if (aiMessage && aiMessage !== 'Notification') {
428
+ return aiMessage;
429
+ }
430
+ }
431
+
432
+ // Fallback to static messages (AI disabled or failed with generic fallback)
433
+ if (count === 1) {
434
+ return getRandomMessage(messages);
435
+ } else {
436
+ const countMessages = isReminder
437
+ ? config.permissionReminderTTSMessagesMultiple
438
+ : config.permissionTTSMessagesMultiple;
439
+
440
+ if (countMessages && countMessages.length > 0) {
441
+ const template = getRandomMessage(countMessages);
442
+ return template.replace('{count}', count.toString());
443
+ }
444
+ return `Attention! There are ${count} permission requests waiting for your approval.`;
445
+ }
446
+ };
447
+
448
+ /**
449
+ * Get a count-aware TTS message for question requests (SDK v1.1.7+)
450
+ * Uses AI generation when enabled, falls back to static messages
451
+ * @param {number} count - Number of question requests
452
+ * @param {boolean} isReminder - Whether this is a reminder message
453
+ * @returns {Promise<string>} The formatted message
454
+ */
455
+ const getQuestionMessage = async (count, isReminder = false) => {
456
+ const messages = isReminder
457
+ ? config.questionReminderTTSMessages
458
+ : config.questionTTSMessages;
459
+
460
+ // If AI messages are enabled, ALWAYS try AI first (regardless of count)
461
+ if (config.enableAIMessages) {
462
+ const aiMessage = await getSmartMessage('question', isReminder, messages, { count, type: 'question' });
463
+ // getSmartMessage returns static message as fallback, so if AI was attempted
464
+ // and succeeded, we'll get the AI message. If it failed, we get static.
465
+ // Check if we got a valid message (not the generic fallback)
466
+ if (aiMessage && aiMessage !== 'Notification') {
467
+ return aiMessage;
468
+ }
469
+ }
470
+
471
+ // Fallback to static messages (AI disabled or failed with generic fallback)
472
+ if (count === 1) {
473
+ return getRandomMessage(messages);
474
+ } else {
475
+ const countMessages = isReminder
476
+ ? config.questionReminderTTSMessagesMultiple
477
+ : config.questionTTSMessagesMultiple;
478
+
479
+ if (countMessages && countMessages.length > 0) {
480
+ const template = getRandomMessage(countMessages);
481
+ return template.replace('{count}', count.toString());
482
+ }
483
+ return `Hey! I have ${count} questions for you. Please check your screen.`;
484
+ }
485
+ };
486
+
487
+ /**
488
+ * Process the batched permission requests as a single notification
489
+ * Called after the batch window expires
490
+ *
491
+ * FIX: Play sound IMMEDIATELY before any AI generation to avoid delay.
492
+ * AI message generation can take 3-15+ seconds, which was delaying sound playback.
493
+ */
494
+ const processPermissionBatch = async () => {
495
+ // Capture and clear the batch
496
+ const batch = [...pendingPermissionBatch];
497
+ const batchCount = batch.length;
498
+ pendingPermissionBatch = [];
499
+ permissionBatchTimeout = null;
500
+
501
+ if (batchCount === 0) {
502
+ debugLog('processPermissionBatch: empty batch, skipping');
503
+ return;
504
+ }
505
+
506
+ debugLog(`processPermissionBatch: processing ${batchCount} permission(s)`);
507
+
508
+ // Set activePermissionId to the first one (for race condition checks)
509
+ // We track all IDs in the batch for proper cleanup
510
+ activePermissionId = batch[0];
511
+
512
+ // Step 1: Show toast IMMEDIATELY (fire and forget - no await)
513
+ const toastMessage = batchCount === 1
514
+ ? "⚠️ Permission request requires your attention"
515
+ : `⚠️ ${batchCount} permission requests require your attention`;
516
+ showToast(toastMessage, "warning", 8000); // No await - instant display
517
+
518
+ // Step 2: Play sound (after toast is triggered)
519
+ const soundLoops = batchCount === 1 ? 2 : Math.min(3, batchCount);
520
+ await playSound(config.permissionSound, soundLoops);
521
+
522
+ // CHECK: Did user already respond while sound was playing?
523
+ if (pendingPermissionBatch.length > 0) {
524
+ // New permissions arrived during sound - they'll be handled in next batch
525
+ debugLog('processPermissionBatch: new permissions arrived during sound');
526
+ }
527
+
528
+ // Step 3: Check race condition - did user respond during sound?
529
+ if (activePermissionId === null) {
530
+ debugLog('processPermissionBatch: user responded during sound - aborting');
531
+ return;
532
+ }
533
+
534
+ // Step 4: Generate AI message for reminder AFTER sound played
535
+ const reminderMessage = await getPermissionMessage(batchCount, true);
536
+
537
+ // Step 5: Schedule TTS reminder if enabled
538
+ if (config.enableTTSReminder && reminderMessage) {
539
+ scheduleTTSReminder('permission', reminderMessage, {
540
+ fallbackSound: config.permissionSound,
541
+ permissionCount: batchCount
542
+ });
543
+ }
544
+
545
+ // Step 6: If TTS-first or both mode, generate and speak immediate message
546
+ if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
547
+ const ttsMessage = await getPermissionMessage(batchCount, false);
548
+ await tts.wakeMonitor();
549
+ await tts.forceVolume();
550
+ await tts.speak(ttsMessage, {
551
+ enableTTS: true,
552
+ fallbackSound: config.permissionSound
553
+ });
554
+ }
555
+
556
+ // Final check: if user responded during notification, cancel scheduled reminder
557
+ if (activePermissionId === null) {
558
+ debugLog('processPermissionBatch: user responded during notification - cancelling reminder');
559
+ cancelPendingReminder('permission');
560
+ }
561
+ };
562
+
563
+ /**
564
+ * Process the batched question requests as a single notification (SDK v1.1.7+)
565
+ * Called after the batch window expires
566
+ *
567
+ * FIX: Play sound IMMEDIATELY before any AI generation to avoid delay.
568
+ * AI message generation can take 3-15+ seconds, which was delaying sound playback.
569
+ */
570
+ const processQuestionBatch = async () => {
571
+ // Capture and clear the batch
572
+ const batch = [...pendingQuestionBatch];
573
+ pendingQuestionBatch = [];
574
+ questionBatchTimeout = null;
575
+
576
+ if (batch.length === 0) {
577
+ debugLog('processQuestionBatch: empty batch, skipping');
578
+ return;
579
+ }
580
+
581
+ // Calculate total number of questions across all batched requests
582
+ // Each batch item is { id, questionCount } where questionCount is the number of questions in that request
583
+ const totalQuestionCount = batch.reduce((sum, item) => sum + (item.questionCount || 1), 0);
584
+
585
+ debugLog(`processQuestionBatch: processing ${batch.length} request(s) with ${totalQuestionCount} total question(s)`);
586
+
587
+ // Set activeQuestionId to the first one (for race condition checks)
588
+ // We track all IDs in the batch for proper cleanup
589
+ activeQuestionId = batch[0]?.id;
590
+
591
+ // Step 1: Show toast IMMEDIATELY (fire and forget - no await)
592
+ const toastMessage = totalQuestionCount === 1
593
+ ? "❓ The agent has a question for you"
594
+ : `❓ The agent has ${totalQuestionCount} questions for you`;
595
+ showToast(toastMessage, "info", 8000); // No await - instant display
596
+
597
+ // Step 2: Play sound (after toast is triggered)
598
+ await playSound(config.questionSound, 2);
599
+
600
+ // CHECK: Did user already respond while sound was playing?
601
+ if (pendingQuestionBatch.length > 0) {
602
+ // New questions arrived during sound - they'll be handled in next batch
603
+ debugLog('processQuestionBatch: new questions arrived during sound');
604
+ }
605
+
606
+ // Step 3: Check race condition - did user respond during sound?
607
+ if (activeQuestionId === null) {
608
+ debugLog('processQuestionBatch: user responded during sound - aborting');
609
+ return;
610
+ }
611
+
612
+ // Step 4: Generate AI message for reminder AFTER sound played
613
+ const reminderMessage = await getQuestionMessage(totalQuestionCount, true);
614
+
615
+ // Step 5: Schedule TTS reminder if enabled
616
+ if (config.enableTTSReminder && reminderMessage) {
617
+ scheduleTTSReminder('question', reminderMessage, {
618
+ fallbackSound: config.questionSound,
619
+ questionCount: totalQuestionCount
620
+ });
621
+ }
622
+
623
+ // Step 6: If TTS-first or both mode, generate and speak immediate message
624
+ if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
625
+ const ttsMessage = await getQuestionMessage(totalQuestionCount, false);
626
+ await tts.wakeMonitor();
627
+ await tts.forceVolume();
628
+ await tts.speak(ttsMessage, {
629
+ enableTTS: true,
630
+ fallbackSound: config.questionSound
631
+ });
632
+ }
633
+
634
+ // Final check: if user responded during notification, cancel scheduled reminder
635
+ if (activeQuestionId === null) {
636
+ debugLog('processQuestionBatch: user responded during notification - cancelling reminder');
637
+ cancelPendingReminder('question');
638
+ }
639
+ };
640
+
641
+ return {
642
+ event: async ({ event }) => {
643
+ try {
644
+ // ========================================
645
+ // USER ACTIVITY DETECTION
646
+ // Cancels pending TTS reminders when user responds
647
+ // ========================================
648
+ // NOTE: OpenCode event types (supporting SDK v1.0.x, v1.1.x, and v1.1.7+):
649
+ // - message.updated: fires when a message is added/updated (use properties.info.role to check user vs assistant)
650
+ // - permission.updated (SDK v1.0.x): fires when a permission request is created
651
+ // - permission.asked (SDK v1.1.1+): fires when a permission request is created (replaces permission.updated)
652
+ // - permission.replied: fires when user responds to a permission request
653
+ // - SDK v1.0.x: uses permissionID, response
654
+ // - SDK v1.1.1+: uses requestID, reply
655
+ // - question.asked (SDK v1.1.7+): fires when agent asks user a question
656
+ // - question.replied (SDK v1.1.7+): fires when user answers a question
657
+ // - question.rejected (SDK v1.1.7+): fires when user dismisses a question
658
+ // - session.created: fires when a new session starts
659
+ //
660
+ // CRITICAL: message.updated fires for EVERY modification to a message (not just creation).
661
+ // Context-injector and other plugins can trigger multiple updates for the same message.
662
+ // We must only treat NEW user messages (after session.idle) as actual user activity.
663
+
664
+ if (event.type === "message.updated") {
665
+ const messageInfo = event.properties?.info;
666
+ const messageId = messageInfo?.id;
667
+ const isUserMessage = messageInfo?.role === 'user';
668
+
669
+ if (isUserMessage && messageId) {
670
+ // Check if this is a NEW user message we haven't seen before
671
+ const isNewMessage = !seenUserMessageIds.has(messageId);
672
+
673
+ // Check if this message arrived AFTER the last session.idle
674
+ // This is the key: only a message sent AFTER idle indicates user responded
675
+ const messageTime = messageInfo?.time?.created;
676
+ const isAfterIdle = lastSessionIdleTime > 0 && messageTime && (messageTime * 1000) > lastSessionIdleTime;
677
+
678
+ if (isNewMessage) {
679
+ seenUserMessageIds.add(messageId);
680
+
681
+ // Only cancel reminders if this is a NEW message AFTER session went idle
682
+ // OR if there are no pending reminders (initial message before any notifications)
683
+ if (isAfterIdle || pendingReminders.size === 0) {
684
+ if (isAfterIdle) {
685
+ lastUserActivityTime = Date.now();
686
+ cancelAllPendingReminders();
687
+ debugLog(`NEW user message AFTER idle: ${messageId} - cancelled pending reminders`);
688
+ } else {
689
+ debugLog(`Initial user message (before any idle): ${messageId} - no reminders to cancel`);
690
+ }
691
+ } else {
692
+ debugLog(`Ignored: user message ${messageId} created BEFORE session.idle (time=${messageTime}, idleTime=${lastSessionIdleTime})`);
693
+ }
694
+ } else {
695
+ // This is an UPDATE to an existing message (e.g., context injection)
696
+ debugLog(`Ignored: update to existing user message ${messageId} (not new activity)`);
697
+ }
698
+ }
699
+ }
700
+
701
+ if (event.type === "permission.replied") {
702
+ // User responded to a permission request (granted or denied)
703
+ // Structure varies by SDK version:
704
+ // - Old SDK: event.properties.{ sessionID, permissionID, response }
705
+ // - New SDK (v1.1.1+): event.properties.{ sessionID, requestID, reply }
706
+ // CRITICAL: Clear activePermissionId FIRST to prevent race condition
707
+ // where permission.updated/asked handler is still running async operations
708
+ const repliedPermissionId = event.properties?.permissionID || event.properties?.requestID;
709
+ const response = event.properties?.response || event.properties?.reply;
710
+
711
+ // Remove this permission from the pending batch (if still waiting)
712
+ if (repliedPermissionId && pendingPermissionBatch.includes(repliedPermissionId)) {
713
+ pendingPermissionBatch = pendingPermissionBatch.filter(id => id !== repliedPermissionId);
714
+ debugLog(`Permission replied: removed ${repliedPermissionId} from pending batch (${pendingPermissionBatch.length} remaining)`);
715
+ }
716
+
717
+ // If batch is now empty and we have a pending batch timeout, we can cancel it
718
+ // (user responded to all permissions before batch window expired)
719
+ if (pendingPermissionBatch.length === 0 && permissionBatchTimeout) {
720
+ clearTimeout(permissionBatchTimeout);
721
+ permissionBatchTimeout = null;
722
+ debugLog('Permission replied: cancelled batch timeout (all permissions handled)');
723
+ }
724
+
725
+ // Match if IDs are equal, or if we have an active permission with unknown ID (undefined)
726
+ // (This happens if permission.updated/asked received an event without permissionID)
727
+ if (activePermissionId === repliedPermissionId || activePermissionId === undefined) {
728
+ activePermissionId = null;
729
+ debugLog(`Permission replied: cleared activePermissionId ${repliedPermissionId || '(unknown)'}`);
730
+ }
731
+ lastUserActivityTime = Date.now();
732
+ cancelPendingReminder('permission'); // Cancel permission-specific reminder
733
+ debugLog(`Permission replied: ${event.type} (response=${response}) - cancelled permission reminder`);
734
+ }
735
+
736
+ if (event.type === "session.created") {
737
+ // New session started - reset tracking state
738
+ lastUserActivityTime = Date.now();
739
+ lastSessionIdleTime = 0;
740
+ activePermissionId = null;
741
+ activeQuestionId = null;
742
+ seenUserMessageIds.clear();
743
+ cancelAllPendingReminders();
744
+
745
+ // Reset permission batch state
746
+ pendingPermissionBatch = [];
747
+ if (permissionBatchTimeout) {
748
+ clearTimeout(permissionBatchTimeout);
749
+ permissionBatchTimeout = null;
750
+ }
751
+
752
+ // Reset question batch state
753
+ pendingQuestionBatch = [];
754
+ if (questionBatchTimeout) {
755
+ clearTimeout(questionBatchTimeout);
756
+ questionBatchTimeout = null;
757
+ }
758
+
759
+ debugLog(`Session created: ${event.type} - reset all tracking state`);
760
+ }
761
+
762
+ // ========================================
763
+ // NOTIFICATION 1: Session Idle (Agent Finished)
764
+ //
765
+ // FIX: Play sound IMMEDIATELY before any AI generation to avoid delay.
766
+ // AI message generation can take 3-15+ seconds, which was delaying sound playback.
767
+ // ========================================
768
+ if (event.type === "session.idle") {
769
+ const sessionID = event.properties?.sessionID;
770
+ if (!sessionID) return;
771
+
772
+ try {
773
+ const session = await client.session.get({ path: { id: sessionID } });
774
+ if (session?.data?.parentID) {
775
+ debugLog(`session.idle: skipped (sub-session ${sessionID})`);
776
+ return;
777
+ }
778
+ } catch (e) {}
779
+
780
+ // Record the time session went idle - used to filter out pre-idle messages
781
+ lastSessionIdleTime = Date.now();
782
+
783
+ debugLog(`session.idle: notifying for session ${sessionID} (idleTime=${lastSessionIdleTime})`);
784
+
785
+ // Step 1: Show toast IMMEDIATELY (fire and forget - no await)
786
+ showToast("✅ Agent has finished working", "success", 5000); // No await - instant display
787
+
788
+ // Step 2: Play sound (after toast is triggered)
789
+ // Only play sound in sound-first, sound-only, or both mode
790
+ if (config.notificationMode !== 'tts-first') {
791
+ await playSound(config.idleSound, 1);
792
+ }
793
+
794
+ // Step 3: Check race condition - did user respond during sound?
795
+ if (lastUserActivityTime > lastSessionIdleTime) {
796
+ debugLog(`session.idle: user active during sound - aborting`);
797
+ return;
798
+ }
799
+
800
+ // Step 4: Generate AI message for reminder AFTER sound played
801
+ const reminderMessage = await getSmartMessage('idle', true, config.idleReminderTTSMessages);
802
+
803
+ // Step 5: Schedule TTS reminder if enabled
804
+ if (config.enableTTSReminder && reminderMessage) {
805
+ scheduleTTSReminder('idle', reminderMessage, {
806
+ fallbackSound: config.idleSound
807
+ });
808
+ }
809
+
810
+ // Step 6: If TTS-first or both mode, generate and speak immediate message
811
+ if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
812
+ const ttsMessage = await getSmartMessage('idle', false, config.idleTTSMessages);
813
+ await tts.wakeMonitor();
814
+ await tts.forceVolume();
815
+ await tts.speak(ttsMessage, {
816
+ enableTTS: true,
817
+ fallbackSound: config.idleSound
818
+ });
819
+ }
820
+ }
821
+
822
+ // ========================================
823
+ // NOTIFICATION 2: Permission Request (BATCHED)
824
+ // ========================================
825
+ // NOTE: OpenCode SDK v1.1.1+ changed permission events:
826
+ // - Old: "permission.updated" with properties.id
827
+ // - New: "permission.asked" with properties.id
828
+ // We support both for backward compatibility.
829
+ //
830
+ // BATCHING: When multiple permissions arrive simultaneously (e.g., 5 at once),
831
+ // we batch them into a single notification instead of playing 5 overlapping sounds.
832
+ if (event.type === "permission.updated" || event.type === "permission.asked") {
833
+ // Capture permissionID
834
+ const permissionId = event.properties?.id;
835
+
836
+ if (!permissionId) {
837
+ debugLog(`${event.type}: permission ID missing. properties keys: ` + Object.keys(event.properties || {}).join(', '));
838
+ }
839
+
840
+ // Add to the pending batch (avoid duplicates)
841
+ if (permissionId && !pendingPermissionBatch.includes(permissionId)) {
842
+ pendingPermissionBatch.push(permissionId);
843
+ debugLog(`${event.type}: added ${permissionId} to batch (now ${pendingPermissionBatch.length} pending)`);
844
+ } else if (!permissionId) {
845
+ // If no ID, still count it (use a placeholder)
846
+ pendingPermissionBatch.push(`unknown-${Date.now()}`);
847
+ debugLog(`${event.type}: added unknown permission to batch (now ${pendingPermissionBatch.length} pending)`);
848
+ }
849
+
850
+ // Reset the batch window timer (debounce)
851
+ // This gives more permissions a chance to arrive before we notify
852
+ if (permissionBatchTimeout) {
853
+ clearTimeout(permissionBatchTimeout);
854
+ }
855
+
856
+ permissionBatchTimeout = setTimeout(async () => {
857
+ try {
858
+ await processPermissionBatch();
859
+ } catch (e) {
860
+ debugLog(`processPermissionBatch error: ${e.message}`);
861
+ }
862
+ }, PERMISSION_BATCH_WINDOW_MS);
863
+
864
+ debugLog(`${event.type}: batch window reset (will process in ${PERMISSION_BATCH_WINDOW_MS}ms if no more arrive)`);
865
+ }
866
+
867
+ // ========================================
868
+ // NOTIFICATION 3: Question Request (BATCHED) - SDK v1.1.7+
869
+ // ========================================
870
+ // The "question" tool allows the LLM to ask users questions during execution.
871
+ // Events: question.asked, question.replied, question.rejected
872
+ //
873
+ // BATCHING: When multiple question requests arrive simultaneously,
874
+ // we batch them into a single notification instead of playing overlapping sounds.
875
+ // NOTE: Each question.asked event can contain multiple questions in its questions array.
876
+ if (event.type === "question.asked") {
877
+ // Capture question request ID and count of questions in this request
878
+ const questionId = event.properties?.id;
879
+ const questionsArray = event.properties?.questions;
880
+ const questionCount = Array.isArray(questionsArray) ? questionsArray.length : 1;
881
+
882
+ if (!questionId) {
883
+ debugLog(`${event.type}: question ID missing. properties keys: ` + Object.keys(event.properties || {}).join(', '));
884
+ }
885
+
886
+ // Add to the pending batch (avoid duplicates by checking ID)
887
+ // Store as object with id and questionCount for proper counting
888
+ const existingIndex = pendingQuestionBatch.findIndex(item => item.id === questionId);
889
+ if (questionId && existingIndex === -1) {
890
+ pendingQuestionBatch.push({ id: questionId, questionCount });
891
+ debugLog(`${event.type}: added ${questionId} with ${questionCount} question(s) to batch (now ${pendingQuestionBatch.length} request(s) pending)`);
892
+ } else if (!questionId) {
893
+ // If no ID, still count it (use a placeholder)
894
+ pendingQuestionBatch.push({ id: `unknown-${Date.now()}`, questionCount });
895
+ debugLog(`${event.type}: added unknown question request with ${questionCount} question(s) to batch (now ${pendingQuestionBatch.length} request(s) pending)`);
896
+ }
897
+
898
+ // Reset the batch window timer (debounce)
899
+ // This gives more questions a chance to arrive before we notify
900
+ if (questionBatchTimeout) {
901
+ clearTimeout(questionBatchTimeout);
902
+ }
903
+
904
+ questionBatchTimeout = setTimeout(async () => {
905
+ try {
906
+ await processQuestionBatch();
907
+ } catch (e) {
908
+ debugLog(`processQuestionBatch error: ${e.message}`);
909
+ }
910
+ }, QUESTION_BATCH_WINDOW_MS);
911
+
912
+ debugLog(`${event.type}: batch window reset (will process in ${QUESTION_BATCH_WINDOW_MS}ms if no more arrive)`);
913
+ }
914
+
915
+ // Handle question.replied - user answered the question(s)
916
+ if (event.type === "question.replied") {
917
+ const repliedQuestionId = event.properties?.requestID;
918
+ const answers = event.properties?.answers;
919
+
920
+ // Remove this question from the pending batch (if still waiting)
921
+ // pendingQuestionBatch is now an array of { id, questionCount } objects
922
+ const existingIndex = pendingQuestionBatch.findIndex(item => item.id === repliedQuestionId);
923
+ if (repliedQuestionId && existingIndex !== -1) {
924
+ pendingQuestionBatch.splice(existingIndex, 1);
925
+ debugLog(`Question replied: removed ${repliedQuestionId} from pending batch (${pendingQuestionBatch.length} remaining)`);
926
+ }
927
+
928
+ // If batch is now empty and we have a pending batch timeout, we can cancel it
929
+ if (pendingQuestionBatch.length === 0 && questionBatchTimeout) {
930
+ clearTimeout(questionBatchTimeout);
931
+ questionBatchTimeout = null;
932
+ debugLog('Question replied: cancelled batch timeout (all questions handled)');
933
+ }
934
+
935
+ // Clear active question ID
936
+ if (activeQuestionId === repliedQuestionId || activeQuestionId === undefined) {
937
+ activeQuestionId = null;
938
+ debugLog(`Question replied: cleared activeQuestionId ${repliedQuestionId || '(unknown)'}`);
939
+ }
940
+ lastUserActivityTime = Date.now();
941
+ cancelPendingReminder('question'); // Cancel question-specific reminder
942
+ debugLog(`Question replied: ${event.type} (answers=${JSON.stringify(answers)}) - cancelled question reminder`);
943
+ }
944
+
945
+ // Handle question.rejected - user dismissed the question
946
+ if (event.type === "question.rejected") {
947
+ const rejectedQuestionId = event.properties?.requestID;
948
+
949
+ // Remove this question from the pending batch (if still waiting)
950
+ // pendingQuestionBatch is now an array of { id, questionCount } objects
951
+ const existingIndex = pendingQuestionBatch.findIndex(item => item.id === rejectedQuestionId);
952
+ if (rejectedQuestionId && existingIndex !== -1) {
953
+ pendingQuestionBatch.splice(existingIndex, 1);
954
+ debugLog(`Question rejected: removed ${rejectedQuestionId} from pending batch (${pendingQuestionBatch.length} remaining)`);
955
+ }
956
+
957
+ // If batch is now empty and we have a pending batch timeout, we can cancel it
958
+ if (pendingQuestionBatch.length === 0 && questionBatchTimeout) {
959
+ clearTimeout(questionBatchTimeout);
960
+ questionBatchTimeout = null;
961
+ debugLog('Question rejected: cancelled batch timeout (all questions handled)');
962
+ }
963
+
964
+ // Clear active question ID
965
+ if (activeQuestionId === rejectedQuestionId || activeQuestionId === undefined) {
966
+ activeQuestionId = null;
967
+ debugLog(`Question rejected: cleared activeQuestionId ${rejectedQuestionId || '(unknown)'}`);
968
+ }
969
+ lastUserActivityTime = Date.now();
970
+ cancelPendingReminder('question'); // Cancel question-specific reminder
971
+ debugLog(`Question rejected: ${event.type} - cancelled question reminder`);
972
+ }
973
+ } catch (e) {
974
+ debugLog(`event handler error: ${e.message}`);
975
+ }
976
+ },
977
+ };
978
+ }