opencode-smart-voice-notify 1.0.10 → 1.0.13

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/README.md CHANGED
@@ -32,6 +32,7 @@ The plugin automatically tries multiple TTS engines in order, falling back if on
32
32
  - Automatic cancellation when user responds
33
33
  - Per-notification type delays (permission requests are more urgent)
34
34
  - **Smart Quota Handling**: Automatically falls back to free Edge TTS if ElevenLabs quota is exceeded
35
+ - **Permission Batching**: Multiple simultaneous permission requests are batched into a single notification (e.g., "5 permission requests require your attention")
35
36
 
36
37
  ### System Integration
37
38
  - **Native Edge TTS**: No external dependencies (Python/pip) required
@@ -40,18 +41,20 @@ The plugin automatically tries multiple TTS engines in order, falling back if on
40
41
  - TUI toast notifications
41
42
  - Cross-platform support (Windows, macOS, Linux)
42
43
 
43
- ## Installation
44
-
45
- ### Option 1: From npm (Recommended)
46
-
47
- Add to your OpenCode config file (`~/.config/opencode/opencode.json`):
48
-
49
- ```json
50
- {
51
- "$schema": "https://opencode.ai/config.json",
52
- "plugin": ["opencode-smart-voice-notify@latest"]
53
- }
54
- ```
44
+ ## Installation
45
+
46
+ ### Option 1: From npm/Bun (Recommended)
47
+
48
+ Add to your OpenCode config file (`~/.config/opencode/opencode.json`):
49
+
50
+ ```json
51
+ {
52
+ "$schema": "https://opencode.ai/config.json",
53
+ "plugin": ["opencode-smart-voice-notify@latest"]
54
+ }
55
+ ```
56
+
57
+ > **Note**: OpenCode will automatically install the plugin using your system's package manager (npm or bun).
55
58
 
56
59
  ### Option 2: From GitHub
57
60
 
@@ -80,10 +83,11 @@ Add to your OpenCode config file (`~/.config/opencode/opencode.json`):
80
83
 
81
84
  ### Automatic Setup
82
85
 
83
- When you first run OpenCode with this plugin installed, it will **automatically create**:
84
-
85
- 1. **`~/.config/opencode/smart-voice-notify.jsonc`** - A comprehensive configuration file with all available options fully documented.
86
- 2. **`~/.config/opencode/assets/*.mp3`** - Bundled notification sound files.
86
+ When you first run OpenCode with this plugin installed, it will **automatically create**:
87
+
88
+ 1. **`~/.config/opencode/smart-voice-notify.jsonc`** - A comprehensive configuration file with all available options fully documented.
89
+ 2. **`~/.config/opencode/assets/*.mp3`** - Bundled notification sound files.
90
+ 3. **`~/.config/opencode/logs/`** - Debug log folder (created when debug logging is enabled).
87
91
 
88
92
  The auto-generated configuration includes all advanced settings, message arrays, and engine options, so you don't have to refer back to the documentation for available settings.
89
93
 
@@ -115,10 +119,16 @@ If you prefer to create the config manually, add a `smart-voice-notify.jsonc` fi
115
119
  "idleReminderDelaySeconds": 30, // For task completion notifications
116
120
  "permissionReminderDelaySeconds": 20, // For permission requests (more urgent)
117
121
 
118
- // Follow-up reminders if user STILL doesn't respond after first TTS
119
- "enableFollowUpReminders": true,
120
- "maxFollowUpReminders": 3, // Max number of follow-up TTS reminders
121
- "reminderBackoffMultiplier": 1.5, // Each follow-up waits longer (30s, 45s, 67s...)
122
+ // Follow-up reminders if user STILL doesn't respond after first TTS
123
+ "enableFollowUpReminders": true,
124
+ "maxFollowUpReminders": 3, // Max number of follow-up TTS reminders
125
+ "reminderBackoffMultiplier": 1.5, // Each follow-up waits longer (30s, 45s, 67s...)
126
+
127
+ // ============================================================
128
+ // PERMISSION BATCHING (Multiple permissions at once)
129
+ // ============================================================
130
+ // When multiple permissions arrive simultaneously, batch them into one notification
131
+ "permissionBatchWindowMs": 800, // Batch window in milliseconds
122
132
 
123
133
  // ============================================================
124
134
  // TTS ENGINE SELECTION
@@ -165,13 +175,18 @@ If you prefer to create the config manually, add a `smart-voice-notify.jsonc` fi
165
175
  "Good news! Everything is done and ready for you.",
166
176
  "Finished! Let me know if you need anything else."
167
177
  ],
