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 +6 -1
- package/boops.default.toml +2 -1
- package/cli/browse +2 -0
- package/index.ts +93 -29
- package/package.json +1 -1
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
|
|
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
|
package/boops.default.toml
CHANGED
|
@@ -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
|
-
"
|
|
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
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
//
|
|
316
|
-
if (event.type === "session.
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|