opencode-plugin-boops 2.6.2 → 2.7.0

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
@@ -5,7 +5,7 @@ Sound notifications for OpenCode - plays pleasant "boop" sounds when tasks compl
5
5
  ## Features
6
6
 
7
7
  - 🎵 Soft, friendly notification sounds when AI completes tasks
8
- - 📡 Gentle alert when AI needs your permission or input
8
+ - 📡 Gentle alert when AI needs your permission, input, or asks a question
9
9
  - 🌐 Works out of the box with online sounds (auto-downloaded and cached)
10
10
  - 🔄 Fully configurable - use URLs or local files
11
11
  - 🐧 Works on Linux (with `paplay` or `aplay`)
@@ -67,6 +67,7 @@ The plugin listens to OpenCode events and plays sounds based on your configurati
67
67
 
68
68
  - **`session.idle`** - AI finishes responding → soft "pristine" notification
69
69
  - **`permission.asked`** - AI needs permission → gentle "relax" chime
70
+ - **`question.asked`** - AI asks a question → gentle "relax" chime
70
71
  - **`session.error`** - An error occurs → friendly "magic" alert
71
72
 
72
73
  **Instant config reload:** The config is reloaded on every event, so changes take effect immediately without restarting OpenCode! Edit your config, trigger an event, and hear the new sound instantly.
@@ -147,6 +148,10 @@ You can configure sounds for any of the 28 OpenCode events:
147
148
  - `permission.asked` - AI needs permission
148
149
  - `permission.replied` - Permission response given
149
150
 
151
+ **Question events (2):**
152
+ - `question.asked` - AI asks a multiple-choice question
153
+ - `question.replied` - User answers a question
154
+
150
155
  **File events (2):**
151
156
  - `file.edited` - File is edited
152
157
  - `file.watcher.updated` - File watcher detects change
@@ -5,8 +5,9 @@
5
5
 
6
6
  [sounds]
7
7
  "session.idle" = "pristine" # AI completes response
8
+ "session.error" = "exclamation" # Error occurs
8
9
  "permission.asked" = "relax" # AI needs permission
9
- "session.error" = "magic" # Error occurs
10
+ "question.asked" = "relax" # AI asks a question (falls back to permission.asked if not set)
10
11
 
11
12
  # You can use:
12
13
  # - Sound names: "pristine" (searches sounds.json by name)
package/cli/browse CHANGED
@@ -34,6 +34,8 @@ const availableEvents = [
34
34
  "message.updated",
35
35
  "permission.asked",
36
36
  "permission.replied",
37
+ "question.asked",
38
+ "question.replied",
37
39
  "server.connected",
38
40
  "session.compacted",
39
41
  "session.created",
package/index.ts CHANGED
@@ -52,6 +52,17 @@ export const BoopsPlugin: Plugin = async ({ client }) => {
52
52
  })
53
53
  }
54
54
 
55
+ // Track last error time to debounce idle sounds after errors
56
+ let lastErrorTime = 0
57
+ const ERROR_DEBOUNCE_MS = 2000
58
+
59
+ // Track pending sounds that can be cancelled
60
+ let pendingSoundTimeout: ReturnType<typeof setTimeout> | null = null
61
+ let pendingSoundCancelled = false
62
+
63
+ // Track which sessions are subagents (have parentID)
64
+ const subagentSessions = new Set<string>()
65
+
55
66
  // Cache directory for downloaded sounds
56
67
  const cacheDir = join(homedir(), ".cache", "opencode", "boops")
57
68
 
@@ -249,33 +260,54 @@ export const BoopsPlugin: Plugin = async ({ client }) => {
249
260
  return null
250
261
  }
251
262
 