168
- "permissionTTSMessages": [
169
- "Attention please! I need your permission to continue.",
170
- "Hey! Quick approval needed to proceed with the task.",
171
- "Heads up! There is a permission request waiting for you.",
172
- "Excuse me! I need your authorization before I can continue.",
173
- "Permission required! Please review and approve when ready."
174
- ],
178
+ "permissionTTSMessages": [
179
+ "Attention please! I need your permission to continue.",
180
+ "Hey! Quick approval needed to proceed with the task.",
181
+ "Heads up! There is a permission request waiting for you.",
182
+ "Excuse me! I need your authorization before I can continue.",
183
+ "Permission required! Please review and approve when ready."
184
+ ],
185
+ // Messages for MULTIPLE permission requests (use {count} placeholder)
186
+ "permissionTTSMessagesMultiple": [
187
+ "Attention please! There are {count} permission requests waiting for your approval.",
188
+ "Hey! {count} permissions need your approval to continue."
189
+ ],
175
190
 
176
191
  // ============================================================
177
192
  // TTS REMINDER MESSAGES (Used after delay if no response)
@@ -183,13 +198,18 @@ If you prefer to create the config manually, add a `smart-voice-notify.jsonc` fi
183
198
  "Still waiting for you! The work is done and ready for review.",
184
199
  "Knock knock! Your completed task is patiently waiting for you."
185
200
  ],
186
- "permissionReminderTTSMessages": [
187
- "Hey! I still need your permission to continue. Please respond!",
188
- "Reminder: There is a pending permission request. I cannot proceed without you.",
189
- "Hello? I am waiting for your approval. This is getting urgent!",
190
- "Please check your screen! I really need your permission to move forward.",
191
- "Still waiting for authorization! The task is on hold until you respond."
192
- ],
201
+ "permissionReminderTTSMessages": [
202
+ "Hey! I still need your permission to continue. Please respond!",
203
+ "Reminder: There is a pending permission request. I cannot proceed without you.",
204
+ "Hello? I am waiting for your approval. This is getting urgent!",
205
+ "Please check your screen! I really need your permission to move forward.",
206
+ "Still waiting for authorization! The task is on hold until you respond."
207
+ ],
208
+ // Reminder messages for MULTIPLE permissions (use {count} placeholder)
209
+ "permissionReminderTTSMessagesMultiple": [
210
+ "Hey! I still need your approval for {count} permissions. Please respond!",
211
+ "Reminder: There are {count} pending permission requests."
212
+ ],
193
213
 
194
214
  // ============================================================
195
215
  // SOUND FILES (relative to OpenCode config directory)
@@ -229,33 +249,44 @@ See `example.config.jsonc` for more details.
229
249
  - **macOS**: Built-in (`afplay`)
230
250
  - **Linux**: `paplay` or `aplay`
231
251
 
232
- ## Events Handled
233
-
234
- | Event | Action |
235
- |-------|--------|
236
- | `session.idle` | Agent finished working - notify user |
237
- | `permission.updated` | Permission request - alert user |
238
- | `permission.replied` | User responded - cancel pending reminders |
239
- | `message.updated` | New user message - cancel pending reminders |
240
- | `session.created` | New session - reset state |
241
-
242
- ## Development
243
-
244
- To develop on this plugin locally:
245
-
246
- 1. Clone the repository:
247
- ```bash
248
- git clone https://github.com/MasuRii/opencode-smart-voice-notify.git
249
- cd opencode-smart-voice-notify
250
- bun install # or npm install
251
- ```
252
+ ## Events Handled
253
+
254
+ | Event | Action |
255
+ |-------|--------|
256
+ | `session.idle` | Agent finished working - notify user |
257
+ | `permission.asked` | Permission request (SDK v1.1.1+) - alert user |
258
+ | `permission.updated` | Permission request (SDK v1.0.x) - alert user |
259
+ | `permission.replied` | User responded - cancel pending reminders |
260
+ | `message.updated` | New user message - cancel pending reminders |
261
+ | `session.created` | New session - reset state |
262
+
263
+ > **Note**: The plugin supports both OpenCode SDK v1.0.x and v1.1.x for backward compatibility.
252
264
 
253
- 2. Link to your OpenCode config:
254
- ```json
255
- {
256
- "plugin": ["file:///absolute/path/to/opencode-smart-voice-notify"]
257
- }
258
- ```
265
+ ## Development
266
+
267
+ To develop on this plugin locally:
268
+
269
+ 1. Clone the repository:
270
+ ```bash
271
+ git clone https://github.com/MasuRii/opencode-smart-voice-notify.git
272
+ cd opencode-smart-voice-notify
273
+ ```
274
+
275
+ 2. Install dependencies:
276
+ ```bash
277
+ # Using Bun (recommended)
278
+ bun install
279
+
280
+ # Or using npm
281
+ npm install
282
+ ```
283
+
284
+ 3. Link to your OpenCode config:
285
+ ```json
286
+ {
287
+ "plugin": ["file:///absolute/path/to/opencode-smart-voice-notify"]
288
+ }
289
+ ```
259
290
 
260
291
  ## Updating
261
292
 
@@ -44,6 +44,16 @@
44
44
  "maxFollowUpReminders": 3, // Max number of follow-up TTS reminders
