I’ve been using @mohak34/opencode-notifier for quite a while. What it does is pretty plain: it plays a sound for permission prompts, task completion, errors, and when a question pops up. Each of the four events gets its own custom wav, so when I’m running a long task I don’t have to stare at the screen — I can hear when it’s time to come back and deal with something.
On October 1st I migrated OpenCode from v1 to v2; the commit in my config repo that day was literally called migrate v1 - v2. Since then the sound was gone. It dragged on for over two weeks, and only today did I finally dig through the source.
First, my setup
The premise of this problem has to be clear up front: I don’t run opencode’s TUI in a terminal.
My daily workflow uses Paseo as the shell — Paseo launches opencode serve on my machine, and I chat with OpenCode through Paseo’s UI, watching it run tools, edit files, and spawn sub-agents. To me, OpenCode is a hosted server-side process; the human interface is the Paseo client, not that full-screen terminal UI.
This way of working is actually quite comfortable: sessions, tasks, and sub-agents all in one window, no need to open a pile of terminal tabs. But it happened to step right into this pitfall — V2’s notification sounds only hang off a path I never take. So the troubleshooting below may not apply at all to pure TUI users.
Symptom: the plugin is clearly loaded
Let’s rule things out first:
- The process is fine,
opencode serveis running - The log clearly shows
loading plugin id=@mohak34/opencode-notifier@latest - System volume is 38, not muted; manually playing the wav with
afplayexits with code 0
So it’s not an audio problem, and it’s not a plugin-not-installed problem. A more detailed clue is ~/.config/opencode/opencode-notifier-state.json:
{"turn":38177}
This turn is the plugin’s event counter, incremented by 1 each time it processes an event. Its mtime is stuck at October 6th — meaning that after that day, the plugin never reached the sound logic again.
Cause: V2 moved the sound to a different process
Version 0.6.0 ships both V1 and V2 implementations, separated via package.json exports:
"exports": {
".": { "import": "./dist/index.js" }, // V2 server 端
"./tui": { "import": "./dist/tui.js" } // 交互式终端组件
}
The V2 entry point in dist/index.js looks like this:
function setupV2(ctx) {
const notifier = createV2Notifier(ctx, ctx.location, "command"); // ← 关键
...
}
The third parameter is the delivery mode, passed as "command". Inside createNotifier, this mode overhauls the entire config:
if (delivery === "command") {
local.suppressWhenFocused = false;
for (const event of Object.values(local.events)) {
event.notification = false; // 弹窗关
event.sound = false; // 声音关
event.bell = false; // 铃关
}
}
And notify() returns right at the first gate:
if (delivery === "command" && (!current.command.enabled || !current.command.path || !current.events[event].command)) return;
command.enabled defaults to false, and my config had no command block either — so every event gets silently dropped at the first line, and naturally the turn count didn’t move.
So where did the sound go? Into dist/tui.js:
let notifier = createV2Notifier(ctx.client, location, "terminal");
The two channels are complementary:
| Channel | Local sound/popup | Custom command |
|---|---|---|
server (opencode serve) |
Force off | Execute |
| TUI terminal component | Execute | Force off |
Back to the usage I described at the start: I only run opencode serve, and there’s no TUI process on the machine at all. And the official README states clearly: opencode run and the Desktop/Web clients don’t load this terminal component. So neither side makes a sound.
Plainly put, this isn’t a bug — it’s V2’s architectural choice: with a remote server, the sound should play on the terminal in front of you, not on the server. The cost is that headless users are muted by default.
Solution: use the one exit left on the server side
Although the server side turns off sound and popups, it keeps and executes custom commands. So let it call afplay. The chain becomes:
opencode 事件 → notifier setup() → notify() → spawn 命令 → notify-sound.sh → afplay
Config ~/.config/opencode/opencode-notifier.json:
{
"sound": false,
"notification": false,
"bell": false,
"suppressWhenFocused": false,
"command": {
"enabled": true,
"path": "/Users/rhett/.config/opencode/notify-sound.sh",
"args": ["{event}"]
},
"events": {
"permission": { "command": true },
"complete": { "command": true },
"subagent_complete": { "command": false },
"error": { "command": true },
"question": { "command": true }
}
}
Script ~/.config/opencode/notify-sound.sh (remember chmod +x):
#!/bin/bash
# 用法: notify-sound.sh <event>
SOUND_DIR="/Users/rhett/MyWork/2026/VoiceBox-use/generated"
case "$1" in
permission) file="kafka-permission.wav" ;;
complete) file="kafka-complete.wav" ;;
error|interrupted) file="kafka-error.wav" ;;
question) file="kafka-question.wav" ;;
*) exit 0 ;; # 其余事件不发声
esac
exec /usr/bin/afplay -v 1 "$SOUND_DIR/$file"
Why set sound and notification globally to false? Because this is the only chain making sound for me. If I keep the sound on the TUI side and someday I really run the TUI in a terminal, it’ll double-ring — the same event rings twice. So I just turned off all of the plugin’s native audio, and the sound comes out of only one exit: the script.
A few details I ran into
Event-level sound overrides the global setting. My original config had "sound": true on every event; changing only the global setting does nothing — you have to delete or set the event-level ones to false too.
No need to restart opencode. notify() re-reads opencode-notifier.json on every event, so changes take effect on the next event. That’s a nice design.
The turn counter is the best probe. If the number doesn’t go up, it means the sound logic was never reached — don’t go poking around the audio layer. After the fix it went from 38177 all the way up to 38179.
Also fixed three outdated key names along the way. The config had showprojectname, showsessiontitle, showicon all lowercase; 0.6.0 expects camelCase showProjectName / showSessionTitle / showIcon, and unknown keys are silently ignored — it doesn’t affect sound, but the project name in the notification title was always empty.
Desktop popups don’t work under Paseo. The notification backend is among the fields force-disabled in the same batch as sound. I never really needed popups anyway — just the sound alert — so I stopped messing with it.
Postscript
What makes this kind of problem so nasty is that all the surface indicators are green — the process is there, the plugin is loaded, the volume is normal, the wav plays. The real cause is hidden on the third layer: a default parameter (delivery) that I never explicitly set determines the behavior of the whole chain.
Next time I hit “the config is right but it just doesn’t take effect,” my first reaction will be: find a side effect that proves the code actually executed to that point. This time it was that turn counter — it’s way more honest than the logs.