oc-sounds 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kai Wilson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,257 @@
1
+ # oc-sounds
2
+
3
+ Plugin for OpenCode V2 that makes prompting a more **sensual** experience.
4
+ Hear the agent think, read, search, edit, run commands, delegate, and finish with
5
+ the [Cuelume](https://github.com/Danilaa1/cuelume) sound palette.
6
+
7
+ ## Install
8
+
9
+ Requires Node.js 22+ and OpenCode V2. Built against
10
+ `@opencode/plugin@0.0.0-beta-19398`.
11
+
12
+ Once the first release is published to npm, install globally with:
13
+
14
+ ```sh
15
+ opencode2 plugin add oc-sounds
16
+ ```
17
+
18
+ All sounds are enabled at **100% volume** by default. No individual sound setup
19
+ is required. The package includes all 17 sound files and needs no browser or
20
+ sound renderer at runtime.
21
+
22
+ For project-only installation, add `oc-sounds` to `plugins` in your project's
23
+ `opencode.jsonc` instead:
24
+
25
+ ```jsonc
26
+ {
27
+ "$schema": "https://opencode.ai/config.json",
28
+ "plugins": [
29
+ {
30
+ "package": "oc-sounds",
31
+ "options": { "volume": 1 }
32
+ }
33
+ ]
34
+ }
35
+ ```
36
+
37
+ OpenCode watches its configuration for changes. If sounds do not start after
38
+ installation, restart its background service:
39
+
40
+ ```sh
41
+ opencode2 service restart
42
+ ```
43
+
44
+ Update an installed package with:
45
+
46
+ ```sh
47
+ opencode2 plugin update oc-sounds
48
+ ```
49
+
50
+ Until the first npm release, follow [Use a local checkout](#use-a-local-checkout).
51
+
52
+ ### Audio playback
53
+
54
+ The plugin automatically chooses an installed player:
55
+
56
+ | Platform | Players, in preference order |
57
+ | --- | --- |
58
+ | Linux | `paplay`, `pw-play`, `ffplay` |
59
+ | macOS | Built-in `afplay`, then `ffplay` |
60
+ | Windows | `ffplay` (install FFmpeg and add it to `PATH`) |
61
+
62
+ On Debian/Ubuntu, `sudo apt install pulseaudio-utils` provides `paplay`.
63
+ On Arch, `sudo pacman -S libpulse` provides it. PipeWire's PulseAudio compatibility
64
+ server also supports `paplay`.
65
+
66
+ Sound plays on the **machine running the OpenCode server**. For a local TUI,
67
+ desktop app, or browser connected to a local server, that is your computer. A
68
+ remote/headless server needs its own accessible audio output.
69
+
70
+ Playback runs asynchronously. Concurrent actions can overlap, and no action is
71
+ throttled. If a player fails, automatic mode tries the next player; if none work,
72
+ the plugin logs one warning and lets the agent continue. Unloading the plugin
73
+ stops its audio processes and event subscription.
74
+
75
+ ## What makes a sound?
76
+
77
+ Each tool invocation gets a starting cue and a success/error cue, including
78
+ custom tools, MCP tools, and nested calls that OpenCode exposes through its tool
79
+ hooks. The built-in tool palette is:
80
+
81
+ | Tool action | Sound |
82
+ | --- | --- |
83
+ | Read files | `page` |
84
+ | Glob, grep, web search | `scan` |
85
+ | Fetch a URL | `droplet` |
86
+ | Write, edit, patch | `toggle` |
87
+ | Shell / code execution | `pulse` |
88
+ | Delegate to a subagent | `arrival` |
89
+ | Load a skill | `sparkle` |
90
+ | Ask the user a question | `chime` |
91
+ | Other tools | `tick` |
92
+ | Tool succeeds / fails | `release` / `error` |
93
+
94
+ Session actions also have cues. The option keys are listed below:
95
+
96
+ | Option key | Action | Default |
97
+ | --- | --- | --- |
98
+ | `prompt` | Input enters the session inbox | `press` |
99
+ | `start` | Agent execution starts | `loading` |
100
+ | `step` | Model step starts | `pulse` |
101
+ | `thinking` | Reasoning block starts | `whisper` |
102
+ | `reply` | Reply block starts | `chime` |
103
+ | `tool` | Fallback tool-start cue | `tick` |
104
+ | `toolComplete` | Tool completes | `release` |
105
+ | `toolError` | Tool fails | `error` |
106
+ | `question` | Question tool starts | `chime` |
107
+ | `permission` | Permission request appears | `bloom` |
108
+ | `permissionReply` | Permission request is answered | `toggle` |
109
+ | `retry` | Model retry is scheduled | `scan` |
110
+ | `complete` | Agent execution succeeds | `success` |
111
+ | `error` | Execution or compaction fails | `error` |
112
+ | `interrupt` | Agent execution is interrupted | `droplet` |
113
+ | `compact` | Context compaction starts | `page` |
114
+ | `compactComplete` | Context compaction finishes | `ready` |
115
+ | `skill` | Skill is activated | `sparkle` |
116
+ | `agent` | Session agent is switched | `arrival` |
117
+
118
+ Reasoning and replies sound once per block, rather than on every streamed token.
119
+ Tool progress updates do not repeat the start cue. Public events are scoped to
120
+ the plugin's location and repeated event IDs are deduplicated.
121
+
122
+ ## Customize
123
+
124
+ Edit the existing plugin entry in `~/.config/opencode/opencode.jsonc` (global)
125
+ or your project's `opencode.jsonc`. Merge it with your other settings:
126
+
127
+ ```jsonc
128
+ {
129
+ "$schema": "https://opencode.ai/config.json",
130
+ "plugins": [
131
+ {
132
+ "package": "oc-sounds",
133
+ "options": {
134
+ "enabled": true,
135
+ "volume": 0.35,
136
+ "player": "auto",
137
+ "cues": {
138
+ "thinking": "whisper",
139
+ "complete": "ready",
140
+ "step": false
141
+ },
142
+ "tools": {
143
+ "read": "tick",
144
+ "shell": "pulse",
145
+ "my_mcp_tool": "sparkle"
146
+ }
147
+ }
148
+ }
149
+ ]
150
+ }
151
+ ```
152
+
153
+ - `enabled`: defaults to `true`. Set to `false` to disable all sounds.
154
+ - `volume`: from `0` to `1`, defaults to `1` (100%). Zero disables playback.
155
+ - `player`: `auto`, `paplay`, `pw-play`, `afplay`, or `ffplay`. An explicit player
156
+ disables automatic fallback to other players.
157
+ - `cues`: override any action in the table, or set it to `false` to mute it.
158
+ Setting `cues.tool` replaces the built-in starting palette for all ordinary
159
+ tools. `question` is configured separately.
160
+ - `tools`: override starting cues by exact effective tool name or built-in short
161
+ name (`read` also matches `functions.read` / `functions_read`). Exact names
162
+ take precedence. `false` mutes that tool's starting cue; completion and error
163
+ cues are controlled separately. Tool overrides take precedence over `cues.tool`.
164
+
165
+ All 17 Cuelume sounds are available:
166
+ `chime`, `sparkle`, `droplet`, `bloom`, `whisper`, `tick`, `press`, `release`,
167
+ `toggle`, `success`, `error`, `page`, `loading`, `ready`, `pulse`, `scan`, `arrival`.
168
+
169
+ ## Development
170
+
171
+ ```sh
172
+ npm ci
173
+ npm test
174
+ npm pack
175
+ ```
176
+
177
+ ### Use a local checkout
178
+
179
+ From the repository directory:
180
+
181
+ ```sh
182
+ npm ci
183
+ npm run build
184
+ ```
185
+
186
+ Add the checkout's **absolute directory path** to `plugins` in your global or
187
+ project OpenCode configuration:
188
+
189
+ ```jsonc
190
+ {
191
+ "$schema": "https://opencode.ai/config.json",
192
+ "plugins": [
193
+ {
194
+ "package": "/absolute/path/to/oc-sounds",
195
+ "options": { "volume": 1 }
196
+ }
197
+ ]
198
+ }
199
+ ```
200
+
201
+ Rebuild after changing TypeScript source. Restart OpenCode's background service
202
+ if it does not pick up changes to the local plugin.
203
+
204
+ ### Regenerate the sounds
205
+
206
+ The 17 mono, 48kHz PCM WAVs are checked into `sounds/` and included in the package.
207
+ Normal builds and installs use these files directly. To regenerate them:
208
+
209
+ ```sh
210
+ npm run sounds:render
211
+ ```
212
+
213
+ The renderer runs the unmodified Cuelume 0.2.2 engine through
214
+ `node-web-audio-api`'s offline context, using seeded noise and preserving the
215
+ sound envelopes and shimmer tails. It requires no browser, Playwright, or audio
216
+ device. Cuelume and the native offline renderer are development dependencies;
217
+ neither is needed by the installed plugin.
218
+
219
+ Tests cover the V2 plugin lifecycle, event isolation/deduplication, concurrent
220
+ tools, muting, cleanup, player fallback, and bundled WAV integrity. Linux player
221
+ tests use fake executables and do not play audio.
222
+
223
+ ### Publish to npm
224
+
225
+ Review and test the package before publishing:
226
+
227
+ ```sh
228
+ npm test
229
+ npm pack --dry-run
230
+ ```
231
+
232
+ The `prepack` hook builds the plugin automatically. The package contains `dist/`,
233
+ `sounds/`, the README, and the license notices; development dependencies and
234
+ rendering scripts are excluded.
235
+
236
+ Sign in to an npm account with publishing access, then publish:
237
+
238
+ ```sh
239
+ npm login
240
+ npm publish --access public
241
+ ```
242
+
243
+ For subsequent releases, commit your changes, bump the version, and publish:
244
+
245
+ ```sh
246
+ npm version patch
247
+ npm publish
248
+ ```
249
+
250
+ Use `minor` or `major` instead of `patch` when appropriate. Each npm release needs
251
+ a new version number. After the first release, update the installation section
252
+ to remove the pending-release wording.
253
+
254
+ ## License
255
+
256
+ MIT. Cuelume sounds are by Daniel Belyi, also MIT. See
257
+ [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for attribution and license text.
@@ -0,0 +1,28 @@
1
+ # Cuelume
2
+
3
+ The bundled WAV files in `sounds/` are rendered from the unmodified sound engine
4
+ and recipes of [Cuelume](https://github.com/Danilaa1/cuelume) v0.2.2 by Daniel Belyi.
5
+ They are generated by `scripts/render-sounds.mjs` using `node-web-audio-api`'s
6
+ offline Web Audio renderer. Cuelume is used only at build time.
7
+
8
+ MIT License
9
+
10
+ Copyright (c) 2026 Daniel Belyi
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
@@ -0,0 +1,34 @@
1
+ export declare const sounds: readonly ["chime", "sparkle", "droplet", "bloom", "whisper", "tick", "press", "release", "toggle", "success", "error", "page", "loading", "ready", "pulse", "scan", "arrival"];
2
+ export type SoundName = (typeof sounds)[number];
3
+ export declare const defaultCues: {
4
+ readonly prompt: "press";
5
+ readonly start: "loading";
6
+ readonly step: "pulse";
7
+ readonly thinking: "whisper";
8
+ readonly reply: "chime";
9
+ readonly tool: "tick";
10
+ readonly toolComplete: "release";
11
+ readonly toolError: "error";
12
+ readonly permission: "bloom";
13
+ readonly permissionReply: "toggle";
14
+ readonly question: "chime";
15
+ readonly retry: "scan";
16
+ readonly complete: "success";
17
+ readonly error: "error";
18
+ readonly interrupt: "droplet";
19
+ readonly compact: "page";
20
+ readonly compactComplete: "ready";
21
+ readonly skill: "sparkle";
22
+ readonly agent: "arrival";
23
+ };
24
+ export type Action = keyof typeof defaultCues;
25
+ export declare const defaultToolCues: Record<string, SoundName>;
26
+ export type PlayerName = "auto" | "paplay" | "pw-play" | "afplay" | "ffplay";
27
+ export interface Options {
28
+ enabled: boolean;
29
+ volume: number;
30
+ player: PlayerName;
31
+ cues: Record<Action, SoundName | false>;
32
+ tools: Record<string, SoundName | false>;
33
+ }
34
+ export declare function parseOptions(input: unknown): Options;
package/dist/config.js ADDED
@@ -0,0 +1,83 @@
1
+ export const sounds = [
2
+ "chime", "sparkle", "droplet", "bloom", "whisper", "tick", "press",
3
+ "release", "toggle", "success", "error", "page", "loading", "ready",
4
+ "pulse", "scan", "arrival",
5
+ ];
6
+ export const defaultCues = {
7
+ prompt: "press",
8
+ start: "loading",
9
+ step: "pulse",
10
+ thinking: "whisper",
11
+ reply: "chime",
12
+ tool: "tick",
13
+ toolComplete: "release",
14
+ toolError: "error",
15
+ permission: "bloom",
16
+ permissionReply: "toggle",
17
+ question: "chime",
18
+ retry: "scan",
19
+ complete: "success",
20
+ error: "error",
21
+ interrupt: "droplet",
22
+ compact: "page",
23
+ compactComplete: "ready",
24
+ skill: "sparkle",
25
+ agent: "arrival",
26
+ };
27
+ export const defaultToolCues = {
28
+ read: "page",
29
+ glob: "scan",
30
+ grep: "scan",
31
+ websearch: "scan",
32
+ webfetch: "droplet",
33
+ write: "toggle",
34
+ edit: "toggle",
35
+ patch: "toggle",
36
+ apply_patch: "toggle",
37
+ shell: "pulse",
38
+ bash: "pulse",
39
+ execute: "pulse",
40
+ subagent: "arrival",
41
+ task: "arrival",
42
+ skill: "sparkle",
43
+ };
44
+ function object(value) {
45
+ if (value && typeof value === "object" && !Array.isArray(value)) {
46
+ return value;
47
+ }
48
+ throw new Error("oc-sounds: expected an options object");
49
+ }
50
+ function cue(value) {
51
+ if (value === false || sounds.includes(value))
52
+ return value;
53
+ throw new Error(`oc-sounds: unknown sound ${String(value)}`);
54
+ }
55
+ export function parseOptions(input) {
56
+ const raw = object(input);
57
+ for (const key of Object.keys(raw)) {
58
+ if (!["enabled", "volume", "player", "cues", "tools"].includes(key)) {
59
+ throw new Error(`oc-sounds: unknown option ${key}`);
60
+ }
61
+ }
62
+ const enabled = raw.enabled ?? true;
63
+ if (typeof enabled !== "boolean")
64
+ throw new Error("oc-sounds: enabled must be a boolean");
65
+ const volume = raw.volume ?? 1;
66
+ if (typeof volume !== "number" || !Number.isFinite(volume) || volume < 0 || volume > 1) {
67
+ throw new Error("oc-sounds: volume must be between 0 and 1");
68
+ }
69
+ const player = raw.player ?? "auto";
70
+ if (!["auto", "paplay", "pw-play", "afplay", "ffplay"].includes(player)) {
71
+ throw new Error(`oc-sounds: unknown player ${String(player)}`);
72
+ }
73
+ const cues = { ...defaultCues };
74
+ for (const [key, value] of Object.entries(object(raw.cues ?? {}))) {
75
+ if (!Object.hasOwn(cues, key))
76
+ throw new Error(`oc-sounds: unknown action ${key}`);
77
+ cues[key] = cue(value);
78
+ }
79
+ const tools = Object.create(null);
80
+ for (const [key, value] of Object.entries(object(raw.tools ?? {})))
81
+ tools[key] = cue(value);
82
+ return { enabled, volume, player: player, cues, tools };
83
+ }
@@ -0,0 +1,10 @@
1
+ import { Plugin } from "@opencode/plugin";
2
+ import { type Action, type Options } from "./config.js";
3
+ import { type Player } from "./player.js";
4
+ export { defaultCues, defaultToolCues, sounds } from "./config.js";
5
+ export type { Action, Options, SoundName } from "./config.js";
6
+ type Event = ReturnType<Plugin.Context["event"]["subscribe"]> extends AsyncIterable<infer E> ? E : never;
7
+ export declare function actionForEvent(event: Event): Action | undefined;
8
+ export declare function setup(ctx: Plugin.Context, makePlayer?: (selection: Options["player"], volume: number) => Player): Promise<Plugin.Cleanup | void>;
9
+ declare const _default: Plugin.Plugin;
10
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,139 @@
1
+ import { Plugin } from "@opencode/plugin";
2
+ import { resolve } from "node:path";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import { defaultToolCues, parseOptions } from "./config.js";
5
+ import { createPlayer } from "./player.js";
6
+ export { defaultCues, defaultToolCues, sounds } from "./config.js";
7
+ const eventActions = {
8
+ "session.inbox.enqueued": "prompt",
9
+ "session.execution.started": "start",
10
+ "session.step.started": "step",
11
+ "session.reasoning.started": "thinking",
12
+ "session.text.started": "reply",
13
+ "session.execution.succeeded": "complete",
14
+ "session.execution.failed": "error",
15
+ "session.execution.interrupted": "interrupt",
16
+ "session.retry.scheduled": "retry",
17
+ "session.compaction.started": "compact",
18
+ "session.compaction.ended": "compactComplete",
19
+ "session.compaction.failed": "error",
20
+ "session.skill.activated": "skill",
21
+ "session.agent.selected": "agent",
22
+ "permission.asked": "permission",
23
+ "permission.replied": "permissionReply",
24
+ };
25
+ export function actionForEvent(event) {
26
+ return eventActions[event.type];
27
+ }
28
+ // Exported separately to exercise the actual plugin lifecycle with a fake player.
29
+ export async function setup(ctx, makePlayer = createPlayer) {
30
+ const options = parseOptions(ctx.options);
31
+ if (!options.enabled || options.volume === 0)
32
+ return;
33
+ const player = makePlayer(options.player, options.volume);
34
+ const controller = new AbortController();
35
+ const registrations = [];
36
+ const emit = (action, tool) => {
37
+ if (controller.signal.aborted)
38
+ return;
39
+ let sound = options.cues[action];
40
+ if (tool !== undefined) {
41
+ const name = tool.split(".").at(-1);
42
+ const short = name.replace(/^(functions|tools)_/, "");
43
+ if (Object.hasOwn(options.tools, tool))
44
+ sound = options.tools[tool];
45
+ else if (Object.hasOwn(options.tools, short))
46
+ sound = options.tools[short];
47
+ // An explicit tool cue replaces the built-in palette for all tools.
48
+ else if (action === "tool" && !Object.hasOwn(ctx.options.cues ?? {}, "tool")) {
49
+ if (Object.hasOwn(defaultToolCues, short))
50
+ sound = defaultToolCues[short];
51
+ }
52
+ }
53
+ if (sound)
54
+ player.play(sound);
55
+ };
56
+ const cleanup = async () => {
57
+ controller.abort();
58
+ player.dispose();
59
+ await Promise.all(registrations.map((registration) => registration.dispose()));
60
+ };
61
+ try {
62
+ registrations.push(await ctx.tool.hook("execute.before", (event) => {
63
+ const name = event.tool.split(/[._]/).at(-1);
64
+ emit(name === "question" ? "question" : "tool", event.tool);
65
+ }));
66
+ registrations.push(await ctx.tool.hook("execute.after", (event) => {
67
+ emit(event.status === "error" ? "toolError" : "toolComplete");
68
+ }));
69
+ }
70
+ catch (error) {
71
+ await cleanup();
72
+ throw error;
73
+ }
74
+ // The public stream is server-wide, while each plugin instance belongs to a
75
+ // location. Resolve events without location metadata before making a sound.
76
+ const locations = new Map();
77
+ const seen = new Set();
78
+ const matches = (location) => resolve(location.directory) === resolve(ctx.location.directory)
79
+ && location.workspaceID === ctx.location.workspaceID;
80
+ const belongsHere = async (event) => {
81
+ if (event.location)
82
+ return matches(event.location);
83
+ if (!("sessionID" in event.data))
84
+ return false;
85
+ const sessionID = event.data.sessionID;
86
+ const cached = locations.get(sessionID);
87
+ if (cached !== undefined)
88
+ return cached;
89
+ try {
90
+ const session = await ctx.session.get({ sessionID }, { signal: controller.signal });
91
+ const result = matches(session.location);
92
+ if (locations.size >= 1024)
93
+ locations.delete(locations.keys().next().value);
94
+ locations.set(sessionID, result);
95
+ return result;
96
+ }
97
+ catch {
98
+ return false;
99
+ }
100
+ };
101
+ const consume = async () => {
102
+ let warned = false;
103
+ while (!controller.signal.aborted) {
104
+ try {
105
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
106
+ if (controller.signal.aborted)
107
+ break;
108
+ if (event.type === "session.moved" || event.type === "session.deleted") {
109
+ locations.delete(event.data.sessionID);
110
+ }
111
+ const action = actionForEvent(event);
112
+ if (!action || seen.has(event.id))
113
+ continue;
114
+ if (!(await belongsHere(event)))
115
+ continue;
116
+ seen.add(event.id);
117
+ if (seen.size > 4096)
118
+ seen.delete(seen.values().next().value);
119
+ emit(action);
120
+ }
121
+ }
122
+ catch {
123
+ if (!controller.signal.aborted && !warned) {
124
+ warned = true;
125
+ console.warn("oc-sounds: event stream disconnected; retrying in one second.");
126
+ }
127
+ }
128
+ if (!controller.signal.aborted) {
129
+ await delay(1000, undefined, { signal: controller.signal }).catch(() => { });
130
+ }
131
+ }
132
+ };
133
+ const task = consume();
134
+ return async () => {
135
+ await cleanup();
136
+ await task;
137
+ };
138
+ }
139
+ export default Plugin.define({ id: "oc-sounds", setup: (ctx) => setup(ctx) });
@@ -0,0 +1,9 @@
1
+ import type { PlayerName, SoundName } from "./config.js";
2
+ type Backend = Exclude<PlayerName, "auto">;
3
+ export interface Player {
4
+ play(sound: SoundName): void;
5
+ dispose(): void;
6
+ }
7
+ export declare function playerArgs(player: Backend, file: string, volume: number): string[];
8
+ export declare function createPlayer(selection: PlayerName, volume: number, warn?: (message: string) => void): Player;
9
+ export {};
package/dist/player.js ADDED
@@ -0,0 +1,102 @@
1
+ import { accessSync, constants } from "node:fs";
2
+ import { spawn } from "node:child_process";
3
+ import { delimiter, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ export function playerArgs(player, file, volume) {
6
+ switch (player) {
7
+ case "paplay": return [`--volume=${Math.round(volume * 65536)}`, file];
8
+ case "pw-play": return [`--volume=${volume}`, file];
9
+ case "afplay": return ["-v", String(volume), file];
10
+ case "ffplay": return ["-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", String(Math.round(volume * 100)), file];
11
+ }
12
+ }
13
+ function executable(name) {
14
+ const names = process.platform === "win32" ? [`${name}.exe`, name] : [name];
15
+ for (const directory of (process.env.PATH ?? "").split(delimiter)) {
16
+ if (!directory)
17
+ continue;
18
+ for (const candidate of names) {
19
+ const file = join(directory, candidate);
20
+ try {
21
+ accessSync(file, constants.X_OK);
22
+ return file;
23
+ }
24
+ catch { /* Try the next PATH entry. */ }
25
+ }
26
+ }
27
+ }
28
+ export function createPlayer(selection, volume, warn = console.warn) {
29
+ const order = selection !== "auto" ? [selection]
30
+ : process.platform === "darwin" ? ["afplay", "ffplay"]
31
+ : process.platform === "win32" ? ["ffplay"] : ["paplay", "pw-play", "ffplay"];
32
+ const backends = order.flatMap((name) => {
33
+ const command = executable(name);
34
+ return command ? [{ name, command }] : [];
35
+ });
36
+ const children = new Set();
37
+ let index = 0;
38
+ let disposed = false;
39
+ let warned = false;
40
+ const unavailable = () => {
41
+ if (warned || disposed)
42
+ return;
43
+ warned = true;
44
+ warn("oc-sounds: audio playback unavailable; install/configure paplay, pw-play, afplay (macOS), or ffplay. Sounds run on the OpenCode server's machine.");
45
+ };
46
+ if (!backends.length)
47
+ unavailable();
48
+ function play(sound) {
49
+ if (disposed || volume === 0)
50
+ return;
51
+ const backend = backends[index];
52
+ if (!backend)
53
+ return;
54
+ const file = fileURLToPath(new URL(`../sounds/${sound}.wav`, import.meta.url));
55
+ let child;
56
+ try {
57
+ child = spawn(backend.command, playerArgs(backend.name, file, volume), {
58
+ stdio: "ignore",
59
+ windowsHide: true,
60
+ shell: false,
61
+ });
62
+ }
63
+ catch {
64
+ fail();
65
+ return;
66
+ }
67
+ children.add(child);
68
+ // Bound hung audio processes; playback never blocks hooks or the event stream.
69
+ const timer = setTimeout(() => child.kill("SIGKILL"), 10_000);
70
+ timer.unref();
71
+ let finished = false;
72
+ const finish = (failed) => {
73
+ if (finished)
74
+ return;
75
+ finished = true;
76
+ clearTimeout(timer);
77
+ children.delete(child);
78
+ if (failed && !disposed)
79
+ fail();
80
+ };
81
+ function fail() {
82
+ if (backends[index] === backend)
83
+ index++;
84
+ if (backends[index])
85
+ play(sound);
86
+ else
87
+ unavailable();
88
+ }
89
+ child.once("error", () => finish(true));
90
+ child.once("close", (code) => finish(code !== 0));
91
+ child.unref();
92
+ }
93
+ return {
94
+ play,
95
+ dispose() {
96
+ disposed = true;
97
+ for (const child of children)
98
+ child.kill("SIGKILL");
99
+ children.clear();
100
+ },
101
+ };
102
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "oc-sounds",
3
+ "version": "0.1.0",
4
+ "description": "Cuelume sound feedback for OpenCode V2 agent actions",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": "./dist/index.js"
11
+ },
12
+ "files": ["dist", "sounds", "THIRD_PARTY_NOTICES.md"],
13
+ "engines": { "node": ">=22" },
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "typecheck": "tsc --noEmit",
17
+ "test": "npm run build && node --test test/*.test.mjs",
18
+ "sounds:render": "node scripts/render-sounds.mjs",
19
+ "prepack": "npm run build"
20
+ },
21
+ "dependencies": {
22
+ "@opencode/plugin": "0.0.0-beta-19398"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22.0.0",
26
+ "cuelume": "0.2.2",
27
+ "node-web-audio-api": "2.2.0",
28
+ "typescript": "^5.9.0"
29
+ }
30
+ }
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file