opencode-plugin-boops 2.6.1 → 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
@@ -22,17 +22,45 @@ const sounds = JSON.parse(readFileSync(soundsPath, "utf-8"));
22
22
 
23
23
  // Available OpenCode events
24
24
  const availableEvents = [
25
- "session.idle", "permission.asked", "session.error",
26
- "command.executed", "file.edited", "file.watcher.updated",
27
- "installation.updated", "lsp.client.diagnostics", "lsp.updated",
28
- "message.part.removed", "message.part.updated", "message.removed", "message.updated",
29
- "permission.replied", "server.connected",
30
- "session.created", "session.compacted", "session.deleted", "session.diff", "session.status", "session.updated",
25
+ "command.executed",
26
+ "file.edited",
27
+ "file.watcher.updated",
28
+ "installation.updated",
29
+ "lsp.client.diagnostics",
30
+ "lsp.updated",
31
+ "message.part.removed",
32
+ "message.part.updated",
33
+ "message.removed",
34
+ "message.updated",
35
+ "permission.asked",
36
+ "permission.replied",
37
+ "question.asked",
38
+ "question.replied",
39
+ "server.connected",
40
+ "session.compacted",
41
+ "session.created",
42
+ "session.deleted",
43
+ "session.diff",
44
+ "session.error",
45
+ "session.idle",
46
+ "session.status",
47
+ "session.updated",
31
48
  "todo.updated",
32
- "tool.execute.before", "tool.execute.after",
33
- "tui.prompt.append", "tui.command.execute", "tui.toast.show"
49
+ "tool.execute.after",
50
+ "tool.execute.before",
51
+ "tui.command.execute",
52
+ "tui.prompt.append",
53
+ "tui.toast.show"
34
54
  ];
35
55
 
56
+ // System/custom events (separate section)
57
+ const systemEvents = [
58
+ "__fallback"
59
+ ];
60
+
61
+ // Combined array for the picker (system events at the end)
62
+ const allEvents = [...availableEvents, ...systemEvents];
63
+
36
64
  // Cache directory
37
65
  const CACHE_DIR = "/tmp/opencode-boops/cache";
38
66
 
@@ -59,9 +87,6 @@ const PLUGIN_INSTALLED = isPluginInstalled();
59
87
 
60
88
  // Event descriptions for the picker
61
89
  const eventDescriptions = {
62
- "session.idle": "AI completes response",
63
- "permission.asked": "AI needs permission",
64
- "session.error": "Error occurs",
65
90
  "command.executed": "Command executed",
66
91
  "file.edited": "File edited",
67
92
  "file.watcher.updated": "File watcher detected change",
@@ -72,20 +97,24 @@ const eventDescriptions = {
72
97
  "message.part.updated": "Message part updated",
73
98
  "message.removed": "Message removed",
74
99
  "message.updated": "Message updated",
100
+ "permission.asked": "AI needs permission",
75
101
  "permission.replied": "Permission response given",
76
102
  "server.connected": "Connected to server",
77
- "session.created": "New session created",
78
103
  "session.compacted": "Session compacted",
104
+ "session.created": "New session created",
79
105
  "session.deleted": "Session deleted",
80
106
  "session.diff": "Session diff generated",
107
+ "session.error": "Error occurs",
108
+ "session.idle": "AI completes response",
81
109
  "session.status": "Session status changed",
82
110
  "session.updated": "Session updated",
83
111
  "todo.updated": "Todo list updated",
84
- "tool.execute.before": "Before tool execution",
85
112
  "tool.execute.after": "After tool execution",
86
- "tui.prompt.append": "Text appended to prompt",
113
+ "tool.execute.before": "Before tool execution",
87
114
  "tui.command.execute": "TUI command executed",
88
- "tui.toast.show": "Toast notification shown"
115
+ "tui.prompt.append": "Text appended to prompt",
116
+ "tui.toast.show": "Toast notification shown",
117
+ "__fallback": "Fallback sound when primary fails"
89
118
  };
90
119
 
91
120
  // Load current boops.toml config
@@ -124,6 +153,14 @@ function saveConfig(config) {
124
153
  }
125
154
  }
126
155
 
