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/LICENSE +21 -21
- package/README.md +332 -54
- package/example.config.jsonc +139 -158
- package/index.js +587 -97
- package/package.json +10 -3
- package/util/ai-messages.js +73 -0
- package/util/config.js +307 -27
- package/util/desktop-notify.js +319 -0
- package/util/focus-detect.js +372 -0
- package/util/per-project-sound.js +90 -0
- package/util/sound-theme.js +129 -0
- package/util/tts.js +123 -69
- package/util/webhook.js +743 -0
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
159
|
-
? soundFile
|
|
160
|
-
: path.join(configDir, soundFile);
|
|
315
|
+
let soundPath = soundFile;
|
|
161
316
|
|
|
162
|
-
|
|
163
|
-
|
|
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(
|
|
170
|
-
debugLog(`playSound: played ${
|
|
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
|
|
202
|
-
* @param {string} type - 'idle', 'permission', or '
|
|
203
|
-
* @param {string}
|
|
204
|
-
* @param {object} options - Additional options (fallbackSound, permissionCount, questionCount)
|
|
205
|
-
*/
|
|
206
|
-
const scheduleTTSReminder = (type,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
//
|
|
538
|
-
if (config.enableTTSReminder &&
|
|
539
|
-
scheduleTTSReminder('permission',
|
|
540
|
-
fallbackSound: config.permissionSound,
|
|
541
|
-
permissionCount: batchCount
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
|
598
|
-
|
|
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:
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
//
|
|
616
|
-
if (config.enableTTSReminder &&
|
|
617
|
-
scheduleTTSReminder('question',
|
|
618
|
-
fallbackSound: config.questionSound,
|
|
619
|
-
questionCount: totalQuestionCount
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
801
|
-
|
|
1233
|
+
const sessionID = event.properties?.sessionID;
|
|
1234
|
+
if (!sessionID) {
|
|
1235
|
+
debugLog(`session.error: skipped (no sessionID)`);
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
802
1238
|
|
|
803
|
-
//
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
|
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
|
|
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.
|
|
1295
|
+
fallbackSound: config.errorSound
|
|
818
1296
|
});
|
|
819
1297
|
}
|
|
820
1298
|
}
|
|
821
1299
|
|
|
822
1300
|
// ========================================
|
|
823
|
-
// NOTIFICATION
|
|
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
|
|
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;
|