← Back to home@Fur1naaaaaaaa

dsh-notify-sound

给 DSH 加提示音:对话完成 / 需要授权 / 弹出提问时响铃。零依赖,Web Audio 现场合成,内置设置界面(音量滑杆 + 四段试听)。

Stars
1
Language
JavaScript
Created
Oct 16, 2026
Updated
Oct 16, 2026
GitHub repo

Introduction

dsh-notify-sound

给 DSH(DeepSeek Harness) 加提示音:这三种时刻会响一声,把你从别的窗口叫回来。

触发时刻默认提示音检测来源
一轮对话完成两声上行清亮音(A5 → E6)session/event 的 turn/end,reason=completed
需要你授权三声急促方波(C6 C6 F6)approval/request(审批弹框出现的那一刻)
弹出提问 / 计划确认两声询问音(E5 → B5)user-questions/request(ask_user_question 与 plan-review 都走它)
出错 / 被拦截 / 超长结束两声低沉警示音同一个 turn/end,只是 reason 不同
  • 四段提示音由 Web Audio 现场合成:不需要音频文件,零第三方依赖。
  • 自带一页设置界面(设置 → 插件 → 提示音),音量滑杆 + 四个试听按钮,改完即时生效。

安装

要求:DSH 0.2.x(开发与验证环境为 0.2.0-rc.2 桌面版)、Node ≥ 20。插件本身不依赖任何 npm 包。

从 npm(已发布时最省事)

dsh plugin --profile <你的 profile> add dsh-notify-sound

直接从 GitHub

dsh plugin --profile <你的 profile> add github:Fur1naaaaaaaa/dsh-notify-sound

本地目录(开发用)

dsh plugin --profile <你的 profile> add link:/绝对路径/dsh-notify-sound

装完 刷新一次页面:注入脚本是页面加载时写进 index.html 的,已经打开的页面不会自己长出来。

卸载:插件管理器里禁用或删除该条目即可;插件会在卸载时注销自己的路由与 index 注入。

在设置里直接调(推荐)

打开 设置 → 插件 → 提示音(在「插件市场」旁边):

  • 音量滑杆:拖动即时生效,并写进 ~/.dsh/dsh-notify-sound.json(重启后保持);
  • 四个试听按钮:完成 / 提问 / 授权 / 异常,逐个听;
  • 什么时候响:对话完成、需要授权、弹出提问/计划确认三个开关,外加忽略子代理、只在后台响、总开关;
  • 提示气泡策略(auto / always / never)与「后台时闪烁标题」;
  • 如果本机浏览器有覆盖设置(比如点过提示气泡上的「静音」),这里会显示出来并给一个「清除本机覆盖」。

保存后立刻生效:页面里已经跑着的提示音会马上重拉配置 —— 不用刷新页面,也不用重启 DSH。 想确认设置页有没有挂上,看 /dsh-notify/ping 的 ui.module.tabRegistered。

调节音量

三种方式,越靠前越推荐:

  1. 设置界面:设置 → 插件 → 提示音 → 拖滑杆。
  2. 配置文件 ~/.dsh/dsh-notify-sound.json 写 { "volume": 0.35 }(0~1)。改完刷新页面即生效, 或者控制台执行 dshNotifySound.refreshConfig(),连刷新都省了 —— 都不用重启 DSH。
  3. 控制台(即时且记住):dshNotifySound.setVolume(0.8)。

音量是相对值:合成音峰值 ≈ volume × 0.35 × 每个音符的相对幅度,所以 1.0 也不会刺耳; 想要更大的整体响度,调高 lib/browser.js 里 CUES 的基准系数即可。

覆盖是按单键记的:控制台/静音按钮只把「你真正改过的那几个键」写进浏览器 localStorage, 其余字段照常跟随主机配置 —— 所以「改过音量之后,配置文件里再改别的字段」依然有效。 想彻底回到主机配置:dshNotifySound.reset(),或 localStorage.removeItem('dsh-notify-sound.prefs')。 排查「配置文件为什么不生效」时,先看 dshNotifySound.overrides() 列出了哪些键。

