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