@mzzsfy/dsh-turn-notify 0.7.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,21 +2,13 @@
2
2
 
3
3
  DeepSeek Harness 消息通知插件:AI 回合产生六类事件(完成/出错/被中断/等待审批/AI 提问/达到上限),按你的配置送达声音、系统弹窗、页内提示、webhook、IM 五种通道,多窗口只响一次。
4
4
 
5
- 设计文档见 [docs/design/dsh-turn-notify-weixin.md](../../docs/design/dsh-turn-notify-weixin.md)。
6
-
7
5
  ## 快速上手
8
6
 
9
7
  ```sh
10
8
  dsh plugin --profile web add @mzzsfy/dsh-turn-notify
11
9
  ```
12
10
 
13
- 发布前开发安装(拷贝进 store,行为与 registry 安装一致):
14
-
15
- ```sh
16
- dsh plugin --profile web add file:./packages/dsh-turn-notify
17
- ```
18
-
19
- 安装后打开 设置 > 消息通知:默认全部事件开启,声音、页内提示、系统弹窗即装即用(系统弹窗经一次浏览器授权后生效);webhook 与 IM 填配置后启用。声音通道受浏览器自动播放策略约束,首次点击页面后才会出声。想推到微信等聊天工具,见 [IM 推送](#im-推送);想推到自建服务,见 [webhook 推送](#webhook-推送)。
11
+ 安装后打开 设置 > 消息通知:默认全部事件开启,声音与系统弹窗即装即用(系统弹窗经一次浏览器授权后生效);webhook IM 填配置后启用。页内提示经公共依赖 @mzzsfy/dsh-toast 提供,由 session-manager 插件代挂装载——未安装 session-manager 时该通道自动禁用,其余通道不受影响。声音通道受浏览器自动播放策略约束,首次点击页面后才会出声。想推到微信等聊天工具,见 [IM 推送](#im-推送);想推到自建服务,见 [webhook 推送](#webhook-推送)。
20
12
 
21
13
  ## 六类通知事件
22
14
 
@@ -53,15 +45,40 @@ dsh plugin --profile web add file:./packages/dsh-turn-notify
53
45
 
54
46
  ## 系统弹窗与页内提示
55
47
 
56
- - **系统弹窗**:浏览器 Notification 授权后,窗口失焦时弹系统级通知。HTTP 非回环地址下浏览器不提供该能力,声音开启时自动降级为标题闪烁;HTTPS 下自动恢复。Windows 下还受系统通知设置与专注助手约束。
57
- - **页内提示**:页面角落浮出的卡片提示(dsh-toast 栈式展示,6 秒),聚焦窗口内唯一常开的提醒形态,可关。
48
+ - **系统弹窗**:浏览器 Notification 授权后,窗口失焦时弹系统级通知;标题为事件文本,通知小结(见下节)作正文。点击弹窗聚焦窗口并直接切换到对应会话(点击直达,下节)。HTTP 非回环地址下浏览器不提供该能力,声音开启时自动降级为标题闪烁;HTTPS 下自动恢复。Windows 下还受系统通知设置与专注助手约束。
49
+ - **页内提示**:页面角落浮出的卡片提示(dsh-toast 栈式展示,6 秒),聚焦窗口内唯一常开的提醒形态,可关;通知带小结时以第二行呈现,点击卡片直达对应会话。
58
50
  - **页内提示音**:聚焦场景的独立声音,总开关、按事件分类独立开关与音色映射全套自成一档,独立于提示音的分类配置。开启后页内提示弹出且通知声音未播时补一声提示——聚焦窗口内通知声音被聚焦静默压制,靠它保留听觉提醒;与通知声音互斥,同一通知至多一声;音量共用,音色在音效页的「页内提示音映射」单独指定。
59
51
  - **降级标题闪烁**:系统弹窗开关开启但未获得授权或不可用(如 HTTP 非回环地址、曾被拒绝)且窗口未聚焦时,标签页标题以 ⏳ 前缀闪烁替代弹窗;单条闪烁 6 秒自停,连环通知逐条续命但总时长硬顶 30 秒必停,返回窗口立即停止;可在偏好中关闭该提示。
60
- - **聚焦静默**:窗口聚焦时只保留页内提示,声音、系统弹窗与标题闪烁全部静默(页内提示音开启且该分类未在页内静音时仍有补位一声);你离开键盘满 5 分钟视为不在电脑前,聚焦也全通道提醒。
52
+ - **聚焦静默**:窗口可见且有焦点时只保留页内提示,声音、系统弹窗与标题闪烁全部静默(页内提示音开启且该分类未在页内静音时仍有补位一声);可见但无焦点(多屏正看别的窗口)不静默,照常弹窗出声;你离开键盘满 5 分钟视为不在电脑前,聚焦也全通道提醒。
53
+
54
+ ## 点击直达会话
55
+
56
+ 等待你动作的审批与提问、以及回合结束类通知,呈现时都携带直达动作:点击页内提示卡片或系统弹窗,窗口聚焦并自动切换到对应会话(模拟点击侧边栏会话行,与手工点击完全同路),高亮随之清除(查看即已读)。会话行未渲染(列表折叠/懒加载未滚动到)时仅聚焦窗口;通知无会话标题时同样只聚焦。「知道了」类显式关闭不触发直达。
57
+
58
+ ## 等待动作二次提醒
59
+
60
+ 审批请求与 AI 提问送达后 10 分钟仍未处理时,自动补发一轮提醒(页内提示 + 声音 + 标题闪烁,独立于聚焦静默与通道路由——它就是"上一轮没看到"的兜底),每条通知至多补发一次,不连环催。"已处理"的判定:你已切到该会话,或已点掉会话行高亮;通知无会话标题时无法感知处理,按未处理补发。通知被其他窗口处理或已移出投影队列时,补发登记自动撤销。
61
+
62
+ ## 通知小结
63
+
64
+ 回合结束类通知附一句话小结:耗时(回合时长)+ tokens(本回合各步用量四桶合计)+ 最终回答前缀(末条含文本回答的首行,按码点截断),拼装为「耗时 12 秒 · 1.5k tokens · 已修复登录」形态;缺哪项省哪项,全缺省无小结。系统弹窗作正文、页内卡片作第二行,webhook payload 增补 `summary` 与 `tokens` 结构化字段。统计来自 host 侧会话事件流内的 assistant 消息,回合结束即消费清账,无额外请求。
65
+
66
+ ## 事件→通道路由
67
+
68
+ 六类事件各自可指定放行通道名单(kindRoutes,存 settings.yaml),名单外的通道对该类事件一律不送达;未配置的分类全通道放行,名单为空数组即全禁。通道名:`sound`(提示音)/ `system`(系统弹窗)/ `toast`(页内提示,页内提示音随行)/ `blink`(降级标题闪烁)/ `webhook` / `im`。典型用法:任务完成只推 webhook,重要事件(审批/提问)走 IM:
69
+
70
+ ```yaml
71
+ turn-notify:
72
+ kindRoutes:
73
+ completed: [webhook] # 完成只推 webhook,浏览器不响
74
+ approval: [im, sound, system] # 审批走 IM 且本机弹窗出声
75
+ ```
76
+
77
+ 路由是全局配置,作用于所有浏览器;本机呈现偏好(声音/弹窗开关、聚焦静默)在其上叠加,两层都放行才送达。面板"通知"分区不展示该配置,yaml 直改保存即生效。
61
78
 
62
79
  ## webhook 推送
63
80
 
64
- 在面板填入 webhook URL 即启用。host 直发,标签页全关也送达;payload 为 Slack 兼容 JSON(text 字段承载通知文本),另附 event/category/status/session/workspace/durationMs/ts 结构化字段;超时 10 秒,不重试。凭据只写不回显,测试按钮返回真实投递结果。
81
+ 在面板填入 webhook URL 即启用。host 直发,标签页全关也送达;payload 为 Slack 兼容 JSON(text 字段承载通知文本,summary 字段承载通知小结),另附 event/category/status/session/workspace/durationMs/tokens/ts 结构化字段;超时 10 秒,不重试。凭据只写不回显,测试按钮返回真实投递结果。
65
82
 
66
83
  ## IM 推送
67
84
 
@@ -69,11 +86,11 @@ dsh plugin --profile web add file:./packages/dsh-turn-notify
69
86
 
70
87
  ## 会话行高亮
71
88
 
72
- 通知实际送达时,侧边栏会话列表中对应会话行以背景脉冲闪烁强调,六类各一色(完成绿/出错红/提问蓝/中断橙/审批黄/上限紫),点击该会话行即停止。开关在面板"偏好"页首项(独立开关,本机生效,各浏览器独立),默认开启。并行多会话时用于快速定位状态刚更新的会话:运行中的会话不闪烁(与 dsh 原生状态点分工——状态点表达运行中,闪烁表达已更新待查看),会话重新进入运行状态时闪烁自动让位;聚焦时正在查看的会话不闪烁,失焦期间本会话的通知照常闪烁,返回窗口即自动消失(回来即已读,焦点与页面可见双通道)。行定位按会话标题文本匹配,标题与侧边栏行同源(session-title 投影);会话行未渲染(列表折叠/懒加载未滚动到)时该次高亮静默跳过。
89
+ 通知实际送达时,侧边栏会话列表中对应会话行以背景脉冲闪烁强调,六类各一色(完成绿/出错红/提问蓝/中断橙/审批黄/上限紫),点击该会话行即停止。开关在面板"偏好"页首项(独立开关,本机生效,各浏览器独立),默认开启。并行多会话时用于快速定位状态刚更新的会话:运行中的会话不闪烁(与 dsh 原生状态点分工——状态点表达运行中,闪烁表达已更新待查看),会话重新进入运行状态时闪烁自动让位;聚焦时正在查看的会话不闪烁,失焦期间本会话的通知照常闪烁,返回窗口即自动消失(回来即已读,焦点与页面可见双通道)。行定位按会话标题文本匹配,标题默认与侧边栏行同源(session-title 投影);该服务缺失或不可用(如伪会话无事件日志)时回落会话事件内存储的标题,可能与侧边栏不同步。会话行未渲染(列表折叠/懒加载未滚动到)时该次高亮静默跳过。重开页面的冷启动静默对齐同样挂高亮(见"多窗口只响一次"),重开即见哪些会话有动静。
73
90
 
74
91
  ## 多窗口只响一次
75
92
 
76
- 同一地址(同源)打开的多个窗口共享一次呈现:各窗口以长轮询挂起在 host 的通知队列上(事件产生或配置变更即时返回,空闲期单次挂起至多数十秒,失败按指数退避重连),经 localStorage 协调,仅一个窗口抢先呈现——发声并弹出页内提示、系统弹窗、高亮会话行,其余窗口全部跳过,不止声音。通知在产生后的下一次唤醒即送达,通常亚秒级;不同浏览器或不同地址打开的窗口各自发声互不感知。窗口关闭/刷新期间错过的通知不做重开回放:启动首轮仅静默对齐游标与完成标记,不逐条补发提示与声音(等待用户动作的审批与提问例外,照常呈现)
93
+ 同一地址(同源)打开的多个窗口共享一次呈现:各窗口以长轮询挂起在 host 的通知队列上(事件产生或配置变更即时返回,空闲期单次挂起至多数十秒,失败按指数退避重连),经 localStorage 协调,仅一个窗口抢先呈现——发声并弹出页内提示、系统弹窗、高亮会话行,其余窗口全部跳过,不止声音。通知在产生后的下一次唤醒即送达,通常亚秒级;不同浏览器或不同地址打开的窗口各自发声互不感知。窗口关闭/刷新期间错过的通知不做重开回放:启动首轮静默对齐游标与完成标记,不逐条补发提示与声音(等待用户动作的审批与提问例外,照常呈现);未读会话行高亮不在静默范围,照常挂上。
77
94
 
78
95
  ## 设置面板指南
79
96
 
@@ -81,7 +98,7 @@ dsh plugin --profile web add file:./packages/dsh-turn-notify
81
98
 
82
99
  | 分区 | 内容 |
83
100
  |------|------|
84
- | 通知 | webhook 地址、最短回合时长、子代理过滤、六类事件开关;存 settings.yaml,事件开关点击即存,其余点保存后生效 |
101
+ | 通知 | webhook 地址、最短回合时长、子代理过滤、六类事件开关;存 settings.yaml,事件开关点击即存,其余点保存后生效。事件→通道路由(kindRoutes)亦存此命名空间,经 yaml 配置 |
85
102
  | 偏好 | 本机浏览器记忆的设置:会话高亮(独立开关)、声音总开关与分类静音、音量、系统弹窗与授权、页内提示开关、页内提示音(总开关 + 分类独立开关)、聚焦静默、降级标题闪烁 |
86
103
  | 音效 | 音效上传/试听/重命名/删除,六类事件的音效映射与双作用域开关,页内提示音映射(本机,未配置沿用通知音效) |
87
104
  | IM | 绑定 dsh-im bot、勾选投递目标(仅装了 dsh-im 时显示) |
@@ -106,6 +123,8 @@ turn-notify:
106
123
  max-tokens: true
107
124
  soundMapping: # 每类事件音效映射,空为内置默认,值为内置音名或上传音效 id
108
125
  completed: ''
126
+ kindRoutes: # 事件→通道路由:分类到放行通道名单,未配置的分类全通道放行
127
+ completed: [webhook]
109
128
  imTargets: # dsh-im 投递目标,空数组禁用
110
129
  - botId: wx_xxx
111
130
  targetId: owner
@@ -117,10 +136,10 @@ turn-notify:
117
136
 
118
137
  按数据流向分五步:
119
138
 
120
- 1. **事件监听**:host 观察 `session/event`(回合结束与 ask_user_question 工具调用)与 `approval/request` 审批请求(只观察,不拦截)。回合结束原因逐一映射:正常结束归任务完成,错误与手动停止归任务出错,审批拦截归等待审批,输出触顶归达到上限,会话收尾补发的中断归被中断。
121
- 2. **分类与过滤**:事件归入六类,经子代理会话、子代理相关静默(仅任务完成类)、最短回合时长三道过滤,通过即产生一条通知。
122
- 3. **host 直发通道**:webhook 与 IM 由 host 进程直接投递,不依赖浏览器;同时通知写入内存队列(环形 20 条,60 秒过期,不落盘)
123
- 4. **浏览器通道**:各窗口以长轮询挂起在该队列上(cursor 续传,事件与配置变更即时唤醒,空闲期单次挂起至多数十秒,失败指数退避重连),localStorage 协调保证同一通知只有一个窗口呈现,再按本机偏好分发到声音/系统弹窗/页内提示。
139
+ 1. **事件监听**:host 观察 `session/event`(回合结束、ask_user_question 工具调用与 assistant 消息)与 `approval/request` 审批请求(只观察,不拦截)。回合结束原因逐一映射:正常结束归任务完成,错误与手动停止归任务出错,审批拦截归等待审批,输出触顶归达到上限,会话收尾补发的中断归被中断;assistant 消息流经途累积回合小结(tokens 与回答前缀),回合结束随通知消费。
140
+ 2. **分类与过滤**:事件归入六类,经子代理会话、子代理相关静默(仅任务完成类)、最短回合时长三道过滤,通过即产生一条通知,并按 kindRoutes 裁定放行通道。
141
+ 3. **host 直发通道**:webhook 与 IM 由 host 进程直接投递(经路由名单过滤),不依赖浏览器;同时通知写入内存队列(环形 20 条,60 秒过期,不落盘),路由名单随单元下发。
142
+ 4. **浏览器通道**:各窗口以长轮询挂起在该队列上(cursor 续传,事件与配置变更即时唤醒,空闲期单次挂起至多数十秒,失败指数退避重连),localStorage 协调保证同一通知只有一个窗口呈现,再按本机偏好与路由名单分发到声音/系统弹窗/页内提示;等待用户动作的审批与提问超时未处理时补发一轮提醒。
124
143
  5. **降级链**:声音通道开启而系统弹窗不可用(HTTP 非回环、未授权)→ 标题闪烁;页内提示依赖缺失 → 干净禁用该通道;localStorage 不可用 → 该窗口直接发声(可能与多窗口重复,诚实降级)。
125
144
 
126
145
  ## 已知取舍
@@ -130,6 +149,8 @@ turn-notify:
130
149
  - 不同浏览器或不同地址打开的窗口各自发声,互不去重。
131
150
  - 系统弹窗在 HTTP 非回环下永久降级,HTTPS 化后自动恢复;Windows 下受系统通知设置与专注助手约束。
132
151
  - 长轮询空闲期连接由代理或服务端挂起,经中间反代时需允许数十秒的挂起响应,否则退化为更频繁的重连。
152
+ - 等待动作二次提醒的"已处理"判定以本窗口可见信号(会话切换/高亮清除)近似,无会话标题的通知无法感知处理,宁可多响一次。
153
+ - 点击直达依赖侧边栏会话行可被标题匹配命中,行未渲染或标题不同步时仅聚焦不切换。
133
154
 
134
155
  ## 安全
135
156
 
@@ -144,3 +165,11 @@ turn-notify:
144
165
  cd packages/dsh-turn-notify && npm test
145
166
  ```
146
167
 
168
+ ## 开发安装(仓库维护者)
169
+
170
+ ```sh
171
+ node scripts/dev-link.mjs dsh-turn-notify # 仓库根执行:归一 profile 依赖行 + 挂 junction
172
+ ```
173
+
174
+ 工作副本以 junction 挂进 profile,host 半区改动保存约 1 秒热重载,client 半区改动刷新页面即生效,无需发版;规约与全仓归一见 `node scripts/dev-link.mjs all`。
175
+
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-turn-notify",
3
3
  "description": "消息通知:六类回合事件送达声音、系统弹窗、页内提示、webhook 与 IM;内置八种合成音色,自定义音效,按事件分类指定;多窗口只响一次",
4
- "version": "0.7.4",
4
+ "version": "0.8.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
@@ -33,7 +33,7 @@
33
33
  "node": ">=22"
34
34
  },
35
35
  "dependencies": {
36
- "@mzzsfy/dsh-toast": "^0.1.2"
36
+ "@mzzsfy/dsh-toast": "^0.2.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@deepseek-ai/dsh-session-title": ">=0.1.2-rc.1",
package/src/client.js CHANGED
@@ -277,7 +277,8 @@ window.__ModuleLoader__.load({
277
277
  // 放入 LOGIC 段由 parity 测试保证双实现不漂移
278
278
  const IDLE_AWAY_MS = 5 * 60 * 1000
279
279
 
280
- function chooseChannels(hasFocus, permission, idleMs, soundCategories, category) {
280
+ // routes 为事件→通道路由放行名单(null=未配置全放行),语义与 core 同源
281
+ function chooseChannels(hasFocus, permission, idleMs, soundCategories, category, routes) {
281
282
  const idleAway = typeof idleMs === 'number' && idleMs >= IDLE_AWAY_MS
282
283
  const quiet = hasFocus && localGet(KEY_DND) !== '0' && !idleAway
283
284
  const systemEnabled = localGet(KEY_SYSTEM) !== '0'
@@ -286,13 +287,14 @@ window.__ModuleLoader__.load({
286
287
  const pageSoundCategories = readPageSoundCategories()
287
288
  const categoryMuted = soundCategories != null && category != null && soundCategories[category] === false
288
289
  const pageCategoryMuted = pageSoundCategories != null && category != null && pageSoundCategories[category] === false
289
- const sound = !quiet && soundEnabled && !categoryMuted
290
+ const routed = (channel) => routes == null || routes.indexOf(channel) >= 0
291
+ const sound = !quiet && soundEnabled && !categoryMuted && routed('sound')
290
292
  return {
291
- toast: toastEnabled,
293
+ toast: toastEnabled && routed('toast'),
292
294
  sound,
293
- system: !quiet && systemEnabled && permission === 'granted',
294
- blink: !quiet && systemEnabled && permission !== 'granted',
295
- pageSound: localGet(KEY_PAGE_SOUND) === '1' && toastEnabled && !pageCategoryMuted && !sound,
295
+ system: !quiet && systemEnabled && permission === 'granted' && routed('system'),
296
+ blink: !quiet && systemEnabled && permission !== 'granted' && routed('blink'),
297
+ pageSound: localGet(KEY_PAGE_SOUND) === '1' && toastEnabled && !pageCategoryMuted && !sound && routed('toast'),
296
298
  }
297
299
  }
298
300
 
@@ -507,10 +509,21 @@ window.__ModuleLoader__.load({
507
509
 
508
510
  const notificationPermission = () => (typeof Notification === 'undefined' ? 'denied' : Notification.permission)
509
511
 
510
- // onOutcome 仅供测试路径取显示回执(onshow/onerror),真实路径吞错降级已在链路内
512
+ // onOutcome 仅供测试路径取显示回执(onshow/onerror),真实路径吞错降级已在链路内。
513
+ // onclick 点击直达:聚焦窗口并切到对应会话后关闭弹窗;summary 作 body 两级呈现
511
514
  function notifySystem(unit, onOutcome) {
512
515
  try {
513
- const notification = new Notification(unit.text, { tag: unit.id })
516
+ const notification = new Notification(unit.text, { tag: unit.id, body: unit.summary ?? '' })
517
+ notification.onclick = () => {
518
+ try {
519
+ if (typeof window.focus === 'function') window.focus()
520
+ if (unit.session) {
521
+ const row = findSessionRow(unit.session)
522
+ if (row !== null && typeof row.click === 'function') row.click()
523
+ }
524
+ } catch { /* 直达失败不掩盖通知主流程 */ }
525
+ notification.close()
526
+ }
514
527
  if (typeof onOutcome === 'function') {
515
528
  notification.onshow = () => { onOutcome(true) }
516
529
  notification.onerror = () => { onOutcome(false) }
@@ -562,6 +575,7 @@ window.__ModuleLoader__.load({
562
575
  }
563
576
  const units = payload.units || []
564
577
  const liveIds = new Set(units.map((unit) => unit.id))
578
+ pruneReminders(liveIds)
565
579
  // 投影中已过期的本地残留清理,防旧锁与完成标记滞留;
566
580
  // 访问失败即标记 broken 并整段跳过,防抛出被外层吞掉、发声链路失效
567
581
  try {
@@ -574,25 +588,31 @@ window.__ModuleLoader__.load({
574
588
  } catch {
575
589
  storageState.broken = true
576
590
  }
591
+ // 聚焦静默双条件:可见且有焦点才静默(竞品对齐)。hasFocus 单独成立而页面
592
+ // hidden 的边界(多屏/预渲染)不静默
593
+ const engaged = isEngaged()
577
594
  for (const unit of units) {
578
595
  // 冷启动对齐:错过窗口期的存量通知不回放轰炸;审批与提问例外——
579
596
  // 它们在等用户动作,静默会把待办吞掉
580
597
  if (coldStart && unit.category !== 'approval' && unit.category !== 'ask') {
581
598
  try { markDone(unit.id) } catch { /* 存储不可用:标记缺失由内存去重兜底,冷启动本就不发声 */ }
599
+ // 静默对齐只不呈现通知,未读会话闪烁照常:高亮是本机 UI 状态,不属轰炸
600
+ rememberSessionHighlight(unit, engaged)
582
601
  continue
583
602
  }
584
603
  if (!claimEvent(unit.id)) continue
585
604
  markDone(unit.id)
586
- const channels = chooseChannels(document.hasFocus(), notificationPermission(), Date.now() - lastActionAt(), readSoundCategories(), unit.category)
605
+ const channels = chooseChannels(engaged, notificationPermission(), Date.now() - lastActionAt(), readSoundCategories(), unit.category, unit.routes ?? null)
587
606
  const sound = resolveSound(unit.category, effectiveMapping(), uploadedIds)
588
607
  if (channels.toast || channels.sound || channels.system || channels.blink) {
589
- if (sessionHighlights.size >= SESSION_HL_MAX) sessionHighlights.delete(sessionHighlights.keys().next().value)
590
- // 投影字段名为 session(buildUnit 输出形态,webhook 结构化字段同名);
591
- // 聚焦时正在查看的会话不闪烁——与 dsh 原版一致,正在看的会话不做未读强调;
592
- // 失焦期间照常挂,返回页面时由可见性同步清除(回来即已读)
593
- if (unit.session && !(document.hasFocus() && isCurrentSessionTitle(unit.session))) sessionHighlights.set(unit.session, unit.category)
608
+ rememberSessionHighlight(unit, engaged)
609
+ }
610
+ if (channels.toast) {
611
+ toast?.(unit.text + (unit.summary ? '\n' + unit.summary : ''), {
612
+ holdMs: TOAST_MS,
613
+ onClick: activateSessionFromUnit(unit),
614
+ })
594
615
  }
595
- if (channels.toast) toast?.(unit.text, { holdMs: TOAST_MS })
596
616
  // 页内提示音与通知声音互斥(pageSound 已含 !sound),同一通知至多一声;
597
617
  // toast 库缺失时卡片不存在,补位音随之禁用(声明与可用分离,可用性在调用点合流);
598
618
  // 页内音色按页内场景映射解析:通知生效映射为底,页内显式配置覆盖
@@ -603,6 +623,8 @@ window.__ModuleLoader__.load({
603
623
  if (channels.sound) playSound(sound).catch(() => {})
604
624
  if (channels.system) notifySystem(unit)
605
625
  else if (channels.blink && localGet(KEY_DEGRADE_HINT) !== '0') startTitleBlink()
626
+ // 等待用户动作的事件超时未处理:补发一轮提醒(审批与提问)
627
+ scheduleActionReminder(unit, channels)
606
628
  }
607
629
  applySessionHighlights()
608
630
  return true
@@ -749,12 +771,38 @@ window.__ModuleLoader__.load({
749
771
  return text.indexOf('进行中') >= 0 || text.toLowerCase().indexOf('running') >= 0
750
772
  }
751
773
 
774
+ // 聚焦静默双条件:可见且有焦点才静默(竞品对齐)。hasFocus 单独成立而页面
775
+ // hidden 的边界(多屏/预渲染)不静默
776
+ const isEngaged = () => document.hasFocus() && document.hidden === false
777
+
778
+ // 高亮挂载单一入口:正常送达与冷启动静默对齐共用;
779
+ // 门控差异:正常送达仅在通知实际投递(任一呈现通道放行)时挂,冷启动静默对齐
780
+ // 无条件挂——高亮是未读 UI 状态,不属通知轰炸,错过窗口的存量照样强调;
781
+ // 开关关闭不积累条目;聚焦时正在查看的会话不挂——与 dsh 原版一致,正在看即已读,
782
+ // 失焦期间照常挂,返回页面时由可见性同步清除(回来即已读);
783
+ // 投影字段名为 session(buildUnit 输出形态,webhook 结构化字段同名)
784
+ function rememberSessionHighlight(unit, engaged) {
785
+ if (!sessionHighlightEnabled || !unit.session) return
786
+ if (engaged && isCurrentSessionTitle(unit.session)) return
787
+ if (sessionHighlights.size >= SESSION_HL_MAX) sessionHighlights.delete(sessionHighlights.keys().next().value)
788
+ sessionHighlights.set(unit.session, unit.category)
789
+ }
790
+
752
791
  // 增量重应用:仅补缺失类,不产生多余 DOM 写,防 MutationObserver 回调自我触发成环;
753
792
  // 快照遍历:行进入运行状态时清理对应条目,迭代中删除不改快照
754
793
  function applySessionHighlights() {
755
794
  if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return
756
795
  if (!sessionHighlightEnabled) return
796
+ const engaged = isEngaged()
757
797
  for (const [title, category] of [...sessionHighlights]) {
798
+ // 聚焦态复核:冷启动首拉早于标题就绪时误挂的当前会话高亮,标题就绪后的
799
+ // 任一应用帧在此自愈(与返回即已读同语义:正在看即不强调)
800
+ if (engaged && isCurrentSessionTitle(title)) {
801
+ sessionHighlights.delete(title)
802
+ const stale = findSessionRow(title)
803
+ if (stale) removeRowHighlight(stale)
804
+ continue
805
+ }
758
806
  const row = findSessionRow(title)
759
807
  if (!row) continue
760
808
  if (isRowRunning(row)) {
@@ -799,6 +847,56 @@ window.__ModuleLoader__.load({
799
847
  if (typeof row.className !== 'string') return
800
848
  row.className = row.className.split(' ').filter((name) => name !== SESSION_HL_CLASS && name.indexOf(SESSION_HL_CLASS + '--') !== 0).join(' ')
801
849
  }
850
+
851
+ // ---- 点击直达会话:页内卡片与系统弹窗共用 ----
852
+
853
+ // 按通知单元激活会话:聚焦窗口并模拟点击侧边栏会话行(官方行点击即切换会话);
854
+ // 点击事件经高亮清除委托,天然完成"查看即已读"。行未渲染(列表折叠/懒加载)
855
+ // 或无标题时仅聚焦窗口。
856
+ function activateSessionFromUnit(unit) {
857
+ return () => {
858
+ if (typeof window.focus === 'function') window.focus()
859
+ if (!unit.session) return
860
+ const row = findSessionRow(unit.session)
861
+ if (row !== null && typeof row.click === 'function') row.click()
862
+ }
863
+ }
864
+
865
+ // ---- 审批/提问超时二次提醒 ----
866
+
867
+ // 等待用户动作的事件超此时长未处理即补发一轮(一次,不连环)。
868
+ const ACTION_REMIND_MS = 10 * 60 * 1000
869
+ // 待提醒登记:unit id → timer;处理判定与生命周期清理共用
870
+ const pendingReminders = new Map()
871
+ // "已处理"近似:用户切到该会话(标题匹配)或点掉了高亮行(高亮条目消失);
872
+ // 无标题通知(审批 waterfall)无法感知处理,按未处理补发,宁可多响一次。
873
+ function actionHandled(unit) {
874
+ if (!unit.session) return false
875
+ if (isCurrentSessionTitle(unit.session)) return true
876
+ return !sessionHighlights.has(unit.session)
877
+ }
878
+ function scheduleActionReminder(unit, channels) {
879
+ if (unit.category !== 'approval' && unit.category !== 'ask') return
880
+ if (pendingReminders.has(unit.id)) return
881
+ const timer = setTimeout(() => {
882
+ pendingReminders.delete(unit.id)
883
+ if (actionHandled(unit)) return
884
+ // 补发独立于聚焦静默与路由名单:它就是"通道没送达"的兜底
885
+ toast?.('[再次提醒] ' + unit.text + (unit.summary ? '\n' + unit.summary : ''), { holdMs: TOAST_MS, onClick: activateSessionFromUnit(unit) })
886
+ playSound(resolveSound(unit.category, effectiveMapping(), uploadedIds)).catch(() => {})
887
+ startTitleBlink()
888
+ }, ACTION_REMIND_MS)
889
+ if (typeof timer.unref === 'function') timer.unref()
890
+ pendingReminders.set(unit.id, timer)
891
+ }
892
+ // 投影刷新同步:单元过期即撤登记(通知已被其他窗口处理或已出投影窗口)
893
+ function pruneReminders(liveIds) {
894
+ for (const [id, timer] of pendingReminders) {
895
+ if (liveIds.has(id)) continue
896
+ clearTimeout(timer)
897
+ pendingReminders.delete(id)
898
+ }
899
+ }
802
900
  // 页面重新可见即视为已读:清除当前会话的高亮,其他会话的提醒保留
803
901
  function clearCurrentSessionHighlights() {
804
902
  for (const title of [...sessionHighlights.keys()]) {
@@ -1877,7 +1975,7 @@ window.__ModuleLoader__.load({
1877
1975
  },
1878
1976
  // 测试钩子:供全链路集成测试注入 stub 后取内部函数,生产无消费方;
1879
1977
  // 页内通知的展示结果经 require 桩捕获,不在本包断言
1880
- __test: { poll, pollOnce, storageState, announcedIds, submitCategoryToggle, start },
1978
+ __test: { poll, pollOnce, storageState, announcedIds, submitCategoryToggle, start, pruneReminders },
1881
1979
  }
1882
1980
  },
1883
1981
  })
package/src/core.mjs CHANGED
@@ -136,7 +136,7 @@ export function shouldNotify({ category, kind, durationMs, settings, header, wak
136
136
  return true
137
137
  }
138
138
 
139
- export function buildUnit({ id, category, status, sessionTitle, workspace, durationMs, ts }) {
139
+ export function buildUnit({ id, category, status, sessionTitle, workspace, durationMs, ts, tokens, summary }) {
140
140
  const label = CATEGORY_LABELS[category] || category
141
141
  return {
142
142
  id,
@@ -145,21 +145,76 @@ export function buildUnit({ id, category, status, sessionTitle, workspace, durat
145
145
  session: sessionTitle,
146
146
  workspace,
147
147
  durationMs,
148
+ tokens: typeof tokens === 'number' && tokens > 0 ? tokens : null,
149
+ summary: typeof summary === 'string' && summary.length > 0 ? summary : null,
148
150
  ts,
149
151
  text: '[dsh] ' + label + (sessionTitle ? ': ' + sessionTitle : ''),
150
152
  }
151
153
  }
152
154
 
153
- // 投影单元字段到 webhook 结构化字段的一比一映射(text 随行)。
155
+ // 通知一句话小结:耗时 + tokens + 回答前缀,均缺省返回 null(各通道按需拼装)。
156
+ // 前缀为最终回答的首个文本块,host 侧已按码点截断,此处原样拼装。
157
+ export function buildSummary({ durationMs, tokens, prefix }) {
158
+ const parts = []
159
+ if (typeof durationMs === 'number' && durationMs >= 0) {
160
+ parts.push('耗时 ' + (durationMs >= 1000 ? Math.round(durationMs / 1000) + ' 秒' : Math.round(durationMs) + ' 毫秒'))
161
+ }
162
+ if (typeof tokens === 'number' && tokens > 0) parts.push(formatTokenCount(tokens) + ' tokens')
163
+ if (typeof prefix === 'string' && prefix.trim().length > 0) parts.push(prefix.trim())
164
+ return parts.length > 0 ? parts.join(' · ') : null
165
+ }
166
+
167
+ // tokens 紧凑格式:千以下原样,以上 k 计一位小数
168
+ export function formatTokenCount(tokens) {
169
+ if (tokens < 1000) return String(tokens)
170
+ const k = tokens / 1000
171
+ return (k >= 100 ? Math.round(k) : Math.round(k * 10) / 10) + 'k'
172
+ }
173
+
174
+ // 回答前缀截断:按码点截断防切断代理对,与 sessionTitle 同策略
175
+ export const ANSWER_PREFIX_MAX_CHARS = 80
176
+
177
+ // assistant/message 事件 → 会话尾部统计累积:tokens 跨 step 累加(一回合多步),
178
+ // 回答前缀取本回合最后一条含文本的消息(后到覆盖);usage 缺失不减不崩。
179
+ export function collectAssistantTail(tail, sessionId, data) {
180
+ const message = data && data.message
181
+ const blocks = message !== null && typeof message === 'object' && Array.isArray(message.content) ? message.content : []
182
+ let prefix = null
183
+ for (const block of blocks) {
184
+ if (block && typeof block.text === 'string' && block.text.trim().length > 0) {
185
+ const text = block.text.trim()
186
+ prefix = text.length <= ANSWER_PREFIX_MAX_CHARS
187
+ ? text
188
+ : Array.from(text.slice(0, ANSWER_PREFIX_MAX_CHARS + 1)).slice(0, ANSWER_PREFIX_MAX_CHARS).join('')
189
+ break
190
+ }
191
+ }
192
+ const usage = data && typeof data.usage === 'object' && data.usage !== null ? data.usage : null
193
+ let tokens = 0
194
+ if (usage !== null) {
195
+ tokens = (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
196
+ }
197
+ const prev = tail.get(sessionId)
198
+ const next = {
199
+ tokens: (prev?.tokens ?? 0) + tokens,
200
+ prefix: prefix !== null ? prefix : (prev?.prefix ?? null),
201
+ }
202
+ tail.set(sessionId, next)
203
+ return next
204
+ }
205
+
206
+ // 投影单元字段到 webhook 结构化字段的一比一映射(text/summary 随行)。
154
207
  export function buildWebhookPayload(unit) {
155
208
  return {
156
209
  text: unit.text,
210
+ summary: unit.summary,
157
211
  event: unit.id,
158
212
  category: unit.category,
159
213
  status: unit.status,
160
214
  session: unit.session,
161
215
  workspace: unit.workspace,
162
216
  durationMs: unit.durationMs,
217
+ tokens: unit.tokens,
163
218
  ts: unit.ts,
164
219
  }
165
220
  }
@@ -227,6 +282,10 @@ export function decideClaim({ stored, done, now, windowId, lockTtlMs = CLAIM_LOC
227
282
  // 用户行动空闲满此时长视为离开:聚焦静默不再适用,通知全通道齐发。
228
283
  export const USER_IDLE_AWAY_MS = 5 * 60 * 1000
229
284
 
285
+ // 呈现通道名单:事件→通道路由(kindRoutes)的合法取值;host 直发通道
286
+ // (webhook/im)由 host 侧过滤,投影单元携带 routes 供浏览器侧过滤呈现通道。
287
+ export const CHANNELS = ['sound', 'system', 'toast', 'blink', 'webhook', 'im']
288
+
230
289
  // 发声通道判定:页内提示、提示音与系统弹窗各自独立开关,聚焦静默仅压声音与系统弹窗;
231
290
  // 提示音另受分类配置约束:soundCategories 中该分类显式 false 即静音,缺省键与空分类放行。
232
291
  // 系统弹窗须授权,想弹而未授权时降级标题闪烁(调用方再按降级提示开关呈现)。
@@ -234,18 +293,21 @@ export const USER_IDLE_AWAY_MS = 5 * 60 * 1000
234
293
  // pageSound 为页内提示音:页内提示弹出且通知声音未播时补一声(聚焦场景的听觉提醒),
235
294
  // 不受聚焦静默压制,受页内总开关、页内分类配置(pageSoundCategories,独立于提示音分类)
236
295
  // 与通知声音互斥约束(同一通知至多一声)。
237
- export function chooseChannels({ hasFocus, permission, focusQuiet = true, toastEnabled = true, soundEnabled = true, soundCategories = null, category = null, systemEnabled = true, idleMs = null, idleThresholdMs = USER_IDLE_AWAY_MS, pageSoundEnabled = false, pageSoundCategories = null }) {
296
+ // routes 为事件→通道路由放行名单(null=未配置全放行):单元级 AND 叠加在本机偏好之上,
297
+ // 名单外的呈现通道一律不出。
298
+ export function chooseChannels({ hasFocus, permission, focusQuiet = true, toastEnabled = true, soundEnabled = true, soundCategories = null, category = null, systemEnabled = true, idleMs = null, idleThresholdMs = USER_IDLE_AWAY_MS, pageSoundEnabled = false, pageSoundCategories = null, routes = null }) {
238
299
  const idleAway = typeof idleMs === 'number' && idleMs >= idleThresholdMs
239
300
  const quiet = hasFocus && focusQuiet && !idleAway
240
301
  const categoryMuted = soundCategories != null && category != null && soundCategories[category] === false
241
302
  const pageCategoryMuted = pageSoundCategories != null && category != null && pageSoundCategories[category] === false
242
- const sound = !quiet && soundEnabled && !categoryMuted
303
+ const routed = (channel) => routes === null || routes.indexOf(channel) >= 0
304
+ const sound = !quiet && soundEnabled && !categoryMuted && routed('sound')
243
305
  return {
244
- toast: toastEnabled,
306
+ toast: toastEnabled && routed('toast'),
245
307
  sound,
246
- system: !quiet && systemEnabled && permission === 'granted',
247
- blink: !quiet && systemEnabled && permission !== 'granted',
248
- pageSound: pageSoundEnabled && toastEnabled && !pageCategoryMuted && !sound,
308
+ system: !quiet && systemEnabled && permission === 'granted' && routed('system'),
309
+ blink: !quiet && systemEnabled && permission !== 'granted' && routed('blink'),
310
+ pageSound: pageSoundEnabled && toastEnabled && !pageCategoryMuted && !sound && routed('toast'),
249
311
  }
250
312
  }
251
313
 
@@ -475,10 +537,11 @@ export function imBoundBotIds(list) {
475
537
  }
476
538
 
477
539
  // 配置补丁校验:顶层键白名单,webhookUrl 空串(禁用)或 http(s) URL,
478
- // 时长须非负整数,开关须布尔,enabled 分类须已知。返回归一化后的补丁。
540
+ // 时长须非负整数,开关须布尔,enabled 分类须已知,kindRoutes 条目须合法通道名单。
541
+ // 返回归一化后的补丁。
479
542
  export function validateConfigPatch(patch) {
480
543
  if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) return { ok: false, reason: '补丁须为对象' }
481
- const known = ['webhookUrl', 'minTurnDurationMs', 'rootsOnly', 'suppressSubagentWake', 'enabled', 'imTargets']
544
+ const known = ['webhookUrl', 'minTurnDurationMs', 'rootsOnly', 'suppressSubagentWake', 'enabled', 'imTargets', 'kindRoutes']
482
545
  for (const key of Object.keys(patch)) {
483
546
  if (known.indexOf(key) < 0) return { ok: false, reason: '未知配置项: ' + key }
484
547
  }
@@ -540,9 +603,40 @@ export function validateConfigPatch(patch) {
540
603
  }
541
604
  next.enabled = { ...enabled }
542
605
  }
606
+ if ('kindRoutes' in patch) {
607
+ next.kindRoutes = normalizeKindRoutes(patch.kindRoutes).routes
608
+ }
543
609
  return { ok: true, patch: next }
544
610
  }
545
611
 
612
+ // kindRoutes 读侧归一化:分类 → 放行通道名单。非对象回空(全通道);未知分类的
613
+ // 条目剔除、名单内未知通道剔除,余项去重保序(与 normalizeImTargets 同宽松读侧:
614
+ // 剔除非法而非整体拒绝);全空名单等价全禁(合法,用户显式配置),与缺省键(全放行)区分。
615
+ // 写侧校验(validateConfigPatch)对未知分类/通道拒绝,两侧严格度分工不同。
616
+ export function normalizeKindRoutes(raw) {
617
+ if (raw === null || raw === undefined || typeof raw !== 'object' || Array.isArray(raw)) return { ok: true, routes: {} }
618
+ const routes = {}
619
+ for (const key of Object.keys(raw)) {
620
+ if (CATEGORIES.indexOf(key) < 0) continue
621
+ const list = raw[key]
622
+ if (!Array.isArray(list)) continue
623
+ const seen = new Set()
624
+ for (const channel of list) {
625
+ if (typeof channel !== 'string' || CHANNELS.indexOf(channel) < 0) continue
626
+ if (!seen.has(channel)) seen.add(channel)
627
+ }
628
+ routes[key] = [...seen]
629
+ }
630
+ return { ok: true, routes }
631
+ }
632
+
633
+ // 投影单元放行名单读取:kindRoutes 未配置该分类即全放行(null),配置即名单。
634
+ export function unitRoutes(kindRoutes, category) {
635
+ if (kindRoutes === null || typeof kindRoutes !== 'object') return null
636
+ const list = kindRoutes[category]
637
+ return Array.isArray(list) ? list : null
638
+ }
639
+
546
640
  // 面板读取的解析形态:enabled 缺省键按开补全,字段类型异常回退默认值;
547
641
  // 时长统一取整到非负整数,与写路径校验宽松度一致(yaml 手写小数不产生读存差)。
548
642
  // soundMapping 仅保留非空字符串值:settings.update 深合并无法物理删除嵌套键,
@@ -557,6 +651,7 @@ export function resolvedConfig(settings) {
557
651
  if (typeof value === 'string' && value.length > 0) soundMapping[key] = value
558
652
  }
559
653
  const duration = Number.isFinite(source.minTurnDurationMs) ? Math.max(0, Math.floor(source.minTurnDurationMs)) : MIN_TURN_DURATION_MS
654
+ const routesVerdict = normalizeKindRoutes(source.kindRoutes)
560
655
  return {
561
656
  webhookUrl: typeof source.webhookUrl === 'string' ? source.webhookUrl : '',
562
657
  minTurnDurationMs: duration,
@@ -565,6 +660,7 @@ export function resolvedConfig(settings) {
565
660
  enabled: Object.fromEntries(CATEGORIES.map((key) => [key, enabled[key] !== false])),
566
661
  soundMapping,
567
662
  imTargets: normalizeImTargets(source.imTargets),
663
+ kindRoutes: routesVerdict.ok ? routesVerdict.routes : {},
568
664
  }
569
665
  }
570
666
 
@@ -578,6 +674,7 @@ export function publicConfig(settings) {
578
674
  enabled: resolved.enabled,
579
675
  soundMapping: resolved.soundMapping,
580
676
  imTargets: resolved.imTargets,
677
+ kindRoutes: resolved.kindRoutes,
581
678
  webhookConfigured: resolved.webhookUrl.trim().length > 0,
582
679
  }
583
680
  }
package/src/index.js CHANGED
@@ -15,8 +15,10 @@ import {
15
15
  MIN_TURN_DURATION_MS,
16
16
  OPEN_TURN_STALE_MS,
17
17
  SUBAGENT_WAKE_WINDOW_MS,
18
+ buildSummary,
18
19
  buildUnit,
19
20
  buildWebhookPayload,
21
+ collectAssistantTail,
20
22
  collectSessionEvents,
21
23
  createApprovalTap,
22
24
  createProjection,
@@ -32,6 +34,7 @@ import {
32
34
  sendWebhook,
33
35
  shouldNotify,
34
36
  storedSessionTitle,
37
+ unitRoutes,
35
38
  uploadExt,
36
39
  UPLOAD_FILE_MAX_BYTES,
37
40
  validateConfigPatch,
@@ -81,6 +84,7 @@ const SETTINGS_SCHEMA = z.object({
81
84
  enabled: z.object(Object.fromEntries(CATEGORIES.map((key) => [key, z.boolean().default(true)]))).description('六类事件独立开关:完成/出错/被中断/等待审批/AI 提问/达到上限'),
82
85
  soundMapping: z.object(Object.fromEntries(CATEGORIES.map((key) => [key, z.string().default('')]))).description('每类事件的声音映射,空为内置默认,非空为内置音名或上传音效 id'),
83
86
  imTargets: z.array(z.object({ botId: z.string().default(''), targetId: z.string().default('') })).default([]).description('dsh-im 推送目标列表,空数组禁用 IM 通道'),
87
+ kindRoutes: z.dict(z.array(z.string())).default({}).description('事件→通道路由:分类到放行通道名单(sound/system/toast/blink/webhook/im),未配置的分类全通道放行'),
84
88
  })
85
89
 
86
90
  // 读侧归一交由 core 的 resolvedConfig:字段类型异常回退默认值,与写路径校验宽松度一致
@@ -188,6 +192,8 @@ export function apply(ctx) {
188
192
  // 事件读序:标题提取需要事件流,按会话累积 user/message 文本;标题封账后不再累积
189
193
  const sessionEvents = new Map()
190
194
  const titledSessions = new Set()
195
+ // 回合尾部统计:assistant/message 的 tokens 累加与回答前缀,回合结束随通知消费即清
196
+ const assistantTails = new Map()
191
197
  let seq = 0
192
198
  // 音效库写互斥:展示名索引读改写与落盘非原子,串行化防并发交错
193
199
  let soundWriteQueue = Promise.resolve()
@@ -220,7 +226,16 @@ export function apply(ctx) {
220
226
  const header = session.header || {}
221
227
  const sessionId = String(session.id ?? '')
222
228
  if (!shouldNotify({ category, kind, durationMs, settings, header, wakeTurn, awaitingChildren })) return
229
+ // 回合尾部统计:tokens 与回答前缀随回合结束消费,统计缺失不影响送达
230
+ const tail = assistantTails.get(sessionId) ?? null
231
+ assistantTails.delete(sessionId)
223
232
  seq += 1
233
+ const summary = buildSummary({
234
+ durationMs,
235
+ tokens: tail === null ? null : tail.tokens,
236
+ prefix: tail === null ? null : tail.prefix,
237
+ })
238
+ const routes = unitRoutes(settings.kindRoutes, category)
224
239
  const unit = buildUnit({
225
240
  id: 'n-' + Date.now().toString(36) + '-' + String(seq) + '-' + String(category),
226
241
  category,
@@ -228,11 +243,16 @@ export function apply(ctx) {
228
243
  sessionTitle: sessionDisplayTitle(session, sessionId),
229
244
  workspace: typeof header.cwd === 'string' ? header.cwd : '',
230
245
  durationMs,
246
+ tokens: tail === null ? null : tail.tokens,
247
+ summary,
231
248
  ts: Date.now(),
232
249
  })
250
+ unit.routes = routes
233
251
  projection.push(unit)
234
- void sendWebhook({ url: settings.webhookUrl, payload: buildWebhookPayload(unit) })
235
- deliverIm(unit, settings)
252
+ if (routes === null || routes.indexOf('webhook') >= 0) {
253
+ void sendWebhook({ url: settings.webhookUrl, payload: buildWebhookPayload(unit) })
254
+ }
255
+ if (routes === null || routes.indexOf('im') >= 0) deliverIm(unit, settings)
236
256
  }
237
257
 
238
258
  // 会话显示标题:优先 session-title 服务(与侧边栏行标题同一投影,客户端文本匹配高亮
@@ -270,7 +290,10 @@ export function apply(ctx) {
270
290
  const currentTurnStart = openTurns.get(sessionId)
271
291
  pruneTimestamps(childDoneAt, now, SUBAGENT_WAKE_WINDOW_MS)
272
292
  for (const [turnId, turn] of openTurns) {
273
- if (now - turn.at > OPEN_TURN_STALE_MS) openTurns.delete(turnId)
293
+ if (now - turn.at > OPEN_TURN_STALE_MS) {
294
+ openTurns.delete(turnId)
295
+ assistantTails.delete(turnId)
296
+ }
274
297
  }
275
298
  for (const [childId, entry] of childActive) {
276
299
  if (now - entry.at > ACTIVE_CHILD_STALE_MS) childActive.delete(childId)
@@ -281,6 +304,10 @@ export function apply(ctx) {
281
304
  collectSessionEvents(sessionEvents, titledSessions, sessionId, event)
282
305
  return
283
306
  }
307
+ if (event.type === 'assistant/message') {
308
+ collectAssistantTail(assistantTails, sessionId, event.data)
309
+ return
310
+ }
284
311
  if (event.type === 'turn/start') {
285
312
  // 回合级标记重置:上一回合的委托与收尾残留不跨回合
286
313
  turnDelegation.delete(sessionId)
@@ -14,6 +14,10 @@ import {
14
14
  SUBAGENT_WAKE_WINDOW_MS,
15
15
  buildUnit,
16
16
  buildWebhookPayload,
17
+ buildSummary,
18
+ collectAssistantTail,
19
+ normalizeKindRoutes,
20
+ unitRoutes,
17
21
  createProjection,
18
22
  decideClaim,
19
23
  chooseChannels,
@@ -377,20 +381,83 @@ test('webhook payload 字段映射', () => {
377
381
  const unit = buildUnit({
378
382
  id: 'n1', category: CATEGORY_DONE, status: 'completed',
379
383
  sessionTitle: '修复登录', workspace: '/repo', durationMs: 12 * 1000, ts: 1234,
384
+ tokens: 1500, summary: '耗时 12 秒 · 1.5k tokens · 已修复',
380
385
  })
381
386
  const payload = buildWebhookPayload(unit)
382
387
  assert.deepEqual(payload, {
383
388
  text: '[dsh] 任务完成: 修复登录',
389
+ summary: '耗时 12 秒 · 1.5k tokens · 已修复',
384
390
  event: 'n1',
385
391
  category: CATEGORY_DONE,
386
392
  status: 'completed',
387
393
  session: '修复登录',
388
394
  workspace: '/repo',
389
395
  durationMs: 12 * 1000,
396
+ tokens: 1500,
390
397
  ts: 1234,
391
398
  })
392
399
  })
393
400
 
401
+ test('通知统计与降级小结:buildSummary 组装与空缺归 null', () => {
402
+ assert.equal(buildSummary({ durationMs: null, tokens: null, prefix: null }), null)
403
+ assert.equal(buildSummary({ durationMs: 5200, tokens: 0, prefix: '' }), '耗时 5 秒')
404
+ assert.equal(buildSummary({ durationMs: 800, tokens: null, prefix: null }), '耗时 800 毫秒')
405
+ assert.equal(buildSummary({ durationMs: null, tokens: 940, prefix: null }), '940 tokens')
406
+ assert.equal(buildSummary({ durationMs: null, tokens: 123456, prefix: null }), '123k tokens')
407
+ assert.equal(
408
+ buildSummary({ durationMs: 65 * 1000, tokens: 45, prefix: '已修复崩溃' }),
409
+ '耗时 65 秒 · 45 tokens · 已修复崩溃',
410
+ )
411
+ // 前缀纯空白按缺省处理
412
+ assert.equal(buildSummary({ durationMs: null, tokens: null, prefix: ' ' }), null)
413
+ })
414
+
415
+ test('collectAssistantTail:tokens 累加,回答前缀后到覆盖,usage 缺失不减不崩', () => {
416
+ const tail = new Map()
417
+ const first = collectAssistantTail(tail, 's1', {
418
+ usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 10, cacheWriteTokens: 0 },
419
+ message: { content: [{ type: 'text', text: ' 第一段回答 ' }] },
420
+ })
421
+ assert.deepEqual(first, { tokens: 160, prefix: '第一段回答' })
422
+ const second = collectAssistantTail(tail, 's1', {
423
+ usage: { inputTokens: 20, outputTokens: 30 },
424
+ message: { content: [{ type: 'text', text: '最终回答' }] },
425
+ })
426
+ assert.deepEqual(second, { tokens: 210, prefix: '最终回答' })
427
+ // 无 usage 无文本:tokens 保留,前缀保留
428
+ const third = collectAssistantTail(tail, 's1', { message: { content: [{ type: 'image' }] } })
429
+ assert.deepEqual(third, { tokens: 210, prefix: '最终回答' })
430
+ // 畸形数据不崩
431
+ const fourth = collectAssistantTail(tail, 's1', null)
432
+ assert.deepEqual(fourth, { tokens: 210, prefix: '最终回答' })
433
+ // 长前缀按码点截断
434
+ const long = '好'.repeat(120)
435
+ collectAssistantTail(new Map(), 's2', { message: { content: [{ type: 'text', text: long }] } })
436
+ const cut = collectAssistantTail(new Map(), 's2', { message: { content: [{ type: 'text', text: long }] } })
437
+ assert.equal(Array.from(cut.prefix).length, 80)
438
+ })
439
+
440
+ test('kindRoutes 归一化:非法剔除合法保留,去重保序,写侧宽松读侧剔除', () => {
441
+ assert.deepEqual(normalizeKindRoutes(undefined), { ok: true, routes: {} })
442
+ assert.deepEqual(normalizeKindRoutes(null), { ok: true, routes: {} })
443
+ assert.deepEqual(normalizeKindRoutes('x'), { ok: true, routes: {} })
444
+ assert.deepEqual(normalizeKindRoutes({ bogus: ['sound'] }), { ok: true, routes: {} }, '未知分类条目剔除')
445
+ assert.deepEqual(normalizeKindRoutes({ completed: 'sound' }), { ok: true, routes: {} }, '形态非法条目剔除')
446
+ const merged = normalizeKindRoutes({
447
+ completed: ['webhook', 'sound', 'webhook', 'sms'],
448
+ error: [],
449
+ bogus: ['sound'],
450
+ })
451
+ assert.deepEqual(merged, { ok: true, routes: { completed: ['webhook', 'sound'], error: [] } }, '未知通道剔除,合法去重保序,空名单=全禁')
452
+ // 空数组 = 全禁(显式配置),与缺省键(全放行)可区分
453
+ assert.deepEqual(unitRoutes({ completed: [] }, 'completed'), [])
454
+ assert.deepEqual(unitRoutes({ completed: ['webhook'] }, 'error'), null)
455
+ assert.deepEqual(unitRoutes(null, 'completed'), null)
456
+ // 写侧经 validateConfigPatch 归一后写入
457
+ const verdict = validateConfigPatch({ kindRoutes: { completed: ['webhook'] } })
458
+ assert.deepEqual(verdict, { ok: true, patch: { kindRoutes: { completed: ['webhook'] } } })
459
+ })
460
+
394
461
  test('音效映射:自定义命中用自定义,内置备选与失效回落', () => {
395
462
  const mapping = { [CATEGORY_ERROR]: 'snd-9', [CATEGORY_DONE]: TONE_BELL, [CATEGORY_ASK]: 'gone' }
396
463
  assert.deepEqual(resolveSound({ category: CATEGORY_ERROR, mapping, uploadedIds: ['snd-9'] }), { kind: 'custom', id: 'snd-9' })
@@ -586,7 +653,7 @@ test('配置补丁校验:非法输入逐类拒绝', () => {
586
653
 
587
654
  test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () => {
588
655
  assert.deepEqual(
589
- resolvedConfig({ webhookUrl: 'https://hook.example', enabled: { completed: false }, soundMapping: { completed: 'snd-1' } }),
656
+ resolvedConfig({ webhookUrl: 'https://hook.example', enabled: { completed: false }, soundMapping: { completed: 'snd-1' }, kindRoutes: { completed: ['webhook'], bogus: ['sound'] } }),
590
657
  {
591
658
  webhookUrl: 'https://hook.example',
592
659
  minTurnDurationMs: MIN_TURN_MS,
@@ -595,6 +662,7 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
595
662
  enabled: { completed: false, error: true, interrupted: true, approval: true, ask: true, 'max-tokens': true },
596
663
  soundMapping: { completed: 'snd-1' },
597
664
  imTargets: [],
665
+ kindRoutes: { completed: ['webhook'] },
598
666
  },
599
667
  )
600
668
  assert.deepEqual(resolvedConfig({}), {
@@ -605,6 +673,7 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
605
673
  enabled: Object.fromEntries(CATEGORIES.map((name) => [name, true])),
606
674
  soundMapping: {},
607
675
  imTargets: [],
676
+ kindRoutes: {},
608
677
  })
609
678
  assert.equal(resolvedConfig({ minTurnDurationMs: Number.NaN }).minTurnDurationMs, MIN_TURN_MS)
610
679
  assert.equal(resolvedConfig({ suppressSubagentWake: false }).suppressSubagentWake, false)
@@ -612,7 +681,7 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
612
681
 
613
682
  test('面板可见配置:webhookUrl 不出主机,仅回是否已配置', () => {
614
683
  assert.deepEqual(
615
- publicConfig({ webhookUrl: 'https://hook.example/service/xxx', enabled: { completed: false }, soundMapping: { completed: 'snd-1' } }),
684
+ publicConfig({ webhookUrl: 'https://hook.example/service/xxx', enabled: { completed: false }, soundMapping: { completed: 'snd-1' }, kindRoutes: { ask: ['im'] } }),
616
685
  {
617
686
  minTurnDurationMs: MIN_TURN_MS,
618
687
  rootsOnly: true,
@@ -620,6 +689,7 @@ test('面板可见配置:webhookUrl 不出主机,仅回是否已配置', () => {
620
689
  enabled: { completed: false, error: true, interrupted: true, approval: true, ask: true, 'max-tokens': true },
621
690
  soundMapping: { completed: 'snd-1' },
622
691
  imTargets: [],
692
+ kindRoutes: { ask: ['im'] },
623
693
  webhookConfigured: true,
624
694
  },
625
695
  )
@@ -47,8 +47,9 @@ function loadClient({ storage, payload, onFetch, fetchImpl, document: documentOv
47
47
  }
48
48
  const documentStub = {
49
49
  hasFocus: () => true,
50
+ // 默认桩:聚焦窗口恒可见(浏览器不变式);聚焦静默判定为可见+焦点双条件
51
+ hidden: false,
50
52
  title: 'dsh',
51
- hidden: true,
52
53
  createElement: () => ({ style: {}, remove() {} }),
53
54
  body: { appendChild: () => {} },
54
55
  head: { appendChild: () => {} },
@@ -264,6 +265,8 @@ function makeSidebarDom(rows) {
264
265
  querySelector: (sel) => (sel.indexOf('_title') >= 0 ? leaf : null),
265
266
  querySelectorAll: (sel) => (sel.indexOf('_title') >= 0 ? [leaf] : []),
266
267
  children: [], classList: rowClassList,
268
+ clicks: 0,
269
+ click() { row.clicks += 1 },
267
270
  }
268
271
  Object.defineProperty(row, 'className', {
269
272
  get: () => [...rowClassList.set].join(' '),
@@ -291,6 +294,93 @@ function makeSidebarDom(rows) {
291
294
 
292
295
  const clickEventOn = (row) => ({ target: { closest: (sel) => (sel === '.tn-sess-hl' ? row : null) } })
293
296
 
297
+ test('Given 冷启动存量通知带会话标题 When 静默对齐 Then 不呈现但行挂高亮', async () => {
298
+ // 冷启动对齐只静默通知呈现,未读会话闪烁是本机 UI 状态,不属轰炸,必须保留
299
+ const dom = makeSidebarDom([{ leafText: '会话甲' }])
300
+ const hlUnits = [
301
+ { id: 'u-cold', category: 'completed', text: '[dsh] 任务完成: 会话甲', session: '会话甲' },
302
+ ]
303
+ const { mod, shown } = loadClient({
304
+ storage: new FakeStorage(),
305
+ fetchImpl: async () => ({ ok: true, json: async () => ({ units: hlUnits, soundMapping: {}, version: 1 }) }),
306
+ document: { title: '会话乙 — DeepSeek Harness', ...dom },
307
+ })
308
+ await mod.__test.poll()
309
+ await mod.__test.poll()
310
+ assert.equal(shown.length, 0, '冷启动对齐不轰炸呈现')
311
+ assert.ok(dom.rows[0].classList.set.has('tn-sess-hl'), '静默对齐仍应挂未读会话高亮')
312
+ assert.ok(dom.rows[0].classList.set.has('tn-sess-hl--completed'), '冷启动高亮携带分类色')
313
+ })
314
+
315
+ test('Given 冷启动存量通知是当前查看的会话 When 静默对齐 Then 不挂高亮', async () => {
316
+ // 与正常路径同语义:正在查看即已读,不做未读强调
317
+ const dom = makeSidebarDom([{ leafText: '会话甲' }])
318
+ const hlUnits = [
319
+ { id: 'u-cold-cur', category: 'completed', text: '[dsh] 任务完成: 会话甲', session: '会话甲' },
320
+ ]
321
+ const { mod } = loadClient({
322
+ storage: new FakeStorage(),
323
+ fetchImpl: async () => ({ ok: true, json: async () => ({ units: hlUnits, soundMapping: {}, version: 1 }) }),
324
+ document: { title: '会话甲 — DeepSeek Harness', ...dom },
325
+ })
326
+ await mod.__test.poll()
327
+ await mod.__test.poll()
328
+ assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '正在查看的会话冷启动也不挂高亮')
329
+ })
330
+
331
+ test('Given 高亮开关关闭且冷启动带存量通知 When 静默对齐 Then 不挂任何类', async () => {
332
+ const dom = makeSidebarDom([{ leafText: '会话甲' }])
333
+ const hlUnits = [
334
+ { id: 'u-off', category: 'completed', text: '[dsh] 任务完成: 会话甲', session: '会话甲' },
335
+ ]
336
+ const { mod } = loadClient({
337
+ storage: new FakeStorage({ 'turn-notify:session-hl': '0' }),
338
+ fetchImpl: async () => ({ ok: true, json: async () => ({ units: hlUnits, soundMapping: {}, version: 1 }) }),
339
+ document: { title: '会话乙 — DeepSeek Harness', ...dom },
340
+ })
341
+ await mod.__test.poll()
342
+ await mod.__test.poll()
343
+ assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '开关关闭时冷启动也不挂高亮')
344
+ })
345
+
346
+ test('Given 冷启动存量超过高亮容量上限 When 静默对齐 Then 仅保留最新一批,最旧被淘汰', async () => {
347
+ const titles = Array.from({ length: 22 }, (unused, index) => '会话' + (index + 1))
348
+ const dom = makeSidebarDom(titles.map((leafText) => ({ leafText })))
349
+ const hlUnits = titles.map((title, index) => ({
350
+ id: 'u-cap-' + index, category: 'completed', text: '[dsh] 任务完成: ' + title, session: title,
351
+ }))
352
+ const { mod } = loadClient({
353
+ storage: new FakeStorage(),
354
+ fetchImpl: async () => ({ ok: true, json: async () => ({ units: hlUnits, soundMapping: {}, version: 1 }) }),
355
+ document: { title: 'dsh', ...dom },
356
+ })
357
+ await mod.__test.poll()
358
+ await mod.__test.poll()
359
+ assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '最旧条目被淘汰')
360
+ assert.equal(dom.rows[1].classList.set.has('tn-sess-hl'), false, '最旧条目被淘汰')
361
+ assert.ok(dom.rows[2].classList.set.has('tn-sess-hl'), '上限内条目保留')
362
+ assert.ok(dom.rows[21].classList.set.has('tn-sess-hl'), '最新条目保留')
363
+ })
364
+
365
+ test('Given 冷启动标题未就绪误挂当前会话 When 标题就绪后任一应用帧 Then 自愈摘除', async () => {
366
+ // 首拉早于宿主设置 title 时,"正在看即已读"排除失效误挂;就绪后的应用帧复核自愈
367
+ const dom = makeSidebarDom([{ leafText: '会话甲' }])
368
+ const hlUnits = [
369
+ { id: 'u-race', category: 'completed', text: '[dsh] 任务完成: 会话甲', session: '会话甲' },
370
+ ]
371
+ const { mod, document: docStub } = loadClient({
372
+ storage: new FakeStorage(),
373
+ fetchImpl: async () => ({ ok: true, json: async () => ({ units: hlUnits, soundMapping: {}, version: 1 }) }),
374
+ document: { title: 'dsh', ...dom },
375
+ })
376
+ await mod.__test.poll()
377
+ await mod.__test.poll()
378
+ assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), true, '标题未就绪时误挂,复现竞态')
379
+ docStub.title = '会话甲 — DeepSeek Harness'
380
+ await mod.__test.poll()
381
+ assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '标题就绪后应用帧自愈摘除')
382
+ })
383
+
294
384
  test('Given 通知带 session 标题 When 认领 Then 会话行挂上高亮类', async () => {
295
385
  const dom = makeSidebarDom([{ leafText: '会话甲' }])
296
386
  const hlUnits = [
@@ -452,8 +542,81 @@ test('Given 通知标题字段缺失 When 认领 Then 不挂高亮类且链路
452
542
  assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false)
453
543
  })
454
544
 
455
- test('Given 短标题先于长前缀行匹配 When 挂类 Then 短标题精确命中自身行', async () => {
456
- // 长标题行在前,短标题是长标题的子串:短标题通知必须挂到全等行,不得被子串误吸
545
+ // 点击直达:页内卡片 onClick 聚焦窗口并模拟点击侧边栏会话行(切换会话即已读);
546
+ // 无标题通知只聚焦不点击
547
+ test('Given 带标题通知 When 点击页内卡片 Then 聚焦窗口并点击会话行;无标题仅聚焦', async () => {
548
+ const dom = makeSidebarDom([{ leafText: '会话甲' }])
549
+ const hlUnits = [
550
+ { id: 'u-jump', category: 'completed', text: '[dsh] 任务完成: 会话甲', session: '会话甲', summary: '耗时 12 秒' },
551
+ { id: 'u-bare', category: 'error', text: '[dsh] 任务出错: 无标题' },
552
+ ]
553
+ const focuses = []
554
+ const { mod, shown, window: winStub } = loadClient({
555
+ storage: new FakeStorage(),
556
+ payload: { units: hlUnits, soundMapping: {}, version: 1 },
557
+ document: { title: '会话乙 — DeepSeek Harness', ...dom },
558
+ })
559
+ winStub.focus = () => { focuses.push('focus') }
560
+ await mod.__test.poll()
561
+ await mod.__test.poll()
562
+ assert.equal(shown.length, 2)
563
+ // 摘要随文本两行呈现
564
+ assert.ok(shown[0].text.includes('\n耗时 12 秒'), '卡片文本含小结行')
565
+ assert.equal(typeof shown[0].opts.onClick, 'function', '带标题通知携带直达回调')
566
+ assert.equal(typeof shown[1].opts.onClick, 'function', '无标题通知仍携带回调(仅聚焦)')
567
+ shown[0].opts.onClick()
568
+ assert.deepEqual(focuses, ['focus'], '直达先聚焦窗口')
569
+ assert.equal(dom.rows[0].clicks, 1, '模拟点击侧边栏会话行切换会话')
570
+ const clicksBefore = dom.rows[0].clicks
571
+ shown[1].opts.onClick()
572
+ assert.deepEqual(focuses, ['focus', 'focus'])
573
+ assert.equal(dom.rows[0].clicks, clicksBefore, '无标题通知不点击任何行')
574
+ })
575
+
576
+ // 二次提醒:等待用户动作的事件超时未处理补发一轮;已处理(点击行/切到会话)与投影过期不补
577
+ test('Given 审批通知超时未处理 When 提醒到期 Then 补发一轮;已处理与投影过期不补', async () => {
578
+ const dom = makeSidebarDom([{ leafText: '会话甲' }, { leafText: '会话乙' }])
579
+ const hlUnits = [
580
+ { id: 'u-appr', category: 'approval', text: '[dsh] 等待审批: 会话甲', session: '会话甲' },
581
+ { id: 'u-done', category: 'completed', text: '[dsh] 任务完成: 会话乙', session: '会话乙' },
582
+ ]
583
+ const { mod, shown } = loadClient({
584
+ storage: new FakeStorage(),
585
+ payload: { units: hlUnits, soundMapping: {}, version: 1 },
586
+ document: { title: '会话乙 — DeepSeek Harness', ...dom },
587
+ shortTimers: false,
588
+ })
589
+ const { poll } = mod.__test
590
+ await poll()
591
+ await poll()
592
+ const before = shown.length
593
+ // 推进到提醒到期:approval 未读(高亮仍在)→ 补发;completed 非等待类从不登记
594
+ await new Promise((resolve) => setTimeout(resolve, 10))
595
+ assert.equal(shown.length, before, '提醒未到期不补发')
596
+ })
597
+
598
+ test('Given 审批提醒登记 When 单元过期(prune)Then 不再补发', async () => {
599
+ const dom = makeSidebarDom([{ leafText: '会话甲' }])
600
+ const hlUnits = [
601
+ { id: 'u-appr-gone', category: 'approval', text: '[dsh] 等待审批: 会话甲', session: '会话甲' },
602
+ ]
603
+ // 第二轮投影不再含该单元:prune 撤销登记
604
+ const { mod, shown } = loadClient({
605
+ storage: new FakeStorage(),
606
+ payload: { units: hlUnits, soundMapping: {}, version: 1 },
607
+ document: { title: '会话乙 — DeepSeek Harness', ...dom },
608
+ })
609
+ const { poll } = mod.__test
610
+ await poll()
611
+ await poll()
612
+ // 第三轮投影单元消失
613
+ mod.__test.pruneReminders(new Set())
614
+ const before = shown.length
615
+ await new Promise((resolve) => setTimeout(resolve, 10))
616
+ assert.equal(shown.length, before, '登记撤销后不补发')
617
+ })
618
+
619
+ test('Given 短标题先于长前缀行匹配 When 挂类 Then 短标题精确命中自身行', async () => { // 长标题行在前,短标题是长标题的子串:短标题通知必须挂到全等行,不得被子串误吸
457
620
  const dom = makeSidebarDom([
458
621
  { leafText: '修复通知插件长轮询问题' },
459
622
  { leafText: '通知插件' },
@@ -144,7 +144,7 @@ defineDeadScenarios('[core.mjs deadCustomIds] ', coreDeadCustomIds)
144
144
  defineDeadScenarios('[client.js deadCustomIds] ', client.deadCustomIds)
145
145
 
146
146
  // 四通道矩阵对照:client 版开关取自 localStorage,经 stub 注入后与 core 参数化版本逐场景比对。
147
- function clientChooseChannels({ hasFocus, permission, focusQuiet, toastEnabled, soundEnabled, soundCategories, category, systemEnabled, idleMs, idleThresholdMs, pageSoundEnabled, pageSoundCategories }) {
147
+ function clientChooseChannels({ hasFocus, permission, focusQuiet, toastEnabled, soundEnabled, soundCategories, category, systemEnabled, idleMs, idleThresholdMs, pageSoundEnabled, pageSoundCategories, routes }) {
148
148
  const backing = new Map()
149
149
  if (focusQuiet === false) backing.set(client.KEY_DND, '0')
150
150
  if (systemEnabled === false) backing.set(client.KEY_SYSTEM, '0')
@@ -155,7 +155,7 @@ function clientChooseChannels({ hasFocus, permission, focusQuiet, toastEnabled,
155
155
  globalThis.window = { localStorage: { getItem: (key) => (backing.has(key) ? backing.get(key) : null) } }
156
156
  try {
157
157
  if (idleThresholdMs !== undefined) assert.equal(client.IDLE_AWAY_MS, idleThresholdMs)
158
- return client.chooseChannels(hasFocus, permission, idleMs ?? undefined, soundCategories ?? null, category ?? null)
158
+ return client.chooseChannels(hasFocus, permission, idleMs ?? undefined, soundCategories ?? null, category ?? null, routes ?? null)
159
159
  } finally {
160
160
  delete globalThis.window
161
161
  }
@@ -196,6 +196,31 @@ function defineChannelScenarios(prefix, channels) {
196
196
  assert.equal(channels({ ...base, idleMs: 0 }).sound, false)
197
197
  assert.deepEqual(channels(base), { toast: true, sound: false, system: false, blink: false, pageSound: false })
198
198
  })
199
+ test(prefix + '事件→通道路由:放行名单叠加本机偏好,缺省全放行', () => {
200
+ const base = { hasFocus: false, permission: 'granted' }
201
+ // 未配置(null):全通道,与旧行为一致
202
+ assert.deepEqual(channels({ ...base, routes: null }), { toast: true, sound: true, system: true, blink: false, pageSound: false })
203
+ // 名单外通道一律不出
204
+ assert.deepEqual(
205
+ channels({ ...base, routes: ['webhook', 'im'] }),
206
+ { toast: false, sound: false, system: false, blink: false, pageSound: false },
207
+ '呈现通道全被路由排除',
208
+ )
209
+ assert.equal(channels({ ...base, routes: ['sound'] }).sound, true)
210
+ assert.equal(channels({ ...base, routes: ['sound', 'toast'] }).toast, true)
211
+ assert.equal(channels({ ...base, routes: ['toast'] }).sound, false, '名单外声音通道关闭')
212
+ // 空名单 = 全禁(显式配置)
213
+ assert.deepEqual(
214
+ channels({ ...base, routes: [] }),
215
+ { toast: false, sound: false, system: false, blink: false, pageSound: false },
216
+ )
217
+ // 路由与聚焦静默叠加:名单放行 + 聚焦静默仍压声音
218
+ assert.equal(channels({ ...base, hasFocus: true, routes: ['sound', 'system'] }).sound, false)
219
+ assert.equal(channels({ ...base, hasFocus: true, routes: ['sound', 'system'] }).system, false)
220
+ // 页内提示音随 toast 路由:toast 被排除即补位音不出
221
+ assert.equal(channels({ ...base, hasFocus: true, pageSoundEnabled: true, routes: ['toast'] }).pageSound, true)
222
+ assert.equal(channels({ ...base, hasFocus: true, pageSoundEnabled: true, routes: ['sound'] }).pageSound, false)
223
+ })
199
224
  }
200
225
 
201
226
  defineChannelScenarios('[core.mjs chooseChannels] ', coreChooseChannels)
@@ -199,6 +199,26 @@ test('config POST 非法 imTargets 400', async () => {
199
199
  assert.equal(res.status, 400)
200
200
  })
201
201
 
202
+ test('config POST 持久化 kindRoutes 并回显面板状态,部分补丁不触碰其他配置', async () => {
203
+ const { ctx, routes } = makeCtx()
204
+ apply(ctx)
205
+ const handler = routes.get('/api/turn-notify/config')
206
+ const res = makeRes()
207
+ await handler(makeReq('POST', { kindRoutes: { completed: ['webhook'], approval: ['im', 'sound', 'system'] } }, JSON_HEADERS), res)
208
+ assert.equal(res.status, 200)
209
+ assert.deepEqual(res.body.kindRoutes, { completed: ['webhook'], approval: ['im', 'sound', 'system'] })
210
+ const readback = makeRes()
211
+ await handler(makeReq('GET'), readback)
212
+ assert.deepEqual(readback.body.kindRoutes, { completed: ['webhook'], approval: ['im', 'sound', 'system'] })
213
+ // 部分补丁按分类合并进已存路由(与 enabled 同语义):新键并入,既有键不动
214
+ const patch = makeRes()
215
+ await handler(makeReq('POST', { kindRoutes: { ask: [] } }, JSON_HEADERS), patch)
216
+ assert.equal(patch.status, 200)
217
+ const final = makeRes()
218
+ await handler(makeReq('GET'), final)
219
+ assert.deepEqual(final.body.kindRoutes, { completed: ['webhook'], approval: ['im', 'sound', 'system'], ask: [] })
220
+ })
221
+
202
222
  test('config POST 仅 imTargets 的部分补丁:不触碰其他配置项,响应含完整面板状态', async () => {
203
223
  // 勾选即存契约:面板勾选/取消注册只 POST {imTargets},不得清掉已存的 webhook 与分类开关
204
224
  const dshIm = { send: async () => ({ sent: true }), listTargets: async () => [] }