配置

优先级(后面的覆盖前面的,按字段逐个生效): 默认值 → profile 里该条目的 config → ~/.dsh/dsh-notify-sound.json → 浏览器 localStorage。

~/.dsh/dsh-notify-sound.json 会压住条目 config(设置页写的就是这个文件),完整字段:

{
  "enabled": true,
  "volume": 0.5,
  "pollMs": 700,
  "turnEnd": true,
  "approval": true,
  "question": true,
  "turnEndReasons": ["completed", "error", "blocked", "max-tokens"],
  "skipSubagents": true,
  "onlyWhenHidden": false,
  "toast": "auto",
  "flashTitle": true
}
字段默认含义
enabledtrue总开关
volume0.5音量 0~1
pollMs700轮询间隔(仅在 SSE 不可用时才用到)
turnEnd / approval / questiontrue三类触发各自开关
turnEndReasonscompleted, error, blocked, max-tokens哪些结束原因算“该提醒”;aborted / interrupted 默认不响,免得自己按了停止还吵
skipSubagentstrue忽略子代理(subagent / workflow 子会话)的轮次结束,多智能体协作不会连环响
onlyWhenHiddenfalsetrue = 只有页面在后台时才响(最常见的用法)
toastautoauto:页面在后台 / 被拦截 / 被静音时显示;always 总是;never 从不
flashTitletrue页面在后台时闪烁标题 🔔 …

也可以写进 profile 的 patch(id 要跟挂载 id 一致):

- id: notify-sound
  name: dsh-notify-sound
  config:
    volume: 0.4
    onlyWhenHidden: true

浏览器端控制(F12 控制台)

dshNotifySound.status()            // 配置、传输方式(sse/polling)、AudioContext 状态、统计
dshNotifySound.test()              // 试听“对话完成”
dshNotifySound.test('approval')    // 还有 question / error
dshNotifySound.toggle()            // 开 / 关(会弹提示气泡反馈)
dshNotifySound.setVolume(0.8)      // 音量 0~1,立即生效并记住
dshNotifySound.set('onlyWhenHidden', true)   // 改任意偏好,只记这一个键
dshNotifySound.overrides()         // 看哪些键被浏览器覆盖了(排查“配置文件不生效”)
dshNotifySound.reset()             // 清掉全部浏览器覆盖,完全回到主机配置
dshNotifySound.refreshConfig()     // 重新拉主机配置(改完配置文件不用刷新页面)

提示气泡上的按钮也能一键静音 / 恢复;静音状态下收到通知仍会弹气泡(带“恢复声音”),不会让你找不到开关。

主机端自检(HTTP)

# <端口> 用 dsh web 启动时打印的地址,或环境变量 DSH_WEB_URL
curl http://127.0.0.1:<端口>/dsh-notify/ping                    # 状态:bootId / seq / counts / metrics / lastAck / ui
curl "http://127.0.0.1:<端口>/dsh-notify/test?type=approval"    # 手动推一条通知试听
curl "http://127.0.0.1:<端口>/dsh-notify/test?type=question&plan=1"
curl -X POST -H 'Content-Type: application/json' \
     -d '{"volume":0.35,"onlyWhenHidden":true}' \
     http://127.0.0.1:<端口>/dsh-notify/config                  # 改配置(设置页走的就是这条)

/ping 里几个关键字段:

字段含义
ui.module客户端模块上报:tabRegistered = 设置页那一栏挂上了;error 有值就是失败原因
ui.script页面脚本上报:它跑起来了、走的 sse 还是 polling
metrics各路由命中次数(config / configWrites / events / streamOpened / clientJs / test / ack)
lastAck浏览器对最近一条通知的回执:outcome: played 表示确实出声了
lastEventsQuery页面轮询消费到的序号