45
45
  "reminderBackoffMultiplier": 1.5, // Each follow-up waits longer (30s, 45s, 67s...)
46
46
 
47
+ // ============================================================
48
+ // PERMISSION BATCHING (Multiple permissions at once)
49
+ // ============================================================
50
+ // When multiple permissions arrive simultaneously (e.g., 5 at once),
51
+ // batch them into a single notification instead of playing 5 overlapping sounds.
52
+ // The notification will say "X permission requests require your attention".
53
+
54
+ // Batch window (ms) - how long to wait for more permissions before notifying
55
+ "permissionBatchWindowMs": 800,
56
+
47
57
  // ============================================================
48
58
  // TTS ENGINE SELECTION
49
59
  // ============================================================
@@ -141,6 +151,16 @@
141
151
  "Excuse me! I need your authorization before I can continue.",
142
152
  "Permission required! Please review and approve when ready."
143
153
  ],
154
+
155
+ // Messages for MULTIPLE permission requests (use {count} placeholder)
156
+ // Used when several permissions arrive simultaneously
157
+ "permissionTTSMessagesMultiple": [
158
+ "Attention please! There are {count} permission requests waiting for your approval.",
159
+ "Hey! {count} permissions need your approval to continue.",
160
+ "Heads up! You have {count} pending permission requests.",
161
+ "Excuse me! I need your authorization for {count} different actions.",
162
+ "{count} permissions required! Please review and approve when ready."
163
+ ],
144
164
 
145
165
  // ============================================================
146
166
  // TTS REMINDER MESSAGES (More urgent - used after delay if no response)
@@ -165,6 +185,15 @@
165
185
  "Still waiting for authorization! The task is on hold until you respond."
166
186
  ],
167
187
 
188
+ // Reminder messages for MULTIPLE permissions (use {count} placeholder)
189
+ "permissionReminderTTSMessagesMultiple": [
190
+ "Hey! I still need your approval for {count} permissions. Please respond!",
191
+ "Reminder: There are {count} pending permission requests. I cannot proceed without you.",
192
+ "Hello? I am waiting for your approval on {count} items. This is getting urgent!",
193
+ "Please check your screen! {count} permissions are waiting for your response.",
194
+ "Still waiting for authorization on {count} requests! The task is on hold."
195
+ ],
196
+
168
197
  // ============================================================
169
198
  // SOUND FILES (For immediate notifications)
170
199
  // These are played first before TTS reminder kicks in
@@ -198,7 +227,8 @@
198
227
  // Consider monitor asleep after this many seconds of inactivity (Windows only)
199
228
  "idleThresholdSeconds": 60,
200
229
 
201
- // Enable debug logging to ~/.config/opencode/smart-voice-notify-debug.log
230
+ // Enable debug logging to ~/.config/opencode/logs/smart-voice-notify-debug.log
231
+ // The logs folder is created automatically when debug logging is enabled
202
232
  // Useful for troubleshooting notification issues
203
233
  "debugLog": false
204
234
  }
package/index.js CHANGED
@@ -27,7 +27,17 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
27
27
 
28
28
  const platform = os.platform();
29
29
  const configDir = process.env.OPENCODE_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencode');
30
- const logFile = path.join(configDir, 'smart-voice-notify-debug.log');
30
+ const logsDir = path.join(configDir, 'logs');
31
+ const logFile = path.join(logsDir, 'smart-voice-notify-debug.log');
32
+
33
+ // Ensure logs directory exists if debug logging is enabled
34
+ if (config.debugLog && !fs.existsSync(logsDir)) {
35
+ try {
36
+ fs.mkdirSync(logsDir, { recursive: true });
37
+ } catch (e) {
38
+ // Silently fail - logging is optional
39
+ }
40
+ }
31
41
 
32
42
  // Track pending TTS reminders (can be cancelled if user responds)
33
43
  const pendingReminders = new Map();
@@ -47,6 +57,20 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
47
57
  // before async notification code runs. Set on permission.updated, cleared on permission.replied.
48
58
  let activePermissionId = null;
49
59
 
60
+ // ========================================
61
+ // PERMISSION BATCHING STATE
62
+ // Batches multiple simultaneous permission requests into a single notification
63
+ // ========================================
64
+
65
+ // Array of permission IDs waiting to be notified (collected during batch window)
66
+ let pendingPermissionBatch = [];
67
+
68
+ // Timeout ID for the batch window (debounce timer)
69
+ let permissionBatchTimeout = null;
70
+
71
+ // Batch window duration in milliseconds (how long to wait for more permissions)
72
+ const PERMISSION_BATCH_WINDOW_MS = config.permissionBatchWindowMs || 800;
73
+
50
74
  /**
51
75
  * Write debug message to log file
52
76
  */
@@ -137,8 +161,8 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
137
161
  * Schedule a TTS reminder if user doesn't respond within configured delay.