156
+ // Add fallbacks section if there are any
157
+ if (config.fallbacks && Object.keys(config.fallbacks).length > 0) {
158
+ content += "\n[fallbacks]\n";
159
+ for (const [key, sound] of Object.entries(config.fallbacks).sort()) {
160
+ content += `${key} = "${sound}"\n`;
161
+ }
162
+ }
163
+
127
164
  writeFileSync(configPath, content);
128
165
  return true;
129
166
  }
@@ -810,9 +847,9 @@ async function browse() {
810
847
 
811
848
  // Instructions
812
849
  if (PLUGIN_INSTALLED) {
813
- console.log(' \x1b[38;5;240m↑/↓ navigate Enter play current Ctrl+S save & exit ESC cancel\x1b[0m');
850
+ console.log(' \x1b[38;5;240m\x1b[38;5;67m↑/↓\x1b[38;5;240m navigate \x1b[38;5;67mEnter\x1b[38;5;240m play current \x1b[38;5;67mCtrl+S\x1b[38;5;240m save & exit \x1b[38;5;67mESC\x1b[38;5;240m cancel\x1b[0m');
814
851
  } else {
815
- console.log(' \x1b[38;5;240m↑/↓ navigate Enter play current ESC cancel \x1b[38;5;208m(save disabled - plugin not installed)\x1b[0m');
852
+ console.log(' \x1b[38;5;240m\x1b[38;5;67m↑/↓\x1b[38;5;240m navigate \x1b[38;5;67mEnter\x1b[38;5;240m play current \x1b[38;5;67mESC\x1b[38;5;240m cancel \x1b[38;5;208m(save disabled - plugin not installed)\x1b[0m');
816
853
  }
817
854
  console.log('');
818
855
 
@@ -821,13 +858,25 @@ async function browse() {
821
858
  // Fixed lines: 4 (top) + 2 (bottom) = 6
822
859
  const fixedLines = 6;
823
860
  const maxVisibleEvents = Math.max(1, termHeight - fixedLines);
824
- const scrollOffset = Math.max(0, Math.min(state.pickerSelectedEvent - Math.floor(maxVisibleEvents / 2), availableEvents.length - maxVisibleEvents));
825
- const endIndex = Math.min(scrollOffset + maxVisibleEvents, availableEvents.length);
861
+ const scrollOffset = Math.max(0, Math.min(state.pickerSelectedEvent - Math.floor(maxVisibleEvents / 2), allEvents.length - maxVisibleEvents));
862
+ const endIndex = Math.min(scrollOffset + maxVisibleEvents, allEvents.length);
826
863
 
827
864
  for (let i = scrollOffset; i < endIndex; i++) {
828
- const event = availableEvents[i];
865
+ const event = allEvents[i];
829
866
  const isSelected = i === state.pickerSelectedEvent;
830
- const currentSound = config.sounds ? config.sounds[event] : null;
867
+
868
+ // Add separator before system events
869
+ if (i === availableEvents.length && i >= scrollOffset) {
870
+ console.log(` ${'─'.repeat(Math.min(60, termWidth - 4))}`);
871
+ }
872
+
873
+ // Get current sound from appropriate config section
874
+ let currentSound = null;
875
+ if (event === '__fallback') {
876
+ currentSound = config.fallbacks ? config.fallbacks.default : null;
877
+ } else {
878
+ currentSound = config.sounds ? config.sounds[event] : null;
879
+ }
831
880
 
832
881
  const selectionBg = isSelected ? '\x1b[48;5;235m' : '';
833
882
  const reset = '\x1b[0m';
@@ -1085,15 +1134,15 @@ async function browse() {
1085
1134
  const config = loadCurrentConfig();
1086
1135
  const boxX = termWidth - pickerWidth;
1087
1136
  const boxStartY = stickyNavbar ? (topBlankLine + 4) : (topBlankLine + 1); // Start below header in sticky mode, else near top
1088
- const boxHeight = Math.min(availableEvents.length + 4, termHeight - boxStartY - fixedFooterLines);
1137
+ const boxHeight = Math.min(allEvents.length + 5, termHeight - boxStartY - fixedFooterLines); // +5 for separator line
1089
1138
 
1090
1139
  // Calculate scroll offset for event list
1091
1140
  const maxVisibleEvents = boxHeight - 6; // Reserve space for header(2) + description(2) + footer(2)
1092
- const eventScrollOffset = Math.max(0, Math.min(state.pickerSelectedEvent - Math.floor(maxVisibleEvents / 2), availableEvents.length - maxVisibleEvents));
1093
- const eventEndIndex = Math.min(eventScrollOffset + maxVisibleEvents, availableEvents.length);
1141
+ const eventScrollOffset = Math.max(0, Math.min(state.pickerSelectedEvent - Math.floor(maxVisibleEvents / 2), allEvents.length - maxVisibleEvents));
1142
+ const eventEndIndex = Math.min(eventScrollOffset + maxVisibleEvents, allEvents.length);
1094
1143
 
1095
1144
  // Get selected event info
1096
- const selectedEvent = availableEvents[state.pickerSelectedEvent];
1145
+ const selectedEvent = allEvents[state.pickerSelectedEvent];
1097
1146
  const eventDesc = eventDescriptions[selectedEvent] || "No description available";
1098
1147
 
1099
1148
  // Draw each line of the picker box
@@ -1101,6 +1150,11 @@ async function browse() {
1101
1150
  const screenY = boxStartY + row;
1102
1151
  process.stdout.write(`\x1b[${screenY};${boxX}H`); // Position cursor
1103
1152
 
1153
+ // Clear the area where we'll draw the picker (from cursor to end of line)
1154
+ // Draw a solid background to cover anything underneath
1155
+ process.stdout.write(' '.repeat(pickerWidth));
1156
+ process.stdout.write(`\x1b[${screenY};${boxX}H`); // Reposition cursor
1157
+
1104
1158
  if (row === 0) {
1105
1159
  // Top border with title
1106
1160
  const title = ` Assign to Event `;
@@ -1132,10 +1186,23 @@ async function browse() {
1132
1186
  } else {
1133
1187
  // Event list item
1134
1188
  const eventIndex = eventScrollOffset + (row - 3);
1189
+
1135
1190
  if (eventIndex < eventEndIndex) {
1136
- const event = availableEvents[eventIndex];
1191
+ const event = allEvents[eventIndex];
1137
1192
  const isSelected = eventIndex === state.pickerSelectedEvent;
1138
- const currentSound = config.sounds ? config.sounds[event] : null;
1193
+
1194
+ // Show separator before system events
1195
+ if (eventIndex === availableEvents.length) {
1196
+ process.stdout.write(`\x1b[38;5;240m│ ${'─'.repeat(pickerWidth - 4)} │\x1b[0m`);
1197
+ } else {
1198
+
1199
+ // Get current sound from appropriate config section
1200
+ let currentSound = null;
1201
+ if (event === '__fallback') {
1202
+ currentSound = config.fallbacks ? config.fallbacks.default : null;
1203
+ } else {
1204
+ currentSound = config.sounds ? config.sounds[event] : null;
1205
+ }
1139
1206
 
1140
1207
  const bg = isSelected ? '\x1b[48;5;235m' : '';
1141
1208
 
@@ -1153,10 +1220,11 @@ async function browse() {
1153
1220
  : event.padEnd(pickerWidth - 6);
1154
1221
  }
1155
1222
 
1156
- const indicator = currentSound ? '•' : ' ';
1157
- const indicatorColor = currentSound ? '\x1b[38;5;76m' : '';
1158
-
1159
- process.stdout.write(`\x1b[38;5;240m│\x1b[0m${bg}${indicatorColor}${indicator}\x1b[0m${bg} ${eventText} \x1b[0m\x1b[38;5;240m│\x1b[0m`);
1223
+ const indicator = currentSound ? '•' : ' ';
1224
+ const indicatorColor = currentSound ? '\x1b[38;5;76m' : '';
1225
+
1226
+ process.stdout.write(`\x1b[38;5;240m│\x1b[0m${bg}${indicatorColor}${indicator}\x1b[0m${bg} ${eventText} \x1b[0m\x1b[38;5;240m│\x1b[0m`);
1227
+ }
1160
1228
  } else {
1161
1229
  // Empty line
1162
1230
  process.stdout.write(`\x1b[38;5;240m│${' '.repeat(pickerWidth - 2)}│\x1b[0m`);
@@ -1176,7 +1244,7 @@ async function browse() {
1176
1244
  if (state.searchQuery || state.searchMode) {
1177
1245
  // Search mode: show search box on left, hints on right
1178
1246
  const searchBox = ` /${state.searchQuery}`;
1179
- const searchHint = '\x1b[38;5;240m/ to refine esc to clear\x1b[0m';
1247
+ const searchHint = '\x1b[38;5;240m\x1b[38;5;67m/\x1b[38;5;240m to refine \x1b[38;5;67mesc\x1b[38;5;240m to clear\x1b[0m';
1180
1248
  const searchBoxVisible = 2 + state.searchQuery.length; // " /" + query
1181
1249
  const hintVisible = getVisibleLength(searchHint);
1182
1250
 
@@ -1216,10 +1284,10 @@ async function browse() {
1216
1284
  let controls;
1217
1285
  if (state.pickerMode) {
1218
1286
  controls = PLUGIN_INSTALLED
1219
- ? ' \x1b[38;5;240m↑/↓ select enter play a assign d unassign ←/esc close\x1b[0m'
1220
- : ' \x1b[38;5;240m↑/↓ select event enter play ←/esc close \x1b[38;5;208m(save disabled)\x1b[0m';
1287
+ ? ' \x1b[38;5;240m\x1b[38;5;67m↑/↓\x1b[38;5;240m select \x1b[38;5;67menter\x1b[38;5;240m play \x1b[38;5;67ma\x1b[38;5;240m assign \x1b[38;5;67md\x1b[38;5;240m unassign \x1b[38;5;67m←/esc\x1b[38;5;240m close\x1b[0m'
1288
+ : ' \x1b[38;5;240m\x1b[38;5;67m↑/↓\x1b[38;5;240m select event \x1b[38;5;67menter\x1b[38;5;240m play \x1b[38;5;67m←/esc\x1b[38;5;240m close \x1b[38;5;208m(save disabled)\x1b[0m';
1221
1289
  } else {
1222
- controls = ' \x1b[38;5;240m↑/↓ navigate ←/→ tags / search s assign enter play q quit\x1b[0m';
1290
+ controls = ' \x1b[38;5;240m\x1b[38;5;67m↑/↓\x1b[38;5;240m navigate \x1b[38;5;67m←/→\x1b[38;5;240m tags \x1b[38;5;67m/\x1b[38;5;240m search \x1b[38;5;67ms\x1b[38;5;240m assign \x1b[38;5;67menter\x1b[38;5;240m play \x1b[38;5;67mq\x1b[38;5;240m quit\x1b[0m';
1223
1291
  }
1224
1292
 
1225
1293
  // Attribution with manual hover/click
@@ -1845,7 +1913,7 @@ async function browse() {
1845
1913
  return;
1846
1914
  } else if (key === '\u001b[B' || key === 'j') {
1847
1915
  // Down arrow
1848
- if (state.pickerSelectedEvent < availableEvents.length - 1) {
1916
+ if (state.pickerSelectedEvent < allEvents.length - 1) {
1849
1917
  state.pickerSelectedEvent++;
1850
1918
  }
1851
1919
  render();
@@ -1853,8 +1921,15 @@ async function browse() {
1853
1921
  } else if (key === '\r' || key === ' ') {
1854
1922
  // Enter/Space - play current sound for selected event
1855
1923
  const config = loadCurrentConfig();
1856
- const event = availableEvents[state.pickerSelectedEvent];
1857
- const currentSound = config.sounds ? config.sounds[event] : null;
1924
+ const event = allEvents[state.pickerSelectedEvent];
1925
+
1926
+ // Get current sound from appropriate config section
1927
+ let currentSound = null;
1928
+ if (event === '__fallback') {
1929
+ currentSound = config.fallbacks ? config.fallbacks.default : null;
1930
+ } else {
1931
+ currentSound = config.sounds ? config.sounds[event] : null;
1932
+ }
1858
1933
  if (currentSound) {
1859
1934
  const soundEntry = sounds.find(s => s.id === currentSound || s.name === currentSound);
1860
1935
  if (soundEntry) {
@@ -1870,10 +1945,16 @@ async function browse() {
1870
1945
  } else if (key === 'a') {
1871
1946
  // 'a' - assign sound to selected event (auto-saves)
1872
1947
  const config = loadCurrentConfig();
1873
- const event = availableEvents[state.pickerSelectedEvent];
1948
+ const event = allEvents[state.pickerSelectedEvent];
1874
1949
 
1875
- if (!config.sounds) config.sounds = {};
1876
- config.sounds[event] = state.pickerSound.name; // Save friendly name, not ID
1950
+ // Check if this is a system event
1951
+ if (event === '__fallback') {
1952
+ if (!config.fallbacks) config.fallbacks = {};
1953
+ config.fallbacks.default = state.pickerSound.name; // Save friendly name
1954
+ } else {
1955
+ if (!config.sounds) config.sounds = {};
1956
+ config.sounds[event] = state.pickerSound.name; // Save friendly name, not ID
1957
+ }
1877
1958
 
1878
1959
  // Auto-save to config file
1879
1960
  saveConfig(config);
@@ -1884,11 +1965,19 @@ async function browse() {
1884
1965
  } else if (key === 'd') {
1885
1966
  // 'd' - unassign/delete sound from selected event
1886
1967
  const config = loadCurrentConfig();
1887
- const event = availableEvents[state.pickerSelectedEvent];
1968
+ const event = allEvents[state.pickerSelectedEvent];
1888
1969
 
1889
- if (config.sounds && config.sounds[event]) {
1890
- delete config.sounds[event];
1891
- saveConfig(config);
1970
+ // Check if this is a system event
1971
+ if (event === '__fallback') {
1972
+ if (config.fallbacks && config.fallbacks.default) {
1973
+ delete config.fallbacks.default;
1974
+ saveConfig(config);
1975
+ }
1976
+ } else {
1977
+ if (config.sounds && config.sounds[event]) {
1978
+ delete config.sounds[event];
1979
+ saveConfig(config);
1980
+ }
1892
1981
  }
1893
1982
 
1894
1983
  // Stay in picker mode
@@ -1985,6 +2074,7 @@ async function browse() {
1985
2074
 
1986
2075
  case '\u001b[A': // Up arrow
1987
2076
  case 'k':
2077
+ if (state.pickerMode) break; // Picker handles its own navigation
1988
2078
  if (state.selectedLine > 0) {
1989
2079
  state.selectedLine--;
1990
2080
  // Skip non-interactive lines
@@ -2026,6 +2116,7 @@ async function browse() {
2026
2116
 
2027
2117
  case '\u001b[B': // Down arrow
2028
2118
  case 'j':
2119
+ if (state.pickerMode) break; // Picker handles its own navigation
2029
2120
  if (state.selectedLine < lines.length - 1) {
2030
2121
  state.selectedLine++;
2031
2122
  // Skip non-interactive lines
@@ -2050,6 +2141,7 @@ async function browse() {
2050
2141
 
2051
2142
  case '\u001b[C': // Right arrow
2052
2143
  case 'l':
2144
+ if (state.pickerMode) break; // Picker doesn't use right arrow
2053
2145
  // Navigate tags right, maintaining sound position
2054
2146
  if (state.selectedTagIndex < allTags.length - 1) {
2055
2147
  state.selectedTagIndex++;
@@ -2072,6 +2164,8 @@ async function browse() {
2072
2164
 
2073
2165
  case '\u001b[D': // Left arrow
2074
2166
  case 'h':
2167
+ // Picker mode: left arrow exits (handled earlier in picker section)
2168
+ if (state.pickerMode) break; // Already handled in picker mode section
2075
2169
  // Navigate tags left, maintaining sound position
2076
2170
  if (state.selectedTagIndex > -1) {
2077
2171
  state.selectedTagIndex--;
@@ -2093,6 +2187,7 @@ async function browse() {
2093
2187
  break;
2094
2188
 
2095
2189
  case '/':
2190
+ if (state.pickerMode) break; // Block search when picker is open
2096
2191
  // Enter search mode (or re-enter if results are showing)
2097
2192
  state.searchMode = true;
2098
2193
  if (!state.searchQuery) {
@@ -2104,6 +2199,7 @@ async function browse() {
2104
2199
  break;
2105
2200
 
2106
2201
  case 's':
2202
+ if (state.pickerMode) break; // Block opening another picker
2107
2203
  // Open event picker for selected sound
2108
2204
  {
2109
2205
  const selectedLine = lines[state.selectedLine];
@@ -2119,6 +2215,7 @@ async function browse() {
2119
2215
  case 'p':
2120
2216
  case '\r': // Enter key
2121
2217
  case ' ': // Space key
2218
+ if (state.pickerMode) break; // Picker handles these keys
2122
2219
  {
2123
2220
  const selectedLine = lines[state.selectedLine];
2124
2221
 
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.1",
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",