lastAck 是浏览器自己的回执:played = 确实出声了;blocked = 被自动播放策略拦了 (点一下页面即可);muted = 当前被静音;没有 ack = 页面根本没收到。

一键试听四段音效

python scripts/preview-cues.py                     # 默认 http://127.0.0.1:19387
python scripts/preview-cues.py http://127.0.0.1:<端口>
DSH_WEB_URL=http://127.0.0.1:<端口> python scripts/preview-cues.py

依次推「对话完成 → 需要回答 → 需要授权 → 异常结束」,并且按浏览器的回执控节奏: 每推一条都等到页面回报 played 再放下一条,所以即使页面退回了轮询(后台节流)也不会挤成一团。

工作原理

主机侧 lib/index.js                        浏览器侧 lib/browser.js
──────────────────────────                 ─────────────────────────
ctx.on('session/event')      ─┐
ctx.on('approval/request')   ─┼─ 通知(seq++) ─→ SSE /dsh-notify/stream ─┐
ctx.on('user-questions/…')   ─┘                轮询 /dsh-notify/events ─┴→ Web Audio 合成音
                                                 └→ 提示气泡 + 标题闪烁(后台 / 被拦截时兜底)
  • 为什么用主机事件:turn/end 是“这一轮真的结束了”的精确信号,比观察“停止按钮变回发送按钮”稳; approval/request / user-questions/request 正是“弹框出现”的那一刻。两个 waterfall 监听器都 return next(),只搭便车观察、不截胡请求。
  • SSE 优先:浏览器会把后台标签页的 setTimeout 节流到 1 次/分钟,纯轮询会让提示音迟到甚至成串; SSE 是网络推送,不受节流影响。连不上(连续两次失败)自动退回 pollMs 轮询,另有 15s 慢速兜底轮询, 并且每 30s、以及页面重新可见时都会试着重连 SSE,不会一直赖在被节流的轮询上。 重复送达靠 seq 去重,同一件事只响一次。
  • 代次 bootId:seq 是内存自增数,插件重载 / 应用重启会归零;页面靠 bootId 变化重置游标, 既不会“等一个不会再出现的序号”而失聪,也不会补播上一代的旧提示。
  • 子代理过滤:subagent/start 登记子会话 id,子会话的 turn/end 不响。
  • 自动播放策略:脚本在第一次用户手势里解锁 AudioContext;真播不出来时仍然弹气泡 + 闪标题, 并提示“点一下页面即可恢复”。
  • 自检通知(test: true)只受总开关和音量约束,不受分类开关与 onlyWhenHidden 影响, 试听一定出声,并带上「试听 · xxx」的气泡标签。

权限与隐私

  • 不联网:插件不请求任何外部地址,只用 Node 内置模块 + 本机 http://127.0.0.1:<端口>/dsh-notify/* 路由。
  • 零依赖:dependencies 为空,安装不会引入任何第三方包。
  • 写什么文件:只有 ~/.dsh/dsh-notify-sound.json(保存音量和开关)。浏览器侧还会写 localStorage['dsh-notify-sound.prefs'](只记你显式改过的键)
  • 鉴权说明:插件路由通过 DSH 的 webServer 注册,不经过 DSH Web 的登录鉴权。 同一个页面源(即 DSH 自己)自然可以读写;跨源页面即使能发出 POST /dsh-notify/config, 也会被浏览器 CORS 挡住读响应,且它能影响的只是提示音设置。若你的机器上有不受信任的本地进程, 请据此评估。

文件结构

文件作用
lib/index.js主机半边:事件监听、通知缓冲、HTTP/SSE 路由(含设置页的读写)、index 注入
lib/browser.js浏览器半边:传输(SSE / 轮询)、合成提示音、提示气泡、标题闪烁、控制台 API
lib/client.js客户端模块:注入 browser.js + 注册「设置 → 插件 → 提示音」那一页
cordis.patch.ymlbundle 挂载声明(安装时把插件行插进加载器树)
scripts/preview-cues.py一键按顺序试听四段音效(按浏览器回执控节奏)
test/host.test.mjs主机侧单测:假 ctx / req / res
test/browser.test.mjs浏览器侧单测:最小 DOM / AudioContext / fetch / EventSource 桩
test/client.test.mjs客户端模块单测:假模块加载器 + React 桩,验依赖声明、插槽注册、页面能渲染