138
162
  * The reminder uses a personalized TTS message.
139
163
  * @param {string} type - 'idle' or 'permission'
140
- * @param {string} message - The TTS message to speak
141
- * @param {object} options - Additional options
164
+ * @param {string} message - The TTS message to speak (used directly, supports count-aware messages)
165
+ * @param {object} options - Additional options (fallbackSound, permissionCount)
142
166
  */
143
167
  const scheduleTTSReminder = (type, message, options = {}) => {
144
168
  // Check if TTS reminders are enabled
@@ -156,7 +180,10 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
156
180
  // Cancel any existing reminder of this type
157
181
  cancelPendingReminder(type);
158
182
 
159
- debugLog(`scheduleTTSReminder: scheduling ${type} TTS in ${delaySeconds}s`);
183
+ // Store permission count for generating count-aware messages in reminders
184
+ const permissionCount = options.permissionCount || 1;
185
+
186
+ debugLog(`scheduleTTSReminder: scheduling ${type} TTS in ${delaySeconds}s (count=${permissionCount})`);
160
187
 
161
188
  const timeoutId = setTimeout(async () => {
162
189
  try {
@@ -174,14 +201,17 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
174
201
  return;
175
202
  }
176
203
 
177
- debugLog(`scheduleTTSReminder: firing ${type} TTS reminder`);
178
-
179
- // Get the appropriate reminder messages (more personalized/urgent)
180
- const reminderMessages = type === 'permission'
181
- ? config.permissionReminderTTSMessages
182
- : config.idleReminderTTSMessages;
204
+ debugLog(`scheduleTTSReminder: firing ${type} TTS reminder (count=${reminder?.permissionCount || 1})`);
183
205
 
184
- const reminderMessage = getRandomMessage(reminderMessages);
206
+ // Get the appropriate reminder message
207
+ // For permissions with count > 1, use the count-aware message generator
208
+ const storedCount = reminder?.permissionCount || 1;
209
+ let reminderMessage;
210
+ if (type === 'permission') {
211
+ reminderMessage = getPermissionMessage(storedCount, true);
212
+ } else {
213
+ reminderMessage = getRandomMessage(config.idleReminderTTSMessages);
214
+ }
185
215
 
186
216
  // Check for ElevenLabs API key configuration issues
187
217
  // If user hasn't responded (reminder firing) and config is missing, warn about fallback
@@ -226,7 +256,15 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
226
256
  return;
227
257
  }
228
258
 
229
- const followUpMessage = getRandomMessage(reminderMessages);
259
+ // Use count-aware message for follow-ups too
260
+ const followUpStoredCount = followUpReminder?.permissionCount || 1;
261
+ let followUpMessage;
262
+ if (type === 'permission') {
263
+ followUpMessage = getPermissionMessage(followUpStoredCount, true);
264
+ } else {
265
+ followUpMessage = getRandomMessage(config.idleReminderTTSMessages);
266
+ }
267
+
230
268
  await tts.wakeMonitor();
231
269
  await tts.forceVolume();
232
270
  await tts.speak(followUpMessage, {
@@ -240,7 +278,8 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
240
278
  pendingReminders.set(type, {
241
279
  timeoutId: followUpTimeoutId,
242
280
  scheduledAt: Date.now(),
243
- followUpCount
281
+ followUpCount,
282
+ permissionCount: storedCount // Preserve the count for follow-ups
244
283
  });
245
284
  }
246
285
  }
@@ -250,11 +289,12 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
250
289
  }
251
290
  }, delayMs);
252
291
 
253
- // Store the pending reminder
292
+ // Store the pending reminder with permission count
254
293
  pendingReminders.set(type, {
255
294
  timeoutId,
256
295
  scheduledAt: Date.now(),
257
- followUpCount: 0
296
+ followUpCount: 0,
297
+ permissionCount // Store count for later use
258
298
  });
259
299
  };
260
300
 
@@ -268,7 +308,8 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
268
308
  soundFile,
269
309
  soundLoops = 1,
270
310
  ttsMessage,
271
- fallbackSound
311
+ fallbackSound,
312
+ permissionCount = 1 // Support permission count for batched notifications
272
313
  } = options;
273
314
 
274
315
  // Step 1: Play the immediate sound notification
@@ -290,7 +331,7 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
290
331
 
291
332
  // Step 2: Schedule TTS reminder if user doesn't respond
292
333
  if (config.enableTTSReminder && ttsMessage) {
293
- scheduleTTSReminder(type, ttsMessage, { fallbackSound });
334
+ scheduleTTSReminder(type, ttsMessage, { fallbackSound, permissionCount });
294
335
  }
295
336
 
296
337
  // Step 3: If TTS-first mode is enabled, also speak immediately
@@ -306,6 +347,108 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
306
347
  }
307
348
  };
308
349
 
