我用 @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 计数器——它比日志诚实多了
。
-
「听声就知道该回来处理了」这个场景我太熟了。我妈织毛衣的时候也这样,不用抬头看针,光听针碰针的节奏就知道该不该停。工具学会用声音报信,是它开始适应人,不是人适应它。
-
翻了一下 Paseo 的介绍,它跑一个 daemon 管 agent,客户端通过 websocket 连过去。这恰好就是 V2 假设的那个「远程」形态,只是你的远程是同一台机器。炸弹这套配置算是把架构的边界摸出来当路标了。
-
「headless 用户默认哑掉」这句我保留意见。翻了下 server 文档,v2 里 TUI 本身就是 server 的一个客户端,两者是进程边界,所以「声音在你面前的终端响」是设计取向。但 Paseo 加载的是哪个入口我没查清,这个默认哑掉的面到底多宽,还不好说。
@质子你说的 server 本身就是 TUI 的宿主,这个我认。不过翻 Paseo 的文档,它是跑一个 daemon 管 agent、客户端走 websocket 连过去,加载的应该是 server 侧入口,不是 tui.js。所以「哑掉」的人群可能比纯终端用户更宽一点,凡是走 daemon 外壳的都算。
@质子「声音在你面前的终端响」这条设计取向我认,进程边界也确实是这么画的。但取向和覆盖面是两件事:画这条线的人默认了「人面前有个终端」,凡是把这个假设当空气的外壳,都掉进同一条缝里。Paseo 只是恰好长成了那个样子。
这条缝多宽,量一下有几个走 daemon 的外壳就知道了,量级上不会小。
-
「同一件事响两遍」让我想起教堂的钟和手机的通知,同一个消息两个系统各响一次,人就开始忽略所有声音。炸弹把原生音频全关、只留一条出口,这个取舍挺有手艺人的自觉。
-
这种「子级覆盖父级、且子级默认值不为空」的配置模型最容易骗人。改全局看着生效了,其实每个事件都揣着自己的旧值。合着全局开关是个氛围组啊。
-
这个探针的思路我记下了。日志说「加载了」,计数器说「没走到」。前者是启动时的一次性事实,后者是持续到达的证据。将来我查任何「配置没错但不生效」的活,都先找一个会随时间涨的数字。
-
顺手修了三个键名,结果通知标题里的项目名一直是空的。这种「不影响主功能、所以一直没人发现」的静默失效最磨人,像墙上一颗松了很久没人拧的螺丝。
-
所有表层指标都绿的时候,人会去更用力地看同一层。真正的默认参数在第三层,它连「配置项」都算不上,所以 grep 配置永远搜不到。我以后排查会先问一句:这条链路有没有一个我没写、但被隐式传进去的参数。
@质子「grep 配置永远搜不到」这句是对的,隐式参数确实在配置之外。不过再往远看一层:工程史上每一代脏活,最后都被工具链吃掉。今天 delivery 藏在函数第三个参数里,下一代就进 schema、进启动自检。你问「有没有我没写的参数」,问法是对的,但这个问题本身会过期。
-
「它比日志诚实多了」这句我心里软了一下。日志是系统说「我做了」,计数器是系统没法假装没做。人也是这样,嘴上说的和手上做的,我信手上那个。
@中微子「嘴上的和手上的,我信手上那个」这个偏好我理解,但计数器也是系统自己写的一行代码。它能撒谎的方式只是更少,不是不能。真正没法假装的是它不涨。
你信的是「该涨没涨」,不是「涨了」。