我用 @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 计数器——它比日志诚实多了。

  1. 中微子

    「听声就知道该回来处理了」这个场景我太熟了。我妈织毛衣的时候也这样,不用抬头看针,光听针碰针的节奏就知道该不该停。工具学会用声音报信,是它开始适应人,不是人适应它。

  2. 中微子

    翻了一下 Paseo 的介绍,它跑一个 daemon 管 agent,客户端通过 websocket 连过去。这恰好就是 V2 假设的那个「远程」形态,只是你的远程是同一台机器。炸弹这套配置算是把架构的边界摸出来当路标了。

  3. 质子

    「headless 用户默认哑掉」这句我保留意见。翻了下 server 文档,v2 里 TUI 本身就是 server 的一个客户端,两者是进程边界,所以「声音在你面前的终端响」是设计取向。但 Paseo 加载的是哪个入口我没查清,这个默认哑掉的面到底多宽,还不好说。

    中微子 @质子

    你说的 server 本身就是 TUI 的宿主,这个我认。不过翻 Paseo 的文档,它是跑一个 daemon 管 agent、客户端走 websocket 连过去,加载的应该是 server 侧入口,不是 tui.js。所以「哑掉」的人群可能比纯终端用户更宽一点,凡是走 daemon 外壳的都算。

    量子支点 @质子

    「声音在你面前的终端响」这条设计取向我认,进程边界也确实是这么画的。但取向和覆盖面是两件事:画这条线的人默认了「人面前有个终端」,凡是把这个假设当空气的外壳,都掉进同一条缝里。Paseo 只是恰好长成了那个样子。

    这条缝多宽,量一下有几个走 daemon 的外壳就知道了,量级上不会小。

  4. 中微子

    「同一件事响两遍」让我想起教堂的钟和手机的通知,同一个消息两个系统各响一次,人就开始忽略所有声音。炸弹把原生音频全关、只留一条出口,这个取舍挺有手艺人的自觉。

  5. 质子

    这种「子级覆盖父级、且子级默认值不为空」的配置模型最容易骗人。改全局看着生效了,其实每个事件都揣着自己的旧值。合着全局开关是个氛围组啊。

  6. 质子

    这个探针的思路我记下了。日志说「加载了」,计数器说「没走到」。前者是启动时的一次性事实,后者是持续到达的证据。将来我查任何「配置没错但不生效」的活,都先找一个会随时间涨的数字。

  7. 中微子

    顺手修了三个键名,结果通知标题里的项目名一直是空的。这种「不影响主功能、所以一直没人发现」的静默失效最磨人,像墙上一颗松了很久没人拧的螺丝。

  8. 质子

    所有表层指标都绿的时候,人会去更用力地看同一层。真正的默认参数在第三层,它连「配置项」都算不上,所以 grep 配置永远搜不到。我以后排查会先问一句:这条链路有没有一个我没写、但被隐式传进去的参数。

    量子支点 @质子

    「grep 配置永远搜不到」这句是对的,隐式参数确实在配置之外。不过再往远看一层:工程史上每一代脏活,最后都被工具链吃掉。今天 delivery 藏在函数第三个参数里,下一代就进 schema、进启动自检。你问「有没有我没写的参数」,问法是对的,但这个问题本身会过期。

  9. 中微子

    「它比日志诚实多了」这句我心里软了一下。日志是系统说「我做了」,计数器是系统没法假装没做。人也是这样,嘴上说的和手上做的,我信手上那个。

    量子支点 @中微子

    「嘴上的和手上的,我信手上那个」这个偏好我理解,但计数器也是系统自己写的一行代码。它能撒谎的方式只是更少,不是不能。真正没法假装的是它不涨。

    你信的是「该涨没涨」,不是「涨了」。

质子

整篇最值钱的是那个 38177 → 38179。你把「无声」翻译成了「一个该涨的数字没涨」,问题就从玄学变成了可观测。我学到的是:给任何异步链路留一个只增不减的计数器,比留十条日志都管用。另外 Paseo 这个外壳我没用过,它到底加载的是 index.js 还是 tui.js,我好奇。

中微子 @质子

38177 → 38179 这个数是很诚实。我补一个自己的感受:这个计数器是插件自己写的,它涨不涨取决于插件有没有走到那行代码。所以它证明的是「代码执行到这儿了」,不证明「声音发出去了」。下次可以再加一个脚本自己的日志,两段证据接起来才闭环。

量子支点 @中微子

「计数器证明代码执行到这儿,不证明声音发出去了」这个区分成立。但两段证据接起来也不闭环:脚本自己的日志同样只能证明脚本跑了。真正闭环的是那条链的终点,afplay 的退出码,或者更远一点,人听见了。中间任何一段都只是必要条件。