opencode-smart-voice-notify 1.2.5 → 1.3.1

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
@@ -3,6 +3,11 @@ import os from 'os';
3
3
  import path from 'path';
4
4
  import { createTTS, getTTSConfig } from './util/tts.js';
5
5
  import { getSmartMessage } from './util/ai-messages.js';
6
+ import { notifyTaskComplete, notifyPermissionRequest, notifyQuestion, notifyError } from './util/desktop-notify.js';
7
+ import { notifyWebhookIdle, notifyWebhookPermission, notifyWebhookError, notifyWebhookQuestion } from './util/webhook.js';
8
+ import { isTerminalFocused } from './util/focus-detect.js';
9
+ import { pickThemeSound } from './util/sound-theme.js';
10
+ import { getProjectSound } from './util/per-project-sound.js';
6
11
 
7
12
  /**
8
13
  * OpenCode Smart Voice Notify Plugin
@@ -23,10 +28,20 @@ import { getSmartMessage } from './util/ai-messages.js';
23
28
  * @type {import("@opencode-ai/plugin").Plugin}
24
29
  */
25
30
  export default async function SmartVoiceNotifyPlugin({ project, client, $, directory, worktree }) {
26
- const config = getTTSConfig();
31
+ let config = getTTSConfig();
32
+
33
+ // Derive project name from worktree path since SDK's Project type doesn't have a 'name' property
34
+ // Example: C:\Repository\opencode-smart-voice-notify -> opencode-smart-voice-notify
35
+ const derivedProjectName = worktree ? path.basename(worktree) : (directory ? path.basename(directory) : null);
36
+
27
37
 
28
38
  // Master switch: if plugin is disabled, return empty handlers immediately
29
- if (config.enabled === false) {
39
+ // Handle both boolean false and string "false"/"disabled"
40
+ const isEnabledInitially = config.enabled !== false &&
41
+ String(config.enabled).toLowerCase() !== 'false' &&
42
+ String(config.enabled).toLowerCase() !== 'disabled';
43
+
44
+ if (!isEnabledInitially) {
30
45
  const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
31
46
  const logsDir = path.join(configDir, 'logs');
32
47
  const logFile = path.join(logsDir, 'smart-voice-notify-debug.log');
@@ -36,13 +51,15 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
36
51
  fs.mkdirSync(logsDir, { recursive: true });
37
52
  }
38
53
  const timestamp = new Date().toISOString();
39
- fs.appendFileSync(logFile, `[${timestamp}] Plugin disabled via config (enabled: false) - no event handlers registered\n`);
54
+ fs.appendFileSync(logFile, `[${timestamp}] Plugin disabled via config (enabled: ${config.enabled}) - no event handlers registered\n`);
40
55
  } catch (e) {}
41
56
  }
42
57
  return {};
43
58
  }
44
59
 
45
- const tts = createTTS({ $, client });
60
+
61
+ let tts = createTTS({ $, client });
62
+
46
63
 
47
64
  const platform = os.platform();
48
65
 
@@ -121,6 +138,43 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
121
138
  } catch (e) {}
122
139
  };
123
140
 
141
+ /**
142
+ * Check if notifications should be suppressed due to terminal focus.
143
+ * Returns true if we should NOT send sound/desktop notifications.
144
+ *
145
+ * Note: TTS reminders are NEVER suppressed by this function.
146
+ * The user might step away after the task completes, so reminders should still work.
147
+ *
148
+ * @returns {Promise<boolean>} True if notifications should be suppressed
149
+ */
150
+ const shouldSuppressNotification = async () => {
151
+ // If alwaysNotify is true, never suppress
152
+ if (config.alwaysNotify) {
153
+ debugLog('shouldSuppressNotification: alwaysNotify=true, not suppressing');
154
+ return false;
155
+ }
156
+
157
+ // If suppressWhenFocused is disabled, don't suppress
158
+ if (!config.suppressWhenFocused) {
159
+ debugLog('shouldSuppressNotification: suppressWhenFocused=false, not suppressing');
160
+ return false;
161
+ }
162
+
163
+ // Check if terminal is focused
164
+ try {
165
+ const isFocused = await isTerminalFocused({ debugLog: config.debugLog });
166
+ if (isFocused) {
167
+ debugLog('shouldSuppressNotification: terminal is focused, suppressing sound/desktop notifications');
168
+ return true;
169
+ }
170
+ } catch (e) {
171
+ debugLog(`shouldSuppressNotification: focus detection error: ${e.message}`);
172
+ // On error, fail open (don't suppress)
173
+ }
174
+
175
+ return false;
176
+ };
177
+
124
178
  /**
125
179
  * Get a random message from an array of messages
126
180
  */
@@ -150,29 +204,164 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
150
204
  };
151
205
 
152
206
  /**
153
- * Play a sound file from assets
207
+ * Send a desktop notification (if enabled).
208
+ * Desktop notifications are independent of sound/TTS and fire immediately.
209
+ *
210
+ * @param {'idle' | 'permission' | 'question' | 'error'} type - Notification type
211
+ * @param {string} message - Notification message
212
+ * @param {object} options - Additional options (count for permission/question/error)
154
213
  */