252
- // Play sound with fallback mechanism
253
- const playSound = async (soundFile: string, eventName: string) => {
263
+ // Play sound with fallback mechanism (supports cancellation)
264
+ const playSound = async (soundFile: string, eventName: string, canCancel = false) => {
254
265
  if (player === "echo") return // Skip if no player
255
266
 
256
- try {
257
- // Resolve the sound file (download if URL, check if local path exists)
258
- const resolvedPath = await resolveSoundPath(soundFile, eventName)
259
-
260
- if (resolvedPath) {
261
- await Bun.$`${player} ${resolvedPath}`.quiet()
262
- return
263
- }
264
-
265
- // Try fallback
266
- const fallback = config.fallbacks?.default
267
- if (fallback) {
268
- const fallbackPath = await resolveSoundPath(fallback, "fallback")
269
- if (fallbackPath) {
270
- await Bun.$`${player} ${fallbackPath}`.quiet()
267
+ const play = async () => {
268
+ try {
269
+ // Resolve the sound file (download if URL, check if local path exists)
270
+ const resolvedPath = await resolveSoundPath(soundFile, eventName)
271
+
272
+ if (resolvedPath) {
273
+ await Bun.$`${player} ${resolvedPath}`.quiet()
271
274
  return
272
275
  }
276
+
277
+ // Try fallback
278
+ const fallback = config.fallbacks?.default
279
+ if (fallback) {
280
+ const fallbackPath = await resolveSoundPath(fallback, "fallback")
281
+ if (fallbackPath) {
282
+ await Bun.$`${player} ${fallbackPath}`.quiet()
283
+ return
284
+ }
285
+ }
286
+
287
+ // Last resort: terminal bell
288
+ await Bun.$`echo -e "\a"`.quiet()
289
+ } catch (error) {
290
+ // Silently fail - don't spam logs for sound errors
273
291
  }
292
+ }
274
293
 
275
- // Last resort: terminal bell
276
- await Bun.$`echo -e "\a"`.quiet()
277
- } catch (error) {
278
- // Silently fail - don't spam logs for sound errors
294
+ if (canCancel) {
295
+ // Clear any pending sound
296
+ if (pendingSoundTimeout) {
297
+ clearTimeout(pendingSoundTimeout)
298
+ pendingSoundTimeout = null
299
+ }
300
+ pendingSoundCancelled = false
301
+
302
+ // Delay slightly to allow error to cancel it
303
+ pendingSoundTimeout = setTimeout(() => {
304
+ pendingSoundTimeout = null
305
+ if (!pendingSoundCancelled) {
306
+ play()
307
+ }
308
+ }, 100)
309
+ } else {
310
+ await play()
279
311
  }
280
312
  }
281
313
 
@@ -312,12 +344,41 @@ export const BoopsPlugin: Plugin = async ({ client }) => {
312
344
  // Reload config on every event for instant updates (no restart needed!)
313
345
  config = await loadConfig()
314
346
 
315
- // Log session.idle events for debugging (helpful for users configuring filters)
316
- if (event.type === "session.idle") {
317
- await log(`session.idle event received`, {
318
- properties: event,
319
- configuredSound: config.sounds["session.idle"]
320
- })
347
+ // Track subagent sessions (sessions with parentID)
348
+ if (event.type === "session.updated") {
349
+ const sessionInfo = event.properties?.info
350
+ if (sessionInfo?.parentID) {
351
+ subagentSessions.add(sessionInfo.id)
352
+ }
353
+ }
354
+
355
+ // Cancel any pending sounds on error
356
+ if (event.type === "session.error") {
357
+ lastErrorTime = Date.now()
358
+ if (pendingSoundTimeout) {
359
+ pendingSoundCancelled = true
360
+ clearTimeout(pendingSoundTimeout)
361
+ pendingSoundTimeout = null
362
+ }
363
+ }
364
+
365
+ // Skip idle sound if error recently fired (for events that bypass cancellation)
366
+ if (event.type === "session.idle" && Date.now() - lastErrorTime < ERROR_DEBOUNCE_MS) {
367
+ return // Don't play idle sound after error
368
+ }
369
+
370
+ // Skip idle for subagent sessions
371
+ const sessionID = event.properties?.sessionID
372
+ if ((event.type === "session.idle" || event.type === "session.status") && sessionID && subagentSessions.has(sessionID)) {
373
+ return // Don't play sound for subagent completions
374
+ }
375
+
376
+ // Handle question.asked -> play permission sound as a fallback
377
+ if (event.type === "question.asked") {
378
+ const questionSound = config.sounds["question.asked"] || config.sounds["permission.asked"]
379
+ if (questionSound) {
380
+ await playSound(questionSound, "question.asked")
381
+ }
321
382
  }
322
383
 
323
384
  const soundConfig = config.sounds[event.type]
@@ -325,13 +386,16 @@ export const BoopsPlugin: Plugin = async ({ client }) => {
325
386
 
326
387
  // Handle simple string (just a sound file path/URL)
327
388
  if (typeof soundConfig === "string") {
328
- await playSound(soundConfig, event.type)
389
+ // Use cancellable sound for idle
390
+ const canCancel = event.type === "session.idle" || event.type === "session.error"
391
+ await playSound(soundConfig, event.type, canCancel)
329
392
  return
330
393
  }
331
394
 
332
395
  // Handle filter object
333
396
  if (matchesFilter(event, soundConfig)) {
334
- await playSound(soundConfig.sound, event.type)
397
+ const canCancel = event.type === "session.idle" || event.type === "session.error"
398
+ await playSound(soundConfig.sound, event.type, canCancel)
335
399
  }
336
400
  },
337
401
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-plugin-boops",
3
- "version": "2.6.2",
3
+ "version": "2.7.0",
4
4
  "description": "Sound notifications for OpenCode - plays pleasant sounds when tasks complete or input is needed",
5
5
  "main": "index.ts",
6
6
  "type": "module",