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