155
- const playSound = async (soundFile, loops = 1) => {
214
+ const sendDesktopNotify = (type, message, options = {}) => {
215
+ if (!config.enableDesktopNotification) return;
216
+
217
+ try {
218
+ // Build options with project name if configured
219
+ // Note: SDK's Project type doesn't have 'name' property, so we use derivedProjectName
220
+ const notifyOptions = {
221
+ projectName: config.showProjectInNotification && derivedProjectName ? derivedProjectName : undefined,
222
+ timeout: config.desktopNotificationTimeout || 5,
223
+ debugLog: config.debugLog,
224
+ count: options.count || 1
225
+ };
226
+
227
+ // Fire and forget (no await) - desktop notification should not block other operations
228
+ // Use the appropriate helper function based on notification type
229
+ if (type === 'idle') {
230
+ notifyTaskComplete(message, notifyOptions).catch(e => {
231
+ debugLog(`Desktop notification error (idle): ${e.message}`);
232
+ });
233
+ } else if (type === 'permission') {
234
+ notifyPermissionRequest(message, notifyOptions).catch(e => {
235
+ debugLog(`Desktop notification error (permission): ${e.message}`);
236
+ });
237
+ } else if (type === 'question') {
238
+ notifyQuestion(message, notifyOptions).catch(e => {
239
+ debugLog(`Desktop notification error (question): ${e.message}`);
240
+ });
241
+ } else if (type === 'error') {
242
+ notifyError(message, notifyOptions).catch(e => {
243
+ debugLog(`Desktop notification error (error): ${e.message}`);
244
+ });
245
+ }
246
+
247
+ debugLog(`sendDesktopNotify: sent ${type} notification`);
248
+ } catch (e) {
249
+ debugLog(`sendDesktopNotify error: ${e.message}`);
250
+ }
251
+ };
252
+
253
+ /**
254
+ * Send a webhook notification (if enabled).
255
+ * Webhook notifications are independent and fire immediately.
256
+ *
257
+ * @param {'idle' | 'permission' | 'question' | 'error'} type - Notification type
258
+ * @param {string} message - Notification message
259
+ * @param {object} options - Additional options (count, sessionId)
260
+ */
261
+ const sendWebhookNotify = (type, message, options = {}) => {
262
+ if (!config.enableWebhook || !config.webhookUrl) return;
263
+
264
+ // Check if this event type is enabled in webhookEvents
265
+ if (Array.isArray(config.webhookEvents) && !config.webhookEvents.includes(type)) {
266
+ debugLog(`sendWebhookNotify: ${type} event skipped (not in webhookEvents)`);
267
+ return;
268
+ }
269
+
270
+ try {
271
+ // Note: SDK's Project type doesn't have 'name' property, so we use derivedProjectName
272
+ const webhookOptions = {
273
+ projectName: derivedProjectName,
274
+ sessionId: options.sessionId,
275
+ count: options.count || 1,
276
+ username: config.webhookUsername,
277
+ debugLog: config.debugLog,
278
+ mention: type === 'permission' ? config.webhookMentionOnPermission : false
279
+ };
280
+
281
+ // Fire and forget (no await)
282
+ if (type === 'idle') {
283
+ notifyWebhookIdle(config.webhookUrl, message, webhookOptions).catch(e => {
284
+ debugLog(`Webhook notification error (idle): ${e.message}`);
285
+ });
286
+ } else if (type === 'permission') {
287
+ notifyWebhookPermission(config.webhookUrl, message, webhookOptions).catch(e => {
288
+ debugLog(`Webhook notification error (permission): ${e.message}`);
289
+ });
290
+ } else if (type === 'question') {
291
+ notifyWebhookQuestion(config.webhookUrl, message, webhookOptions).catch(e => {
292
+ debugLog(`Webhook notification error (question): ${e.message}`);
293
+ });
294
+ } else if (type === 'error') {
295
+ notifyWebhookError(config.webhookUrl, message, webhookOptions).catch(e => {
296
+ debugLog(`Webhook notification error (error): ${e.message}`);
297
+ });
298
+ }
299
+
300
+ debugLog(`sendWebhookNotify: sent ${type} notification`);
301
+ } catch (e) {
302
+ debugLog(`sendWebhookNotify error: ${e.message}`);
303
+ }
304
+ };
305
+
306
+ /**
307
+ * Play a sound file from assets or theme
308
+ * @param {string} soundFile - Default sound file path
309
+ * @param {number} loops - Number of times to loop
310
+ * @param {string} eventType - Event type for theme support (idle, permission, error, question)
311
+ */
312
+ const playSound = async (soundFile, loops = 1, eventType = null) => {
156
313
  if (!config.enableSound) return;
157
314
  try {
158
- const soundPath = path.isAbsolute(soundFile)
159
- ? soundFile
160
- : path.join(configDir, soundFile);
315
+ let soundPath = soundFile;
161
316
 
162
- if (!fs.existsSync(soundPath)) {
163
- debugLog(`playSound: file not found: ${soundPath}`);
317
+ // Phase 6: Per-project sound assignment
318
+ // Only applies to 'idle' (task completion) events for project identification
319
+ if (eventType === 'idle' && config.perProjectSounds) {
320
+ const projectSound = getProjectSound(project, config);
321
+ if (projectSound) {
322
+ soundPath = projectSound;
323
+ }
324
+ }
325
+
326
+ // If a theme is configured, try to pick a sound from it
327
+ // Theme sounds have higher priority than per-project sounds if both are set
328
+ if (eventType && config.soundThemeDir) {
329
+ const themeSound = pickThemeSound(eventType, config);
330
+ if (themeSound) {
331
+ soundPath = themeSound;
332
+ }
333
+ }
334
+
335
+ const finalPath = path.isAbsolute(soundPath)
336
+ ? soundPath
337
+ : path.join(configDir, soundPath);
338
+
339
+ if (!fs.existsSync(finalPath)) {
340
+ debugLog(`playSound: file not found: ${finalPath}`);
341
+ // If we tried a theme sound and it failed, fallback to the default soundFile
342
+ if (soundPath !== soundFile) {
343
+ const fallbackPath = path.isAbsolute(soundFile) ? soundFile : path.join(configDir, soundFile);
344
+ if (fs.existsSync(fallbackPath)) {
345
+ await tts.wakeMonitor();
346
+ await tts.forceVolume();
347
+ await tts.playAudioFile(fallbackPath, loops);
348
+ debugLog(`playSound: fell back to default sound ${fallbackPath}`);
349
+ return;
350
+ }
351
+ }
164
352
  return;
165
353
  }
166
354
 
167
355
  await tts.wakeMonitor();
168
356
  await tts.forceVolume();
169
- await tts.playAudioFile(soundPath, loops);
170
- debugLog(`playSound: played ${soundPath} (${loops}x)`);
357
+ await tts.playAudioFile(finalPath, loops);
358
+ debugLog(`playSound: played ${finalPath} (${loops}x)`);
171
359
  } catch (e) {
172
360
  debugLog(`playSound error: ${e.message}`);
173
361
  }
174
362
  };
175
363
 
364
+
176
365
  /**
177
366
  * Cancel any pending TTS reminder for a given type
178
367
  */
@@ -196,26 +385,46 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
196
385
  pendingReminders.clear();
197
386
  };
198
387
 
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 = {}) => {
388
+ /**
389
+ * Schedule a TTS reminder if user doesn't respond within configured delay.
390
+ * The reminder generates an AI message WHEN IT FIRES (not immediately), avoiding wasteful early AI calls.
391
+ * @param {string} type - 'idle', 'permission', 'question', or 'error'
392
+ * @param {string} _message - DEPRECATED: No longer used (AI message is generated when reminder fires)
393
+ * @param {object} options - Additional options (fallbackSound, permissionCount, questionCount, errorCount, aiContext)
394
+ */
395
+ const scheduleTTSReminder = (type, _message, options = {}) => {
207
396
  // Check if TTS reminders are enabled
208
397
  if (!config.enableTTSReminder) {
209
398
  debugLog(`scheduleTTSReminder: TTS reminders disabled`);
210
399
  return;
211
400
  }
212
401
 
402
+ // Granular reminder control
403
+ if (type === 'idle' && config.enableIdleReminder === false) {
404
+ debugLog(`scheduleTTSReminder: idle reminders disabled via config`);
405
+ return;
406
+ }
407
+ if (type === 'permission' && config.enablePermissionReminder === false) {
408
+ debugLog(`scheduleTTSReminder: permission reminders disabled via config`);
409
+ return;
410
+ }
411
+ if (type === 'question' && config.enableQuestionReminder === false) {
412
+ debugLog(`scheduleTTSReminder: question reminders disabled via config`);
413
+ return;
414
+ }
415
+ if (type === 'error' && config.enableErrorReminder === false) {
416
+ debugLog(`scheduleTTSReminder: error reminders disabled via config`);
417
+ return;
418
+ }
419
+
213
420
  // Get delay from config (in seconds, convert to ms)
214
421
  let delaySeconds;
215
422
  if (type === 'permission') {
216
423
  delaySeconds = config.permissionReminderDelaySeconds || config.ttsReminderDelaySeconds || 30;
217
424
  } else if (type === 'question') {
218
425
  delaySeconds = config.questionReminderDelaySeconds || config.ttsReminderDelaySeconds || 25;
426
+ } else if (type === 'error') {
427
+ delaySeconds = config.errorReminderDelaySeconds || config.ttsReminderDelaySeconds || 20;
219
428
  } else {
220
429
  delaySeconds = config.idleReminderDelaySeconds || config.ttsReminderDelaySeconds || 30;
221
430
  }
@@ -225,7 +434,10 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
225
434
  cancelPendingReminder(type);
226
435
 
227
436
  // Store count for generating count-aware messages in reminders
228
- const itemCount = options.permissionCount || options.questionCount || 1;
437
+ const itemCount = options.permissionCount || options.questionCount || options.errorCount || 1;
438
+
439
+ // Store AI context for context-aware follow-up messages
440
+ const aiContext = options.aiContext || {};
229
441
 
230
442
  debugLog(`scheduleTTSReminder: scheduling ${type} TTS in ${delaySeconds}s (count=${itemCount})`);
231
443
 
@@ -248,15 +460,20 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
248
460
  debugLog(`scheduleTTSReminder: firing ${type} TTS reminder (count=${reminder?.itemCount || 1})`);
249
461
 
250
462
  // Get the appropriate reminder message
251
- // For permissions/questions with count > 1, use the count-aware message generator
463
+ // For permissions/questions/errors with count > 1, use the count-aware message generator
464
+ // Pass stored AI context for context-aware message generation
252
465
  const storedCount = reminder?.itemCount || 1;
466
+ const storedAiContext = reminder?.aiContext || {};
253
467
  let reminderMessage;
254
468
  if (type === 'permission') {
255
- reminderMessage = await getPermissionMessage(storedCount, true);
469
+ reminderMessage = await getPermissionMessage(storedCount, true, storedAiContext);
256
470
  } else if (type === 'question') {
257
- reminderMessage = await getQuestionMessage(storedCount, true);
471
+ reminderMessage = await getQuestionMessage(storedCount, true, storedAiContext);
472
+ } else if (type === 'error') {
473
+ reminderMessage = await getErrorMessage(storedCount, true, storedAiContext);
258
474
  } else {
259
- reminderMessage = await getSmartMessage('idle', true, config.idleReminderTTSMessages);
475
+ // Pass stored AI context for idle reminders (context-aware AI feature)
476
+ reminderMessage = await getSmartMessage('idle', true, config.idleReminderTTSMessages, storedAiContext);
260
477
  }
261
478
 
262
479
  // Check for ElevenLabs API key configuration issues
@@ -303,14 +520,19 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
303
520
  }
304
521
 
305
522
  // Use count-aware message for follow-ups too
523
+ // Pass stored AI context for context-aware message generation
306
524
  const followUpStoredCount = followUpReminder?.itemCount || 1;
525
+ const followUpAiContext = followUpReminder?.aiContext || {};
307
526
  let followUpMessage;
308
527
  if (type === 'permission') {
309
- followUpMessage = await getPermissionMessage(followUpStoredCount, true);
528
+ followUpMessage = await getPermissionMessage(followUpStoredCount, true, followUpAiContext);
310
529
  } else if (type === 'question') {
311
- followUpMessage = await getQuestionMessage(followUpStoredCount, true);
530
+ followUpMessage = await getQuestionMessage(followUpStoredCount, true, followUpAiContext);
531
+ } else if (type === 'error') {
532
+ followUpMessage = await getErrorMessage(followUpStoredCount, true, followUpAiContext);
312
533
  } else {
313
- followUpMessage = await getSmartMessage('idle', true, config.idleReminderTTSMessages);
534
+ // Pass stored AI context for idle follow-ups (context-aware AI feature)
535
+ followUpMessage = await getSmartMessage('idle', true, config.idleReminderTTSMessages, followUpAiContext);
314
536
  }
315
537
 
316
538
  await tts.wakeMonitor();
@@ -327,7 +549,8 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
327
549
  timeoutId: followUpTimeoutId,
328
550
  scheduledAt: Date.now(),
329
551
  followUpCount,
330
- itemCount: storedCount // Preserve the count for follow-ups
552
+ itemCount: storedCount, // Preserve the count for follow-ups
553
+ aiContext: storedAiContext // Preserve AI context for follow-ups
331
554
  });