350
+ /**
351
+ * Get a count-aware TTS message for permission requests
352
+ * @param {number} count - Number of permission requests
353
+ * @param {boolean} isReminder - Whether this is a reminder message
354
+ * @returns {string} The formatted message
355
+ */
356
+ const getPermissionMessage = (count, isReminder = false) => {
357
+ const messages = isReminder
358
+ ? config.permissionReminderTTSMessages
359
+ : config.permissionTTSMessages;
360
+
361
+ if (count === 1) {
362
+ // Single permission - use regular message
363
+ return getRandomMessage(messages);
364
+ } else {
365
+ // Multiple permissions - use count-aware messages if available, or format dynamically
366
+ const countMessages = isReminder
367
+ ? config.permissionReminderTTSMessagesMultiple
368
+ : config.permissionTTSMessagesMultiple;
369
+
370
+ if (countMessages && countMessages.length > 0) {
371
+ // Use configured multi-permission messages (replace {count} placeholder)
372
+ const template = getRandomMessage(countMessages);
373
+ return template.replace('{count}', count.toString());
374
+ } else {
375
+ // Fallback: generate a dynamic message
376
+ return `Attention! There are ${count} permission requests waiting for your approval.`;
377
+ }
378
+ }
379
+ };
380
+
381
+ /**
382
+ * Process the batched permission requests as a single notification
383
+ * Called after the batch window expires
384
+ */
385
+ const processPermissionBatch = async () => {
386
+ // Capture and clear the batch
387
+ const batch = [...pendingPermissionBatch];
388
+ const batchCount = batch.length;
389
+ pendingPermissionBatch = [];
390
+ permissionBatchTimeout = null;
391
+
392
+ if (batchCount === 0) {
393
+ debugLog('processPermissionBatch: empty batch, skipping');
394
+ return;
395
+ }
396
+
397
+ debugLog(`processPermissionBatch: processing ${batchCount} permission(s)`);
398
+
399
+ // Set activePermissionId to the first one (for race condition checks)
400
+ // We track all IDs in the batch for proper cleanup
401
+ activePermissionId = batch[0];
402
+
403
+ // Show toast with count
404
+ const toastMessage = batchCount === 1
405
+ ? "⚠️ Permission request requires your attention"
406
+ : `⚠️ ${batchCount} permission requests require your attention`;
407
+ await showToast(toastMessage, "warning", 8000);
408
+
409
+ // CHECK: Did user already respond while we were showing toast?
410
+ if (pendingPermissionBatch.length > 0) {
411
+ // New permissions arrived during toast - they'll be handled in next batch
412
+ debugLog('processPermissionBatch: new permissions arrived during toast');
413
+ }
414
+
415
+ // Check if any permission was already replied to
416
+ if (activePermissionId === null) {
417
+ debugLog('processPermissionBatch: aborted - user already responded');
418
+ return;
419
+ }
420
+
421
+ // Get count-aware TTS message
422
+ const ttsMessage = getPermissionMessage(batchCount, false);
423
+ const reminderMessage = getPermissionMessage(batchCount, true);
424
+
425
+ // Smart notification: sound first, TTS reminder later
426
+ await smartNotify('permission', {
427
+ soundFile: config.permissionSound,
428
+ soundLoops: batchCount === 1 ? 2 : Math.min(3, batchCount), // More loops for more permissions
429
+ ttsMessage: reminderMessage,
430
+ fallbackSound: config.permissionSound,
431
+ // Pass count for potential use in notification
432
+ permissionCount: batchCount
433
+ });
434
+
435
+ // Speak immediately if in TTS-first or both mode (with count-aware message)
436
+ if (config.notificationMode === 'tts-first' || config.notificationMode === 'both') {
437
+ await tts.wakeMonitor();
438
+ await tts.forceVolume();
439
+ await tts.speak(ttsMessage, {
440
+ enableTTS: true,
441
+ fallbackSound: config.permissionSound
442
+ });
443
+ }
444
+
445
+ // Final check: if user responded during notification, cancel scheduled reminder
446
+ if (activePermissionId === null) {
447
+ debugLog('processPermissionBatch: user responded during notification - cancelling reminder');
448
+ cancelPendingReminder('permission');
449
+ }
450
+ };
451
+
309
452
  return {
310
453
  event: async ({ event }) => {
311
454
  try {
@@ -313,9 +456,13 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
313
456
  // USER ACTIVITY DETECTION
314
457
  // Cancels pending TTS reminders when user responds
315
458
  // ========================================
316
- // NOTE: OpenCode event types (as of SDK v1.0.203):
459
+ // NOTE: OpenCode event types (supporting SDK v1.0.x and v1.1.x):
317
460
  // - message.updated: fires when a message is added/updated (use properties.info.role to check user vs assistant)
461
+ // - permission.updated (SDK v1.0.x): fires when a permission request is created
462
+ // - permission.asked (SDK v1.1.1+): fires when a permission request is created (replaces permission.updated)
318
463
  // - permission.replied: fires when user responds to a permission request
464
+ // - SDK v1.0.x: uses permissionID, response
465
+ // - SDK v1.1.1+: uses requestID, reply
319
466
  // - session.created: fires when a new session starts
320
467
  //
321
468
  // CRITICAL: message.updated fires for EVERY modification to a message (not just creation).
@@ -361,20 +508,37 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
361
508
 
362
509
  if (event.type === "permission.replied") {
363
510
  // User responded to a permission request (granted or denied)
364
- // Structure: event.properties.{ sessionID, permissionID, response }
511
+ // Structure varies by SDK version:
512
+ // - Old SDK: event.properties.{ sessionID, permissionID, response }
513
+ // - New SDK (v1.1.1+): event.properties.{ sessionID, requestID, reply }
365
514
  // CRITICAL: Clear activePermissionId FIRST to prevent race condition
366
- // where permission.updated handler is still running async operations
367
- const repliedPermissionId = event.properties?.permissionID;
515
+ // where permission.updated/asked handler is still running async operations
516
+ const repliedPermissionId = event.properties?.permissionID || event.properties?.requestID;
517
+ const response = event.properties?.response || event.properties?.reply;
518
+
519
+ // Remove this permission from the pending batch (if still waiting)
520
+ if (repliedPermissionId && pendingPermissionBatch.includes(repliedPermissionId)) {
521
+ pendingPermissionBatch = pendingPermissionBatch.filter(id => id !== repliedPermissionId);
522
+ debugLog(`Permission replied: removed ${repliedPermissionId} from pending batch (${pendingPermissionBatch.length} remaining)`);
523
+ }
524
+
525
+ // If batch is now empty and we have a pending batch timeout, we can cancel it
526
+ // (user responded to all permissions before batch window expired)
527
+ if (pendingPermissionBatch.length === 0 && permissionBatchTimeout) {
528
+ clearTimeout(permissionBatchTimeout);
529
+ permissionBatchTimeout = null;
530
+ debugLog('Permission replied: cancelled batch timeout (all permissions handled)');
531
+ }
368
532
 
369
533
  // Match if IDs are equal, or if we have an active permission with unknown ID (undefined)
370
- // (This happens if permission.updated received an event without permissionID)
534
+ // (This happens if permission.updated/asked received an event without permissionID)
371
535
  if (activePermissionId === repliedPermissionId || activePermissionId === undefined) {
372
536
  activePermissionId = null;
373
537
  debugLog(`Permission replied: cleared activePermissionId ${repliedPermissionId || '(unknown)'}`);
374
538
  }
375
539
  lastUserActivityTime = Date.now();
376
540
  cancelPendingReminder('permission'); // Cancel permission-specific reminder
377
- debugLog(`Permission replied: ${event.type} (response=${event.properties?.response}) - cancelled permission reminder`);
541
+ debugLog(`Permission replied: ${event.type} (response=${response}) - cancelled permission reminder`);
378
542
  }
379
543
 
380
544
  if (event.type === "session.created") {
@@ -384,6 +548,14 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
384
548
  activePermissionId = null;
385
549
  seenUserMessageIds.clear();
386
550
  cancelAllPendingReminders();
551
+
552
+ // Reset permission batch state
553
+ pendingPermissionBatch = [];
554
+ if (permissionBatchTimeout) {
555
+ clearTimeout(permissionBatchTimeout);
556
+ permissionBatchTimeout = null;
557
+ }
558
+
387
559
  debugLog(`Session created: ${event.type} - reset all tracking state`);
388
560
  }
389
561
 
@@ -418,42 +590,48 @@ export default async function SmartVoiceNotifyPlugin({ project, client, $, direc
418
590
  }
419
591
 
420
592
  // ========================================
421
- // NOTIFICATION 2: Permission Request
593
+ // NOTIFICATION 2: Permission Request (BATCHED)
422
594
  // ========================================
423
- if (event.type === "permission.updated") {
424
- // CRITICAL: Capture permissionID IMMEDIATELY (before any async work)
425
- // This prevents race condition where user responds before we finish notifying
426
- // NOTE: In permission.updated, the property is 'id', but in permission.replied it is 'permissionID'
595
+ // NOTE: OpenCode SDK v1.1.1+ changed permission events:
596
+ // - Old: "permission.updated" with properties.id
597
+ // - New: "permission.asked" with properties.id
598
+ // We support both for backward compatibility.
599
+ //
600
+ // BATCHING: When multiple permissions arrive simultaneously (e.g., 5 at once),
601
+ // we batch them into a single notification instead of playing 5 overlapping sounds.
602
+ if (event.type === "permission.updated" || event.type === "permission.asked") {
603
+ // Capture permissionID
427
604
  const permissionId = event.properties?.id;
428
605
 
429
606
  if (!permissionId) {
430
- debugLog('permission.updated: permission ID missing. properties keys: ' + Object.keys(event.properties || {}).join(', '));
607
+ debugLog(`${event.type}: permission ID missing. properties keys: ` + Object.keys(event.properties || {}).join(', '));
431
608
  }
432
609
 
433
- activePermissionId = permissionId;
434
-
435
- debugLog(`permission.updated: notifying (permissionId=${permissionId})`);
436
- await showToast("⚠️ Permission request requires your attention", "warning", 8000);
437
-
438
- // CHECK: Did user already respond while we were showing toast?
439
- if (activePermissionId !== permissionId) {
440
- debugLog(`permission.updated: aborted - user already responded (activePermissionId cleared)`);
441
- return;
610
+ // Add to the pending batch (avoid duplicates)
611
+ if (permissionId && !pendingPermissionBatch.includes(permissionId)) {
612
+ pendingPermissionBatch.push(permissionId);
613
+ debugLog(`${event.type}: added ${permissionId} to batch (now ${pendingPermissionBatch.length} pending)`);
614
+ } else if (!permissionId) {
615
+ // If no ID, still count it (use a placeholder)
616
+ pendingPermissionBatch.push(`unknown-${Date.now()}`);
617
+ debugLog(`${event.type}: added unknown permission to batch (now ${pendingPermissionBatch.length} pending)`);
442
618
  }
443
-
444
- // Smart notification: sound first, TTS reminder later
445
- await smartNotify('permission', {
446
- soundFile: config.permissionSound,
447
- soundLoops: 2,
448
- ttsMessage: getRandomMessage(config.permissionTTSMessages),
449
- fallbackSound: config.permissionSound
450
- });
451
619
 
452
- // Final check after smartNotify: if user responded during sound playback, cancel the scheduled reminder
453
- if (activePermissionId !== permissionId) {
454
- debugLog(`permission.updated: user responded during notification - cancelling any scheduled reminder`);
455
- cancelPendingReminder('permission');
620
+ // Reset the batch window timer (debounce)
621
+ // This gives more permissions a chance to arrive before we notify
622
+ if (permissionBatchTimeout) {
623
+ clearTimeout(permissionBatchTimeout);
456
624
  }
625
+
626
+ permissionBatchTimeout = setTimeout(async () => {
627
+ try {
628
+ await processPermissionBatch();
629
+ } catch (e) {
630
+ debugLog(`processPermissionBatch error: ${e.message}`);
631
+ }
632
+ }, PERMISSION_BATCH_WINDOW_MS);
633
+
634
+ debugLog(`${event.type}: batch window reset (will process in ${PERMISSION_BATCH_WINDOW_MS}ms if no more arrive)`);
457
635
  }
458
636
  } catch (e) {
459
637
  debugLog(`event handler error: ${e.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-smart-voice-notify",
3
- "version": "1.0.10",
3
+ "version": "1.0.13",
4
4
  "description": "Smart voice notification plugin for OpenCode with multiple TTS engines (ElevenLabs, Edge TTS, Windows SAPI) and intelligent reminder system",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -35,13 +35,14 @@
35
35
  },
36
36
  "homepage": "https://github.com/MasuRii/opencode-smart-voice-notify#readme",
37
37
  "engines": {
38
- "node": ">=18.0.0"
38
+ "node": ">=18.0.0",
39
+ "bun": ">=1.0.0"
39
40
  },
40
41
  "dependencies": {
41
- "@elevenlabs/elevenlabs-js": "^2.28.0",
42
+ "@elevenlabs/elevenlabs-js": "^2.29.0",
42
43
  "msedge-tts": "^2.0.3"
43
44
  },
44
45
  "peerDependencies": {
45
- "@opencode-ai/plugin": "^1.0.0"
46
+ "@opencode-ai/plugin": "^1.1.4"
46
47
  }
47
48
  }
package/util/config.js CHANGED
@@ -189,6 +189,16 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
189
189
  "Excuse me! I need your authorization before I can continue.",
190
190
  "Permission required! Please review and approve when ready."
191
191
  ], 4)},
192
+
193
+ // Messages for MULTIPLE permission requests (use {count} placeholder)
194
+ // Used when several permissions arrive simultaneously
195
+ "permissionTTSMessagesMultiple": ${formatJSON(overrides.permissionTTSMessagesMultiple || [
196
+ "Attention please! There are {count} permission requests waiting for your approval.",
197
+ "Hey! {count} permissions need your approval to continue.",
198
+ "Heads up! You have {count} pending permission requests.",
199
+ "Excuse me! I need your authorization for {count} different actions.",
200
+ "{count} permissions required! Please review and approve when ready."
201
+ ], 4)},
192
202
 
193
203
  // ============================================================
194
204
  // TTS REMINDER MESSAGES (More urgent - used after delay if no response)
@@ -213,6 +223,24 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
213
223
  "Still waiting for authorization! The task is on hold until you respond."
214
224
  ], 4)},
215
225
 
226
+ // Reminder messages for MULTIPLE permissions (use {count} placeholder)
227
+ "permissionReminderTTSMessagesMultiple": ${formatJSON(overrides.permissionReminderTTSMessagesMultiple || [
228
+ "Hey! I still need your approval for {count} permissions. Please respond!",
229
+ "Reminder: There are {count} pending permission requests. I cannot proceed without you.",
230
+ "Hello? I am waiting for your approval on {count} items. This is getting urgent!",
231
+ "Please check your screen! {count} permissions are waiting for your response.",
232
+ "Still waiting for authorization on {count} requests! The task is on hold."
233
+ ], 4)},
234
+
235
+ // ============================================================
236
+ // PERMISSION BATCHING (Multiple permissions at once)
237
+ // ============================================================
238
+ // When multiple permissions arrive simultaneously, batch them into one notification
239
+ // This prevents overlapping sounds when 5+ permissions come at once
240
+
241
+ // Batch window (ms) - how long to wait for more permissions before notifying
242
+ "permissionBatchWindowMs": ${overrides.permissionBatchWindowMs !== undefined ? overrides.permissionBatchWindowMs : 800},
243
+
216
244
  // ============================================================
217
245
  // SOUND FILES (For immediate notifications)
218
246
  // These are played first before TTS reminder kicks in
@@ -246,7 +274,8 @@ const generateDefaultConfig = (overrides = {}, version = '1.0.0') => {
246
274
  // Consider monitor asleep after this many seconds of inactivity (Windows only)
247
275
  "idleThresholdSeconds": ${overrides.idleThresholdSeconds !== undefined ? overrides.idleThresholdSeconds : 60},
248
276
 
249
- // Enable debug logging to ~/.config/opencode/smart-voice-notify-debug.log
277
+ // Enable debug logging to ~/.config/opencode/logs/smart-voice-notify-debug.log
278
+ // The logs folder is created automatically when debug logging is enabled
250
279
  // Useful for troubleshooting notification issues
251
280
  "debugLog": ${overrides.debugLog !== undefined ? overrides.debugLog : false}
252
281
  }`;
package/util/tts.js CHANGED
@@ -71,6 +71,14 @@ export const getTTSConfig = () => {
71
71
  'Excuse me! I need your authorization before I can continue.',
72
72
  'Permission required! Please review and approve when ready.'
73
73
  ],
74
+ // Messages for MULTIPLE permission requests (use {count} placeholder)
75
+ permissionTTSMessagesMultiple: [
76
+ 'Attention please! There are {count} permission requests waiting for your approval.',
77
+ 'Hey! {count} permissions need your approval to continue.',
78
+ 'Heads up! You have {count} pending permission requests.',
79
+ 'Excuse me! I need your authorization for {count} different actions.',
80
+ '{count} permissions required! Please review and approve when ready.'
81
+ ],
74
82
 
75
83
  // ============================================================
76
84
  // TTS REMINDER MESSAGES (More urgent/personalized - used after delay)
@@ -91,6 +99,17 @@ export const getTTSConfig = () => {
91
99
  'Please check your screen! I really need your permission to move forward.',
92
100
  'Still waiting for authorization! The task is on hold until you respond.'
93
101
  ],
102
+ // Reminder messages for MULTIPLE permissions (use {count} placeholder)
103
+ permissionReminderTTSMessagesMultiple: [
104
+ 'Hey! I still need your approval for {count} permissions. Please respond!',
105
+ 'Reminder: There are {count} pending permission requests. I cannot proceed without you.',
106
+ 'Hello? I am waiting for your approval on {count} items. This is getting urgent!',
107
+ 'Please check your screen! {count} permissions are waiting for your response.',
108
+ 'Still waiting for authorization on {count} requests! The task is on hold.'
109
+ ],
110
+
111
+ // Permission batch window (ms) - how long to wait for more permissions before notifying
112
+ permissionBatchWindowMs: 800,
94
113
 
95
114
  // ============================================================
96
115
  // SOUND FILES (Used for immediate notifications)
@@ -120,7 +139,17 @@ let elevenLabsQuotaExceeded = false;
120
139
  */
121
140
  export const createTTS = ({ $, client }) => {
122
141
  const config = getTTSConfig();
123
- const logFile = path.join(configDir, 'smart-voice-notify-debug.log');
142
+ const logsDir = path.join(configDir, 'logs');
143
+ const logFile = path.join(logsDir, 'smart-voice-notify-debug.log');
144
+
145
+ // Ensure logs directory exists if debug logging is enabled
146
+ if (config.debugLog && !fs.existsSync(logsDir)) {
147
+ try {
148
+ fs.mkdirSync(logsDir, { recursive: true });
149
+ } catch (e) {
150
+ // Silently fail - logging is optional
151
+ }
152
+ }
124
153
 
125
154
  // Debug logging function (defined early so it can be passed to Linux platform)
126
155
  const debugLog = (message) => {