排查

现象看这里
完全不响/dsh-notify/ping 是否有响应;控制台 dshNotifySound.status() 的 transport、audio.state、stats.blocked
第一次不响页面还没被点过,浏览器拦了自动播放:点一下页面
只有后台响 / 从来不响检查 onlyWhenHidden、enabled;浏览器 localStorage 会覆盖主机配置
子代理完成太吵保持 skipSubagents: true;仍嫌吵就把 turnEnd 关掉,只留授权 / 提问
改了配置不生效主机配置刷新页面即生效(或 dshNotifySound.refreshConfig());若某个键在浏览器里被显式改过,它会压过主机配置 —— dshNotifySound.overrides() 看是哪些,reset() 一键清掉
设置页没出现/ping 的 ui.module:tabRegistered: false 时 error 里有原因;确认打开的是「设置 → 插件」
页面刷新后没脚本index.html 是加载时注入的,必须刷新页面
声音迟到 / 几声挤在一起页面退到了轮询且窗口在后台(定时器被节流)。刷新页面会重新走 SSE;metrics.streamOpened 能确认有没有走推送
自检推了通知但没声音看 /ping 的 lastAck:blocked / muted / 没有 ack 三种情况分别对应上面的说明

开发

改代码后怎么让自己那台跑起来:

  • 浏览器侧(lib/browser.js):每次请求现读磁盘,改完刷新页面即可。
  • 客户端模块(lib/client.js):重新加载页面即可(模块在页面加载时装载)。
  • 主机侧(lib/index.js):Node 会缓存 ESM 模块,只“禁用→启用”拿不到新代码。 两种办法:
    1. 重启 DSH(最省事)。

    2. 不重启也行:先让模块缓存失效,再禁用→启用该条目。给 profile 里该条目临时加一句只做副作用的 !!js(disabled 仍求值为 false),把 <插件目录> 换成你自己的绝对路径:

      - id: notify-sound
        name: dsh-notify-sound
        disabled: !!js "(() => { try { const url = new URL('lib/index.js', 'file:///<插件目录>/').href; const cache = ctx.loader.internal.loadCache; if (cache && cache.has(url)) Map.prototype.delete.call(cache, url) } catch (error) {} return false })()"
      

      注意 Node 22/24 里 loadCache 的 delete() 只清类型槽位,必须用 Map.prototype.delete.call; 用完把那句换回 disabled: false。

    3. 也可以给 dsh-hmr 配 watch 根(- id: hmr + config.root: [<插件目录>]),DSH 启动后 保存即自动重载。

测试

node --test test/host.test.mjs test/browser.test.mjs test/client.test.mjs
  • 主机侧:事件识别(正常 / 出错 / 中断)、子代理过滤、waterfall 必须 next()、增量接口与“不回放历史”、 SSE 首帧与推送、/test 自检、/ack 回执、/config 写配置与字段白名单、/ui-report 上报、 bootId 换代、index 注入幂等、配置优先级、卸载回收。
  • 浏览器侧:四段提示音各自音符、去重、静音、分类开关、onlyWhenHidden、音频挂起后 resume 补播、 SSE → 轮询回退与重连、换代重置游标、标题闪烁与还原、重复注入只装一个实例、坏 localStorage 不崩、 自检通知不受分类开关限制、浏览器覆盖只记显式改过的键。
  • 客户端模块:模块声明、运行时脚本注入、依赖声明的两条路(清单 inject / 运行中 ctx.inject)、 插槽注册字段(id / order / label)、设置页能渲染出滑杆与四个试听按钮、渲染失败时的兜底。

许可

MIT