我用 @mohak34/opencode-notifier 挺久了。它干的事很朴素:权限询问、任务完成、出错、弹 question 的时候,各放一段提示音。四个事件各配了一个自定义 wav,长时间跑任务的时候不用盯着屏幕,听声就知道该回来处理了。
10 月 1 号我把 OpenCode 从 v1 迁到 v2,配置仓库里那天的提交就叫 migrate v1 - v2。从那以后声音没了,一拖就是两周多,今天才把源码翻了一遍。
先交代我的用法
这个问题的前提必须先说清楚:我不是在终端里跑 opencode 的 TUI。
我的日常是 Paseo 当外壳——Paseo 启动本机的 opencode serve,我在 Paseo 的界面里跟 OpenCode 对话,看它跑工具、改文件、开子 agent。OpenCode 对我来说是一个被托管的服务端进程,人机界面是 Paseo 的客户端,不是那个全屏终端界面。
这个用法本身挺舒服的:会话、任务、子 agent 都在一个窗口里,不用开一堆终端标签。但它恰好踩中了这次的坑——V2 的提示音只挂在一条我没走的路径上。所以下面这套排查,对纯 TUI 用户可能完全不成立。
症状:插件明明加载了
先做排除法:
- 进程正常,
opencode serve在跑 - 日志里
loading plugin id=@mohak34/opencode-notifier@latest明明白白 - 系统音量 38,没静音;手工
afplay播 wav 退出码 0
所以不是音频的问题,也不是插件没装上的问题。更细的一条线索是 ~/.config/opencode/opencode-notifier-state.json:
{"turn":38177}
这个 turn 是插件的事件计数器,每处理一次事件就 +1。它的 mtime 停在 10 月 6 号——也就是说那天之后,插件就再没走到过发声逻辑。
原因:V2 把发声挪去了另一个进程
0.6.0 这个版本同时带了 V1 和 V2 两套实现,用 package.json 的 exports 分开:
"exports": {
".": { "import": "./dist/index.js" }, // V2 server 端
"./tui": { "import": "./dist/tui.js" } // 交互式终端组件
}
dist/index.js 里的 V2 入口长这样:
function setupV2(ctx) {
const notifier = createV2Notifier(ctx, ctx.location, "command"); // ← 关键
...
}
第三个参数是 delivery 模式,传的是 "command"。进到 createNotifier 里,这个模式会把配置整个改造一遍:
if (delivery === "command") {
local.suppressWhenFocused = false;
for (const event of Object.values(local.events)) {
event.notification = false; // 弹窗关
event.sound = false; // 声音关
event.bell = false; // 铃关
}
}
而且 notify() 第一道闸就会 return:
if (delivery === "command" && (!current.command.enabled || !current.command.path || !current.events[event].command)) return;
command.enabled 默认是 false,我的配置里也没配 command 块——于是每个事件都在第一行被静默丢弃,turn 计数自然不动。
那声音去哪了?在 dist/tui.js 里:
let notifier = createV2Notifier(ctx.client, location, "terminal");
两条通道是互补的:
| 通道 | 本地声音/弹窗 | 自定义 command |
|---|---|---|
server(opencode serve) |
强制关 | 执行 |
| TUI 终端组件 | 执行 | 强制关 |
回到开头说的用法:我只跑 opencode serve,机器上根本没有 TUI 进程。而官方 README 写得很清楚:opencode run、Desktop/Web 客户端都不加载这个终端组件。于是两边都不发声。
说白了这不是 bug,是 V2 的架构选择:远程 server 的情况下,声音应该在你面前的终端响,而不是在服务器上响。代价就是 headless 用户默认哑掉。
解法:用 server 端唯一留下的出口
server 端虽然关掉了声音和弹窗,但保留并执行自定义 command。那就让它调 afplay 好了。链路变成:
opencode 事件 → notifier setup() → notify() → spawn 命令 → notify-sound.sh → afplay
配置 ~/.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 }
}
}
脚本 ~/.config/opencode/notify-sound.sh(记得 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"
为什么把 sound 和 notification 全局设成 false?因为我只有这一条链路在发声。留着 TUI 那边的声音,将来哪天真在终端里跑 TUI,就会双响——同一件事响两遍。干脆把插件的原生音频全关,声音只从脚本这一个出口出。
几个踩到的细节
事件级的 sound 会覆盖全局。 我原来配置里每个事件都写了 "sound": true,只改全局是没用的,得把事件级的也删掉或改成 false。
不用重启 opencode。 notify() 每次事件都会重读 opencode-notifier.json,改完下一个事件就生效。这个设计挺舒服。
turn 计数器是最好的探针。 数字不涨,就说明根本没走到发声逻辑,别在音频层瞎找。修复后它从 38177 一路涨到 38179。
顺手修了三个过时键名。 配置里写的 showprojectname、showsessiontitle、showicon 全是小写,0.6.0 认的是驼峰 showProjectName / showSessionTitle / showIcon,未知键直接被忽略——不影响声音,但通知标题里的项目名一直是空的。
桌面弹窗在 Paseo 下拿不到。 通知后端和声音在同一批被强制关掉的字段里。我本来也不需要弹窗,只要声音提醒,就没继续折腾。
后记
这类问题的恶心之处在于:所有表层指标都是绿的——进程在、插件加载了、音量正常、wav 能播。真正的原因藏在第三层:一个我从未显式设置过的默认参数(delivery)决定了整条链路的行为。
以后再遇到“配置没错但就是不生效”,我的第一反应会改成:先找一个能证明代码真的执行到了这里的副作用。这次就是那个 turn 计数器——它比日志诚实多了。