332
555
  }
333
556
  }
@@ -337,12 +560,13 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
337
560
  }
338
561
  }, delayMs);
339
562
 
340
- // Store the pending reminder with item count
563
+ // Store the pending reminder with item count and AI context
341
564
  pendingReminders.set(type, {
342
565
  timeoutId,
343
566
  scheduledAt: Date.now(),
344
567
  followUpCount: 0,
345
- itemCount // Store count for later use
568
+ itemCount, // Store count for later use
569
+ aiContext // Store AI context for context-aware follow-ups
346
570
  });
347
571
  };
348
572
 
@@ -363,9 +587,10 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
363
587
 
364
588
  // Step 1: Play the immediate sound notification
365
589
  if (soundFile) {
366
- await playSound(soundFile, soundLoops);
590
+ await playSound(soundFile, soundLoops, type);
367
591
  }
368
592
 
593
+
369
594
  // CRITICAL FIX: Check if user responded during sound playback
370
595
  // For idle notifications: check if there was new activity after the idle start
371
596
  if (type === 'idle' && lastUserActivityTime > lastSessionIdleTime) {
@@ -411,16 +636,19 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
411
636
  * Uses AI generation when enabled, falls back to static messages
412
637
  * @param {number} count - Number of permission requests
413
638
  * @param {boolean} isReminder - Whether this is a reminder message
639
+ * @param {object} aiContext - Optional context for AI message generation (projectName, sessionTitle, etc.)
414
640
  * @returns {Promise<string>} The formatted message
415
641
  */
416
- const getPermissionMessage = async (count, isReminder = false) => {
642
+ const getPermissionMessage = async (count, isReminder = false, aiContext = {}) => {
417
643
  const messages = isReminder
418
644
  ? config.permissionReminderTTSMessages
419
645
  : config.permissionTTSMessages;
420
646
 
421
647
  // If AI messages are enabled, ALWAYS try AI first (regardless of count)
422
648
  if (config.enableAIMessages) {
423
- const aiMessage = await getSmartMessage('permission', isReminder, messages, { count, type: 'permission' });
649
+ // Merge count/type info with any provided context (projectName, sessionTitle, etc.)
650
+ const fullContext = { count, type: 'permission', ...aiContext };
651
+ const aiMessage = await getSmartMessage('permission', isReminder, messages, fullContext);
424
652
  // getSmartMessage returns static message as fallback, so if AI was attempted
425
653
  // and succeeded, we'll get the AI message. If it failed, we get static.
426
654
  // Check if we got a valid message (not the generic fallback)
@@ -450,16 +678,19 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
450
678
  * Uses AI generation when enabled, falls back to static messages
451
679
  * @param {number} count - Number of question requests
452
680
  * @param {boolean} isReminder - Whether this is a reminder message
681
+ * @param {object} aiContext - Optional context for AI message generation (projectName, sessionTitle, etc.)
453
682
  * @returns {Promise<string>} The formatted message
454
683
  */
455
- const getQuestionMessage = async (count, isReminder = false) => {
684
+ const getQuestionMessage = async (count, isReminder = false, aiContext = {}) => {
456
685
  const messages = isReminder
457
686
  ? config.questionReminderTTSMessages
458
687
  : config.questionTTSMessages;
459
688
 
460
689
  // If AI messages are enabled, ALWAYS try AI first (regardless of count)
461
690
  if (config.enableAIMessages) {
462
- const aiMessage = await getSmartMessage('question', isReminder, messages, { count, type: 'question' });
691
+ // Merge count/type info with any provided context (projectName, sessionTitle, etc.)
692
+ const fullContext = { count, type: 'question', ...aiContext };
693
+ const aiMessage = await getSmartMessage('question', isReminder, messages, fullContext);
463
694
  // getSmartMessage returns static message as fallback, so if AI was attempted
464
695
  // and succeeded, we'll get the AI message. If it failed, we get static.
465
696
  // Check if we got a valid message (not the generic fallback)
@@ -484,6 +715,48 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
484
715
  }
485
716
  };
486
717
 
718
+ /**
719
+ * Get a count-aware TTS message for error notifications
720
+ * Uses AI generation when enabled, falls back to static messages
721
+ * @param {number} count - Number of errors
722
+ * @param {boolean} isReminder - Whether this is a reminder message
723
+ * @param {object} aiContext - Optional context for AI message generation (projectName, sessionTitle, etc.)
724
+ * @returns {Promise<string>} The formatted message
725
+ */
726
+ const getErrorMessage = async (count, isReminder = false, aiContext = {}) => {
727
+ const messages = isReminder
728
+ ? config.errorReminderTTSMessages
729
+ : config.errorTTSMessages;
730
+
731
+ // If AI messages are enabled, ALWAYS try AI first (regardless of count)
732
+ if (config.enableAIMessages) {
733
+ // Merge count/type info with any provided context (projectName, sessionTitle, etc.)
734
+ const fullContext = { count, type: 'error', ...aiContext };
735
+ const aiMessage = await getSmartMessage('error', isReminder, messages, fullContext);
736
+ // getSmartMessage returns static message as fallback, so if AI was attempted
737
+ // and succeeded, we'll get the AI message. If it failed, we get static.
738
+ // Check if we got a valid message (not the generic fallback)
739
+ if (aiMessage && aiMessage !== 'Notification') {
740
+ return aiMessage;
741
+ }
742
+ }
743
+
744
+ // Fallback to static messages (AI disabled or failed with generic fallback)
745
+ if (count === 1) {
746
+ return getRandomMessage(messages);
747
+ } else {
748
+ const countMessages = isReminder
749
+ ? config.errorReminderTTSMessagesMultiple
750
+ : config.errorTTSMessagesMultiple;
751
+
752
+ if (countMessages && countMessages.length > 0) {
753
+ const template = getRandomMessage(countMessages);
754
+ return template.replace('{count}', count.toString());
755
+ }
756
+ return `Alert! There are ${count} errors that need your attention.`;
757
+ }
758
+ };
759
+
487
760
  /**
488
761
  * Process the batched permission requests as a single notification
489
762
  * Called after the batch window expires
@@ -509,15 +782,42 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
509
782
  // We track all IDs in the batch for proper cleanup
510
783
  activePermissionId = batch[0];
511
784
 
785
+ // Build context for AI message generation (context-aware AI feature)
786
+ // For permissions, we only have project name (no session fetch to avoid delay)
787
+ const aiContext = {
788
+ projectName: derivedProjectName
789
+ };
790
+
791
+ // Check if we should suppress sound/desktop notifications due to focus
792
+ const suppressPermission = await shouldSuppressNotification();
793
+
512
794
  // Step 1: Show toast IMMEDIATELY (fire and forget - no await)
795
+ // Toast is always shown (it's inside the terminal, so not disruptive if focused)
513
796
  const toastMessage = batchCount === 1
514
797
  ? "⚠️ Permission request requires your attention"
515
798
  : `⚠️ ${batchCount} permission requests require your attention`;
516
799
  showToast(toastMessage, "warning", 8000); // No await - instant display
517
800
 
518
- // Step 2: Play sound (after toast is triggered)
801
+ // Step 1b: Send desktop notification (only if not suppressed)
802
+ const desktopMessage = batchCount === 1
803
+ ? 'Agent needs permission to proceed. Please review the request.'
804
+ : `${batchCount} permission requests are waiting for your approval.`;
805
+ if (!suppressPermission) {
806
+ sendDesktopNotify('permission', desktopMessage, { count: batchCount });
807
+ } else {
808
+ debugLog('processPermissionBatch: desktop notification suppressed (terminal focused)');
809
+ }
810
+
811
+ // Step 1c: Send webhook notification
812
+ sendWebhookNotify('permission', desktopMessage, { count: batchCount });
813
+
814
+ // Step 2: Play sound (only if not suppressed)
519
815
  const soundLoops = batchCount === 1 ? 2 : Math.min(3, batchCount);
520
- await playSound(config.permissionSound, soundLoops);
816
+ if (!suppressPermission) {
817
+ await playSound(config.permissionSound, soundLoops, 'permission');
818
+ } else {
819
+ debugLog('processPermissionBatch: sound suppressed (terminal focused)');
820
+ }
521
821
 
522
822
  // CHECK: Did user already respond while sound was playing?
523
823
  if (pendingPermissionBatch.length > 0) {
@@ -525,26 +825,27 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
525
825
  debugLog('processPermissionBatch: new permissions arrived during sound');
526
826
  }
527
827
 
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
828
+ // Step 3: Check race condition - did user respond during sound?
829
+ if (activePermissionId === null) {
830
+ debugLog('processPermissionBatch: user responded during sound - aborting');
831
+ return;
832
+ }
833
+
834
+ // Step 4: Schedule TTS reminder if enabled
835
+ // NOTE: The AI message is generated ONLY when the reminder fires (inside scheduleTTSReminder)
836
+ // This avoids wasteful immediate AI generation in sound-first mode - the user might respond before the reminder fires
837
+ // IMPORTANT: Skip TTS reminder entirely in 'sound-only' mode
838
+ if (config.enableTTSReminder && config.notificationMode !== 'sound-only') {
839
+ scheduleTTSReminder('permission', null, {
840
+ fallbackSound: config.permissionSound,
841
+ permissionCount: batchCount,
842
+ aiContext // Pass context for reminder message generation
843
+ });
844
+ }
845
+
846
+ // Step 5: If TTS-first or both mode, generate and speak immediate message
546
847
  if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
547
- const ttsMessage = await getPermissionMessage(batchCount, false);
848
+ const ttsMessage = await getPermissionMessage(batchCount, false, aiContext);
548
849
  await tts.wakeMonitor();
549
850
  await tts.forceVolume();
550
851
  await tts.speak(ttsMessage, {
@@ -588,14 +889,41 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
588
889
  // We track all IDs in the batch for proper cleanup
589
890
  activeQuestionId = batch[0]?.id;
590
891
 
892
+ // Build context for AI message generation (context-aware AI feature)
893
+ // For questions, we only have project name (no session fetch to avoid delay)
894
+ const aiContext = {
895
+ projectName: derivedProjectName
896
+ };
897
+
898
+ // Check if we should suppress sound/desktop notifications due to focus
899
+ const suppressQuestion = await shouldSuppressNotification();
900
+
591
901
  // Step 1: Show toast IMMEDIATELY (fire and forget - no await)
902
+ // Toast is always shown (it's inside the terminal, so not disruptive if focused)
592
903
  const toastMessage = totalQuestionCount === 1
593
904
  ? "❓ The agent has a question for you"
594
905
  : `❓ The agent has ${totalQuestionCount} questions for you`;
595
906
  showToast(toastMessage, "info", 8000); // No await - instant display
596
907
 
597
- // Step 2: Play sound (after toast is triggered)
598
- await playSound(config.questionSound, 2);
908
+ // Step 1b: Send desktop notification (only if not suppressed)
909
+ const desktopMessage = totalQuestionCount === 1
910
+ ? 'The agent has a question and needs your input.'
911
+ : `The agent has ${totalQuestionCount} questions for you. Please check your screen.`;
912
+ if (!suppressQuestion) {
913
+ sendDesktopNotify('question', desktopMessage, { count: totalQuestionCount });
914
+ } else {
915
+ debugLog('processQuestionBatch: desktop notification suppressed (terminal focused)');
916
+ }
917
+
918
+ // Step 1c: Send webhook notification
919
+ sendWebhookNotify('question', desktopMessage, { count: totalQuestionCount });
920
+
921
+ // Step 2: Play sound (only if not suppressed)
922
+ if (!suppressQuestion) {
923
+ await playSound(config.questionSound, 2, 'question');
924
+ } else {
925
+ debugLog('processQuestionBatch: sound suppressed (terminal focused)');
926
+ }
599
927
 
600
928
  // CHECK: Did user already respond while sound was playing?
601
929
  if (pendingQuestionBatch.length > 0) {
@@ -603,26 +931,27 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
603
931
  debugLog('processQuestionBatch: new questions arrived during sound');
604
932
  }
605
933
 
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
934
+ // Step 3: Check race condition - did user respond during sound?
935
+ if (activeQuestionId === null) {
936
+ debugLog('processQuestionBatch: user responded during sound - aborting');
937
+ return;
938
+ }
939
+
940
+ // Step 4: Schedule TTS reminder if enabled
941
+ // NOTE: The AI message is generated ONLY when the reminder fires (inside scheduleTTSReminder)
942
+ // This avoids wasteful immediate AI generation in sound-first mode - the user might respond before the reminder fires
943
+ // IMPORTANT: Skip TTS reminder entirely in 'sound-only' mode
944
+ if (config.enableTTSReminder && config.notificationMode !== 'sound-only') {
945
+ scheduleTTSReminder('question', null, {
946
+ fallbackSound: config.questionSound,
947
+ questionCount: totalQuestionCount,
948
+ aiContext // Pass context for reminder message generation
949
+ });
950
+ }
951
+
952
+ // Step 5: If TTS-first or both mode, generate and speak immediate message
624
953
  if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
625
- const ttsMessage = await getQuestionMessage(totalQuestionCount, false);
954
+ const ttsMessage = await getQuestionMessage(totalQuestionCount, false, aiContext);
626
955
  await tts.wakeMonitor();
627
956
  await tts.forceVolume();
628
957
  await tts.speak(ttsMessage, {
@@ -640,7 +969,36 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
640
969
 
641
970
  return {
642
971
  event: async ({ event }) => {
972
+ // Reload config on every event to support live configuration changes
973
+ // without requiring a plugin restart.
974
+ config = getTTSConfig();
975
+
976
+ // Update TTS utility instance with latest config
977
+ // Note: createTTS internally calls getTTSConfig(), so it will have up-to-date values
978
+ tts = createTTS({ $, client });
979
+
980
+ // Master switch check - if disabled, skip all event processing
981
+ // Handle both boolean false and string "false"/"disabled"
982
+ const isPluginEnabled = config.enabled !== false &&
983
+ String(config.enabled).toLowerCase() !== 'false' &&
984
+ String(config.enabled).toLowerCase() !== 'disabled';
985
+
986
+ if (!isPluginEnabled) {
987
+ // Cancel any pending reminders if the plugin was just disabled
988
+ if (pendingReminders.size > 0) {
989
+ debugLog('Plugin disabled via config - cancelling all pending reminders');
990
+ cancelAllPendingReminders();
991
+ }
992
+
993
+ // Only log once per event to avoid flooding
994
+ if (event.type === "session.idle" || event.type === "permission.asked" || event.type === "question.asked") {
995
+ debugLog(`Plugin is disabled via config (enabled: ${config.enabled}) - skipping ${event.type}`);
996
+ }
997
+ return;
998
+ }
999
+
643
1000
  try {
1001
+
644
1002
  // ========================================
645
1003
  // USER ACTIVITY DETECTION
646
1004
  // Cancels pending TTS reminders when user responds
@@ -766,61 +1124,181 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
766
1124
  // AI message generation can take 3-15+ seconds, which was delaying sound playback.
767
1125
  // ========================================
768
1126
  if (event.type === "session.idle") {
1127
+ // Check if idle notifications are enabled
1128
+ if (config.enableIdleNotification === false) {
1129
+ debugLog('session.idle: skipped (enableIdleNotification=false)');
1130
+ return;
1131
+ }
1132
+
769
1133
  const sessionID = event.properties?.sessionID;
770
1134
  if (!sessionID) return;
771
1135
 
1136
+ // Fetch session details for context-aware AI and sub-session filtering
1137
+ let sessionData = null;
772
1138
  try {
773
1139
  const session = await client.session.get({ path: { id: sessionID } });
774
- if (session?.data?.parentID) {
1140
+ sessionData = session?.data;
1141
+ if (sessionData?.parentID) {
775
1142
  debugLog(`session.idle: skipped (sub-session ${sessionID})`);
776
1143
  return;
777
1144
  }
778
1145
  } catch (e) {}
779
1146
 
1147
+ // Build context for AI message generation (used when enableContextAwareAI is true)
1148
+ // Note: SDK's Project type doesn't have 'name' property, so we use derivedProjectName
1149
+ const aiContext = {
1150
+ projectName: derivedProjectName,
1151
+ sessionTitle: sessionData?.title,
1152
+ sessionSummary: sessionData?.summary ? {
1153
+ files: sessionData.summary.files,
1154
+ additions: sessionData.summary.additions,
1155
+ deletions: sessionData.summary.deletions
1156
+ } : undefined
1157
+ };
1158
+
780
1159
  // Record the time session went idle - used to filter out pre-idle messages
781
1160
  lastSessionIdleTime = Date.now();
782
1161
 
783
1162
  debugLog(`session.idle: notifying for session ${sessionID} (idleTime=${lastSessionIdleTime})`);
784
1163
 
1164
+ // Check if we should suppress sound/desktop notifications due to focus
1165
+ const suppressIdle = await shouldSuppressNotification();
1166
+
785
1167
  // Step 1: Show toast IMMEDIATELY (fire and forget - no await)
1168
+ // Toast is always shown (it's inside the terminal, so not disruptive if focused)
786
1169
  showToast("✅ Agent has finished working", "success", 5000); // No await - instant display
787
1170
 
788
- // Step 2: Play sound (after toast is triggered)
1171
+ // Step 1b: Send desktop notification (only if not suppressed)
1172
+ if (!suppressIdle) {
1173
+ sendDesktopNotify('idle', 'Agent has finished working. Your code is ready for review.');
1174
+ } else {
1175
+ debugLog('session.idle: desktop notification suppressed (terminal focused)');
1176
+ }
1177
+
1178
+ // Step 1c: Send webhook notification
1179
+ sendWebhookNotify('idle', 'Agent has finished working. Your code is ready for review.', { sessionId: sessionID });
1180
+
1181
+ // Step 2: Play sound (only if not suppressed)
789
1182
  // Only play sound in sound-first, sound-only, or both mode
790
1183
  if (config.notificationMode !== 'tts-first') {
791
- await playSound(config.idleSound, 1);
1184
+ if (!suppressIdle) {
1185
+ await playSound(config.idleSound, 1, 'idle');
1186
+ } else {
1187
+ debugLog('session.idle: sound suppressed (terminal focused)');
1188
+ }
792
1189
  }
793
1190
 
794
- // Step 3: Check race condition - did user respond during sound?
795
- if (lastUserActivityTime > lastSessionIdleTime) {
796
- debugLog(`session.idle: user active during sound - aborting`);
1191
+ // Step 3: Check race condition - did user respond during sound?
1192
+ if (lastUserActivityTime > lastSessionIdleTime) {
1193
+ debugLog(`session.idle: user active during sound - aborting`);
1194
+ return;
1195
+ }
1196
+
1197
+ // Step 4: Schedule TTS reminder if enabled
1198
+ // NOTE: The AI message is generated ONLY when the reminder fires (inside scheduleTTSReminder)
1199
+ // This avoids wasteful immediate AI generation in sound-first mode - the user might respond before the reminder fires
1200
+ // IMPORTANT: Skip TTS reminder entirely in 'sound-only' mode
1201
+ if (config.enableTTSReminder && config.notificationMode !== 'sound-only') {
1202
+ scheduleTTSReminder('idle', null, {
1203
+ fallbackSound: config.idleSound,
1204
+ aiContext // Pass context for reminder message generation
1205
+ });
1206
+ }
1207
+
1208
+ // Step 5: If TTS-first or both mode, generate and speak immediate message
1209
+ if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
1210
+ const ttsMessage = await getSmartMessage('idle', false, config.idleTTSMessages, aiContext);
1211
+ await tts.wakeMonitor();
1212
+ await tts.forceVolume();
1213
+ await tts.speak(ttsMessage, {
1214
+ enableTTS: true,
1215
+ fallbackSound: config.idleSound
1216
+ });
1217
+ }
1218
+ }
1219
+
1220
+ // ========================================
1221
+ // NOTIFICATION 2: Session Error (Agent encountered an error)
1222
+ //
1223
+ // FIX: Play sound IMMEDIATELY before any AI generation to avoid delay.
1224
+ // AI message generation can take 3-15+ seconds, which was delaying sound playback.
1225
+ // ========================================
1226
+ if (event.type === "session.error") {
1227
+ // Check if error notifications are enabled
1228
+ if (config.enableErrorNotification === false) {
1229
+ debugLog('session.error: skipped (enableErrorNotification=false)');
797
1230
  return;
798
1231
  }
799
1232
 
800
- // Step 4: Generate AI message for reminder AFTER sound played
801
- const reminderMessage = await getSmartMessage('idle', true, config.idleReminderTTSMessages);
1233
+ const sessionID = event.properties?.sessionID;
1234
+ if (!sessionID) {
1235
+ debugLog(`session.error: skipped (no sessionID)`);
1236
+ return;
1237
+ }
802
1238
 
803
- // Step 5: Schedule TTS reminder if enabled
804
- if (config.enableTTSReminder && reminderMessage) {
805
- scheduleTTSReminder('idle', reminderMessage, {
806
- fallbackSound: config.idleSound
807
- });
1239
+ // Skip sub-sessions (child sessions spawned for parallel operations)
1240
+ try {
1241
+ const session = await client.session.get({ path: { id: sessionID } });
1242
+ if (session?.data?.parentID) {
1243
+ debugLog(`session.error: skipped (sub-session ${sessionID})`);
1244
+ return;
1245
+ }
1246
+ } catch (e) {}
1247
+
1248
+ debugLog(`session.error: notifying for session ${sessionID}`);
1249
+
1250
+ // Check if we should suppress sound/desktop notifications due to focus
1251
+ const suppressError = await shouldSuppressNotification();
1252
+
1253
+ // Step 1: Show toast IMMEDIATELY (fire and forget - no await)
1254
+ // Toast is always shown (it's inside the terminal, so not disruptive if focused)
1255
+ showToast("❌ Agent encountered an error", "error", 8000); // No await - instant display
1256
+
1257
+ // Step 1b: Send desktop notification (only if not suppressed)
1258
+ if (!suppressError) {
1259
+ sendDesktopNotify('error', 'The agent encountered an error and needs your attention.');
1260
+ } else {
1261
+ debugLog('session.error: desktop notification suppressed (terminal focused)');
808
1262
  }
1263
+
1264
+ // Step 1c: Send webhook notification
1265
+ sendWebhookNotify('error', 'The agent encountered an error and needs your attention.', { sessionId: sessionID });
809
1266
 
810
- // Step 6: If TTS-first or both mode, generate and speak immediate message
1267
+ // Step 2: Play sound (only if not suppressed)
1268
+ // Only play sound in sound-first, sound-only, or both mode
1269
+ if (config.notificationMode !== 'tts-first') {
1270
+ if (!suppressError) {
1271
+ await playSound(config.errorSound, 2, 'error'); // Play twice for urgency
1272
+ } else {
1273
+ debugLog('session.error: sound suppressed (terminal focused)');
1274
+ }
1275
+ }
1276
+
1277
+ // Step 3: Schedule TTS reminder if enabled
1278
+ // NOTE: The AI message is generated ONLY when the reminder fires (inside scheduleTTSReminder)
1279
+ // This avoids wasteful immediate AI generation in sound-first mode - the user might respond before the reminder fires
1280
+ // IMPORTANT: Skip TTS reminder entirely in 'sound-only' mode
1281
+ if (config.enableTTSReminder && config.notificationMode !== 'sound-only') {
1282
+ scheduleTTSReminder('error', null, {
1283
+ fallbackSound: config.errorSound,
1284
+ errorCount: 1
1285
+ });
1286
+ }
1287
+
1288
+ // Step 4: If TTS-first or both mode, generate and speak immediate message
811
1289
  if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
812
- const ttsMessage = await getSmartMessage('idle', false, config.idleTTSMessages);
1290
+ const ttsMessage = await getErrorMessage(1, false);
813
1291
  await tts.wakeMonitor();
814
1292
  await tts.forceVolume();
815
1293
  await tts.speak(ttsMessage, {
816
1294
  enableTTS: true,
817
- fallbackSound: config.idleSound
1295
+ fallbackSound: config.errorSound
818
1296
  });
819
1297
  }
820
1298
  }
821
1299
 
822
1300
  // ========================================
823
- // NOTIFICATION 2: Permission Request (BATCHED)
1301
+ // NOTIFICATION 3: Permission Request (BATCHED)
824
1302
  // ========================================
825
1303
  // NOTE: OpenCode SDK v1.1.1+ changed permission events:
826
1304
  // - Old: "permission.updated" with properties.id
@@ -830,6 +1308,12 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
830
1308
  // BATCHING: When multiple permissions arrive simultaneously (e.g., 5 at once),
831
1309
  // we batch them into a single notification instead of playing 5 overlapping sounds.
832
1310
  if (event.type === "permission.updated" || event.type === "permission.asked") {
1311
+ // Check if permission notifications are enabled
1312
+ if (config.enablePermissionNotification === false) {
1313
+ debugLog(`${event.type}: skipped (enablePermissionNotification=false)`);
1314
+ return;
1315
+ }
1316
+
833
1317
  // Capture permissionID
834
1318
  const permissionId = event.properties?.id;
835
1319
 
@@ -865,7 +1349,7 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
865
1349
  }
866
1350
 
867
1351
  // ========================================
868
- // NOTIFICATION 3: Question Request (BATCHED) - SDK v1.1.7+
1352
+ // NOTIFICATION 4: Question Request (BATCHED) - SDK v1.1.7+
869
1353
  // ========================================
870
1354
  // The "question" tool allows the LLM to ask users questions during execution.
871
1355
  // Events: question.asked, question.replied, question.rejected
@@ -874,6 +1358,12 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
874
1358
  // we batch them into a single notification instead of playing overlapping sounds.
875
1359
  // NOTE: Each question.asked event can contain multiple questions in its questions array.
876
1360
  if (event.type === "question.asked") {
1361
+ // Check if question notifications are enabled
1362
+ if (config.enableQuestionNotification === false) {
1363
+ debugLog('question.asked: skipped (enableQuestionNotification=false)');
1364
+ return;
1365
+ }
1366
+
877
1367
  // Capture question request ID and count of questions in this request
878
1368
  const questionId = event.properties?.id;
879
1369
  const questionsArray = event.properties?.questions;