@mzzsfy/dsh-turn-notify 0.9.0 → 0.11.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
@@ -1,6 +1,6 @@
1
1
  # @mzzsfy/dsh-turn-notify
2
2
 
3
- DeepSeek Harness 消息通知插件:AI 回合产生六类事件(完成/出错/被中断/等待审批/AI 提问/达到上限),按你的配置送达声音、系统弹窗、页内提示、webhook、IM 五种通道,多窗口只响一次。
3
+ DeepSeek Harness 消息通知插件:AI 回合产生六类事件(完成/出错/被中断/等待审批/AI 提问/达到上限),按你的配置送达声音、系统弹窗、页内提示、webhook、IM、宿主桌面通知六种通道,多窗口只响一次。
4
4
 
5
5
  ## 快速上手
6
6
 
@@ -8,7 +8,7 @@ DeepSeek Harness 消息通知插件:AI 回合产生六类事件(完成/出错/
8
8
  dsh plugin --profile web add @mzzsfy/dsh-turn-notify
9
9
  ```
10
10
 
11
- 安装后打开 设置 > 消息通知:默认全部事件开启,声音与系统弹窗即装即用(系统弹窗经一次浏览器授权后生效);webhook 与 IM 填配置后启用。页内提示经公共依赖 @mzzsfy/dsh-toast 提供,由 session-manager 插件代挂装载——未安装 session-manager 时该通道自动禁用,其余通道不受影响。声音通道受浏览器自动播放策略约束,首次点击页面后才会出声。想推到微信等聊天工具,见 [IM 推送](#im-推送);想推到自建服务,见 [webhook 推送](#webhook-推送)。
11
+ 安装后打开 设置 > 消息通知:默认全部事件开启,声音与系统弹窗即装即用(系统弹窗经一次浏览器授权后生效);webhook、IM 与宿主桌面通知填配置后启用。页内提示经公共依赖 @mzzsfy/dsh-toast 提供,由 session-manager 插件代挂装载——未安装 session-manager 时该通道自动禁用,其余通道不受影响。声音通道受浏览器自动播放策略约束,首次点击页面后才会出声。想推到微信等聊天工具,见 [IM 推送](#im-推送);想推到自建服务,见 [webhook 推送](#webhook-推送);想在宿主机桌面弹系统通知(浏览器全关也送达),见 [宿主桌面通知](#宿主桌面通知)。
12
12
 
13
13
  ## 六类通知事件
14
14
 
@@ -65,7 +65,7 @@ dsh plugin --profile web add @mzzsfy/dsh-turn-notify
65
65
 
66
66
  ## 事件→通道路由
67
67
 
68
- 六类事件各自可指定放行通道名单(kindRoutes,存 settings.yaml),名单外的通道对该类事件一律不送达;未配置的分类全通道放行,名单为空数组即全禁。通道名:`sound`(提示音)/ `system`(系统弹窗)/ `toast`(页内提示,页内提示音随行)/ `blink`(降级标题闪烁)/ `webhook` / `im`。典型用法:任务完成只推 webhook,重要事件(审批/提问)走 IM:
68
+ 六类事件各自可指定放行通道名单(kindRoutes,存 settings.yaml),名单外的通道对该类事件一律不送达;未配置的分类全通道放行,名单为空数组即全禁。通道名:`sound`(提示音)/ `system`(系统弹窗)/ `toast`(页内提示,页内提示音随行)/ `blink`(降级标题闪烁)/ `webhook` / `im` / `host`(宿主桌面通知)。典型用法:任务完成只推 webhook,重要事件(审批/提问)走 IM:
69
69
 
70
70
  ```yaml
71
71
  turn-notify:
@@ -80,6 +80,15 @@ turn-notify:
80
80
 
81
81
  在面板填入 webhook URL 即启用。host 直发,标签页全关也送达;payload 为 Slack 兼容 JSON(text 字段承载通知文本,summary 字段承载通知小结),另附 event/category/status/session/workspace/durationMs/tokens/ts 结构化字段;超时 10 秒,不重试。URL 原文随配置响应回显、保存即提交输入框内容(清空并保存即禁用),测试按钮返回真实投递结果。
82
82
 
83
+ ## 宿主桌面通知
84
+
85
+ 开启面板"通知"页的宿主通知开关后,通知触发时由宿主进程直接弹操作系统级桌面通知,零依赖:macOS 走 osascript,Linux 走 notify-send,Windows 走 PowerShell 调 WinRT toast;超时 10 秒,失败即弃不重试,不影响其余通道。去重方向为**浏览器优先**:本机浏览器在场(2 秒内有在途长轮询)时由浏览器呈现,宿主让位,正常路径同机零重复;本机浏览器离场(标签页全关或仅远程浏览器在线)时宿主补位。重复只发生在在场判定的边界情况(认可窗口边界与网络抖动),宁可重复不可漏。两个开关判定一致,任一开启即生效:
86
+
87
+ - **宿主机弹桌面通知**:启用宿主通知通道(浏览器优先去重内建)。
88
+ - **仅当本机无浏览器接收时补位**:与总开关等效,作为显式的补位语义表达保留。
89
+
90
+ "本机浏览器在场"按投影长轮询记账:回环来源 + 续传 cursor + 同源形态的 GET 在服务端挂起期间计为在途,断连(标签页关闭/请求中止)经连接关闭事件即时出账,最近活动在 2 秒认可窗口内仍算在场——优雅断连的盲区不超过 2 秒;异常断连(睡眠、强杀、断网)服务端无感知,受长轮询挂起上限约束,最坏约 25 秒后自愈出账。浏览器在场但本机呈现偏好全关(声音/弹窗/页内全关或系统通知未授权)时,宿主同样让位,该事件本机无 OS 级通知(仅剩会话行高亮)。宿主在无桌面的服务器上时 spawn 会静默失败,无害;测试页"测试宿主通知"返回真实结果,可据此验证宿主机桌面环境是否可用。聚焦时宿主通知照弹(宿主无法感知窗口焦点,聚焦静默是浏览器侧偏好)。
91
+
83
92
  ## IM 推送
84
93
 
85
94
  安装 [@xmanrui/dsh-im](https://www.npmjs.com/package/@xmanrui/dsh-im) 后自动启用,可推送到微信等九种渠道。投递目标的新建与平台测试在 dsh-im 设置页完成,本插件只做选择:从已绑 bot 的目标目录勾选即保存,支持绑定多个 bot。触发逻辑与 webhook 一致,fire-and-forget 不重试;bot 离线时该次通知弃置不补发。
@@ -88,6 +97,10 @@ turn-notify:
88
97
 
89
98
  通知实际送达时,侧边栏会话列表中对应会话行以背景脉冲闪烁强调,六类各一色(完成绿/出错红/提问蓝/中断橙/审批黄/上限紫),点击该会话行即停止。开关在面板"偏好"页首项(独立开关,本机生效,各浏览器独立),默认开启。并行多会话时用于快速定位状态刚更新的会话:运行中的会话不闪烁(与 dsh 原生状态点分工——状态点表达运行中,闪烁表达已更新待查看),会话重新进入运行状态时闪烁自动让位;聚焦时正在查看的会话不闪烁,失焦期间本会话的通知照常闪烁,返回窗口即自动消失(回来即已读,焦点与页面可见双通道)。行定位按会话标题文本匹配,标题默认与侧边栏行同源(session-title 投影);该服务缺失或不可用(如伪会话无事件日志)时回落会话事件内存储的标题,可能与侧边栏不同步。会话行未渲染(列表折叠/懒加载未滚动到)时该次高亮静默跳过。重开页面的冷启动静默对齐同样挂高亮(见"多窗口只响一次"),重开即见哪些会话有动静。
90
99
 
100
+ ## 工作区文件夹运行标记
101
+
102
+ 侧边栏会话列表按工作区分组(官方 WorkspaceBrowser 分组模式)时,有运行中会话的工作区文件夹组头显示脉冲运行点并给文件夹图标着色,游离(未归属任何工作区)的可见运行中会话使末位「未分组」组头同样标记——折叠与展开同样生效。归属与运行态一律取服务快照投影,DOM 仅按下标对位官方组容器;组容器数漂移(单列表模式/搜索态/结构变更)即清除标记不误标。图标着色让位官方 folderActive(展开且含当前会话)态。开关在面板"偏好"页"会话列表"卡(存宿主配置,各浏览器共用),默认开启,变更刷新页面生效。
103
+
91
104
  ## 多窗口只响一次
92
105
 
93
106
  同一地址(同源)打开的多个窗口共享一次呈现:各窗口以长轮询挂起在 host 的通知队列上(事件产生或配置变更即时返回,空闲期单次挂起至多数十秒,失败按指数退避重连),经 localStorage 协调,仅一个窗口抢先呈现——发声并弹出页内提示、系统弹窗、高亮会话行,其余窗口全部跳过,不止声音。通知在产生后的下一次唤醒即送达,通常亚秒级;不同浏览器或不同地址打开的窗口各自发声互不感知。窗口关闭/刷新期间错过的通知不做重开回放:启动首轮静默对齐游标与完成标记,不逐条补发提示与声音(等待用户动作的审批与提问例外,照常呈现);未读会话行高亮不在静默范围,照常挂上。
@@ -98,11 +111,11 @@ turn-notify:
98
111
 
99
112
  | 分区 | 内容 |
100
113
  |------|------|
101
- | 通知 | webhook 地址、最短回合时长、子代理过滤、六类事件开关;存 settings.yaml,事件开关点击即存,其余点保存后生效。事件→通道路由(kindRoutes)亦存此命名空间,经 yaml 配置 |
114
+ | 通知 | webhook 地址、最短回合时长、子代理过滤、宿主通知(总开关与回退)、六类事件开关;存 settings.yaml,事件开关点击即存,其余点保存后生效。事件→通道路由(kindRoutes)亦存此命名空间,经 yaml 配置 |
102
115
  | 偏好 | 本机浏览器记忆的设置:会话高亮(独立开关)、声音总开关与分类静音、音量、系统弹窗与授权、页内提示开关、页内提示音(总开关 + 分类独立开关)、聚焦静默、降级标题闪烁 |
103
116
  | 音效 | 音效上传/试听/重命名/删除,六类事件的音效映射与双作用域开关,页内提示音映射(本机,未配置沿用通知音效) |
104
117
  | IM | 绑定 dsh-im bot、勾选投递目标(仅装了 dsh-im 时显示) |
105
- | 测试 | 声音/页内/系统/webhook/IM 逐通道点火,回执即真实结果 |
118
+ | 测试 | 声音/页内/系统/宿主/webhook/IM 逐通道点火,回执即真实结果 |
106
119
 
107
120
  ## settings.yaml 参考
108
121
 
@@ -114,6 +127,8 @@ turn-notify:
114
127
  minTurnDurationMs: 5000 # 最短回合时长(毫秒),回合结束类通知短于此不送达
115
128
  rootsOnly: true # 子代理会话不通知
116
129
  suppressSubagentWake: true # 子代理相关回合不通知:后台委托未收尾或收尾唤醒(仅任务完成类)
130
+ hostNotify: false # 宿主机桌面通知总开关;浏览器优先,本机浏览器在场(2 秒内有在途长轮询)时让位
131
+ hostNotifyFallback: false # 宿主通知补位:本机浏览器离场(2 秒内无在途长轮询)时弹宿主桌面通知,与总开关判定一致
117
132
  enabled: # 六类事件独立开关
118
133
  completed: true
119
134
  error: true
@@ -138,13 +153,14 @@ turn-notify:
138
153
 
139
154
  1. **事件监听**:host 观察 `session/event`(回合结束、ask_user_question 工具调用与 assistant 消息)与 `approval/request` 审批请求(只观察,不拦截)。回合结束原因逐一映射:正常结束归任务完成,错误与手动停止归任务出错,审批拦截归等待审批,输出触顶归达到上限,会话收尾补发的中断归被中断;assistant 消息流经途累积回合小结(tokens 与回答前缀),回合结束随通知消费。
140
155
  2. **分类与过滤**:事件归入六类,经子代理会话、子代理相关静默(仅任务完成类)、最短回合时长三道过滤,通过即产生一条通知,并按 kindRoutes 裁定放行通道。
141
- 3. **host 直发通道**:webhook 与 IM 由 host 进程直接投递(经路由名单过滤),不依赖浏览器;同时通知写入内存队列(环形 20 条,60 秒过期,不落盘),路由名单随单元下发。
156
+ 3. **host 直发通道**:webhook、IM 与宿主桌面通知由 host 进程直接投递(经路由名单过滤),不依赖浏览器;宿主通知按本机(回环来源)浏览器在场(在途长轮询)浏览器优先去重,离场补位;同时通知写入内存队列(环形 20 条,60 秒过期,不落盘),路由名单随单元下发。
142
157
  4. **浏览器通道**:各窗口以长轮询挂起在该队列上(cursor 续传,事件与配置变更即时唤醒,空闲期单次挂起至多数十秒,失败指数退避重连),localStorage 协调保证同一通知只有一个窗口呈现,再按本机偏好与路由名单分发到声音/系统弹窗/页内提示;等待用户动作的审批与提问超时未处理时补发一轮提醒。
143
158
  5. **降级链**:声音通道开启而系统弹窗不可用(HTTP 非回环、未授权)→ 标题闪烁;页内提示依赖缺失 → 干净禁用该通道;localStorage 不可用 → 该窗口直接发声(可能与多窗口重复,诚实降级)。
144
159
 
145
160
  ## 已知取舍
146
161
 
147
- - 标签页全关时,仅 webhook 与 IM 送达(声音与弹窗的浏览器前提)。
162
+ - 标签页全关时,仅 webhook、IM 与宿主桌面通知(如开启)送达(声音与弹窗的浏览器前提)。
163
+ - 宿主桌面通知为浏览器优先去重:本机浏览器在场(2 秒内有在途长轮询)时宿主让位,边界情况(认可窗口边界、网络抖动、反代致来源恒为回环而恒判在场)宁可不弹不误弹,优雅断连的离场盲区不超过 2 秒,异常断连受长轮询挂起上限约束(最坏约 25 秒);聚焦时宿主通知照弹(宿主无法感知窗口焦点);无桌面的宿主上 spawn 静默失败,无害。在场记账的形态校验(回环 + 续传 cursor + 同源)为启发式,防无意探活误压制与网页伪造在场(DNS rebinding 将域解析至回环的页面同源可过校验,但该页面同时已握有全部写配置 API,通知压制非最大损失面),不构成对抗本机进程的安全边界。
148
164
  - IM 依赖 dsh-im 的 bot 在线,离线时该次通知弃置不补发;成功回执仅代表平台受理。
149
165
  - 不同浏览器或不同地址打开的窗口各自发声,互不去重。
150
166
  - 系统弹窗在 HTTP 非回环下永久降级,HTTPS 化后自动恢复;Windows 下受系统通知设置与专注助手约束。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-turn-notify",
3
- "description": "消息通知:六类回合事件送达声音、系统弹窗、页内提示、webhook 与 IM;内置八种合成音色,自定义音效,按事件分类指定;多窗口只响一次",
4
- "version": "0.9.0",
3
+ "description": "消息通知:六类回合事件送达声音、系统弹窗、页内提示、webhook、IM 与宿主桌面通知;内置八种合成音色,自定义音效,按事件分类指定;多窗口只响一次",
4
+ "version": "0.11.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -35,9 +35,9 @@ window.__ModuleLoader__.load({
35
35
  // 显示回执等待上限:超时未回执按环境层拦截给出诊断
36
36
  const SYSTEM_SHOW_TIMEOUT_MS = 3 * 1000
37
37
 
38
- // 导航图标声明:交给 dsh-settings-nav-icons 统一渲染(本插件分区 → bell);
39
- // 该插件未就绪时入队,由其启动时排空
40
- const NAV_ICON = { '消息通知': 'bell' }
38
+ // 导航图标声明:交给 dsh-settings-nav-icons 统一渲染;双键 = 分区 label + 市场
39
+ // 短名(发现页收录显示形态);该插件未就绪时入队,由其启动时排空
40
+ const NAV_ICON = { '消息通知': 'bell', 'dsh-turn-notify': 'bell' }
41
41
  if (window.__navicIcons !== undefined) window.__navicIcons.register(NAV_ICON)
42
42
  else if (Array.isArray(window.__navicIconQueue)) window.__navicIconQueue.push(NAV_ICON)
43
43
  else window.__navicIconQueue = [NAV_ICON]
@@ -277,7 +277,8 @@ window.__ModuleLoader__.load({
277
277
  // 放入 LOGIC 段由 parity 测试保证双实现不漂移
278
278
  const IDLE_AWAY_MS = 5 * 60 * 1000
279
279
 
280
- // routes 为事件→通道路由放行名单(null=未配置全放行),语义与 core 同源
280
+ // routes 为事件→通道路由放行名单(null=未配置全放行),语义与 core 同源。
281
+ // 宿主通知去重由浏览器优先模型承载:本机浏览器在场时宿主让位,本窗口照常弹系统通知
281
282
  function chooseChannels(hasFocus, permission, idleMs, soundCategories, category, routes) {
282
283
  const idleAway = typeof idleMs === 'number' && idleMs >= IDLE_AWAY_MS
283
284
  const quiet = hasFocus && localGet(KEY_DND) !== '0' && !idleAway
@@ -863,6 +864,55 @@ window.__ModuleLoader__.load({
863
864
  row.className = row.className.split(' ').filter((name) => name !== SESSION_HL_CLASS && name.indexOf(SESSION_HL_CLASS + '--') !== 0).join(' ')
864
865
  }
865
866
 
867
+ // ---- 工作区文件夹运行标记 ----
868
+
869
+ // 官方组容器 CSS module 类名后缀(哈希前缀随构建漂移,后缀稳定)与自家标记属性;
870
+ // 扫描合并窗口覆盖聊天流式输出的高频 DOM mutation
871
+ const FOLD_SECTION_SELECTOR = 'div[class$="_groupSection"]'
872
+ const FOLD_RUNNING_FLAG = 'data-tn-folder-running'
873
+ const FOLD_SCAN_DELAY_MS = 50
874
+
875
+ // 运行标记状态:官方 dsh-client-ui-workspace 分组语义的投影——
876
+ // 归属走 workspace 账本 sessionIds 一级映射(官方 owningGroupKey 同构,无 cwd 回退),
877
+ // 游离会话入末位未分组桶;可见性镜像官方 sessionVisible(subagent / 已归档 /
878
+ // 空白且非当前排除),组内任一可见会话 running 即该工作区运行中。
879
+ // ids 保工作区登记序(官方组渲染序 = 快照 items 序),供 DOM 组容器按下标对位
880
+ function folderRunningState(sessionsState, workspacesState) {
881
+ const byId = (sessionsState && sessionsState.byId) || {}
882
+ const ids = (sessionsState && sessionsState.ids) || []
883
+ const current = sessionsState ? sessionsState.current : undefined
884
+ const workspaces = workspacesState && Array.isArray(workspacesState.items) ? workspacesState.items : []
885
+ const archived = new Set((workspacesState && workspacesState.archivedSessionIds) || [])
886
+ const visible = (row) => Boolean(row)
887
+ && row.origin !== 'subagent'
888
+ && !archived.has(row.id)
889
+ && (!row.blank || row.id === current)
890
+ const running = new Set()
891
+ const accounted = new Set()
892
+ const workspaceIds = []
893
+ for (const workspace of workspaces) {
894
+ // 主键不做防御过滤,与官方 groupByWorkspace 同构:items 长度即组容器对位基准,
895
+ // 过滤会让 ids 短于容器数,恰差一时错位成「末位未分组桶」对位
896
+ workspaceIds.push(workspace.workspaceId)
897
+ const sessionIds = Array.isArray(workspace.sessionIds) ? workspace.sessionIds : []
898
+ for (const sessionId of sessionIds) {
899
+ const row = byId[sessionId]
900
+ if (!row) continue
901
+ accounted.add(sessionId)
902
+ if (row.running && visible(row)) running.add(workspace.workspaceId)
903
+ }
904
+ }
905
+ let ungrouped = false
906
+ for (const sessionId of ids) {
907
+ if (accounted.has(sessionId)) continue
908
+ const row = byId[sessionId]
909
+ if (row && row.running && visible(row)) ungrouped = true
910
+ }
911
+ return { ids: workspaceIds, running, ungrouped }
912
+ }
913
+
914
+ const FOLD_SWITCH_TITLE = '侧边栏会话列表按工作区分组时,有运行中会话的文件夹组头显示运行点并给文件夹图标着色,文件夹折叠与展开同样生效;游离会话所在的「未分组」组同样标记;单列表模式下无此标记。停用即全部隐藏,刷新页面生效。'
915
+
866
916
  // ---- 点击直达会话:页内卡片与系统弹窗共用 ----
867
917
 
868
918
  // 按通知单元激活会话:聚焦窗口并模拟点击侧边栏会话行(官方行点击即切换会话);
@@ -1016,6 +1066,9 @@ window.__ModuleLoader__.load({
1016
1066
  minTurnDurationMs: 5 * 1000,
1017
1067
  rootsOnly: true,
1018
1068
  suppressSubagentWake: true,
1069
+ hostNotify: false,
1070
+ hostNotifyFallback: false,
1071
+ folderRunningEnabled: true,
1019
1072
  enabled: Object.fromEntries(CATEGORIES.map((key) => [key, true])),
1020
1073
  imTargets: [],
1021
1074
  }
@@ -1133,6 +1186,18 @@ window.__ModuleLoader__.load({
1133
1186
  '@media (prefers-reduced-motion: reduce) {',
1134
1187
  ' .tn-sess-hl { animation:none; background-color:color-mix(in srgb, var(--tn-sess-color, #4c8dff) 50%, var(--dsw-alias-bg-base, #202020) 50%); }',
1135
1188
  '}',
1189
+ // 工作区文件夹运行标记:官方组容器自带 position:relative,运行点绝对定位于
1190
+ // 组容器左缘、垂直对齐组头行中线(行高 34px 之半即 top 17px,落于组头行左内边距
1191
+ // 区),零布局位移;图标着色让位官方 folderActive(展开含当前)态;色值取官方
1192
+ // ongoing 运行点的实际色值(--dsh-state-ongoing 在官方仅定义于其状态点组件局部,
1193
+ // 组容器上下文读不到,直接引用其回退源)
1194
+ 'div[class$="_groupSection"][data-tn-folder-running] { --tn-fold-running-color: var(--dsw-static-deepseek-450); }',
1195
+ 'div[class$="_groupSection"][data-tn-folder-running]::before { content:""; position:absolute; left:0; top:17px; width:5px; height:5px;',
1196
+ ' margin-top:-2.5px; border-radius:999px; background:var(--tn-fold-running-color);',
1197
+ ' animation:tn-fold-running-pulse 1.2s ease-in-out infinite; }',
1198
+ 'div[class$="_groupSection"][data-tn-folder-running] span[class$="_folder"]:not([class$="_folderActive"]) { color:var(--tn-fold-running-color); }',
1199
+ '@keyframes tn-fold-running-pulse { 0%, 100% { opacity:1; } 50% { opacity:.35; } }',
1200
+ '@media (prefers-reduced-motion: reduce) { div[class$="_groupSection"][data-tn-folder-running]::before { animation:none; } }',
1136
1201
  ].join('\n')
1137
1202
 
1138
1203
  function h(type, props) {
@@ -1368,7 +1433,7 @@ window.__ModuleLoader__.load({
1368
1433
  if (raw.length === 0) throw new Error('最短回合时长不能为空')
1369
1434
  const trimmedUrl = urlDraft.trim()
1370
1435
  // 所见即所存:输入框即配置值,清空并保存即禁用 webhook 通道
1371
- const patchBody = { webhookUrl: trimmedUrl, minTurnDurationMs: Number(raw), rootsOnly: config.rootsOnly, suppressSubagentWake: config.suppressSubagentWake }
1436
+ const patchBody = { webhookUrl: trimmedUrl, minTurnDurationMs: Number(raw), rootsOnly: config.rootsOnly, suppressSubagentWake: config.suppressSubagentWake, hostNotify: config.hostNotify, hostNotifyFallback: config.hostNotifyFallback }
1372
1437
  const res = await api('/api/turn-notify/config', { method: 'POST', body: JSON.stringify(patchBody) })
1373
1438
  setConfig({ ...DEFAULT_CONFIG, ...res })
1374
1439
  if (res.soundMapping) setMappingState(res.soundMapping)
@@ -1405,6 +1470,21 @@ window.__ModuleLoader__.load({
1405
1470
  })
1406
1471
  }
1407
1472
 
1473
+ // 文件夹运行标记开关:乐观回填,服务端失败回滚;变更刷新页面生效(标记
1474
+ // 注入在插件激活时按配置装配)
1475
+ async function toggleFolderRunning(checked) {
1476
+ const previous = config.folderRunningEnabled
1477
+ setConfig({ ...config, folderRunningEnabled: checked })
1478
+ try {
1479
+ const res = await api('/api/turn-notify/config', { method: 'POST', body: JSON.stringify({ folderRunningEnabled: checked }) })
1480
+ setConfig({ ...DEFAULT_CONFIG, ...res })
1481
+ patch('工作区文件夹运行标记已' + (res.folderRunningEnabled !== false ? '启用' : '停用') + ',刷新页面后生效')
1482
+ } catch (error) {
1483
+ setConfig((prev) => ({ ...prev, folderRunningEnabled: previous }))
1484
+ patch('切换失败:' + (error && error.message ? error.message : String(error)), 'error')
1485
+ }
1486
+ }
1487
+
1408
1488
  async function requestPermission() {
1409
1489
  try {
1410
1490
  const next = await Notification.requestPermission()
@@ -1465,6 +1545,16 @@ window.__ModuleLoader__.load({
1465
1545
  }, SYSTEM_TEST_DELAY_MS)
1466
1546
  }
1467
1547
 
1548
+ // 宿主桌面通知通道单独测试:等待 spawn 结束,真实结果随响应返回
1549
+ async function testHostNotify() {
1550
+ try {
1551
+ const result = await api('/api/turn-notify/test-host', { method: 'POST' })
1552
+ patch(result.ok ? '宿主通知已触发(' + result.detail + ')' : '宿主通知失败:' + result.detail, result.ok ? 'ok' : 'error')
1553
+ } catch (error) {
1554
+ patch('发送失败:' + (error && error.message ? error.message : String(error)), 'error')
1555
+ }
1556
+ }
1557
+
1468
1558
  async function testWebhook() {
1469
1559
  // 草稿与已保存值不同即存在未保存改动,测试的只能是已保存配置
1470
1560
  if (urlDraft.trim() !== String(config.webhookUrl || '').trim()) {
@@ -1611,7 +1701,7 @@ window.__ModuleLoader__.load({
1611
1701
  return h('div', { className: 'tn-panel' },
1612
1702
  h('div', { className: 'tn-head' },
1613
1703
  h('span', { className: 'tn-head__title' }, '消息通知'),
1614
- h('span', { className: 'tn-head__hint' }, '保存即生效;标签页全关时仅 webhook 与 IM 送达'),
1704
+ h('span', { className: 'tn-head__hint' }, '保存即生效;标签页全关时仅 webhook、IM 与宿主通知(如开启)送达'),
1615
1705
  ),
1616
1706
  h('div', { className: 'tn-tabs', role: 'tablist' },
1617
1707
  tabs.map((item) => h('button', {
@@ -1664,6 +1754,20 @@ window.__ModuleLoader__.load({
1664
1754
  }),
1665
1755
  ' 后台委托未收尾或收尾唤醒的回合不通知(仅完成类)'),
1666
1756
  ]),
1757
+ field('宿主通知', [
1758
+ h('label', { className: 'tn-meta tn-switch', title: '通知触发时由宿主进程弹系统级桌面通知(osascript/notify-send/PowerShell toast);浏览器优先:本机有浏览器窗口在线(2 秒内有在途长轮询)时由浏览器呈现,宿主不重复弹;本机无浏览器(标签页全关或仅远程浏览器)时宿主弹' },
1759
+ ...switchToggle({
1760
+ checked: config.hostNotify,
1761
+ onChange: (e) => setConfig({ ...config, hostNotify: e.target.checked }),
1762
+ }),
1763
+ ' 宿主机弹桌面通知'),
1764
+ h('label', { className: 'tn-meta tn-switch', title: '本机浏览器在场(2 秒内有在途长轮询)时由浏览器呈现,离场时宿主补位桌面通知;与总开关判定一致,任一开启即生效' },
1765
+ ...switchToggle({
1766
+ checked: config.hostNotifyFallback,
1767
+ onChange: (e) => setConfig({ ...config, hostNotifyFallback: e.target.checked }),
1768
+ }),
1769
+ ' 仅当本机无浏览器接收时补位'),
1770
+ ], '由宿主进程直接弹 OS 桌面通知,服务器/浏览器全关场景可达;浏览器优先——本机浏览器在线时由浏览器呈现,宿主不重复弹,离场(2 秒内无在途长轮询)时宿主补位,边界情况(窗口边界与网络抖动)宁重复不漏。需点保存生效,测试页可逐条点火验证。'),
1667
1771
  field('事件分类', h('div', { className: 'tn-pills' },
1668
1772
  CATEGORIES.map((category) => h('span', {
1669
1773
  className: 'tn-pill' + (config.enabled[category] ? ' tn-pill--on' : ''),
@@ -1767,6 +1871,20 @@ window.__ModuleLoader__.load({
1767
1871
  ' 弹窗不可用时以标题闪烁替代'),
1768
1872
  ]),
1769
1873
  ) : null,
1874
+ activeTab === '偏好' ? h('div', { className: 'tn-card' },
1875
+ h('div', { className: 'tn-card__head' },
1876
+ h('span', { className: 'tn-card__title' }, '会话列表'),
1877
+ h('span', { className: 'tn-card__sub' }, '存宿主配置,各浏览器共用'),
1878
+ ),
1879
+ field('运行标记', [
1880
+ h('label', { className: 'tn-meta tn-switch', title: FOLD_SWITCH_TITLE },
1881
+ ...switchToggle({
1882
+ checked: config.folderRunningEnabled !== false,
1883
+ onChange: (e) => void toggleFolderRunning(e.target.checked),
1884
+ }),
1885
+ ' 有运行中会话的文件夹显示运行点'),
1886
+ ], '侧边栏按工作区分组时,运行中的文件夹组头显示脉冲运行点并给图标着色,折叠与展开同样生效;变更刷新页面生效。'),
1887
+ ) : null,
1770
1888
  activeTab === '音效' ? [
1771
1889
  h('div', { className: 'tn-card' },
1772
1890
  h('div', { className: 'tn-card__head' },
@@ -1964,6 +2082,7 @@ window.__ModuleLoader__.load({
1964
2082
  }, '测试声音'),
1965
2083
  h('button', { className: 'tn-btn', title: '弹出一条页内卡片;页内提示音已开启时随卡片补一声(点火测试,不经分类静音约束)', onClick: testPageNotification }, '测试页内通知'),
1966
2084
  h('button', { className: 'tn-btn', title: '弹一条系统通知验证授权与送达;未授权会先引导授权', onClick: testSystemNotification }, '测试系统通知'),
2085
+ h('button', { className: 'tn-btn', title: '由宿主进程弹一条系统级桌面通知(osascript/notify-send/PowerShell toast),验证宿主通道可用性;弹在宿主所在机器', onClick: () => void testHostNotify() }, '测试宿主通知'),
1967
2086
  h('button', { className: 'tn-btn', title: '向已配置的 webhook 发送真实测试事件,回执显示投递结果;未配置时提示失败', onClick: () => void testWebhook() }, '测试 webhook'),
1968
2087
  config.imAvailable ? h('button', { className: 'tn-btn', disabled: busy, title: '向全部已配置目标发送真实测试事件,逐目标显示结果', onClick: () => void testIm() }, '测试 IM 通知') : null,
1969
2088
  ),
@@ -1973,17 +2092,95 @@ window.__ModuleLoader__.load({
1973
2092
  }
1974
2093
 
1975
2094
  return {
1976
- inject: ['slots'],
2095
+ inject: ['slots', 'sessions', 'workspaces'],
1977
2096
  apply(ctx) {
2097
+ const sessions = ctx.get('sessions')
2098
+ const workspaces = ctx.get('workspaces')
1978
2099
  // 样式挂载宿主文档级:通知栈在面板未打开时也要有完整样式
1979
2100
  ctx.effect(() => {
1980
2101
  const style = document.createElement('style')
2102
+ // 自带 data-plugin:缺失时宿主 claimStyles 会把它归属给后续材质化插件,其 HMR 重建即误删
2103
+ style.setAttribute('data-plugin', '@mzzsfy/dsh-turn-notify')
1981
2104
  style.textContent = CSS
1982
2105
  document.head.appendChild(style)
1983
2106
  return () => style.remove()
1984
2107
  }, 'turn-notify styles')
1985
2108
  // 激活即轮询:通知链路不依赖设置面板是否打开过
1986
2109
  start()
2110
+ // 工作区文件夹运行标记:服务快照投影 + DOM 组容器下标对位,状态变化经
2111
+ // 订阅与 DOM 变更(合并窗口)双通道驱动;开关读宿主配置,变更刷新页面生效
2112
+ let foldEnabled = null
2113
+ ctx.effect(() => {
2114
+ if (typeof MutationObserver === 'undefined' || typeof document.querySelectorAll !== 'function') return
2115
+ let disposed = false
2116
+ let timer = null
2117
+ function clearAll() {
2118
+ document.querySelectorAll('[' + FOLD_RUNNING_FLAG + ']').forEach((section) => {
2119
+ section.removeAttribute(FOLD_RUNNING_FLAG)
2120
+ })
2121
+ }
2122
+ function scan() {
2123
+ const sections = document.querySelectorAll(FOLD_SECTION_SELECTOR)
2124
+ if (sections.length === 0) {
2125
+ clearAll()
2126
+ return
2127
+ }
2128
+ // 开关未决时仅清除:终态正确且停用用户刷新页面不闪现标记
2129
+ if (foldEnabled === null) {
2130
+ clearAll()
2131
+ return
2132
+ }
2133
+ const state = foldEnabled
2134
+ ? folderRunningState(sessions.list.getSnapshot(), workspaces.list.getSnapshot())
2135
+ : { ids: [], running: new Set(), ungrouped: false }
2136
+ const expected = state.ids.length
2137
+ // 数量不变量:组容器数 = 工作区数 或 工作区数+1(末位未分组桶);
2138
+ // 漂移(单列表模式 / 搜索态 / 官方结构变更)即清除标记,不误标
2139
+ const ungroupedLast = sections.length === expected + 1
2140
+ if (sections.length !== expected && !ungroupedLast) {
2141
+ clearAll()
2142
+ return
2143
+ }
2144
+ sections.forEach((section, index) => {
2145
+ const running = index < expected ? state.running.has(state.ids[index]) : ungroupedLast && state.ungrouped
2146
+ // 同值跳过:流式输出期间扫描高频,重复写属性触发无谓的 CSS 重匹配
2147
+ if (running === section.hasAttribute(FOLD_RUNNING_FLAG)) return
2148
+ if (running) section.setAttribute(FOLD_RUNNING_FLAG, '')
2149
+ else section.removeAttribute(FOLD_RUNNING_FLAG)
2150
+ })
2151
+ }
2152
+ let scheduled = false
2153
+ const schedule = () => {
2154
+ if (disposed || scheduled) return
2155
+ scheduled = true
2156
+ timer = setTimeout(() => {
2157
+ scheduled = false
2158
+ timer = null
2159
+ if (!disposed) scan()
2160
+ }, FOLD_SCAN_DELAY_MS)
2161
+ }
2162
+ api('/api/turn-notify/config')
2163
+ .then((payload) => {
2164
+ foldEnabled = payload ? payload.folderRunningEnabled !== false : true
2165
+ })
2166
+ .catch(() => {
2167
+ foldEnabled = true
2168
+ })
2169
+ .finally(schedule)
2170
+ const unsubscribeSessions = sessions.list.subscribe(schedule)
2171
+ const unsubscribeWorkspaces = workspaces.list.subscribe(schedule)
2172
+ const observer = new MutationObserver(schedule)
2173
+ observer.observe(document.documentElement, { childList: true, subtree: true })
2174
+ schedule()
2175
+ return () => {
2176
+ disposed = true
2177
+ if (timer !== null) clearTimeout(timer)
2178
+ observer.disconnect()
2179
+ unsubscribeSessions()
2180
+ unsubscribeWorkspaces()
2181
+ clearAll()
2182
+ }
2183
+ }, 'turn-notify folder running mark')
1987
2184
  ctx.slots.inject('settings.section', () =>
1988
2185
  ctx.slots.register(
1989
2186
  { name: 'settings.section', id: 'turn-notify', order: 41, label: '消息通知' },
package/src/core.mjs CHANGED
@@ -283,8 +283,8 @@ export function decideClaim({ stored, done, now, windowId, lockTtlMs = CLAIM_LOC
283
283
  export const USER_IDLE_AWAY_MS = 5 * 60 * 1000
284
284
 
285
285
  // 呈现通道名单:事件→通道路由(kindRoutes)的合法取值;host 直发通道
286
- // (webhook/im)由 host 侧过滤,投影单元携带 routes 供浏览器侧过滤呈现通道。
287
- export const CHANNELS = ['sound', 'system', 'toast', 'blink', 'webhook', 'im']
286
+ // (webhook/im/host)由 host 侧过滤,投影单元携带 routes 供浏览器侧过滤呈现通道。
287
+ export const CHANNELS = ['sound', 'system', 'toast', 'blink', 'webhook', 'im', 'host']
288
288
 
289
289
  // 发声通道判定:页内提示、提示音与系统弹窗各自独立开关,聚焦静默仅压声音与系统弹窗;
290
290
  // 提示音另受分类配置约束:soundCategories 中该分类显式 false 即静音,缺省键与空分类放行。
@@ -496,6 +496,106 @@ export async function sendWebhook({ url, payload, fetchImpl = fetch }) {
496
496
 
497
497
  const CONFIG_WEBHOOK_SCHEMES = ['http:', 'https:']
498
498
 
499
+ // 宿主桌面通知 spawn 超时:与 webhook 同语义,超时强杀不重试
500
+ export const HOST_NOTIFY_TIMEOUT_MS = 10 * 1000
501
+
502
+ // 回环判定:IPv6 环回、IPv4 环回与 IPv4 映射形态的 IPv4 环回;空与非法为假。
503
+ // 回环来源的浏览器窗口即与宿主同机,是本机去重的判定依据
504
+ export function isLoopbackAddress(address) {
505
+ if (typeof address !== 'string' || address.length === 0) return false
506
+ return address === '::1' || address.startsWith('127.') || address.startsWith('::ffff:127.')
507
+ }
508
+
509
+ // 宿主通知决策:浏览器优先——本机浏览器在场由浏览器呈现,宿主让位(正常路径
510
+ // 零重复);离场由宿主补位。重复只发生在在场判定的边界情况(认可窗口边界与
511
+ // 网络抖动),宁可重复不可漏。两开关均关不弹,任一开启即启用该通道。
512
+ export function hostNotifyWanted({ hostNotify, hostNotifyFallback, localBrowserPresent }) {
513
+ if (hostNotify !== true && hostNotifyFallback !== true) return false
514
+ return localBrowserPresent !== true
515
+ }
516
+
517
+ // 本机浏览器在场判定:有在途长轮询即在场信号(无论时间戳新旧);无在途时,
518
+ // 最近活动在认可窗口内(长轮询刚完成、断连刚出账)仍算在场,超窗或从未在场为假
519
+ export function isLocalBrowserPresent({ inFlightPolls, lastSeenAt, now, windowMs }) {
520
+ if (typeof inFlightPolls === 'number' && inFlightPolls > 0) return true
521
+ return typeof lastSeenAt === 'number' && lastSeenAt > 0 && now - lastSeenAt <= windowMs
522
+ }
523
+
524
+ // 本机浏览器轮询判定:回环来源 + 长轮询续传形态(带 cursor)+ fetch 请求形态
525
+ // + Origin 对账。投影为无守卫 GET,记账须排除可伪造在场的形态:no-cors(img
526
+ // 跨站探活)、无 cursor 的手工探测、跨源 fetch(同机恶意网页,sec-fetch-mode
527
+ // 同为 cors 无法区分,凭 Origin 与 Host 不符识别;同源 GET fetch 不带 Origin,
528
+ // 无 Origin 放行,旧浏览器同样覆盖)
529
+ export function isLocalBrowserPoll({ remoteAddress, hasCursor, secFetchMode, origin, host }) {
530
+ if (!isLoopbackAddress(remoteAddress)) return false
531
+ if (hasCursor !== true) return false
532
+ if (secFetchMode !== undefined && secFetchMode !== 'cors') return false
533
+ if (typeof origin === 'string' && origin.length > 0) {
534
+ try {
535
+ if (new URL(origin).host !== host) return false
536
+ } catch {
537
+ return false
538
+ }
539
+ }
540
+ return true
541
+ }
542
+
543
+ // 文本规整:换行压平为空格,防命令行参数与通知文案中的换行歧义
544
+ const flattenNotifyText = (text) => String(text).replace(/[\r\n]+/g, ' ')
545
+
546
+ // AppleScript 字符串字面量:反斜杠与双引号转义
547
+ const appleScriptQuote = (text) => '"' + flattenNotifyText(text).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'
548
+
549
+ // PowerShell 单引号字符串字面量:单引号加倍
550
+ const powerShellQuote = (text) => "'" + flattenNotifyText(text).replace(/'/g, "''") + "'"
551
+
552
+ // Windows toast 的调用方标识:未打包进程借用系统 PowerShell 的 AppUserModelId,
553
+ // 缺此注册标识 toast 不展示
554
+ const WINDOWS_POWERSHELL_AUMID = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe'
555
+
556
+ // 平台命令构造:零依赖 spawn 所需的可执行文件与参数;未知平台返回 null。
557
+ // darwin 走 AppleScript 通知,linux 走 libnotify,win32 走 WinRT toast 双行模板
558
+ export function hostNotifyCommand(platform, title, body) {
559
+ if (platform === 'darwin') {
560
+ return { file: 'osascript', args: ['-e', 'display notification ' + appleScriptQuote(body) + ' with title ' + appleScriptQuote(title)] }
561
+ }
562
+ if (platform === 'linux') {
563
+ // '--' 终结选项解析:正文(回答前缀)以 '-' 开头时防被 getopt 误作选项簇
564
+ return { file: 'notify-send', args: ['--', flattenNotifyText(title), flattenNotifyText(body)] }
565
+ }
566
+ if (platform === 'win32') {
567
+ const script = [
568
+ '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null',
569
+ '$t = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)',
570
+ "$e = $t.GetElementsByTagName('text')",
571
+ '$e.Item(0).AppendChild($t.CreateTextNode(' + powerShellQuote(title) + ')) | Out-Null',
572
+ '$e.Item(1).AppendChild($t.CreateTextNode(' + powerShellQuote(body) + ')) | Out-Null',
573
+ '[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier(' + powerShellQuote(WINDOWS_POWERSHELL_AUMID) + ').Show([Windows.UI.Notifications.ToastNotification]::new($t))',
574
+ ].join('; ')
575
+ return { file: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-Command', script] }
576
+ }
577
+ return null
578
+ }
579
+
580
+ // 宿主桌面通知:spawn 平台命令并等待退出,真实结果返回;超时强杀,失败即弃
581
+ // 不重试(fire-and-forget,与 webhook 同语义)。execFileImpl 须为注入的
582
+ // node:child_process execFile,保持纯逻辑层可测。
583
+ export async function sendHostNotify({ title, body, platform, execFileImpl }) {
584
+ const command = hostNotifyCommand(platform, title, body)
585
+ if (command === null) return { ok: false, detail: '不支持的平台: ' + String(platform) }
586
+ try {
587
+ await new Promise((resolve, reject) => {
588
+ execFileImpl(command.file, command.args, { timeout: HOST_NOTIFY_TIMEOUT_MS, windowsHide: true }, (error) => {
589
+ if (error) reject(error)
590
+ else resolve()
591
+ })
592
+ })
593
+ return { ok: true, detail: '已触发宿主通知' }
594
+ } catch (error) {
595
+ return { ok: false, detail: error && error.message ? error.message : String(error) }
596
+ }
597
+ }
598
+
499
599
  // imTargets 读侧归一化:非数组回空,剔除形态非法项,仅保留两字段
500
600
  export function normalizeImTargets(raw) {
501
601
  if (!Array.isArray(raw)) return []
@@ -541,7 +641,7 @@ export function imBoundBotIds(list) {
541
641
  // 返回归一化后的补丁。
542
642
  export function validateConfigPatch(patch) {
543
643
  if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) return { ok: false, reason: '补丁须为对象' }
544
- const known = ['webhookUrl', 'minTurnDurationMs', 'rootsOnly', 'suppressSubagentWake', 'enabled', 'imTargets', 'kindRoutes']
644
+ const known = ['webhookUrl', 'minTurnDurationMs', 'rootsOnly', 'suppressSubagentWake', 'enabled', 'imTargets', 'kindRoutes', 'hostNotify', 'hostNotifyFallback', 'folderRunningEnabled']
545
645
  for (const key of Object.keys(patch)) {
546
646
  if (known.indexOf(key) < 0) return { ok: false, reason: '未知配置项: ' + key }
547
647
  }
@@ -594,6 +694,18 @@ export function validateConfigPatch(patch) {
594
694
  if (typeof patch.suppressSubagentWake !== 'boolean') return { ok: false, reason: 'suppressSubagentWake 须为布尔' }
595
695
  next.suppressSubagentWake = patch.suppressSubagentWake
596
696
  }
697
+ if ('hostNotify' in patch) {
698
+ if (typeof patch.hostNotify !== 'boolean') return { ok: false, reason: 'hostNotify 须为布尔' }
699
+ next.hostNotify = patch.hostNotify
700
+ }
701
+ if ('hostNotifyFallback' in patch) {
702
+ if (typeof patch.hostNotifyFallback !== 'boolean') return { ok: false, reason: 'hostNotifyFallback 须为布尔' }
703
+ next.hostNotifyFallback = patch.hostNotifyFallback
704
+ }
705
+ if ('folderRunningEnabled' in patch) {
706
+ if (typeof patch.folderRunningEnabled !== 'boolean') return { ok: false, reason: 'folderRunningEnabled 须为布尔' }
707
+ next.folderRunningEnabled = patch.folderRunningEnabled
708
+ }
597
709
  if ('enabled' in patch) {
598
710
  const enabled = patch.enabled
599
711
  if (enabled === null || typeof enabled !== 'object' || Array.isArray(enabled)) return { ok: false, reason: 'enabled 须为对象' }
@@ -657,6 +769,9 @@ export function resolvedConfig(settings) {
657
769
  minTurnDurationMs: duration,
658
770
  rootsOnly: source.rootsOnly !== false,
659
771
  suppressSubagentWake: source.suppressSubagentWake !== false,
772
+ hostNotify: source.hostNotify === true,
773
+ hostNotifyFallback: source.hostNotifyFallback === true,
774
+ folderRunningEnabled: source.folderRunningEnabled !== false,
660
775
  enabled: Object.fromEntries(CATEGORIES.map((key) => [key, enabled[key] !== false])),
661
776
  soundMapping,
662
777
  imTargets: normalizeImTargets(source.imTargets),
@@ -671,10 +786,13 @@ export function publicConfig(settings) {
671
786
  minTurnDurationMs: resolved.minTurnDurationMs,
672
787
  rootsOnly: resolved.rootsOnly,
673
788
  suppressSubagentWake: resolved.suppressSubagentWake,
789
+ hostNotify: resolved.hostNotify,
790
+ hostNotifyFallback: resolved.hostNotifyFallback,
791
+ folderRunningEnabled: resolved.folderRunningEnabled,
674
792
  enabled: resolved.enabled,
675
793
  soundMapping: resolved.soundMapping,
676
794
  imTargets: resolved.imTargets,
677
795
  kindRoutes: resolved.kindRoutes,
678
796
  webhookUrl: resolved.webhookUrl,
679
797
  }
680
- }
798
+ }