dsh-notify-say 0.4.58 → 0.4.59

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/CHANGELOG.md CHANGED
@@ -3,6 +3,10 @@
3
3
  > 本文件由 `tools/release-build.ps1` 从开发提交自动生成,**请勿手工编辑**。
4
4
  > 只列面向使用者的变更(新增功能与修复),不含内部维护提交。
5
5
 
6
+ ## v0.4.59 — 2026-10-18
7
+
8
+ - fix(notify): v0.4.59 —— 修两个 P1:弹窗开关失效 + 分叉会话漏报
9
+
6
10
  ## v0.4.58 — 2026-10-18
7
11
 
8
12
  - feat(ui): v0.4.58 —— 自重载入口并入「版本与更新」
package/README.md CHANGED
@@ -336,7 +336,7 @@ npm run test:route # 需要 --import loader 的接线测试
336
336
  npm run test:audio-wire
337
337
  ```
338
338
 
339
- **426 条测试通过。** 绝大多数是纯逻辑测试,克隆仓库后 `npm test` 即可跑。
339
+ **434 条测试通过。** 绝大多数是纯逻辑测试,克隆仓库后 `npm test` 即可跑。
340
340
 
341
341
  少数用例需要真实的 DSH 宿主(用于验证 Cordis 上下文行为)。
342
342
  找不到宿主时它们会**自动 skip** —— 注意 skip 不等于通过。
package/lib/index.js CHANGED
@@ -542,7 +542,25 @@ function questionSummary(toolName, rawArgs) {
542
542
  function isSubagent(session) {
543
543
  try {
544
544
  const h = session && session.header;
545
- return !!(h && (h.parentSession || h.origin === 'subagent' || (h.delegationDepth || 0) > 0));
545
+ if (!h) return false;
546
+ // ⚠️ v0.4.59 修正(外部审阅指出的规则矛盾):
547
+ //
548
+ // 原来只要有 `parentSession` 就判为子代理。但本文件 `isDescendantOf()`
549
+ // 的注释已经写明:`sessions.fork` 产生的**普通分叉会话** header 也携带
550
+ // parentSession(指向源会话),而 origin 为空、是用户自己的顶层会话。
551
+ //
552
+ // 后果很实际:默认 `subagents: false` 时,普通 fork 会话的完成
553
+ // 通知会被当成"子代理的"过滤掉 —— 用户会发现"我这个会话怎么不响"。
554
+ //
555
+ // 现在与 isDescendantOf 用同一套语义:只有 origin === 'subagent'
556
+ // 或 delegationDepth > 0 才算子代理。
557
+ //
558
+ // 为什么不是简单删掉 parentSession:真正的子代理关系仍需识别,
559
+ // 只是**不能仅凭** parentSession 认定 —— 它的语义是「派生自谁」,
560
+ // 不是「谁的子代理」。
561
+ if (h.origin === 'subagent') return true;
562
+ if ((h.delegationDepth || 0) > 0) return true;
563
+ return false;
546
564
  } catch {
547
565
  return false;
548
566
  }
@@ -997,6 +1015,28 @@ export function apply(ctx, config) {
997
1015
 
998
1016
  // 只有 "none" 是完全不出声;其余都走声音路径
999
1017
  const wantSound = cfg.voice && source !== 'none';
1018
+ // ── 视觉通道(v0.4.59)────────────────────────────────────────────
1019
+ //
1020
+ // ⚠️ 外部审阅指出的真 bug:原来只有 TTS 分支看 `cfg.showToast`,
1021
+ // 语音包 / 系统提示音 / 自定义音频一律让 ps1 弹窗 —— 于是
1022
+ // **关掉「显示通知弹窗」后仍会弹 Toast**。
1023
+ //
1024
+ // 更糟的是两个都关的情况:旧代码照样 spawn ps1(带 -NoSound),
1025
+ // ps1 在没有 -SoundOnly 时就会弹窗 —— 与设置页刚加的
1026
+ // 「弹窗与声音都已关闭」提示直接矛盾。
1027
+ //
1028
+ // 现在把两个通道彻底分开:
1029
+ // wantToast=false → 传 -SoundOnly(只出声,不弹窗)
1030
+ // wantSound=false → 传 -NoSound(只弹窗,不出声)
1031
+ // 两个都 false → **不启动任何进程**
1032
+ //
1033
+ // 默认值按 `!== false` 判:配置缺字段时按"开着"处理,
1034
+ // 不会因为 undefined 把用户的提醒静默掉。
1035
+ const wantToast = cfg.showToast !== false;
1036
+ if (!wantToast && !wantSound) {
1037
+ appendNotifyLog('[skip] 弹窗与声音都已关闭,不启动提醒进程');
1038
+ return;
1039
+ }
1000
1040
  // 本事件对应的事件类(done/fail/question/approval/blocked),
1001
1041
  // 用于查"按事件覆盖"的各种参数(v0.4.9)。
1002
1042
  const aKind = audioKindOf(psKind) || 'done';
@@ -1036,7 +1076,7 @@ export function apply(ctx, config) {
1036
1076
  ];
1037
1077
  if (vVoice) ttsArgs.push('-VoiceName', vVoice);
1038
1078
  // TTS 自己会出声,所以 notify.ps1 只负责弹窗(-NoSound)
1039
- if (cfg.showToast) spawnNotify(notifyArgs(psKind, title, body, true));
1079
+ if (wantToast) spawnNotify(notifyArgs(psKind, title, body, true));
1040
1080
  spawnTts(ttsArgs);
1041
1081
  return;
1042
1082
  }
@@ -1055,7 +1095,7 @@ export function apply(ctx, config) {
1055
1095
  // **不传 -Voice/-Mode**,并且 ps1 侧见到 -SystemSound 就跳过语音池
1056
1096
  // (否则 -Kind fail 会先选中 fail 池的 wav,永远走不到系统音效)。
1057
1097
  if (wantSound && source === 'system') {
1058
- const a = baseNotifyArgs(psKind, title, body, false);
1098
+ const a = baseNotifyArgs(psKind, title, body, false, !wantToast);
1059
1099
  const explicitTone = kindOr(cfg.kindSystemSound, aKind, '');
1060
1100
  a.push('-SystemSound', explicitTone || 'auto');
1061
1101
  spawnNotify(a);
@@ -1071,7 +1111,7 @@ export function apply(ctx, config) {
1071
1111
  const kind = audioKindOf(psKind);
1072
1112
  const file = resolveCustomFile(kind, cfg);
1073
1113
  if (file) {
1074
- const a = baseNotifyArgs(psKind, title, body, false);
1114
+ const a = baseNotifyArgs(psKind, title, body, false, !wantToast);
1075
1115
  a.push('-CustomFile', file);
1076
1116
  // v0.4.16:自定义音频的"标准化"是**可选**的。
1077
1117
  //
@@ -1085,23 +1125,28 @@ export function apply(ctx, config) {
1085
1125
  }
1086
1126
  // 没配文件:不要静默无声,退到系统提示音(audio-source 的回退链已算过,
1087
1127
  // 但这里再兜一次,防止 effectiveSource 的 hasCustomFile 判断与读取不一致)
1088
- const a2 = baseNotifyArgs(psKind, title, body, false);
1128
+ const a2 = baseNotifyArgs(psKind, title, body, false, !wantToast);
1089
1129
  a2.push('-SystemSound', 'auto');
1090
1130
  spawnNotify(a2);
1091
1131
  return;
1092
1132
  }
1093
1133
 
1094
1134
  // ── 其余音源(bundled)走 dsh-notify.ps1 ──────────────────────────────
1095
- spawnNotify(notifyArgs(psKind, title, body, !wantSound, source));
1135
+ // 第 5 个参数是 soundOnly(v0.4.59)。原来这里传的是 source,
1136
+ // 而 notifyArgs 只认 4 个参数 —— 那个多传的值一直被静默忽略。
1137
+ spawnNotify(notifyArgs(psKind, title, body, !wantSound, !wantToast));
1096
1138
  }
1097
1139
 
1098
1140
  /** 只有基础参数的 notify 调用(供 system 音源拼装)。 */
1099
- function baseNotifyArgs(psKind, title, body, noSound) {
1141
+ function baseNotifyArgs(psKind, title, body, noSound, soundOnly) {
1100
1142
  const args = [
1101
1143
  '-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File',
1102
1144
  SCRIPT_PATH, '-Kind', psKind, '-Title', title, '-Body', body,
1103
1145
  ];
1104
1146
  if (noSound) args.push('-NoSound');
1147
+ // 只出声不弹窗(v0.4.59)。与 -NoSound 互斥,调用方保证。
1148
+ if (soundOnly) args.push('-SoundOnly');
1149
+
1105
1150
  // v0.4.29:把「用户目录;插件目录」传给 ps1,让它两个区域都查
1106
1151
  const roots = voiceRootsArg();
1107
1152
  if (roots) args.push('-VoiceRoot', roots);
@@ -1112,12 +1157,14 @@ export function apply(ctx, config) {
1112
1157
  }
1113
1158
 
1114
1159
  /** 构造 dsh-notify.ps1 的参数。noSound=true 表示只弹窗不出声。 */
1115
- function notifyArgs(psKind, title, body, noSound) {
1160
+ function notifyArgs(psKind, title, body, noSound, soundOnly) {
1116
1161
  const args = [
1117
1162
  '-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File',
1118
1163
  SCRIPT_PATH, '-Kind', psKind, '-Title', title, '-Body', body,
1119
1164
  ];
1120
1165
  if (noSound) args.push('-NoSound');
1166
+ // 只出声不弹窗(v0.4.59)。与 -NoSound 互斥,调用方保证。
1167
+ if (soundOnly) args.push('-SoundOnly');
1121
1168
  // v0.4.29:把「用户目录;插件目录」传给 ps1,让它两个区域都查
1122
1169
  const roots = voiceRootsArg();
1123
1170
  if (roots) args.push('-VoiceRoot', roots);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-notify-say",
3
- "version": "0.4.58",
3
+ "version": "0.4.59",
4
4
  "description": "Desktop notifications for DSH Desktop that actually speak: Windows TTS out of the box (zero download), VOICEVOX voice packs optional, a selectable system-sound fallback, and an idle gate that waits for running subagents before saying the turn is done. Includes a settings-page GUI (sound source, per-event overrides, jump-back-to-session, cooldowns) and a self hot-reload for development.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,226 @@
1
+ /**
2
+ * 视觉通道与听觉通道的独立性(v0.4.59)。
3
+ *
4
+ * ## 为什么这个文件必须存在
5
+ *
6
+ * 外部审阅在 v0.4.56 上指出一个真 bug:
7
+ *
8
+ * `showToast=false` **只在 TTS 分支被处理**;语音包 / 系统提示音 /
9
+ * 自定义音频一律让 `dsh-notify.ps1` 自己弹窗 —— 于是"关掉弹窗"对
10
+ * 这三种音源根本没生效。
11
+ *
12
+ * 而当时的 `audio-wiring.test.mjs` 的 A3 用例**只测了 TTS**,
13
+ * 所以这个 bug 一直绿灯。我改完代码后测试也全绿 —— 再次印证:
14
+ * **没覆盖到的组合等于没测。**
15
+ *
16
+ * 这个文件按"五种音源 × 两个开关的全部组合"做参数级断言。
17
+ *
18
+ * ## 关键契约
19
+ *
20
+ * | showToast | voice | 期望 |
21
+ * |-----------|-------|------|
22
+ * | true | true | 弹窗 + 出声(notify 不带任何抑制开关) |
23
+ * | true | false | 只弹窗(notify 带 `-NoSound`) |
24
+ * | false | true | 只出声(notify 带 `-SoundOnly`) |
25
+ * | false | false | **不启动任何进程** |
26
+ *
27
+ * 注意 `-NoSound` 与 `-SoundOnly` **互斥**,同时出现说明逻辑错了。
28
+ *
29
+ * 需带 loader 跑:`npm run test:toast`
30
+ */
31
+ import { test } from 'node:test';
32
+ import assert from 'node:assert/strict';
33
+ import { mkdtempSync, rmSync } from 'node:fs';
34
+ import { tmpdir } from 'node:os';
35
+ import { join } from 'node:path';
36
+
37
+ const HOME = mkdtempSync(join(tmpdir(), 'notify-channels-'));
38
+ process.env.DSH_HOME = HOME;
39
+ process.on('exit', () => { try { rmSync(HOME, { recursive: true, force: true }); } catch { /* ignore */ } });
40
+
41
+ const { apply } = await import('../lib/index.js');
42
+
43
+ function stubActive() {
44
+ return globalThis.__STUB_CHILD_PROCESS__ === true;
45
+ }
46
+
47
+ function skipReason() {
48
+ if (!process.env.DSH_ROUTE_LOADER && !stubActive()) return '需 npm run test:toast(带 --import loader)';
49
+ if (!stubActive()) return 'loader stub 未生效';
50
+ return false;
51
+ }
52
+
53
+ function mkCtx() {
54
+ const handlers = new Map();
55
+ return {
56
+ on(name, fn) { handlers.set(name, fn); return { dispose() { handlers.delete(name); } }; },
57
+ effect(fn) { try { const d = fn(); return { dispose() { try { if (typeof d === 'function') d(); } catch { /* ignore */ } } }; } catch { return { dispose() { /* ignore */ } }; } },
58
+ inject(_names, fn) { try { fn({ webServer: null }); } catch { /* ignore */ } return { dispose() { /* ignore */ } }; },
59
+ get() { return undefined; },
60
+ logger: { info() { /* silent */ }, warn() { /* silent */ }, error() { /* silent */ } },
61
+ emit(name, ...args) { const h = handlers.get(name); if (h) h(...args); },
62
+ };
63
+ }
64
+
65
+ /** 触发一次"完成",返回 spawn 的 argv 列表。 */
66
+ function runDone(pluginCfg) {
67
+ globalThis.__SPAWN_CALLS = [];
68
+ const ctx = mkCtx();
69
+ apply(ctx, { idleGate: false, subagents: false, ...pluginCfg });
70
+
71
+ const agent = { id: 'agent-1', session: { id: 'sess-1' }, status: 'running' };
72
+ ctx.emit('session/event', agent.session, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } });
73
+ agent.status = 'idle';
74
+ ctx.emit('agent/status', { agent, status: 'running' });
75
+ ctx.emit('agent/status', { agent, status: 'idle' });
76
+
77
+ return (globalThis.__SPAWN_CALLS || []).map((c) => (c && (c.args || c.argv)) || []);
78
+ }
79
+
80
+ function scriptOf(argv) {
81
+ const arr = Array.isArray(argv) ? argv : [];
82
+ const i = arr.indexOf('-File');
83
+ return i >= 0 && arr[i + 1] ? String(arr[i + 1]).replace(/\\/g, '/').split('/').pop() : '';
84
+ }
85
+
86
+ /** 只留下 dsh-notify.ps1 的调用(负责 Toast/系统音/自定义音频)。 */
87
+ function notifyCalls(calls) {
88
+ return calls.filter((a) => scriptOf(a) === 'dsh-notify.ps1');
89
+ }
90
+
91
+ /** 只留下 tts-speak.ps1 的调用。 */
92
+ function ttsCalls(calls) {
93
+ return calls.filter((a) => scriptOf(a) === 'tts-speak.ps1');
94
+ }
95
+
96
+ const SOURCES = ['tts', 'bundled', 'system', 'custom', 'none'];
97
+
98
+ // ── M1: 两个都关 → 一个进程都不该启动 ──────────────────────────────────
99
+ test('M1: 弹窗与声音都关时,不启动任何进程(五种音源)', { skip: skipReason() }, () => {
100
+ for (const source of SOURCES) {
101
+ const calls = runDone({ audioSource: source, voice: false, showToast: false });
102
+ assert.equal(calls.length, 0,
103
+ `source=${source} 且 voice/showToast 都关时不该 spawn 任何东西,实际 ${JSON.stringify(calls.map(scriptOf))}`);
104
+ }
105
+ });
106
+
107
+ // ── M2: 只出声 → notify 必须带 -SoundOnly,且不得带 -NoSound ────────────
108
+ test('M2: showToast=false 且 voice=true 时,notify 带 -SoundOnly', { skip: skipReason() }, () => {
109
+ // 这条就是审阅指出的 bug:语音包/系统音/自定义音频原来不带这个开关,
110
+ // 于是"关掉弹窗"形同虚设。
111
+ for (const source of ['bundled', 'system', 'custom']) {
112
+ const calls = runDone({ audioSource: source, voice: true, showToast: false });
113
+ const n = notifyCalls(calls);
114
+ assert.equal(n.length, 1, `source=${source} 应恰好调一次 notify.ps1,实际 ${n.length}`);
115
+ assert.ok(n[0].includes('-SoundOnly'),
116
+ `source=${source} 应传 -SoundOnly(只出声不弹窗),实际参数: ${JSON.stringify(n[0])}`);
117
+ assert.ok(!n[0].includes('-NoSound'),
118
+ `source=${source} 不应同时传 -NoSound(与 -SoundOnly 互斥)`);
119
+ }
120
+ });
121
+
122
+ // ── M3: 只弹窗 → notify 必须带 -NoSound,且不得带 -SoundOnly ────────────
123
+ test('M3: showToast=true 且 voice=false 时,notify 带 -NoSound', { skip: skipReason() }, () => {
124
+ for (const source of ['bundled', 'system', 'custom', 'none']) {
125
+ const calls = runDone({ audioSource: source, voice: false, showToast: true });
126
+ const n = notifyCalls(calls);
127
+ assert.equal(n.length, 1, `source=${source} 应恰好调一次 notify.ps1,实际 ${n.length}`);
128
+ assert.ok(n[0].includes('-NoSound'),
129
+ `source=${source} 应传 -NoSound(只弹窗不出声),实际: ${JSON.stringify(n[0])}`);
130
+ assert.ok(!n[0].includes('-SoundOnly'),
131
+ `source=${source} 不应同时传 -SoundOnly`);
132
+ }
133
+ });
134
+
135
+ // ── M4: 两个都开 → 两个抑制开关都不该出现 ──────────────────────────────
136
+ test('M4: showToast=true 且 voice=true 时,两个抑制开关都不出现', { skip: skipReason() }, () => {
137
+ for (const source of ['bundled', 'system', 'custom']) {
138
+ const calls = runDone({ audioSource: source, voice: true, showToast: true });
139
+ const n = notifyCalls(calls);
140
+ assert.equal(n.length, 1, `source=${source} 应调一次 notify.ps1`);
141
+ assert.ok(!n[0].includes('-NoSound'), `source=${source} 不该抑制声音`);
142
+ assert.ok(!n[0].includes('-SoundOnly'), `source=${source} 不该抑制弹窗`);
143
+ }
144
+ });
145
+
146
+ // ── M5: TTS 走两个进程,各管一个通道 ───────────────────────────────────
147
+ test('M5: TTS 由两个进程分别负责声音与弹窗', { skip: skipReason() }, () => {
148
+ // 两个都开:tts-speak.ps1 出声、notify.ps1 弹窗(带 -NoSound 避免重复出声)
149
+ {
150
+ const calls = runDone({ audioSource: 'tts', voice: true, showToast: true });
151
+ assert.equal(ttsCalls(calls).length, 1, '应调一次 tts-speak.ps1');
152
+ const n = notifyCalls(calls);
153
+ assert.equal(n.length, 1, '应调一次 notify.ps1 负责弹窗');
154
+ assert.ok(n[0].includes('-NoSound'), 'TTS 自己出声,notify 应带 -NoSound');
155
+ }
156
+ // 只出声:不调 notify
157
+ {
158
+ const calls = runDone({ audioSource: 'tts', voice: true, showToast: false });
159
+ assert.equal(ttsCalls(calls).length, 1, '应仍调 TTS');
160
+ assert.equal(notifyCalls(calls).length, 0, 'showToast=false 时不该调 notify.ps1');
161
+ }
162
+ // 只弹窗:不调 TTS
163
+ {
164
+ const calls = runDone({ audioSource: 'tts', voice: false, showToast: true });
165
+ assert.equal(ttsCalls(calls).length, 0, 'voice=false 时不该调 TTS');
166
+ const n = notifyCalls(calls);
167
+ assert.equal(n.length, 1, '应调一次 notify.ps1 弹窗');
168
+ assert.ok(n[0].includes('-NoSound'), '不该出声');
169
+ }
170
+ });
171
+
172
+ // ── M6: none 音源:不出声但可弹窗 ──────────────────────────────────────
173
+ test('M6: audioSource=none 只弹窗、不出声', { skip: skipReason() }, () => {
174
+ const calls = runDone({ audioSource: 'none', voice: true, showToast: true });
175
+ assert.equal(ttsCalls(calls).length, 0, 'none 不该调 TTS');
176
+ const n = notifyCalls(calls);
177
+ assert.equal(n.length, 1, 'none 仍应弹窗');
178
+ assert.ok(n[0].includes('-NoSound'), 'none 不该出声');
179
+ });
180
+
181
+ // ── M7: 普通 fork 会话不能被当成子代理过滤掉 ────────────────────────────
182
+ test('M7: 普通 fork(有 parentSession、无 origin)仍会提醒', { skip: skipReason() }, () => {
183
+ // ⚠️ 外部审阅指出的规则矛盾:isSubagent() 原来只看 parentSession。
184
+ // 但 sessions.fork 产生的**普通分叉会话** header 也带 parentSession
185
+ // (origin 为空、是用户自己的顶层会话),于是默认 subagents:false 时
186
+ // 它的完成通知会被当成"子代理的"过滤掉 —— 用户觉得"我这个会话怎么不响"。
187
+ globalThis.__SPAWN_CALLS = [];
188
+ const ctx = mkCtx();
189
+ apply(ctx, { idleGate: false, subagents: false, audioSource: 'tts', voice: true, showToast: true });
190
+
191
+ // fork 会话:parentSession 有值,origin 为空
192
+ const forked = {
193
+ id: 'agent-fork',
194
+ session: { id: 'sess-fork', header: { parentSession: 'root-1' } },
195
+ status: 'running',
196
+ };
197
+ ctx.emit('session/event', forked.session, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } });
198
+ forked.status = 'idle';
199
+ ctx.emit('agent/status', { agent: forked, status: 'running' });
200
+ ctx.emit('agent/status', { agent: forked, status: 'idle' });
201
+
202
+ const calls = (globalThis.__SPAWN_CALLS || []).map((c) => (c && (c.args || c.argv)) || []);
203
+ assert.ok(calls.length > 0,
204
+ '普通 fork 会话的完成**应该**提醒;被当成子代理过滤掉了(isSubagent 误判)');
205
+ });
206
+
207
+ test('M8: 真子代理(origin=subagent)在 subagents=false 时仍被过滤', { skip: skipReason() }, () => {
208
+ // 负例的另一半:不能为了修 fork 误判,把真子代理也放进来。
209
+ globalThis.__SPAWN_CALLS = [];
210
+ const ctx = mkCtx();
211
+ apply(ctx, { idleGate: false, subagents: false, audioSource: 'tts', voice: true, showToast: true });
212
+
213
+ const sub = {
214
+ id: 'agent-sub',
215
+ session: { id: 'sess-sub', header: { origin: 'subagent', parentSession: 'root-1' } },
216
+ status: 'running',
217
+ };
218
+ ctx.emit('session/event', sub.session, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } });
219
+ sub.status = 'idle';
220
+ ctx.emit('agent/status', { agent: sub, status: 'running' });
221
+ ctx.emit('agent/status', { agent: sub, status: 'idle' });
222
+
223
+ const calls = (globalThis.__SPAWN_CALLS || []).map((c) => (c && (c.args || c.argv)) || []);
224
+ assert.equal(calls.length, 0,
225
+ 'subagents=false 时真子代理不该提醒(过滤规则不能因为修 fork 而失效)');
226
+ });