dsh-notify-say 0.4.21 → 0.4.24
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/lib/client.js +40 -7
- package/lib/config-route.js +248 -209
- package/lib/http-guard.js +212 -0
- package/lib/index.js +1822 -1768
- package/package.json +1 -1
- package/tools/notify-config.ps1 +472 -471
package/lib/index.js
CHANGED
|
@@ -1,1768 +1,1822 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-notify-say — Host half(官方插件规范形态)。
|
|
3
|
-
*
|
|
4
|
-
* 证据基础(0.1.7-rc.2 app.asar 一手抽取,均存 scratch/asar-extract/):
|
|
5
|
-
* - 导出形态:references/host-plugin.md「Host plugin export forms」——
|
|
6
|
-
* `export function apply(ctx, config) {}`,资源用 ctx.effect/ctx.on 注册。
|
|
7
|
-
* - 事件:@deepseek-ai/dsh-agent-preset-registry/lib/typert.host.js SessionEventMap(:647)——
|
|
8
|
-
* 'turn/end': { turn: number; reason: TurnEndReason }(turn 是数字序号,不是对象)
|
|
9
|
-
* TurnEndReasonMap(:803):completed / aborted / blocked / error / max-tokens /
|
|
10
|
-
* interrupted / forked,均为 { kind: ... } 对象 → 用 reason.kind 判分支。
|
|
11
|
-
* 'approval/asked': { id, toolName, callId?, reason? }
|
|
12
|
-
* 'approval/decided': { id, outcome } (审批审计事件对)
|
|
13
|
-
* SessionHeader(:655):parentSession? / origin?: 'subagent' / delegationDepth? → 子代判定。
|
|
14
|
-
* - 审批接缝:@deepseek-ai/dsh-user-approval/lib/index.js:176 —— approval/request 是
|
|
15
|
-
* waterfall,fail-closed 默认(无应答者 → 'unavailable')。本插件 listener 必须
|
|
16
|
-
* return next() 并透传返回值,绝不阻断、不改写决定。
|
|
17
|
-
* - 事件消费方式:SessionEventMap 事件经 session/event 流观测(旧 dsh-notify-done 在
|
|
18
|
-
* 同源 Cordis 事件系统上实证的做法;practices.md 亦把 session/event 订阅与
|
|
19
|
-
* sessionProjections 并列为观测手段)。turn/end 不是独立 ctx 事件名。
|
|
20
|
-
*
|
|
21
|
-
* 行为(v1 规格,见 CARD.md):
|
|
22
|
-
* - F1 回合完成:仅 reason.kind==='completed' 播「完成」;error/blocked 换文案 +
|
|
23
|
-
* 系统蜂鸣(无对应语句池资产,不播"完成"语音以免误导);aborted/max-tokens/
|
|
24
|
-
* interrupted/forked 默认静默(notifyOnAborted)。按会话去抖(默认 60s)。
|
|
25
|
-
* - F2 审批:监听 approval/request waterfall(放行 + 提醒)与 approval/asked/
|
|
26
|
-
* approval/decided 审计对(幂等 + 对账:同一 id 只提醒一次,decided 后清除,
|
|
27
|
-
* 连串审批按同会话同工具去抖,默认 15s)。替代旧插件的手工 pending 缓存。
|
|
28
|
-
* - F3 语音/Toast:spawn 本包自带 dsh-notify.ps1(fire-and-forget,带并发上限 +
|
|
29
|
-
* 超时强杀,沿用旧插件加固逻辑)。
|
|
30
|
-
* - F4:无 client 半侧(references 无文档明载 client↔host 通道),Toast 按钮走
|
|
31
|
-
* 官方 dsh://open 仅唤起窗口。
|
|
32
|
-
*
|
|
33
|
-
* 不做的事:不注册 Config schema(references 未给出 `export const Config` 的
|
|
34
|
-
* schema 方言,臆造有激活失败风险);config 只在本函数规范化,改配置 = 改
|
|
35
|
-
* profile 补丁层的行 config 后重启/重载(practices.md 的推荐落点)。
|
|
36
|
-
*
|
|
37
|
-
* ───────────────────────────────────────────────────────────────────────────
|
|
38
|
-
* v0.3.0「空闲门(idle gate)」——修正误报(用户 2026-10-16 反馈)
|
|
39
|
-
* ───────────────────────────────────────────────────────────────────────────
|
|
40
|
-
* 问题:主会话这一轮跑完(turn/end completed)时,它派生的子代理或后台任务
|
|
41
|
-
* 可能**仍在运行**;旧实现只看 reason.kind,于是弹出"任务完成"误导用户。
|
|
42
|
-
*
|
|
43
|
-
* 根因:`turn/end completed` 的语义是「这一轮对话跑完了」,不是「整个工作树空闲了」。
|
|
44
|
-
*
|
|
45
|
-
* 本机一手证据(0.1.7-rc.2 `.runtime` 源码):
|
|
46
|
-
* - ctx.jobs(@deepseek-ai/dsh-jobs Service Definition):
|
|
47
|
-
* list(caller) → JobSnapshot[],status ∈ running|stopping|completed|killed|failed
|
|
48
|
-
* (dsh-jobs-local/lib/index.js:178)
|
|
49
|
-
* onJobsChanged(listener) → 可见集变化时回调,返回 disposer(同上 :264)
|
|
50
|
-
* - ctx.agents(@deepseek-ai/dsh-agent AgentRegistry):
|
|
51
|
-
* list() → 全部活 agent;get(id);isOwnedBy(id, owner)
|
|
52
|
-
* (dsh-agent/lib/types/index.d.ts:341,350,355)
|
|
53
|
-
* - agent/status:idle ⇄ running 边沿;文档原文「idle means no driver remains
|
|
54
|
-
* scheduled or active」(dsh-agent/lib/types/runtime-types.d.ts:247)——
|
|
55
|
-
* 比 turn/end 更接近"真空闲",故用作**主触发点**。
|
|
56
|
-
* - 子代理判定:SessionHeader.origin==='subagent' + parentSession 链
|
|
57
|
-
* (dsh-session/lib/types/types.d.ts:71,81,87)。
|
|
58
|
-
* ⚠️ **不能单用 parentSession**:sessions.fork 的分叉会话 header 也带
|
|
59
|
-
* parentSession 但 origin 为空,仍是顶层会话(dsh-messager src/index.ts:278-280
|
|
60
|
-
* 同款结论,其注释明确点出这个坑)。
|
|
61
|
-
* - ⚠️ **continuable 子代理不注册 job**:dsh-base/cordis.patch.yml:354 的 subagent
|
|
62
|
-
* 工具默认 backgroundMode=continuable,走 subagents.startContinuable →
|
|
63
|
-
* ctx.agents.create({parentAgent})(dsh-subagent/lib/index.js:1077)。
|
|
64
|
-
* 只有 one-shot 后台子代理与后台 pwsh 走 jobs.start
|
|
65
|
-
* (dsh-tool-subagent/lib/index.js:538、dsh-tool-pwsh/lib/index.js:387)。
|
|
66
|
-
* ⇒ 空闲门必须**同时**查 jobs 与 agents 两路,否则漏判最常用的 continuable 子代理。
|
|
67
|
-
*
|
|
68
|
-
* 业界对照(2026-10-16 实读源码,非二手转述):
|
|
69
|
-
* - Claude Code 官方 Stop hook 输入直接携带 `background_tasks` 与 `session_crons`,
|
|
70
|
-
* 文档原文:这两个数组让 hook 区分 "session is done" 与 "session is paused waiting
|
|
71
|
-
* for background work to wake it back up"(code.claude.com/docs/en/hooks)。本实现的
|
|
72
|
-
* 「空闲门」与它同构。
|
|
73
|
-
* - DSH 生态同类插件(dsh-notifier ★24 / dsh-messager ★9 / dsh-perlica-ding ★7 /
|
|
74
|
-
* dsh-workmate ★5 / dsh-plugin-notify ★7)**均只过滤子代理自身的完成事件,
|
|
75
|
-
* 无一等待子代理结束**——即本问题在整个生态尚未被解决。
|
|
76
|
-
* - 借鉴其两处已验证细节:① 触发用 agent/status 的 running→idle 边沿;
|
|
77
|
-
* ② 子代理判定失败时走主任务分支("宁可多报一条 done,不静默用户自己的任务",
|
|
78
|
-
* dsh-notifier src/server/events/impl/session/index.ts:70-77 注释)。
|
|
79
|
-
*
|
|
80
|
-
* 设计:completed 时先过空闲门 → 有在飞工作则**挂起**(不播),订阅
|
|
81
|
-
* onJobsChanged + agent/status 重估,全部清空后播**一次**(天然合并多个子代理);
|
|
82
|
-
* 超过 idleGateTimeoutSeconds 兜底播报(防子代理卡死导致永不播报);
|
|
83
|
-
* 主会话重新进入 running(用户已开始新一轮)则取消挂起。
|
|
84
|
-
* 失败一律 fail-visible:判定异常时走主任务分支照常播报,不静默用户。
|
|
85
|
-
*/
|
|
86
|
-
import { spawn } from 'node:child_process';
|
|
87
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs';
|
|
88
|
-
import { fileURLToPath } from 'node:url';
|
|
89
|
-
import { dirname, join } from 'node:path';
|
|
90
|
-
|
|
91
|
-
import { readConfig, mergeConfig, describe as describeConfig } from './config-store.js';
|
|
92
|
-
import { loadSchema, buildConfig, readConfig as readSchemaConfig, FIELD_DEFAULTS } from './config-schema.js';
|
|
93
|
-
import { createSessionBridge, ROUTE_PREFIX } from './session-bridge.js';
|
|
94
|
-
import { createConfigRoute } from './config-route.js';
|
|
95
|
-
import {
|
|
96
|
-
audioKindOf, resolveSource, effectiveSource, resolveCustomFile,
|
|
97
|
-
} from './audio-source.js';
|
|
98
|
-
import {
|
|
99
|
-
import {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
*
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
*
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if (typeof v === '
|
|
296
|
-
if (typeof v === '
|
|
297
|
-
if (
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
if (typeof inner === '
|
|
304
|
-
if (typeof inner === '
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
if (typeof inner === '
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
out.
|
|
333
|
-
out.
|
|
334
|
-
out.
|
|
335
|
-
out.
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
* @param {
|
|
350
|
-
* @param {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
if (typeof v === '
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
* `
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
* @param
|
|
382
|
-
* @param
|
|
383
|
-
* @param
|
|
384
|
-
* @param
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const
|
|
413
|
-
const
|
|
414
|
-
const
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
* @param
|
|
458
|
-
* @param
|
|
459
|
-
* @
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
let
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
if (
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
* -
|
|
487
|
-
*
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
* @param
|
|
491
|
-
* @param
|
|
492
|
-
* @
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
if (j
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
if (
|
|
511
|
-
if (a.
|
|
512
|
-
if (
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
528
|
-
* →
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
* @
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
const
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
|
|
587
|
-
const
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
if (typeof v === '
|
|
591
|
-
if (typeof v === '
|
|
592
|
-
if (
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
if (typeof inner === '
|
|
598
|
-
if (typeof inner === '
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
if (typeof inner === '
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
dictToFlat(g.
|
|
623
|
-
dictToFlat(g.
|
|
624
|
-
dictToFlat(g.
|
|
625
|
-
dictToFlat(g.
|
|
626
|
-
dictToFlat(g.
|
|
627
|
-
dictToFlat(g.
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
//
|
|
631
|
-
// ·
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
// -
|
|
659
|
-
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
*
|
|
665
|
-
*
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
let
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
const
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
//
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
//
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
//
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
const
|
|
750
|
-
const
|
|
751
|
-
const
|
|
752
|
-
const
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
//
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
settled
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
try {
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
child.once('
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
*
|
|
803
|
-
*
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
* @
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
/**
|
|
821
|
-
|
|
822
|
-
* @param {string}
|
|
823
|
-
* @param {string}
|
|
824
|
-
* @param {
|
|
825
|
-
*
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
//
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
//
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
//
|
|
843
|
-
//
|
|
844
|
-
//
|
|
845
|
-
//
|
|
846
|
-
//
|
|
847
|
-
//
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
const
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
//
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
+ `
|
|
876
|
-
+ `
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
//
|
|
883
|
-
|
|
884
|
-
const
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
//
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
//
|
|
909
|
-
//
|
|
910
|
-
//
|
|
911
|
-
//
|
|
912
|
-
//
|
|
913
|
-
//
|
|
914
|
-
//
|
|
915
|
-
//
|
|
916
|
-
//
|
|
917
|
-
//
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
const
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
//
|
|
928
|
-
//
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
const
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
a
|
|
937
|
-
|
|
938
|
-
//
|
|
939
|
-
//
|
|
940
|
-
//
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
//
|
|
949
|
-
|
|
950
|
-
a2
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
args.push('-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
//
|
|
979
|
-
|
|
980
|
-
const
|
|
981
|
-
const
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
args.push('-
|
|
985
|
-
|
|
986
|
-
//
|
|
987
|
-
//
|
|
988
|
-
//
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
*
|
|
997
|
-
*
|
|
998
|
-
*
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
const
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1014
|
-
//
|
|
1015
|
-
// →
|
|
1016
|
-
//
|
|
1017
|
-
//
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
let
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
*
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
1069
|
-
*
|
|
1070
|
-
*
|
|
1071
|
-
* -
|
|
1072
|
-
* -
|
|
1073
|
-
*
|
|
1074
|
-
*
|
|
1075
|
-
*
|
|
1076
|
-
*
|
|
1077
|
-
*
|
|
1078
|
-
*
|
|
1079
|
-
*
|
|
1080
|
-
*
|
|
1081
|
-
*
|
|
1082
|
-
*
|
|
1083
|
-
*
|
|
1084
|
-
* @param {string}
|
|
1085
|
-
* @param {
|
|
1086
|
-
* @param {
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
const
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
const
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
//
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
1116
|
-
// ·
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
1119
|
-
//
|
|
1120
|
-
//
|
|
1121
|
-
//
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
//
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
*
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
const
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
//
|
|
1178
|
-
//
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
*
|
|
1188
|
-
*
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
if (agent
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
let
|
|
1215
|
-
let
|
|
1216
|
-
let
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
//
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
//
|
|
1228
|
-
//
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
//
|
|
1240
|
-
//
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
//
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
*
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
const
|
|
1275
|
-
const
|
|
1276
|
-
const
|
|
1277
|
-
const
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
const
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
if (!
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
const
|
|
1318
|
-
const
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
+ `
|
|
1337
|
-
+ `
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
//
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
else if (type === 'approval/
|
|
1349
|
-
else if (type === '
|
|
1350
|
-
else if (type === 'tool/
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
//
|
|
1359
|
-
//
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
const
|
|
1366
|
-
const
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
if (!
|
|
1373
|
-
|
|
1374
|
-
//
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
pendingDone.
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
//
|
|
1412
|
-
//
|
|
1413
|
-
//
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
const
|
|
1420
|
-
const
|
|
1421
|
-
const
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
//
|
|
1435
|
-
//
|
|
1436
|
-
//
|
|
1437
|
-
//
|
|
1438
|
-
//
|
|
1439
|
-
//
|
|
1440
|
-
//
|
|
1441
|
-
//
|
|
1442
|
-
//
|
|
1443
|
-
//
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
//
|
|
1454
|
-
//
|
|
1455
|
-
//
|
|
1456
|
-
//
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
const
|
|
1460
|
-
const
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
//
|
|
1467
|
-
//
|
|
1468
|
-
//
|
|
1469
|
-
//
|
|
1470
|
-
//
|
|
1471
|
-
//
|
|
1472
|
-
//
|
|
1473
|
-
//
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
//
|
|
1494
|
-
//
|
|
1495
|
-
//
|
|
1496
|
-
//
|
|
1497
|
-
//
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
const
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
//
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
//
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
//
|
|
1683
|
-
//
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
*
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1
|
+
/**
|
|
2
|
+
* dsh-notify-say — Host half(官方插件规范形态)。
|
|
3
|
+
*
|
|
4
|
+
* 证据基础(0.1.7-rc.2 app.asar 一手抽取,均存 scratch/asar-extract/):
|
|
5
|
+
* - 导出形态:references/host-plugin.md「Host plugin export forms」——
|
|
6
|
+
* `export function apply(ctx, config) {}`,资源用 ctx.effect/ctx.on 注册。
|
|
7
|
+
* - 事件:@deepseek-ai/dsh-agent-preset-registry/lib/typert.host.js SessionEventMap(:647)——
|
|
8
|
+
* 'turn/end': { turn: number; reason: TurnEndReason }(turn 是数字序号,不是对象)
|
|
9
|
+
* TurnEndReasonMap(:803):completed / aborted / blocked / error / max-tokens /
|
|
10
|
+
* interrupted / forked,均为 { kind: ... } 对象 → 用 reason.kind 判分支。
|
|
11
|
+
* 'approval/asked': { id, toolName, callId?, reason? }
|
|
12
|
+
* 'approval/decided': { id, outcome } (审批审计事件对)
|
|
13
|
+
* SessionHeader(:655):parentSession? / origin?: 'subagent' / delegationDepth? → 子代判定。
|
|
14
|
+
* - 审批接缝:@deepseek-ai/dsh-user-approval/lib/index.js:176 —— approval/request 是
|
|
15
|
+
* waterfall,fail-closed 默认(无应答者 → 'unavailable')。本插件 listener 必须
|
|
16
|
+
* return next() 并透传返回值,绝不阻断、不改写决定。
|
|
17
|
+
* - 事件消费方式:SessionEventMap 事件经 session/event 流观测(旧 dsh-notify-done 在
|
|
18
|
+
* 同源 Cordis 事件系统上实证的做法;practices.md 亦把 session/event 订阅与
|
|
19
|
+
* sessionProjections 并列为观测手段)。turn/end 不是独立 ctx 事件名。
|
|
20
|
+
*
|
|
21
|
+
* 行为(v1 规格,见 CARD.md):
|
|
22
|
+
* - F1 回合完成:仅 reason.kind==='completed' 播「完成」;error/blocked 换文案 +
|
|
23
|
+
* 系统蜂鸣(无对应语句池资产,不播"完成"语音以免误导);aborted/max-tokens/
|
|
24
|
+
* interrupted/forked 默认静默(notifyOnAborted)。按会话去抖(默认 60s)。
|
|
25
|
+
* - F2 审批:监听 approval/request waterfall(放行 + 提醒)与 approval/asked/
|
|
26
|
+
* approval/decided 审计对(幂等 + 对账:同一 id 只提醒一次,decided 后清除,
|
|
27
|
+
* 连串审批按同会话同工具去抖,默认 15s)。替代旧插件的手工 pending 缓存。
|
|
28
|
+
* - F3 语音/Toast:spawn 本包自带 dsh-notify.ps1(fire-and-forget,带并发上限 +
|
|
29
|
+
* 超时强杀,沿用旧插件加固逻辑)。
|
|
30
|
+
* - F4:无 client 半侧(references 无文档明载 client↔host 通道),Toast 按钮走
|
|
31
|
+
* 官方 dsh://open 仅唤起窗口。
|
|
32
|
+
*
|
|
33
|
+
* 不做的事:不注册 Config schema(references 未给出 `export const Config` 的
|
|
34
|
+
* schema 方言,臆造有激活失败风险);config 只在本函数规范化,改配置 = 改
|
|
35
|
+
* profile 补丁层的行 config 后重启/重载(practices.md 的推荐落点)。
|
|
36
|
+
*
|
|
37
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
38
|
+
* v0.3.0「空闲门(idle gate)」——修正误报(用户 2026-10-16 反馈)
|
|
39
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
40
|
+
* 问题:主会话这一轮跑完(turn/end completed)时,它派生的子代理或后台任务
|
|
41
|
+
* 可能**仍在运行**;旧实现只看 reason.kind,于是弹出"任务完成"误导用户。
|
|
42
|
+
*
|
|
43
|
+
* 根因:`turn/end completed` 的语义是「这一轮对话跑完了」,不是「整个工作树空闲了」。
|
|
44
|
+
*
|
|
45
|
+
* 本机一手证据(0.1.7-rc.2 `.runtime` 源码):
|
|
46
|
+
* - ctx.jobs(@deepseek-ai/dsh-jobs Service Definition):
|
|
47
|
+
* list(caller) → JobSnapshot[],status ∈ running|stopping|completed|killed|failed
|
|
48
|
+
* (dsh-jobs-local/lib/index.js:178)
|
|
49
|
+
* onJobsChanged(listener) → 可见集变化时回调,返回 disposer(同上 :264)
|
|
50
|
+
* - ctx.agents(@deepseek-ai/dsh-agent AgentRegistry):
|
|
51
|
+
* list() → 全部活 agent;get(id);isOwnedBy(id, owner)
|
|
52
|
+
* (dsh-agent/lib/types/index.d.ts:341,350,355)
|
|
53
|
+
* - agent/status:idle ⇄ running 边沿;文档原文「idle means no driver remains
|
|
54
|
+
* scheduled or active」(dsh-agent/lib/types/runtime-types.d.ts:247)——
|
|
55
|
+
* 比 turn/end 更接近"真空闲",故用作**主触发点**。
|
|
56
|
+
* - 子代理判定:SessionHeader.origin==='subagent' + parentSession 链
|
|
57
|
+
* (dsh-session/lib/types/types.d.ts:71,81,87)。
|
|
58
|
+
* ⚠️ **不能单用 parentSession**:sessions.fork 的分叉会话 header 也带
|
|
59
|
+
* parentSession 但 origin 为空,仍是顶层会话(dsh-messager src/index.ts:278-280
|
|
60
|
+
* 同款结论,其注释明确点出这个坑)。
|
|
61
|
+
* - ⚠️ **continuable 子代理不注册 job**:dsh-base/cordis.patch.yml:354 的 subagent
|
|
62
|
+
* 工具默认 backgroundMode=continuable,走 subagents.startContinuable →
|
|
63
|
+
* ctx.agents.create({parentAgent})(dsh-subagent/lib/index.js:1077)。
|
|
64
|
+
* 只有 one-shot 后台子代理与后台 pwsh 走 jobs.start
|
|
65
|
+
* (dsh-tool-subagent/lib/index.js:538、dsh-tool-pwsh/lib/index.js:387)。
|
|
66
|
+
* ⇒ 空闲门必须**同时**查 jobs 与 agents 两路,否则漏判最常用的 continuable 子代理。
|
|
67
|
+
*
|
|
68
|
+
* 业界对照(2026-10-16 实读源码,非二手转述):
|
|
69
|
+
* - Claude Code 官方 Stop hook 输入直接携带 `background_tasks` 与 `session_crons`,
|
|
70
|
+
* 文档原文:这两个数组让 hook 区分 "session is done" 与 "session is paused waiting
|
|
71
|
+
* for background work to wake it back up"(code.claude.com/docs/en/hooks)。本实现的
|
|
72
|
+
* 「空闲门」与它同构。
|
|
73
|
+
* - DSH 生态同类插件(dsh-notifier ★24 / dsh-messager ★9 / dsh-perlica-ding ★7 /
|
|
74
|
+
* dsh-workmate ★5 / dsh-plugin-notify ★7)**均只过滤子代理自身的完成事件,
|
|
75
|
+
* 无一等待子代理结束**——即本问题在整个生态尚未被解决。
|
|
76
|
+
* - 借鉴其两处已验证细节:① 触发用 agent/status 的 running→idle 边沿;
|
|
77
|
+
* ② 子代理判定失败时走主任务分支("宁可多报一条 done,不静默用户自己的任务",
|
|
78
|
+
* dsh-notifier src/server/events/impl/session/index.ts:70-77 注释)。
|
|
79
|
+
*
|
|
80
|
+
* 设计:completed 时先过空闲门 → 有在飞工作则**挂起**(不播),订阅
|
|
81
|
+
* onJobsChanged + agent/status 重估,全部清空后播**一次**(天然合并多个子代理);
|
|
82
|
+
* 超过 idleGateTimeoutSeconds 兜底播报(防子代理卡死导致永不播报);
|
|
83
|
+
* 主会话重新进入 running(用户已开始新一轮)则取消挂起。
|
|
84
|
+
* 失败一律 fail-visible:判定异常时走主任务分支照常播报,不静默用户。
|
|
85
|
+
*/
|
|
86
|
+
import { spawn } from 'node:child_process';
|
|
87
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs';
|
|
88
|
+
import { fileURLToPath } from 'node:url';
|
|
89
|
+
import { dirname, join } from 'node:path';
|
|
90
|
+
|
|
91
|
+
import { readConfig, mergeConfig, describe as describeConfig } from './config-store.js';
|
|
92
|
+
import { loadSchema, buildConfig, readConfig as readSchemaConfig, FIELD_DEFAULTS } from './config-schema.js';
|
|
93
|
+
import { createSessionBridge, ROUTE_PREFIX } from './session-bridge.js';
|
|
94
|
+
import { createConfigRoute } from './config-route.js';
|
|
95
|
+
import {
|
|
96
|
+
audioKindOf, resolveSource, effectiveSource, resolveCustomFile,
|
|
97
|
+
} from './audio-source.js';
|
|
98
|
+
import { checkLocalOrigin, throttle } from './http-guard.js';
|
|
99
|
+
import { selfReload, watchPackage, computeFingerprint } from './dev-reload.js';
|
|
100
|
+
import { pickTtsText, sanitizeTtsText, MAX_TEXT_LEN } from './tts-lines.js';
|
|
101
|
+
|
|
102
|
+
const PLUGIN_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
103
|
+
const SCRIPT_PATH = join(PLUGIN_ROOT, 'dsh-notify.ps1');
|
|
104
|
+
/** Windows 自带 TTS 的脚本(零下载也能说话)。 */
|
|
105
|
+
const TTS_PATH = join(PLUGIN_ROOT, 'tts-speak.ps1');
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 与 ps1 共用的日志文件路径(`%TEMP%\dsh-notify-say\notify.log`)。
|
|
109
|
+
*
|
|
110
|
+
* 让 host 侧的 `[skip]` 记录与 ps1 侧的 `[toast]` / `[preview]` 落在同一文件,
|
|
111
|
+
* 事后能按时间顺序看出「这一刻到底播了还是没播、没播是因为什么」。
|
|
112
|
+
*
|
|
113
|
+
* @returns {string} 绝对路径
|
|
114
|
+
*/
|
|
115
|
+
function notifyLogPath() {
|
|
116
|
+
const tmp = process.env.TEMP || process.env.TMP || '';
|
|
117
|
+
return tmp ? join(tmp, 'dsh-notify-say', 'notify.log') : '';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 追加一行到 notify.log。**永不抛异常**(日志失败不能影响播报)。
|
|
122
|
+
* @param {string} message
|
|
123
|
+
*/
|
|
124
|
+
/**
|
|
125
|
+
* 日志轮转阈值(字节)与保留策略。
|
|
126
|
+
*
|
|
127
|
+
* 加轮转的原因:本次新增的 `[skip]` 记录把"没播"也写进日志后,写入频率显著上升
|
|
128
|
+
* (每次跳过都一行)。不轮转的话,长时间运行会无限增长。
|
|
129
|
+
*
|
|
130
|
+
* 策略:超过 1 MB 就改名为 `notify.log.1`(覆盖旧的),新日志重新开始。
|
|
131
|
+
* 只保留 1 份历史,够事后排查,也不会占太多空间。
|
|
132
|
+
*/
|
|
133
|
+
const LOG_MAX_BYTES = 1024 * 1024;
|
|
134
|
+
|
|
135
|
+
/** 超过阈值时轮转一次(尽力而为,失败不影响写入)。 */
|
|
136
|
+
function rotateNotifyLogIfNeeded(p) {
|
|
137
|
+
try {
|
|
138
|
+
if (!existsSync(p)) return;
|
|
139
|
+
if (statSync(p).size < LOG_MAX_BYTES) return;
|
|
140
|
+
renameSync(p, p + '.1');
|
|
141
|
+
} catch { /* 轮转失败就继续往原文件追加 */ }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 追加一行到 notify.log。**永不抛异常**(日志失败不能影响播报)。
|
|
146
|
+
* @param {string} message
|
|
147
|
+
*/
|
|
148
|
+
function appendNotifyLog(message) {
|
|
149
|
+
try {
|
|
150
|
+
const p = notifyLogPath();
|
|
151
|
+
if (!p) return;
|
|
152
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
153
|
+
rotateNotifyLogIfNeeded(p);
|
|
154
|
+
const d = new Date();
|
|
155
|
+
const pad = (n, w = 2) => String(n).padStart(w, '0');
|
|
156
|
+
const ts = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} `
|
|
157
|
+
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
158
|
+
appendFileSync(p, `${ts} ${message}\n`, 'utf8');
|
|
159
|
+
} catch { /* 日志失败不影响主流程 */ }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 审查建议采纳:不依赖宿主 PATH,直接定位 System32 的 Windows PowerShell 5.1
|
|
163
|
+
// (Toast 用 WinRT 投影,只在 5.1 可用;用 env 兜底避免硬编码盘符)。
|
|
164
|
+
const POWERSHELL_EXE =
|
|
165
|
+
`${process.env.SystemRoot || 'C:\\Windows'}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
|
|
166
|
+
|
|
167
|
+
// 默认值**单一来源**:用户可见项全部来自 config-schema.js 的 FIELD_DEFAULTS
|
|
168
|
+
// (独立审查 B5 指出原先把它们抄了两份,注释里自己写着"勿各写一份")。
|
|
169
|
+
// 这里只补 2 个**不对外暴露**的内部节流值 —— 它们不进 settings 表单。
|
|
170
|
+
const DEFAULTS = Object.freeze({
|
|
171
|
+
...FIELD_DEFAULTS,
|
|
172
|
+
// 毫秒形态的运行时值,由秒换算而来(FIELD_DEFAULTS 里是秒,schema 也是秒)。
|
|
173
|
+
// ⚠️ 不在这里写死 60_000 —— 默认值的唯一来源是 FIELD_DEFAULTS.debounceSeconds。
|
|
174
|
+
debounceMs: FIELD_DEFAULTS.debounceSeconds * 1000,
|
|
175
|
+
globalCooldownMs: FIELD_DEFAULTS.globalCooldownSeconds * 1000,
|
|
176
|
+
// 下面两个不对外暴露,只留在本文件。
|
|
177
|
+
approvalDebounceMs: 15_000,
|
|
178
|
+
idleGateTimeoutMs: 600_000, // 兜底:挂起超过此时长仍播报(防子代理卡死)
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ── 资源保护(移植自旧 dsh-notify-done lib/index.js:70-83 的实测加固)────────
|
|
182
|
+
// spawn 并发上限 + 30s 超时强杀(音频驱动/Toast 偶发 hang,旧踩坑 #13);
|
|
183
|
+
// 去抖 Map 带 TTL 清理与条目上限,防会话 id 无限增长。
|
|
184
|
+
const SPAWN_TIMEOUT_MS = 30_000;
|
|
185
|
+
const MAX_PENDING_SPAWNS = 4;
|
|
186
|
+
const MAP_TTL_MS = 30 * 60_000;
|
|
187
|
+
const MAP_MAX = 512;
|
|
188
|
+
/** 子代理祖先链最大回溯跳数(防御性上界,正常深度远小于此)。 */
|
|
189
|
+
const MAX_ANCESTOR_HOPS = 64;
|
|
190
|
+
|
|
191
|
+
// ── 方案C:提问提醒(老壳时代功能补齐,2026-09-25)────────────────────────
|
|
192
|
+
// 提问期间回合不结束(turn/end 永不触发),须只读观测 session/event 的 tool/call。
|
|
193
|
+
// 工具名依据旧 dsh-notify-done(:64-67):ask_user_question(dsh-tool-ask-user)、
|
|
194
|
+
// exit_plan_mode(dsh-plan-mode;/plan 是斜杠命令不是工具,见踩坑 #84)。
|
|
195
|
+
const QUESTION_TOOLS = new Set(['ask_user_question', 'exit_plan_mode']);
|
|
196
|
+
const QUESTION_TTL_MS = MAP_TTL_MS; // 同一提问 callId 只提醒一次的幂等窗口
|
|
197
|
+
|
|
198
|
+
function normalizeConfig(raw) {
|
|
199
|
+
const out = { ...DEFAULTS };
|
|
200
|
+
if (!raw || typeof raw !== 'object') return out;
|
|
201
|
+
for (const k of ['notifyOnComplete', 'notifyOnError', 'notifyOnBlocked', 'notifyOnAborted',
|
|
202
|
+
'notifyOnApproval', 'notifyOnQuestion', 'voice', 'subagents', 'idleGate',
|
|
203
|
+
// v0.4.2
|
|
204
|
+
'showToast', 'jumpWhenBackground', 'jumpWhenForeground',
|
|
205
|
+
// v0.4.3:开发开关
|
|
206
|
+
'devReload']) {
|
|
207
|
+
if (typeof raw[k] === 'boolean') out[k] = raw[k];
|
|
208
|
+
}
|
|
209
|
+
if (raw.voiceMode === 'fixed' || raw.voiceMode === 'random') out.voiceMode = raw.voiceMode;
|
|
210
|
+
if (typeof raw.voiceName === 'string' && /^\w[\w-]*$/.test(raw.voiceName)) out.voiceName = raw.voiceName;
|
|
211
|
+
const v = Number(raw.volume);
|
|
212
|
+
if (Number.isFinite(v) && v >= 0 && v <= 100) out.volume = Math.round(v);
|
|
213
|
+
const d = Number(raw.debounceSeconds);
|
|
214
|
+
if (Number.isFinite(d) && d >= 0 && d <= 3600) out.debounceMs = Math.round(d * 1000);
|
|
215
|
+
const a = Number(raw.approvalDebounceSeconds);
|
|
216
|
+
if (Number.isFinite(a) && a >= 0 && a <= 3600) out.approvalDebounceMs = Math.round(a * 1000);
|
|
217
|
+
const g = Number(raw.idleGateTimeoutSeconds);
|
|
218
|
+
if (Number.isFinite(g) && g >= 0 && g <= 86400) out.idleGateTimeoutMs = Math.round(g * 1000);
|
|
219
|
+
// v0.4.7:跨会话冷却(0 = 关闭)
|
|
220
|
+
const gc = Number(raw.globalCooldownSeconds);
|
|
221
|
+
if (Number.isFinite(gc) && gc >= 0 && gc <= 3600) out.globalCooldownMs = Math.round(gc * 1000);
|
|
222
|
+
|
|
223
|
+
// ── v0.4.2:音源与 TTS ───────────────────────────────────────────────────
|
|
224
|
+
if (typeof raw.audioSource === 'string'
|
|
225
|
+
&& ['tts', 'bundled', 'system', 'custom', 'none'].includes(raw.audioSource)) {
|
|
226
|
+
out.audioSource = raw.audioSource;
|
|
227
|
+
}
|
|
228
|
+
if (typeof raw.ttsVoice === 'string' && raw.ttsVoice.length <= 120) out.ttsVoice = raw.ttsVoice;
|
|
229
|
+
const r = Number(raw.ttsRate);
|
|
230
|
+
if (Number.isFinite(r) && r >= -10 && r <= 10) out.ttsRate = Math.round(r);
|
|
231
|
+
|
|
232
|
+
// ── v0.4.16:语音包数字增益 ──────────────────────────────────────────────
|
|
233
|
+
//
|
|
234
|
+
// 为什么需要:实测 247 个语音包峰值 最小 24.7% / 中位 40.4% / 最大 84.4%,
|
|
235
|
+
// 素材录得偏轻;而 MCI 音量上限只等于原始幅度 1.0 倍、**无法超过**,
|
|
236
|
+
// 所以 GUI 拉满仍偏小。只能靠数字增益补(ps1 侧按峰值限幅,不会破音)。
|
|
237
|
+
const vg = Number(raw.voiceGain);
|
|
238
|
+
if (Number.isFinite(vg) && vg >= 1.0 && vg <= 4.0) out.voiceGain = Math.round(vg * 10) / 10;
|
|
239
|
+
if (typeof raw.normalizeCustomAudio === 'boolean') out.normalizeCustomAudio = raw.normalizeCustomAudio;
|
|
240
|
+
|
|
241
|
+
// v0.4.4:自定义音频。
|
|
242
|
+
// 存储是扁平的 customFileDone / customFileFail / …(config-store 白名单只认标量),
|
|
243
|
+
// 这里拼成 audio-source.js 期望的 `customFiles: { done: 路径 }`。
|
|
244
|
+
// 路径合法性已在 config-store.sanitize 里校验过,这里只做搬运。
|
|
245
|
+
const cf = {};
|
|
246
|
+
for (const kind of ['done', 'fail', 'question', 'approval', 'blocked']) {
|
|
247
|
+
const key = 'customFile' + kind.charAt(0).toUpperCase() + kind.slice(1);
|
|
248
|
+
const val = raw[key];
|
|
249
|
+
if (typeof val === 'string' && val.trim()) cf[kind] = val.trim();
|
|
250
|
+
}
|
|
251
|
+
out.customFiles = cf;
|
|
252
|
+
|
|
253
|
+
// v0.4.5:按事件覆盖音源。
|
|
254
|
+
// 存的是扁平键(kindSourceFail = 'system'),拼成 audio-source.js 期望的
|
|
255
|
+
// `audioSourceByKind: { fail: 'system' }`。
|
|
256
|
+
// 空串表示"跟随全局默认",故不写进字典(保持配置最小)。
|
|
257
|
+
const byKind = {};
|
|
258
|
+
for (const kind of ['done', 'fail', 'question', 'approval', 'blocked']) {
|
|
259
|
+
const key = 'kindSource' + kind.charAt(0).toUpperCase() + kind.slice(1);
|
|
260
|
+
const val = raw[key];
|
|
261
|
+
if (typeof val === 'string') {
|
|
262
|
+
const s = val.trim().toLowerCase();
|
|
263
|
+
if (s && ['tts', 'bundled', 'system', 'custom', 'none'].includes(s)) byKind[kind] = s;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
out.audioSourceByKind = byKind;
|
|
267
|
+
|
|
268
|
+
// v0.4.8:TTS 自定义台词(每种事件可覆盖内置台词库)。
|
|
269
|
+
// 存储是扁平键(ttsTextDone / ttsTextFail …),拼成 tts-lines.js 要的
|
|
270
|
+
// `ttsTexts: { done: '...' }`。空值不写进字典(保持"用内置库")。
|
|
271
|
+
const tt = {};
|
|
272
|
+
for (const kind of ['done', 'fail', 'question', 'approval', 'blocked']) {
|
|
273
|
+
const key = 'ttsText' + kind.charAt(0).toUpperCase() + kind.slice(1);
|
|
274
|
+
const val = sanitizeTtsText(raw[key]);
|
|
275
|
+
if (val) tt[kind] = val;
|
|
276
|
+
}
|
|
277
|
+
out.ttsTexts = tt;
|
|
278
|
+
|
|
279
|
+
// ── v0.4.9:每种音源的参数按事件覆盖 ─────────────────────────────────────
|
|
280
|
+
//
|
|
281
|
+
// 存储是扁平键(kindSystemSoundFail / kindTtsVoiceDone …),这里拼成
|
|
282
|
+
// 几个按事件索引的字典,供 notify() 在组装参数时查。
|
|
283
|
+
//
|
|
284
|
+
// ⚠️ 只收**标量**(v0.4.13 修,2026-10-17 实测踩到)。
|
|
285
|
+
//
|
|
286
|
+
// merged 里的值可能来自 schema 层,而 schemastery 的值是 **ref 对象**
|
|
287
|
+
// (带 .get() 的包装)。若原样存进字典,notify() 取出来就是对象,
|
|
288
|
+
// 传给 PowerShell 变成字符串 "[object Object]" → SAPI 合成失败 → **没声音**。
|
|
289
|
+
//
|
|
290
|
+
// 实测症状:日志里 `voice=[object Object] rate=[object Object]`,
|
|
291
|
+
// 播放静默失败,但弹窗正常 —— 极难从表象定位。
|
|
292
|
+
//
|
|
293
|
+
// 空串/缺失/无法解包的对象一律视为"跟随全局",不写进字典。
|
|
294
|
+
const scalarOf = (v) => {
|
|
295
|
+
if (typeof v === 'string') return v;
|
|
296
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : undefined;
|
|
297
|
+
if (typeof v === 'boolean') return v;
|
|
298
|
+
if (v && typeof v === 'object') {
|
|
299
|
+
// schemastery ref
|
|
300
|
+
if (typeof v.get === 'function') {
|
|
301
|
+
try {
|
|
302
|
+
const inner = v.get();
|
|
303
|
+
if (typeof inner === 'string') return inner;
|
|
304
|
+
if (typeof inner === 'number') return Number.isFinite(inner) ? inner : undefined;
|
|
305
|
+
if (typeof inner === 'boolean') return inner;
|
|
306
|
+
} catch { /* ignore */ }
|
|
307
|
+
}
|
|
308
|
+
// 其它可解包对象
|
|
309
|
+
if (typeof v.valueOf === 'function') {
|
|
310
|
+
try {
|
|
311
|
+
const inner = v.valueOf();
|
|
312
|
+
if (inner !== v) {
|
|
313
|
+
if (typeof inner === 'string') return inner;
|
|
314
|
+
if (typeof inner === 'number') return Number.isFinite(inner) ? inner : undefined;
|
|
315
|
+
}
|
|
316
|
+
} catch { /* ignore */ }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return undefined;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const pick = (prefix) => {
|
|
323
|
+
const m = {};
|
|
324
|
+
for (const kind of ['done', 'fail', 'question', 'approval', 'blocked']) {
|
|
325
|
+
const key = prefix + kind.charAt(0).toUpperCase() + kind.slice(1);
|
|
326
|
+
const val = scalarOf(raw[key]);
|
|
327
|
+
if (val === undefined || val === '') continue;
|
|
328
|
+
m[kind] = val;
|
|
329
|
+
}
|
|
330
|
+
return m;
|
|
331
|
+
};
|
|
332
|
+
out.kindTtsVoice = pick('kindTtsVoice');
|
|
333
|
+
out.kindTtsRate = pick('kindTtsRate');
|
|
334
|
+
out.kindVoiceMode = pick('kindVoiceMode');
|
|
335
|
+
out.kindVoiceName = pick('kindVoiceName');
|
|
336
|
+
out.kindSystemSound = pick('kindSystemSound');
|
|
337
|
+
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* 取某事件的按事件覆盖值;没有或不是标量就用全局默认。
|
|
343
|
+
*
|
|
344
|
+
* ⚠️ 必须只返回**标量**(v0.4.13)。
|
|
345
|
+
* 上游 normalizeConfig 已经过滤过一遍,但这里再挡一层:
|
|
346
|
+
* 一旦对象漏到 PowerShell 参数里就变成 "[object Object]",
|
|
347
|
+
* SAPI 会静默失败(弹窗有、没声音),极难排查。
|
|
348
|
+
*
|
|
349
|
+
* @param {object} map 按事件索引的字典(如 cfg.kindSystemSound)
|
|
350
|
+
* @param {string} kind done|fail|question|approval|blocked
|
|
351
|
+
* @param {*} fallback 全局默认值
|
|
352
|
+
*/
|
|
353
|
+
function kindOr(map, kind, fallback) {
|
|
354
|
+
if (map && typeof map === 'object') {
|
|
355
|
+
const v = map[kind];
|
|
356
|
+
if (typeof v === 'string' || typeof v === 'boolean') return v;
|
|
357
|
+
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
|
358
|
+
}
|
|
359
|
+
return fallback;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** 去抖 Map 记账:写入前清 TTL 过期项;条目超上限时整体重置(宁可漏一条通知)。 */
|
|
363
|
+
function rememberAt(map, key, now) {
|
|
364
|
+
if (map.size >= MAP_MAX) {
|
|
365
|
+
for (const [k, at] of map) if (now - at > MAP_TTL_MS) map.delete(k);
|
|
366
|
+
if (map.size >= MAP_MAX) map.clear();
|
|
367
|
+
}
|
|
368
|
+
map.set(key, now);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* 有界记账(值不是时间戳的 Map 用这个)。
|
|
373
|
+
*
|
|
374
|
+
* `lastTurnEnd`(值 {kind, at})与 `prevStatus`(值 'idle'|'running')不能走
|
|
375
|
+
* `rememberAt`(它写的是裸时间戳),但同样必须防无限增长:宿主进程长期运行下,
|
|
376
|
+
* 每个见过 turn/end 的会话、每个注册过的 agent 都会留一条,只增不减。
|
|
377
|
+
*
|
|
378
|
+
* 策略:超上限时先按 `tsOf` 清过期的,仍超就整体重置——与 `rememberAt` 同口径
|
|
379
|
+
* (宁可漏一条通知,也不让内存无界增长)。
|
|
380
|
+
*
|
|
381
|
+
* @param map - 目标 Map。
|
|
382
|
+
* @param key - 键。
|
|
383
|
+
* @param value - 值。
|
|
384
|
+
* @param now - 当前时间戳(ms)。
|
|
385
|
+
* @param tsOf - 从值取时间戳(用于判过期)。
|
|
386
|
+
*/
|
|
387
|
+
function rememberBounded(map, key, value, now, tsOf) {
|
|
388
|
+
if (map.size >= MAP_MAX) {
|
|
389
|
+
for (const [k, v] of map) {
|
|
390
|
+
const at = tsOf(v);
|
|
391
|
+
if (typeof at === 'number' && now - at > MAP_TTL_MS) map.delete(k);
|
|
392
|
+
}
|
|
393
|
+
if (map.size >= MAP_MAX) map.clear();
|
|
394
|
+
}
|
|
395
|
+
map.set(key, value);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function isRecent(map, key, now, windowMs) {
|
|
399
|
+
const at = map.get(key);
|
|
400
|
+
return at !== undefined && now - at < windowMs;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** 从 tool/call 的原始参数提取通知文案(arguments 是模型产出的 JSON 字符串;移植自旧插件 :117-131)。 */
|
|
404
|
+
function questionSummary(toolName, rawArgs) {
|
|
405
|
+
let args = {};
|
|
406
|
+
try { args = typeof rawArgs === 'string' ? (JSON.parse(rawArgs || '{}') || {}) : (rawArgs || {}); } catch { args = {}; }
|
|
407
|
+
if (toolName === 'exit_plan_mode') {
|
|
408
|
+
const plan = typeof args.plan === 'string' ? args.plan : '';
|
|
409
|
+
const first = plan.split(/\r?\n/).find((l) => l.trim().length > 0) || '计划已就绪';
|
|
410
|
+
return { text: first.replace(/^#+\s*/, '').slice(0, 60), hint: '(计划待审阅)' };
|
|
411
|
+
}
|
|
412
|
+
const qs = Array.isArray(args.questions) ? args.questions : [];
|
|
413
|
+
const first = qs[0] || {};
|
|
414
|
+
const text = String(first.question || first.header || '有事情需要你确认').replace(/\s+/g, ' ').slice(0, 60);
|
|
415
|
+
const n = Array.isArray(first.options) ? first.options.length : 0;
|
|
416
|
+
const more = qs.length > 1 ? `(共 ${qs.length} 个问题)` : '';
|
|
417
|
+
return { text, hint: (n > 0 ? `(${n} 个选项)` : '') + more };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** 子代判定:SessionHeader 线谱字段(typert.host.js:655,与旧插件同构)。 */
|
|
421
|
+
function isSubagent(session) {
|
|
422
|
+
try {
|
|
423
|
+
const h = session && session.header;
|
|
424
|
+
return !!(h && (h.parentSession || h.origin === 'subagent' || (h.delegationDepth || 0) > 0));
|
|
425
|
+
} catch {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function sessionKey(session) {
|
|
431
|
+
try {
|
|
432
|
+
return session && session.id !== undefined ? String(session.id) : '__unknown__';
|
|
433
|
+
} catch {
|
|
434
|
+
return '__unknown__';
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** turn/end 的 reason.kind(reason 是 { kind } 对象;字符串仅作向前兼容容错)。 */
|
|
439
|
+
export function reasonKindOf(data) {
|
|
440
|
+
return data && data.reason && typeof data.reason === 'object'
|
|
441
|
+
? String(data.reason.kind || '')
|
|
442
|
+
: String((data && data.reason) || '');
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* 一个活 agent 是否为 rootId 的**子代理后代**(任意深度)。
|
|
447
|
+
*
|
|
448
|
+
* 判定分两步,缺一不可:
|
|
449
|
+
* ① 沿 parentSession 链向上回溯到 rootId;
|
|
450
|
+
* ② 链上每一跳的 header.origin 必须是 'subagent'。
|
|
451
|
+
*
|
|
452
|
+
* 第 ② 步是必需的:`sessions.fork` 产生的分叉会话 header 也携带 parentSession
|
|
453
|
+
* (指向源会话),但 origin 为空、且它是用户自己的**顶层**会话——把它算作后代会让
|
|
454
|
+
* 主会话的完成通知被另一个独立会话拖住。dsh-messager 在 src/index.ts:278-280
|
|
455
|
+
* 记录了同款结论。
|
|
456
|
+
*
|
|
457
|
+
* @param agent - 待判定的活 agent。
|
|
458
|
+
* @param rootId - 根会话 id。
|
|
459
|
+
* @param byId - id → agent 的索引(Map)。
|
|
460
|
+
* @returns 是后代则为 true;任何异常一律返回 false(调用方按"非后代"处理)。
|
|
461
|
+
*/
|
|
462
|
+
export function isDescendantOf(agent, rootId, byId) {
|
|
463
|
+
try {
|
|
464
|
+
const root = String(rootId);
|
|
465
|
+
let cur = agent;
|
|
466
|
+
let hops = 0;
|
|
467
|
+
while (cur && hops < MAX_ANCESTOR_HOPS) {
|
|
468
|
+
const h = cur.session && cur.session.header;
|
|
469
|
+
if (!h || h.origin !== 'subagent') return false;
|
|
470
|
+
const p = h.parentSession;
|
|
471
|
+
if (p === undefined || p === null) return false;
|
|
472
|
+
if (String(p) === root) return true;
|
|
473
|
+
cur = byId && typeof byId.get === 'function' ? byId.get(String(p)) : undefined;
|
|
474
|
+
hops += 1;
|
|
475
|
+
}
|
|
476
|
+
return false;
|
|
477
|
+
} catch {
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* 汇总"仍在飞"的工作(纯函数,便于单测)。
|
|
484
|
+
*
|
|
485
|
+
* 两路缺一不可(见文件头 continuable 说明):
|
|
486
|
+
* - jobSnapshots:后台任务(one-shot 子代理、后台 pwsh、PTY),running/stopping 即在飞。
|
|
487
|
+
* - agentList:活 agent 中,本会话的**子代理后代**且 status==='running' 即在飞
|
|
488
|
+
* (覆盖不注册 job 的 continuable 子代理)。
|
|
489
|
+
*
|
|
490
|
+
* @param jobSnapshots - ctx.jobs.list(caller) 的结果。
|
|
491
|
+
* @param agentList - ctx.agents.list() 的结果。
|
|
492
|
+
* @param rootId - 根会话 id。
|
|
493
|
+
* @returns 在飞项数组(空数组 = 工作树空闲)。
|
|
494
|
+
*/
|
|
495
|
+
export function collectInFlight(jobSnapshots, agentList, rootId) {
|
|
496
|
+
const items = [];
|
|
497
|
+
const jobs = Array.isArray(jobSnapshots) ? jobSnapshots : [];
|
|
498
|
+
for (const j of jobs) {
|
|
499
|
+
if (!j) continue;
|
|
500
|
+
if (j.status === 'running' || j.status === 'stopping') {
|
|
501
|
+
items.push({ kind: 'job', id: String(j.id), label: String(j.label || '') });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const list = Array.isArray(agentList) ? agentList : [];
|
|
505
|
+
const byId = new Map();
|
|
506
|
+
for (const a of list) {
|
|
507
|
+
if (a && a.id !== undefined) byId.set(String(a.id), a);
|
|
508
|
+
}
|
|
509
|
+
for (const a of list) {
|
|
510
|
+
if (!a || a.id === undefined) continue;
|
|
511
|
+
if (String(a.id) === String(rootId)) continue;
|
|
512
|
+
if (a.status !== 'running') continue;
|
|
513
|
+
if (!isDescendantOf(a, rootId, byId)) continue;
|
|
514
|
+
items.push({ kind: 'subagent', id: String(a.id), label: '' });
|
|
515
|
+
}
|
|
516
|
+
return items;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* 把内部 `cfg` 映射成**给前端看的 schema 形状**。
|
|
521
|
+
*
|
|
522
|
+
* ## 为什么需要这一层
|
|
523
|
+
*
|
|
524
|
+
* 内部 cfg 用"运行时友好"的字段名(`debounceMs` 毫秒、`voiceMode` 等),
|
|
525
|
+
* 而设置页 GUI 收发的是 schema 键名(`debounceSeconds` 秒、`audioSource` …)。
|
|
526
|
+
* 两者**不是同一个东西**。直接把 cfg 交给前端会导致:
|
|
527
|
+
*
|
|
528
|
+
* cfg 里没有 `debounceSeconds` 这个键 → 前端从 DEFAULTS 展开拿到默认 60
|
|
529
|
+
* → 用户把冷却调成 0(关闭),界面却显示 60 秒,看起来"改了没生效"。
|
|
530
|
+
* (2026-10-17 用户实测反馈,就是这么来的。)
|
|
531
|
+
*
|
|
532
|
+
* ## 为什么不复用 config-store 的 describe()
|
|
533
|
+
*
|
|
534
|
+
* 那个函数只覆盖 v0.4.0 时代的 12 个老字段,此后新增的
|
|
535
|
+
* audioSource / 冷却 / 台词 / 按事件覆盖全都没有 —— 复用它等于一半字段丢失。
|
|
536
|
+
*
|
|
537
|
+
* @param {object} c normalizeConfig 的产物
|
|
538
|
+
* @returns {object} schema 键名的扁平对象
|
|
539
|
+
*/
|
|
540
|
+
export function effectiveForClient(c) {
|
|
541
|
+
const g = c && typeof c === 'object' ? c : {};
|
|
542
|
+
const out = {
|
|
543
|
+
voice: g.voice,
|
|
544
|
+
voiceMode: g.voiceMode,
|
|
545
|
+
voiceName: g.voiceName,
|
|
546
|
+
volume: g.volume,
|
|
547
|
+
notifyOnComplete: g.notifyOnComplete,
|
|
548
|
+
notifyOnError: g.notifyOnError,
|
|
549
|
+
notifyOnBlocked: g.notifyOnBlocked,
|
|
550
|
+
notifyOnAborted: g.notifyOnAborted,
|
|
551
|
+
notifyOnApproval: g.notifyOnApproval,
|
|
552
|
+
notifyOnQuestion: g.notifyOnQuestion,
|
|
553
|
+
subagents: g.subagents,
|
|
554
|
+
idleGate: g.idleGate,
|
|
555
|
+
// v0.4.2+
|
|
556
|
+
audioSource: g.audioSource,
|
|
557
|
+
showToast: g.showToast,
|
|
558
|
+
ttsVoice: g.ttsVoice,
|
|
559
|
+
ttsRate: g.ttsRate,
|
|
560
|
+
jumpWhenBackground: g.jumpWhenBackground,
|
|
561
|
+
jumpWhenForeground: g.jumpWhenForeground,
|
|
562
|
+
// v0.4.7 冷却:毫秒 → 秒(前端下拉用的是秒)
|
|
563
|
+
debounceSeconds: Math.round((Number(g.debounceMs) || 0) / 1000),
|
|
564
|
+
globalCooldownSeconds: Math.round((Number(g.globalCooldownMs) || 0) / 1000),
|
|
565
|
+
// v0.4.3
|
|
566
|
+
devReload: g.devReload,
|
|
567
|
+
// v0.4.16:音量增益与自定义音频标准化。
|
|
568
|
+
//
|
|
569
|
+
// ⚠️ 这两个漏过一次(v0.4.18 补):normalizeConfig 里算了,
|
|
570
|
+
// 但没加到这里,于是前端 GET 拿不到 → 滑杆显示默认值 →
|
|
571
|
+
// 用户拖动后写回的又是旧值,看起来"增益功能不存在"。
|
|
572
|
+
// 这是 v0.4.10 那个"内部形状未映射到前端形状"的同类错误 ——
|
|
573
|
+
// 每加一个配置项,**必须同时**改 normalizeConfig 和 effectiveForClient。
|
|
574
|
+
//
|
|
575
|
+
// 兜底给 2.0:即使 g 里没有这个字段(老配置文件 / 只读场景),
|
|
576
|
+
// 前端也要能确定滑杆位置。用 undefined 会让滑杆跳到最左端(1.0),
|
|
577
|
+
// 看起来像"增益被关了"。
|
|
578
|
+
voiceGain: typeof g.voiceGain === 'number' ? g.voiceGain : 2.0,
|
|
579
|
+
normalizeCustomAudio: g.normalizeCustomAudio === true,
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
// 按事件的字典 → 扁平键(与 config-store 白名单的键名一致)
|
|
583
|
+
//
|
|
584
|
+
// ⚠️ 同 normalizeConfig 的道理:**只输出标量**。
|
|
585
|
+
// 这里是"给前端看"的形状,若混进 ref 对象,前端写回时又会被存成对象,
|
|
586
|
+
// 下一轮就变成 "[object Object]" 传给 PowerShell(v0.4.13 修)。
|
|
587
|
+
const KINDS = ['done', 'fail', 'question', 'approval', 'blocked'];
|
|
588
|
+
const cap = (k) => k.charAt(0).toUpperCase() + k.slice(1);
|
|
589
|
+
const asScalar = (v) => {
|
|
590
|
+
if (typeof v === 'string') return v;
|
|
591
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : undefined;
|
|
592
|
+
if (typeof v === 'boolean') return v;
|
|
593
|
+
if (v && typeof v === 'object') {
|
|
594
|
+
if (typeof v.get === 'function') {
|
|
595
|
+
try {
|
|
596
|
+
const inner = v.get();
|
|
597
|
+
if (typeof inner === 'string') return inner;
|
|
598
|
+
if (typeof inner === 'number') return Number.isFinite(inner) ? inner : undefined;
|
|
599
|
+
if (typeof inner === 'boolean') return inner;
|
|
600
|
+
} catch { /* ignore */ }
|
|
601
|
+
}
|
|
602
|
+
if (typeof v.valueOf === 'function') {
|
|
603
|
+
try {
|
|
604
|
+
const inner = v.valueOf();
|
|
605
|
+
if (inner !== v) {
|
|
606
|
+
if (typeof inner === 'string') return inner;
|
|
607
|
+
if (typeof inner === 'number') return Number.isFinite(inner) ? inner : undefined;
|
|
608
|
+
}
|
|
609
|
+
} catch { /* ignore */ }
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return undefined;
|
|
613
|
+
};
|
|
614
|
+
const dictToFlat = (dict, prefix) => {
|
|
615
|
+
const d = dict && typeof dict === 'object' ? dict : {};
|
|
616
|
+
for (const k of KINDS) {
|
|
617
|
+
const val = asScalar(d[k]);
|
|
618
|
+
if (val === undefined || val === '') continue;
|
|
619
|
+
out[prefix + cap(k)] = val;
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
dictToFlat(g.ttsTexts, 'ttsText');
|
|
623
|
+
dictToFlat(g.kindTtsVoice, 'kindTtsVoice');
|
|
624
|
+
dictToFlat(g.kindTtsRate, 'kindTtsRate');
|
|
625
|
+
dictToFlat(g.kindVoiceMode, 'kindVoiceMode');
|
|
626
|
+
dictToFlat(g.kindVoiceName, 'kindVoiceName');
|
|
627
|
+
dictToFlat(g.kindSystemSound, 'kindSystemSound');
|
|
628
|
+
dictToFlat(g.customFiles, 'customFile');
|
|
629
|
+
|
|
630
|
+
// audioSourceByKind 两种形态都给:
|
|
631
|
+
// · 字典 —— GUI 的「按事件覆盖音源」读它(v0.4.10 的修法)
|
|
632
|
+
// · 扁平 kindSource<Kind> —— 与其它按事件键一致,便于排查
|
|
633
|
+
const abk = (g.audioSourceByKind && typeof g.audioSourceByKind === 'object')
|
|
634
|
+
? { ...g.audioSourceByKind } : {};
|
|
635
|
+
out.audioSourceByKind = abk;
|
|
636
|
+
for (const k of KINDS) {
|
|
637
|
+
if (abk[k] !== undefined && abk[k] !== '') out['kindSource' + cap(k)] = abk[k];
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return out;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export function apply(ctx, config) {
|
|
644
|
+
// ── 配置来源(v0.4.0 起三源合并)──────────────────────────────────────────
|
|
645
|
+
//
|
|
646
|
+
// 优先级(后覆盖前):
|
|
647
|
+
// 1. DEFAULTS(本文件常量)
|
|
648
|
+
// 2. schemastery Config 的解析值(官方 settings 层:schema 默认 + base(patch) + 用户设置页)
|
|
649
|
+
// 3. `$DSH_HOME/storages/dsh-notify-say/config.json`(CLI 写的运行时覆盖)
|
|
650
|
+
//
|
|
651
|
+
// 为什么还要第 3 层:设置页要开浏览器点,而用户在终端里想「一条命令把音量调到 40」
|
|
652
|
+
// 时 CLI 更顺手。CLI 走 config-store,GUI 走官方 settings,两者最终都汇进 cfg,
|
|
653
|
+
// 不会出现「改了 A 但 B 还是旧值」的分裂。
|
|
654
|
+
//
|
|
655
|
+
// 稳定性设计(本插件的首要目标):
|
|
656
|
+
// - 任一配置源出问题(缺 $DSH_HOME / JSON 坏 / schema 解析失败)都**静默降级**,
|
|
657
|
+
// 插件行为退回到「只有 patch 配置」的旧语义,绝不因配置层导致通知丢失。
|
|
658
|
+
// - 整体 try/catch;失败时保持上一次成功解析的 cfg。
|
|
659
|
+
// - 只在生效值真的变化时记日志,避免每次播报刷屏。
|
|
660
|
+
const rawConfig = config;
|
|
661
|
+
const schemaLog = (m) => console.warn('[dsh-notify-say] ' + m);
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* 从 schema 化 config 取普通值;拿不到就退回原始对象。
|
|
665
|
+
*
|
|
666
|
+
* ⚠️ 注意(独立审查 B4 指出的注释与实现不符,已更正):
|
|
667
|
+
* `readSchemaConfig` 对**缺失的键会填 FIELD_DEFAULTS**,因此它**恒返回 12 个键**,
|
|
668
|
+
* 不能用「键数 > 0」判断 schema 是否真的生效 —— 那样判定恒真。
|
|
669
|
+
*
|
|
670
|
+
* 正确的判据:看**输入对象本身**是否带 schemastery ref(字段有 `.get` 方法)。
|
|
671
|
+
* 官方语义下 `apply(ctx, config)` 收到的是 ref;未接管时是普通值。
|
|
672
|
+
*/
|
|
673
|
+
function entryFromSchema() {
|
|
674
|
+
try {
|
|
675
|
+
if (rawConfig == null || typeof rawConfig !== 'object') return null;
|
|
676
|
+
// 以「输入里有没有 ref」判断,而不是看输出键数
|
|
677
|
+
const looksLikeRefs = Object.keys(FIELD_DEFAULTS).some((k) => {
|
|
678
|
+
const slot = rawConfig[k];
|
|
679
|
+
return slot != null && typeof slot === 'object' && typeof slot.get === 'function';
|
|
680
|
+
});
|
|
681
|
+
if (!looksLikeRefs) return null;
|
|
682
|
+
return readSchemaConfig(rawConfig);
|
|
683
|
+
} catch {
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
let cfg = normalizeConfig(config);
|
|
689
|
+
let lastConfigSig = '';
|
|
690
|
+
|
|
691
|
+
/** 重新解析生效配置;失败时保持上一次的值(绝不因配置问题丢通知)。 */
|
|
692
|
+
function refreshConfig() {
|
|
693
|
+
try {
|
|
694
|
+
// 第 2 层:官方 settings(schema)解析值
|
|
695
|
+
const fromSchema = entryFromSchema();
|
|
696
|
+
// 第 3 层:CLI 写的 storages 覆盖
|
|
697
|
+
const file = readConfig();
|
|
698
|
+
const fileValues = file.ok ? file.value : {};
|
|
699
|
+
|
|
700
|
+
// 合并顺序:patch(原始) → schema 值 → 文件值
|
|
701
|
+
const merged = mergeConfig(
|
|
702
|
+
mergeConfig(rawConfig, fromSchema || {}),
|
|
703
|
+
fileValues,
|
|
704
|
+
);
|
|
705
|
+
cfg = normalizeConfig(merged);
|
|
706
|
+
|
|
707
|
+
const sig = JSON.stringify(describeConfig(cfg));
|
|
708
|
+
if (sig !== lastConfigSig) {
|
|
709
|
+
lastConfigSig = sig;
|
|
710
|
+
if (!file.ok && file.reason !== 'no-home') {
|
|
711
|
+
console.warn(`[dsh-notify-say] config.json 读取失败(${file.reason}),已回退到上层配置`);
|
|
712
|
+
}
|
|
713
|
+
console.log(`[dsh-notify-say] config: voice=${cfg.voice} mode=${cfg.voiceMode} name=${cfg.voiceName} vol=${cfg.volume}`);
|
|
714
|
+
}
|
|
715
|
+
} catch (e) {
|
|
716
|
+
console.warn('[dsh-notify-say] refreshConfig failed, keeping previous config: '
|
|
717
|
+
+ String((e && e.message) || e));
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
refreshConfig();
|
|
721
|
+
|
|
722
|
+
// 会话状态桥实例:notify() 往里写,HTTP route 往外读(client 半侧消费)。
|
|
723
|
+
// 进程内、不落盘 —— 重启后"刚才那条通知"就该失效,这是正确语义。
|
|
724
|
+
const bridge = createSessionBridge();
|
|
725
|
+
|
|
726
|
+
// 配置 route:client 设置卡片读写音量/音色/开关。
|
|
727
|
+
// onChanged 立刻重读配置 —— 让"点一下即生效"成立,不必重启宿主。
|
|
728
|
+
const configRoute = createConfigRoute({
|
|
729
|
+
onChanged: () => { try { refreshConfig(); } catch { /* ignore */ } },
|
|
730
|
+
// 返回**给前端看的形状**(schema 键名),而不是内部 cfg。
|
|
731
|
+
//
|
|
732
|
+
// ⚠️ 这里踩过一个隐蔽的坑(2026-10-17):
|
|
733
|
+
// 原先直接返回 `cfg`,但 cfg 是 normalizeConfig 的产物,用的是**内部字段名**:
|
|
734
|
+
// cfg.debounceMs = 0(毫秒)
|
|
735
|
+
// 而前端读的是 schema 键名 `debounceSeconds`。cfg 里**没有**这个键,
|
|
736
|
+
// 于是从 DEFAULTS 展开拿到 60 —— 用户把冷却调成 0(关闭),
|
|
737
|
+
// 界面却显示 60 秒,看起来"改了没生效"。
|
|
738
|
+
//
|
|
739
|
+
// 正确做法:把内部值**映射回 schema 形状**再给前端。
|
|
740
|
+
// 注意必须把当前 cfg 传进去 —— 写成 effectiveForClient() 会得到全空对象,
|
|
741
|
+
// 表现为"所有设置都显示默认值"(2026-10-17 实测踩到)。
|
|
742
|
+
getEffective: () => effectiveForClient(cfg),
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
let pendingSpawns = 0;
|
|
746
|
+
const lastTurnNotify = new Map(); // sessionId -> ts // 跨会话冷却用:**任意**会话最近一次播报的时刻。
|
|
747
|
+
// 与 lastTurnNotify(按会话)是不同粒度,故单独一个标量而非 Map。
|
|
748
|
+
let lastAnyNotifyAt = 0;
|
|
749
|
+
const approvalSeen = new Map(); // 'id:<approvalId>' | 'session:<sid>|<tool>' -> ts
|
|
750
|
+
const questionSeen = new Map(); // 'sid:callId' -> ts(同一提问只提醒一次;answered 即清)
|
|
751
|
+
const lastTurnEnd = new Map(); // sessionId -> { kind, at }(agent/status idle 时取用)
|
|
752
|
+
const pendingDone = new Map(); // sessionId -> { session, psKind, title, body, timer, inflight }
|
|
753
|
+
const prevStatus = new Map(); // agentId -> 'idle' | 'running'(边沿判定)
|
|
754
|
+
/** fire-and-forget spawn:并发上限 + 超时 taskkill /T 兜底(旧插件实测加固的移植)。 */
|
|
755
|
+
function spawnNotify(args) {
|
|
756
|
+
if (pendingSpawns >= MAX_PENDING_SPAWNS) {
|
|
757
|
+
// 除了 console 还要写 notify.log —— 否则"通知被丢弃"在日志里完全看不到,
|
|
758
|
+
// 排查时只会觉得"插件没反应"(2026-10-17 实测踩到)。
|
|
759
|
+
const tail = (() => {
|
|
760
|
+
try {
|
|
761
|
+
const i = args.indexOf('-File');
|
|
762
|
+
return i >= 0 ? String(args[i + 1]).split(/[\\/]/).pop() : '?';
|
|
763
|
+
} catch { return '?'; }
|
|
764
|
+
})();
|
|
765
|
+
appendNotifyLog(`[spawn-drop] ${tail} pending=${pendingSpawns}/${MAX_PENDING_SPAWNS} (throttled)`);
|
|
766
|
+
console.warn(`[dsh-notify-say] spawn throttled (${pendingSpawns} pending), dropping notification`);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
pendingSpawns += 1;
|
|
770
|
+
let child;
|
|
771
|
+
try {
|
|
772
|
+
child = spawn(POWERSHELL_EXE, args, { stdio: 'ignore', windowsHide: true });
|
|
773
|
+
} catch (e) {
|
|
774
|
+
pendingSpawns -= 1;
|
|
775
|
+
console.warn('[dsh-notify-say] spawn failed: ' + String((e && e.message) || e));
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
let settled = false;
|
|
779
|
+
const settle = () => {
|
|
780
|
+
if (settled) return;
|
|
781
|
+
settled = true;
|
|
782
|
+
clearTimeout(killer);
|
|
783
|
+
pendingSpawns = Math.max(0, pendingSpawns - 1);
|
|
784
|
+
};
|
|
785
|
+
const killer = setTimeout(() => {
|
|
786
|
+
try { child.kill(); } catch { /* ignore */ }
|
|
787
|
+
try {
|
|
788
|
+
spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
789
|
+
} catch { /* ignore */ }
|
|
790
|
+
setTimeout(settle, 1000);
|
|
791
|
+
}, SPAWN_TIMEOUT_MS);
|
|
792
|
+
child.once('exit', settle);
|
|
793
|
+
child.once('error', settle);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** 跑 TTS 脚本(与 spawnNotify 共用并发上限与超时兜底)。 */
|
|
797
|
+
function spawnTts(args) {
|
|
798
|
+
spawnNotify(args);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* 某个事件在 TTS 音源下该念什么台词。
|
|
803
|
+
*
|
|
804
|
+
* 委托给 lib/tts-lines.js(多条随机 + 用户自定义 + 文本校验),
|
|
805
|
+
* 这样"念什么"这件事有独立的一方负责,也能单独测。
|
|
806
|
+
*
|
|
807
|
+
* @param {string} psKind done | fail | chime | approval | question
|
|
808
|
+
* @returns {string}
|
|
809
|
+
*/
|
|
810
|
+
function ttsTextFor(psKind) {
|
|
811
|
+
const kind = audioKindOf(psKind) || 'done';
|
|
812
|
+
try {
|
|
813
|
+
return pickTtsText(kind, cfg);
|
|
814
|
+
} catch {
|
|
815
|
+
// 台词选取失败不该让整条通知哑掉,退回简短兜底
|
|
816
|
+
return '有新消息';
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** 组参并 spawn 本包自带 ps1(Toast + 可选语音;kind 决定语句池/chime)。 */
|
|
821
|
+
/**
|
|
822
|
+
* @param {string} psKind done | fail | chime | approval | question
|
|
823
|
+
* @param {string} title
|
|
824
|
+
* @param {string} body
|
|
825
|
+
* @param {object} [session] 可选;用于记录"这条通知属于哪个会话",
|
|
826
|
+
* client 半侧在窗口被唤到前台时据此跳转。缺失则该能力不生效,通知本身不受影响。
|
|
827
|
+
*/
|
|
828
|
+
function notify(psKind, title, body, session) {
|
|
829
|
+
// 播报前现读配置:让 CLI/GUI 改的值立即生效,无需重载宿主。
|
|
830
|
+
refreshConfig();
|
|
831
|
+
|
|
832
|
+
// 记下"这条通知属于哪个会话",供 client 半侧点击后跳转。
|
|
833
|
+
// 失败绝不影响通知本身(跳会话是增强功能)。
|
|
834
|
+
try {
|
|
835
|
+
const sid = session && session.id !== undefined ? String(session.id) : '';
|
|
836
|
+
if (sid) bridge.setLast(sid);
|
|
837
|
+
} catch { /* ignore */ }
|
|
838
|
+
|
|
839
|
+
// ── 音源分流(v0.4.2)────────────────────────────────────────────────
|
|
840
|
+
//
|
|
841
|
+
// 判定交给 lib/audio-source.js(纯函数,17 条测试锁住),
|
|
842
|
+
// 这里只负责"按结果执行"。核心是:**任何配置组合都要有合理结果**,
|
|
843
|
+
// 拿不到就按回退链降级,绝不静默。
|
|
844
|
+
//
|
|
845
|
+
// 注意初值:不能用 `let source = 'tts'` 这种"看着像默认值"的写法 ——
|
|
846
|
+
// 它在 try 成功时被覆盖、失败时被设成 none,永远用不到,属于误导性死代码
|
|
847
|
+
// (自测时发现:把它改成 'bundled' 所有测试仍然全绿,说明它确实无效)。
|
|
848
|
+
// 这里显式用 null 表示"还没有结论",解析失败才落到 none。
|
|
849
|
+
let source = null;
|
|
850
|
+
try {
|
|
851
|
+
const kind = audioKindOf(psKind);
|
|
852
|
+
const eff = effectiveSource(resolveSource(kind, cfg), {
|
|
853
|
+
hasTtsVoice: true, // Windows 一定有可用 TTS(实测)
|
|
854
|
+
hasBundledVoice: hasVoiceLine(psKind),
|
|
855
|
+
hasCustomFile: !!resolveCustomFile(kind, cfg),
|
|
856
|
+
});
|
|
857
|
+
source = eff.source;
|
|
858
|
+
if (eff.fellBack) {
|
|
859
|
+
appendNotifyLog(`[audio] fallback ${eff.from}->${eff.source} kind=${psKind}`);
|
|
860
|
+
}
|
|
861
|
+
} catch {
|
|
862
|
+
source = 'none'; // 解析本身炸了:宁可只弹窗,也不要乱出声
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// 只有 "none" 是完全不出声;其余都走声音路径
|
|
866
|
+
const wantSound = cfg.voice && source !== 'none';
|
|
867
|
+
// 本事件对应的事件类(done/fail/question/approval/blocked),
|
|
868
|
+
// 用于查"按事件覆盖"的各种参数(v0.4.9)。
|
|
869
|
+
const aKind = audioKindOf(psKind) || 'done';
|
|
870
|
+
|
|
871
|
+
// 可观测性(v0.4.12):每一步决策都留痕。
|
|
872
|
+
// 2026-10-17 排查"有弹窗没声音"时,最耗时的就是分不清
|
|
873
|
+
// "没走 TTS 分支" 还是 "走了但 spawn 被丢",因为两种情况的日志一样。
|
|
874
|
+
appendNotifyLog(`[plan] kind=${aKind} source=${String(source)} `
|
|
875
|
+
+ `audioSource=${String(cfg.audioSource)} voice=${String(cfg.voice)} `
|
|
876
|
+
+ `wantSound=${String(wantSound)} showToast=${String(cfg.showToast)} `
|
|
877
|
+
+ `byKind=${JSON.stringify(cfg.audioSourceByKind || {})}`);
|
|
878
|
+
|
|
879
|
+
// ── TTS:零下载也能说话 ──────────────────────────────────────────────
|
|
880
|
+
if (wantSound && source === 'tts') {
|
|
881
|
+
const line = ttsTextFor(psKind);
|
|
882
|
+
// 发音人与语速都可以按事件覆盖(kindTtsVoice*/kindTtsRate*),
|
|
883
|
+
// 没设就用全局 ttsVoice / ttsRate。
|
|
884
|
+
const vVoice = kindOr(cfg.kindTtsVoice, aKind, cfg.ttsVoice);
|
|
885
|
+
const vRate = kindOr(cfg.kindTtsRate, aKind, cfg.ttsRate || 0);
|
|
886
|
+
|
|
887
|
+
// 可观测性(v0.4.12):把"决定走 TTS"和"槽位是否够"写进日志。
|
|
888
|
+
// 2026-10-17 排查"弹窗有、声音无"时,卡在无法区分
|
|
889
|
+
// (a) 没走 TTS 分支 (b) 走了但 spawn 被并发上限丢弃
|
|
890
|
+
// 这两者日志长得一样(都只有一条 [toast]),只能靠猜。
|
|
891
|
+
// 现在明确留痕,后续同类问题一眼可辨。
|
|
892
|
+
appendNotifyLog(`[tts-plan] text=${JSON.stringify(line)} voice=${vVoice || '(默认)'} `
|
|
893
|
+
+ `rate=${vRate} pending=${pendingSpawns}/${MAX_PENDING_SPAWNS}`);
|
|
894
|
+
|
|
895
|
+
const ttsArgs = [
|
|
896
|
+
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File',
|
|
897
|
+
TTS_PATH, '-Text', line, '-Volume', String(cfg.volume), '-Rate', String(vRate),
|
|
898
|
+
];
|
|
899
|
+
if (vVoice) ttsArgs.push('-VoiceName', vVoice);
|
|
900
|
+
// TTS 自己会出声,所以 notify.ps1 只负责弹窗(-NoSound)
|
|
901
|
+
if (cfg.showToast) spawnNotify(notifyArgs(psKind, title, body, true));
|
|
902
|
+
spawnTts(ttsArgs);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// ── 系统提示音:不碰语音池 ───────────────────────────────────────────
|
|
907
|
+
//
|
|
908
|
+
// 默认传 `-SystemSound auto`,由 ps1 按 `-Kind` 映射:
|
|
909
|
+
// 失败/拦截 → Hand/Exclamation(警告感)
|
|
910
|
+
// 提问/审批 → Question
|
|
911
|
+
// 完成 → Asterisk(轻快)
|
|
912
|
+
// 这样"失败"和"完成"听起来不一样,不会被误判。
|
|
913
|
+
//
|
|
914
|
+
// v0.4.9:如果用户给这个事件单独指定了音效(kindSystemSoundFail 等),
|
|
915
|
+
// 就传那个具体的名字,不再用 auto。
|
|
916
|
+
//
|
|
917
|
+
// **不传 -Voice/-Mode**,并且 ps1 侧见到 -SystemSound 就跳过语音池
|
|
918
|
+
// (否则 -Kind fail 会先选中 fail 池的 wav,永远走不到系统音效)。
|
|
919
|
+
if (wantSound && source === 'system') {
|
|
920
|
+
const a = baseNotifyArgs(psKind, title, body, false);
|
|
921
|
+
const explicitTone = kindOr(cfg.kindSystemSound, aKind, '');
|
|
922
|
+
a.push('-SystemSound', explicitTone || 'auto');
|
|
923
|
+
spawnNotify(a);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// ── 自定义音频:播放用户指定的文件 ───────────────────────────────────
|
|
928
|
+
//
|
|
929
|
+
// 路径来自 `cfg.customFiles[kind]`(normalizeConfig 已把扁平键拼好)。
|
|
930
|
+
// 播放交给 dsh-notify.ps1 的 -CustomFile:它直接用 MCI 播这个文件,
|
|
931
|
+
// 不碰语音池,也不回落(用户明确指定了要放什么)。
|
|
932
|
+
if (wantSound && source === 'custom') {
|
|
933
|
+
const kind = audioKindOf(psKind);
|
|
934
|
+
const file = resolveCustomFile(kind, cfg);
|
|
935
|
+
if (file) {
|
|
936
|
+
const a = baseNotifyArgs(psKind, title, body, false);
|
|
937
|
+
a.push('-CustomFile', file);
|
|
938
|
+
// v0.4.16:自定义音频的"标准化"是**可选**的。
|
|
939
|
+
//
|
|
940
|
+
// 默认不做 —— 那是用户自己的素材,可能特意做得轻或响,不该擅自改。
|
|
941
|
+
// 打开后按统一目标峰值处理(ps1 侧对 16-bit PCM wav 生效)。
|
|
942
|
+
if (cfg.normalizeCustomAudio === true) {
|
|
943
|
+
a.push('-Gain', String(Number(cfg.voiceGain) > 1 ? Number(cfg.voiceGain) : 2.0));
|
|
944
|
+
}
|
|
945
|
+
spawnNotify(a);
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
// 没配文件:不要静默无声,退到系统提示音(audio-source 的回退链已算过,
|
|
949
|
+
// 但这里再兜一次,防止 effectiveSource 的 hasCustomFile 判断与读取不一致)
|
|
950
|
+
const a2 = baseNotifyArgs(psKind, title, body, false);
|
|
951
|
+
a2.push('-SystemSound', 'auto');
|
|
952
|
+
spawnNotify(a2);
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// ── 其余音源(bundled)走 dsh-notify.ps1 ──────────────────────────────
|
|
957
|
+
spawnNotify(notifyArgs(psKind, title, body, !wantSound, source));
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** 只有基础参数的 notify 调用(供 system 音源拼装)。 */
|
|
961
|
+
function baseNotifyArgs(psKind, title, body, noSound) {
|
|
962
|
+
const args = [
|
|
963
|
+
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File',
|
|
964
|
+
SCRIPT_PATH, '-Kind', psKind, '-Title', title, '-Body', body,
|
|
965
|
+
];
|
|
966
|
+
if (noSound) args.push('-NoSound');
|
|
967
|
+
args.push('-Volume', String(cfg.volume));
|
|
968
|
+
return args;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/** 构造 dsh-notify.ps1 的参数。noSound=true 表示只弹窗不出声。 */
|
|
972
|
+
function notifyArgs(psKind, title, body, noSound) {
|
|
973
|
+
const args = [
|
|
974
|
+
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File',
|
|
975
|
+
SCRIPT_PATH, '-Kind', psKind, '-Title', title, '-Body', body,
|
|
976
|
+
];
|
|
977
|
+
if (noSound) args.push('-NoSound');
|
|
978
|
+
// v0.4.9:音色模式与音色名都可按事件覆盖(kindVoiceMode*/kindVoiceName*),
|
|
979
|
+
// 没设就用全局 voiceMode / voiceName。
|
|
980
|
+
const aKind = audioKindOf(psKind) || 'done';
|
|
981
|
+
const mode = kindOr(cfg.kindVoiceMode, aKind, cfg.voiceMode);
|
|
982
|
+
const name = kindOr(cfg.kindVoiceName, aKind, cfg.voiceName);
|
|
983
|
+
if (mode === 'random') args.push('-Mode', 'random');
|
|
984
|
+
else if (name) args.push('-Voice', name);
|
|
985
|
+
args.push('-Volume', String(cfg.volume));
|
|
986
|
+
// v0.4.16:语音包数字增益。
|
|
987
|
+
// MCI 的音量上限只等于原始幅度 1.0 倍,而本包素材峰值中位仅 40.4%,
|
|
988
|
+
// 所以"GUI 拉满仍偏轻"是必然 —— 只能靠数字增益补。
|
|
989
|
+
// 只对语音包(bundled)生效;自定义音频默认原样,由用户单独开标准化。
|
|
990
|
+
const g = Number(cfg.voiceGain);
|
|
991
|
+
if (Number.isFinite(g) && g > 1) args.push('-Gain', String(g));
|
|
992
|
+
return args;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* 某个事件是否有可用的语音包文件。
|
|
997
|
+
*
|
|
998
|
+
* 只做"够不够决定走哪条路"的粗判,不做精确选曲
|
|
999
|
+
* (精确选曲在 ps1 里,那里才知道 -Voice/-Mode 的完整语义)。
|
|
1000
|
+
*/
|
|
1001
|
+
function hasVoiceLine(psKind) {
|
|
1002
|
+
try {
|
|
1003
|
+
const sub = (psKind === 'question' || psKind === 'approval' || psKind === 'fail') ? psKind : '';
|
|
1004
|
+
const dir = sub ? join(PLUGIN_ROOT, 'sounds', 'voice-lines', sub) : join(PLUGIN_ROOT, 'sounds', 'voice-lines');
|
|
1005
|
+
|
|
1006
|
+
// 目录不存在 = 确定没有(只有这种情况才降级)
|
|
1007
|
+
if (!existsSync(dir)) {
|
|
1008
|
+
appendNotifyLog(`[voice-check] no-dir dir=${dir}`);
|
|
1009
|
+
return false;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// 能列举就精确判断;**列举失败(宿主沙箱限制)则乐观放行**,交给 ps1 裁决。
|
|
1013
|
+
//
|
|
1014
|
+
// 2026-10-17 实测:宿主进程里 existsSync=true 但 readdirSync 抛异常
|
|
1015
|
+
// (日志 wav=-3),于是被判成"没有语音包" → fallback bundled->system
|
|
1016
|
+
// → 用户选了语音包却只听到蜂鸣。
|
|
1017
|
+
// 而 ps1 是独立子进程、不受沙箱限制,明明能读到 103 个 wav 并正常播放。
|
|
1018
|
+
// 结论:判断不了 ≠ 没有,宁可放行。
|
|
1019
|
+
try {
|
|
1020
|
+
const cnt = readdirSync(dir).filter((f) => f.toLowerCase().endsWith('.wav')).length;
|
|
1021
|
+
appendNotifyLog(`[voice-check] ok wav=${cnt} dir=${dir}`);
|
|
1022
|
+
return cnt > 0;
|
|
1023
|
+
} catch (e) {
|
|
1024
|
+
appendNotifyLog(`[voice-check] unreadable(${(e && e.code) || 'ERR'}) -> assume-yes dir=${dir}`);
|
|
1025
|
+
return true;
|
|
1026
|
+
}
|
|
1027
|
+
} catch (e) {
|
|
1028
|
+
appendNotifyLog(`[voice-check] threw: ${(e && e.message) || e}`);
|
|
1029
|
+
return true; // 连判断都失败也乐观放行,由 ps1 兜底
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function shouldNotifySession(session) {
|
|
1034
|
+
return cfg.subagents || !isSubagent(session);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/** 读宿主服务(非 strict:容许由其它作用域提供;任何失败都返回 undefined)。 */
|
|
1038
|
+
function svc(name) {
|
|
1039
|
+
try { return ctx.get(name, false); } catch { return undefined; }
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* 当前"仍在飞"的工作快照。
|
|
1044
|
+
* 只读旁路:任一路取不到就退化为空(fail-visible —— 宁可多播一条完成,
|
|
1045
|
+
* 也不因为读不到服务而静默用户自己的任务)。
|
|
1046
|
+
*/
|
|
1047
|
+
function inFlightOf(agent) {
|
|
1048
|
+
let jobs = [];
|
|
1049
|
+
let agents = [];
|
|
1050
|
+
try {
|
|
1051
|
+
const js = svc('jobs');
|
|
1052
|
+
if (js && typeof js.list === 'function') jobs = js.list(agent) || [];
|
|
1053
|
+
} catch (e) {
|
|
1054
|
+
console.warn('[dsh-notify-say] jobs.list failed: ' + String((e && e.message) || e));
|
|
1055
|
+
}
|
|
1056
|
+
try {
|
|
1057
|
+
const as = svc('agents');
|
|
1058
|
+
if (as && typeof as.list === 'function') agents = as.list() || [];
|
|
1059
|
+
} catch (e) {
|
|
1060
|
+
console.warn('[dsh-notify-say] agents.list failed: ' + String((e && e.message) || e));
|
|
1061
|
+
}
|
|
1062
|
+
return collectInFlight(jobs, agents, agent && agent.id);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* 记录「为什么这一次没有播报」。
|
|
1067
|
+
*
|
|
1068
|
+
* ## 为什么需要它
|
|
1069
|
+
*
|
|
1070
|
+
* 用户 2026-10-17 问「刚才那次完成了怎么没响」。当时我查不出来,因为:
|
|
1071
|
+
* - 去抖拦下 → **完全不写日志**
|
|
1072
|
+
* - 事件开关关闭 → **完全不写日志**
|
|
1073
|
+
* - 空闲门挂起 → 只写 console(宿主 stdout,不在 notify.log)
|
|
1074
|
+
*
|
|
1075
|
+
* 也就是说,只有「播了」才有记录,「没播」是黑盒。这条缺陷比任何功能都更该先修:
|
|
1076
|
+
* 看不到原因,就无法判断是配置问题还是代码问题,也没法回答用户的疑问。
|
|
1077
|
+
*
|
|
1078
|
+
* ## 统一格式
|
|
1079
|
+
*
|
|
1080
|
+
* YYYY-MM-DD HH:MM:SS [skip] <reason> kind=<kind> sess=<id> <detail>
|
|
1081
|
+
*
|
|
1082
|
+
* 与 ps1 写的 `[toast]` / `[preview]` 行共用同一个 notify.log,便于对照阅读。
|
|
1083
|
+
*
|
|
1084
|
+
* @param {string} reason 简短的机器可读原因:debounce / switch-off / subagent / idle-gate / ...
|
|
1085
|
+
* @param {string} kind 事件类别(completed/error/blocked/...)
|
|
1086
|
+
* @param {object} session
|
|
1087
|
+
* @param {string} [detail] 附加说明(如具体时长、哪个开关)
|
|
1088
|
+
*/
|
|
1089
|
+
function skipLog(reason, kind, session, detail) {
|
|
1090
|
+
try {
|
|
1091
|
+
const sid = session && session.id !== undefined ? String(session.id) : '?';
|
|
1092
|
+
const line = `[skip] ${reason} kind=${kind || '?'} sess=${sid}${detail ? ' ' + detail : ''}`;
|
|
1093
|
+
// 与 ps1 相同的落点,便于在同一文件里对照「播了 / 没播」
|
|
1094
|
+
appendNotifyLog(line);
|
|
1095
|
+
} catch { /* 日志失败绝不影响主流程 */ }
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/** 实际播报(按会话去抖 + 可选跨会话冷却;返回是否真的发出)。 */
|
|
1099
|
+
function emitDone(session, psKind, title, body) {
|
|
1100
|
+
const key = sessionKey(session);
|
|
1101
|
+
const now = Date.now();
|
|
1102
|
+
|
|
1103
|
+
// ── 第一级:同会话去抖 ────────────────────────────────────────────────
|
|
1104
|
+
// 语义:同一会话连续完成多次,只提醒最后一次(几乎是噪音)。
|
|
1105
|
+
// 可调为 0 表示关闭 —— 有人希望"每个结果都听见"。
|
|
1106
|
+
if (cfg.debounceMs > 0 && isRecent(lastTurnNotify, key, now, cfg.debounceMs)) {
|
|
1107
|
+
// 去抖拦下时**必须留痕**:这是最容易被误判成"插件坏了"的情形
|
|
1108
|
+
skipLog('debounce', psKind, session,
|
|
1109
|
+
`same session within ${Math.round(cfg.debounceMs / 1000)}s`);
|
|
1110
|
+
return false;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// ── 第二级:跨会话冷却(v0.4.7)──────────────────────────────────────
|
|
1114
|
+
//
|
|
1115
|
+
// 语义完全不同,所以是**独立**的一级:
|
|
1116
|
+
// · 同会话去抖 = "这次和上次是同一件事,别重复"
|
|
1117
|
+
// · 跨会话冷却 = "我已经被叫过来了,不必再叫"
|
|
1118
|
+
//
|
|
1119
|
+
// 用户 2026-10-17 指出两类人诉求相反:
|
|
1120
|
+
// A 型:每个结果都要听见,否则不知道做完了 → 默认关闭(0)
|
|
1121
|
+
// B 型:听到一个就过来盯着了,再响就烦 → 自己打开
|
|
1122
|
+
// 所以默认值为 0(关闭),保持现有行为不变。
|
|
1123
|
+
if (cfg.globalCooldownMs > 0) {
|
|
1124
|
+
const lastAny = Number(lastAnyNotifyAt) || 0;
|
|
1125
|
+
if (lastAny > 0 && now - lastAny < cfg.globalCooldownMs) {
|
|
1126
|
+
skipLog('global-cooldown', psKind, session,
|
|
1127
|
+
`any session within ${Math.round(cfg.globalCooldownMs / 1000)}s `
|
|
1128
|
+
+ `(last ${Math.round((now - lastAny) / 1000)}s ago)`);
|
|
1129
|
+
return false;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
rememberAt(lastTurnNotify, key, now);
|
|
1134
|
+
lastAnyNotifyAt = now;
|
|
1135
|
+
notify(psKind, title, body, session);
|
|
1136
|
+
return true;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/** 清除某个会话的挂起项(含计时器)。 */
|
|
1140
|
+
function clearPending(id) {
|
|
1141
|
+
const p = pendingDone.get(id);
|
|
1142
|
+
if (!p) return;
|
|
1143
|
+
clearTimeout(p.timer);
|
|
1144
|
+
pendingDone.delete(id);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/** 兑现挂起项:播报一次。viaTimeout 表示是超时兜底(文案加提示,不误导为"全部完成")。 */
|
|
1148
|
+
function flushPending(id, viaTimeout) {
|
|
1149
|
+
const p = pendingDone.get(id);
|
|
1150
|
+
if (!p) return;
|
|
1151
|
+
clearTimeout(p.timer);
|
|
1152
|
+
pendingDone.delete(id);
|
|
1153
|
+
const body = viaTimeout ? `${p.body}(仍有子任务在跑,先提醒你)` : p.body;
|
|
1154
|
+
// 这条是「终于播了」,不是「跳过」——用 [flush] 前缀,避免与 [skip] 混淆。
|
|
1155
|
+
// (独立审查 2026-10-17 指出:原先写成 [skip] idle-gate-flush,会让人误判成没播。)
|
|
1156
|
+
appendNotifyLog(`[flush] idle-gate kind=${p.psKind} sess=${sessionKey(p.session)} `
|
|
1157
|
+
+ (viaTimeout ? 'timeout-fallback' : 'worktree-idle'));
|
|
1158
|
+
emitDone(p.session, p.psKind, p.title, body);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* 空闲门:工作树空闲就播报,否则挂起等待。
|
|
1163
|
+
* 挂起期间新的同类完成会重置挂起项(合并为一次播报)。
|
|
1164
|
+
*/
|
|
1165
|
+
function scheduleOrNotify(session, agent, psKind, title, body) {
|
|
1166
|
+
const id = sessionKey(session);
|
|
1167
|
+
const inflight = inFlightOf(agent);
|
|
1168
|
+
if (inflight.length === 0) {
|
|
1169
|
+
clearPending(id);
|
|
1170
|
+
emitDone(session, psKind, title, body);
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
clearPending(id);
|
|
1174
|
+
const timer = setTimeout(() => flushPending(id, true), cfg.idleGateTimeoutMs);
|
|
1175
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
1176
|
+
pendingDone.set(id, { session, psKind, title, body, timer, inflight });
|
|
1177
|
+
// 双写:console(宿主 stdout,开发/测试断言用)+ notify.log(用户与事后排查用)。
|
|
1178
|
+
// 之前只写 console,导致用户问"刚才怎么没响"时在 notify.log 里查不到任何痕迹;
|
|
1179
|
+
// 但**不能**用 notify.log 取代 console —— 既有测试与宿主日志都依赖这一行。
|
|
1180
|
+
console.log(`[dsh-notify-say] idle-gate: 挂起完成播报,等待 ${inflight.length} 项在飞工作 `
|
|
1181
|
+
+ `(${inflight.map((i) => i.kind + ':' + i.id).join(', ')})`);
|
|
1182
|
+
skipLog('idle-gate-hold', psKind, session,
|
|
1183
|
+
`waiting ${inflight.length} inflight (${inflight.map((i) => i.kind + ':' + i.id).join(', ')})`);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* 重估所有挂起项(由 jobs 变化 / agent 状态变化触发)。
|
|
1188
|
+
* 三种归宿:① 主会话已重新 running → 取消(用户已在交互);② 工作树空闲 → 播报;
|
|
1189
|
+
* ③ agent 已消失 → 视为结束,播报。
|
|
1190
|
+
*/
|
|
1191
|
+
function reevaluatePending(trigger) {
|
|
1192
|
+
if (pendingDone.size === 0) return;
|
|
1193
|
+
for (const id of [...pendingDone.keys()]) {
|
|
1194
|
+
const p = pendingDone.get(id);
|
|
1195
|
+
if (!p) continue;
|
|
1196
|
+
let agent;
|
|
1197
|
+
try {
|
|
1198
|
+
const as = svc('agents');
|
|
1199
|
+
agent = as && typeof as.get === 'function' ? as.get(id) : undefined;
|
|
1200
|
+
} catch { agent = undefined; }
|
|
1201
|
+
if (!agent) { flushPending(id, false); continue; }
|
|
1202
|
+
if (agent.status === 'running') { clearPending(id); continue; }
|
|
1203
|
+
const inflight = inFlightOf(agent);
|
|
1204
|
+
if (inflight.length === 0) {
|
|
1205
|
+
flushPending(id, false);
|
|
1206
|
+
} else {
|
|
1207
|
+
p.inflight = inflight;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/** completed / error / blocked / 其它 四分支 → 文案 + 门控 → 交给空闲门。 */
|
|
1213
|
+
function handleDone(session, agent, kind) {
|
|
1214
|
+
let gate;
|
|
1215
|
+
let psKind;
|
|
1216
|
+
let title;
|
|
1217
|
+
let body;
|
|
1218
|
+
if (kind === 'completed') {
|
|
1219
|
+
gate = cfg.notifyOnComplete; psKind = 'done';
|
|
1220
|
+
title = 'DSH 任务完成'; body = '一轮任务跑完了,回来看看结果吧';
|
|
1221
|
+
} else if (kind === 'error') {
|
|
1222
|
+
// v0.3.1:error 改播 fail 语句池(日语道歉语音,用户 2026-10-17 拍板)。
|
|
1223
|
+
// 池缺失时由 ps1 侧落到系统蜂鸣——**不**回落到 done 池(否则会播成"完成了")。
|
|
1224
|
+
gate = cfg.notifyOnError; psKind = 'fail';
|
|
1225
|
+
title = 'DSH 回合出错'; body = '上一轮回合以错误结束,回来看看出了什么问题';
|
|
1226
|
+
} else if (kind === 'blocked') {
|
|
1227
|
+
// blocked = 消息/目标轮次被 preStep 拦下、**根本没跑起来**(见 dsh-agent-loop:941),
|
|
1228
|
+
// 不是"跑了但失败",故维持蜂鸣而非 fail 池的"失败"台词(语义不同,别混)。
|
|
1229
|
+
// 实测:近 20 天 932 个会话中 "kind":"blocked" 命中 0 次,属极罕见路径。
|
|
1230
|
+
gate = cfg.notifyOnBlocked; psKind = 'chime';
|
|
1231
|
+
title = 'DSH 回合被阻塞'; body = '回合已阻塞,可能需要你介入处理';
|
|
1232
|
+
} else {
|
|
1233
|
+
// aborted / max-tokens / interrupted / forked:默认静默(notifyOnAborted)
|
|
1234
|
+
gate = cfg.notifyOnAborted; psKind = 'chime';
|
|
1235
|
+
title = 'DSH 回合提前结束'; body = '回合结束原因:' + (kind || 'unknown');
|
|
1236
|
+
}
|
|
1237
|
+
// 事件类别开关关闭 → 不播
|
|
1238
|
+
if (!gate) {
|
|
1239
|
+
// 必须报**真实生效的配置键**,不能从 kind 名拼。
|
|
1240
|
+
// 独立审查 2026-10-17 指出:原先用 `notifyOn${kind首字母大写}`,
|
|
1241
|
+
// 对 max-tokens/interrupted/forked 会拼出 `notifyOnMax-tokens` 这类不存在的键。
|
|
1242
|
+
const gateKey = kind === 'completed' ? 'notifyOnComplete'
|
|
1243
|
+
: kind === 'error' ? 'notifyOnError'
|
|
1244
|
+
: kind === 'blocked' ? 'notifyOnBlocked'
|
|
1245
|
+
: 'notifyOnAborted';
|
|
1246
|
+
skipLog('switch-off', kind, session, `${gateKey}=false`);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
// 空闲门只对「完成」生效:error/blocked 是**当下就需要人介入**的信号,
|
|
1250
|
+
// 等子代理结束再报会延误处理;它们立即播报(error 走 fail 语句池,不进空闲门)。
|
|
1251
|
+
if (cfg.idleGate && kind === 'completed' && agent) {
|
|
1252
|
+
scheduleOrNotify(session, agent, psKind, title, body);
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
emitDone(session, psKind, title, body);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/** 审批提醒正文。 */
|
|
1259
|
+
function approvalBody(toolName, reason) {
|
|
1260
|
+
return '待批准:' + (toolName || '未知') + (reason ? ',' + reason : '');
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* approval/asked(SessionEventMap 审计事件,带稳定 id):主提醒路径。
|
|
1265
|
+
* 幂等:同 id 只提醒一次;同会话同工具在去抖窗内只响一次(连串审批)。
|
|
1266
|
+
*/
|
|
1267
|
+
function handleApprovalAsked(session, data) {
|
|
1268
|
+
// 审批提醒:开关关闭、或该会话被过滤(子代理)→ 不播
|
|
1269
|
+
if (!cfg.notifyOnApproval || !shouldNotifySession(session)) {
|
|
1270
|
+
skipLog(cfg.notifyOnApproval ? 'subagent-filtered' : 'switch-off', 'approval', session,
|
|
1271
|
+
cfg.notifyOnApproval ? 'subagents=false' : 'notifyOnApproval=false');
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
const id = data && data.id ? 'id:' + String(data.id) : '';
|
|
1275
|
+
const toolName = (data && data.toolName) || '未知';
|
|
1276
|
+
const reason = data && data.reason ? String(data.reason) : '';
|
|
1277
|
+
const now = Date.now();
|
|
1278
|
+
const stKey = 'session:' + sessionKey(session) + '|' + toolName;
|
|
1279
|
+
if (isRecent(approvalSeen, stKey, now, cfg.approvalDebounceMs)) {
|
|
1280
|
+
skipLog('debounce', 'approval', session, `same tool within ${Math.round(cfg.approvalDebounceMs / 1000)}s`);
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
if (id && approvalSeen.has(id)) {
|
|
1284
|
+
skipLog('dedupe', 'approval', session, `already-notified id=${id}`);
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
rememberAt(approvalSeen, stKey, now);
|
|
1288
|
+
if (id) rememberAt(approvalSeen, id, now);
|
|
1289
|
+
notify('approval', 'DSH 需要你确认', approvalBody(toolName, reason), session);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/** approval/decided:清除该请求的幂等标记,之后的同类新审批可再次提醒。 */
|
|
1293
|
+
function handleApprovalDecided(data) {
|
|
1294
|
+
const id = data && data.id ? 'id:' + String(data.id) : '';
|
|
1295
|
+
if (id) approvalSeen.delete(id);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/** 会话标题(正文线索):sessionTitle 服务优先,退回会话 id 前缀(旧插件 :433-439 同构)。 */
|
|
1299
|
+
function sessionTitleOf(session) {
|
|
1300
|
+
try {
|
|
1301
|
+
const svcTitle = svc('sessionTitle');
|
|
1302
|
+
const snap = svcTitle && typeof svcTitle.get === 'function' ? svcTitle.get(session) : undefined;
|
|
1303
|
+
if (snap && snap.title) return String(snap.title);
|
|
1304
|
+
} catch { /* 服务不可用 → 退回 id 前缀 */ }
|
|
1305
|
+
const k = sessionKey(session);
|
|
1306
|
+
return k === '__unknown__' ? '未知会话' : k.slice(0, 12);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/** 提问提醒(方案C):tool/call 命中提问工具 → kind=question(question 语句池语音)。 */
|
|
1310
|
+
function handleQuestionCall(session, d) {
|
|
1311
|
+
if (!QUESTION_TOOLS.has(d.name)) return;
|
|
1312
|
+
if (!cfg.notifyOnQuestion || !shouldNotifySession(session)) {
|
|
1313
|
+
skipLog(cfg.notifyOnQuestion ? 'subagent-filtered' : 'switch-off', 'question', session,
|
|
1314
|
+
cfg.notifyOnQuestion ? 'subagents=false' : 'notifyOnQuestion=false');
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
const callId = String(d.callId || '');
|
|
1318
|
+
const key = sessionKey(session) + ':' + callId;
|
|
1319
|
+
const now = Date.now();
|
|
1320
|
+
if (isRecent(questionSeen, key, now, QUESTION_TTL_MS)) {
|
|
1321
|
+
skipLog('dedupe', 'question', session, `same callId=${callId} already notified`);
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
rememberAt(questionSeen, key, now);
|
|
1325
|
+
const q = questionSummary(d.name, d.arguments);
|
|
1326
|
+
notify('question', 'DSH 在等你回答', '[' + sessionTitleOf(session) + '] ' + q.text + q.hint, session);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/** tool/result 配对(content[0].toolCallId ↔ tool/call.callId)→ 清理标记(回答已产生)。 */
|
|
1330
|
+
function handleQuestionResult(session, d) {
|
|
1331
|
+
const cid = d && d.message && d.message.content && d.message.content[0] && d.message.content[0].toolCallId;
|
|
1332
|
+
if (cid) questionSeen.delete(sessionKey(session) + ':' + String(cid));
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
console.log(`[dsh-notify-say] active (complete=${cfg.notifyOnComplete} error=${cfg.notifyOnError} `
|
|
1336
|
+
+ `blocked=${cfg.notifyOnBlocked} aborted=${cfg.notifyOnAborted} approval=${cfg.notifyOnApproval} `
|
|
1337
|
+
+ `question=${cfg.notifyOnQuestion} voice=${cfg.voice}/${cfg.voiceMode} debounce=${Math.round(cfg.debounceMs / 1000)}s `
|
|
1338
|
+
+ `idleGate=${cfg.idleGate} idleGateTimeout=${Math.round(cfg.idleGateTimeoutMs / 1000)}s)`);
|
|
1339
|
+
|
|
1340
|
+
// ── 会话事件流:turn/end 记 reason + approval/asked、approval/decided 审计对 + 提问 ──
|
|
1341
|
+
ctx.on('session/event', (session, event) => {
|
|
1342
|
+
try {
|
|
1343
|
+
const type = event && event.type;
|
|
1344
|
+
if (type === 'turn/end') {
|
|
1345
|
+
// 只记账不播报:播报由 agent/status 的 running→idle 边沿触发(更接近"真空闲",
|
|
1346
|
+
// 且能在用户连发多轮时自然合并为一次)。
|
|
1347
|
+
rememberBounded(lastTurnEnd, sessionKey(session), { kind: reasonKindOf(event.data), at: Date.now() }, Date.now(), (v) => v && v.at);
|
|
1348
|
+
} else if (type === 'approval/asked') handleApprovalAsked(session, event.data);
|
|
1349
|
+
else if (type === 'approval/decided') handleApprovalDecided(event.data);
|
|
1350
|
+
else if (type === 'tool/call') handleQuestionCall(session, event.data);
|
|
1351
|
+
else if (type === 'tool/result') handleQuestionResult(session, event.data);
|
|
1352
|
+
} catch (e) {
|
|
1353
|
+
// 只读旁路:观测/提醒失败绝不影响会话
|
|
1354
|
+
console.warn('[dsh-notify-say] session/event handler failed: ' + String((e && e.message) || e));
|
|
1355
|
+
}
|
|
1356
|
+
}, { global: true });
|
|
1357
|
+
|
|
1358
|
+
// ── agent 状态边沿:running→idle 判完成;任何变化都重估挂起项 ────────────
|
|
1359
|
+
// 依据:agent/status 文档「idle means no driver remains scheduled or active」
|
|
1360
|
+
// (dsh-agent/lib/types/runtime-types.d.ts:247)。子代理的 idle 正是唤醒信号。
|
|
1361
|
+
ctx.on('agent/status', (payload) => {
|
|
1362
|
+
try {
|
|
1363
|
+
const agent = payload && payload.agent;
|
|
1364
|
+
if (!agent) return;
|
|
1365
|
+
const status = payload.status;
|
|
1366
|
+
const id = String(agent.id);
|
|
1367
|
+
const prev = prevStatus.get(id);
|
|
1368
|
+
// 有界记账(值带时间戳,供 TTL 清理用;读边沿时取 .status)
|
|
1369
|
+
rememberBounded(prevStatus, id, { status, at: Date.now() }, Date.now(), (v) => v && v.at);
|
|
1370
|
+
// 子代理结束 / 主会话状态变化 → 重估挂起项
|
|
1371
|
+
reevaluatePending('agent/status');
|
|
1372
|
+
if (status !== 'idle' || !prev || prev.status !== 'running') return;
|
|
1373
|
+
if (!shouldNotifySession(agent.session)) {
|
|
1374
|
+
// 这是**最常见**的一类"不播":子代理会话完成、而 subagents=false。
|
|
1375
|
+
// 独立审查 2026-10-17 指出这里原先漏了留痕,恰是用户最想在日志里看到的。
|
|
1376
|
+
const rec0 = lastTurnEnd.get(id);
|
|
1377
|
+
skipLog('subagent-filtered', rec0 && rec0.kind ? rec0.kind : 'completed',
|
|
1378
|
+
agent.session, 'subagents=false');
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
const rec = lastTurnEnd.get(id);
|
|
1382
|
+
// 取不到 reason 时按 completed 处理(fail-visible:宁可多播,不静默用户的任务)
|
|
1383
|
+
handleDone(agent.session, agent, rec && rec.kind ? rec.kind : 'completed');
|
|
1384
|
+
} catch (e) {
|
|
1385
|
+
console.warn('[dsh-notify-say] agent/status handler failed: ' + String((e && e.message) || e));
|
|
1386
|
+
}
|
|
1387
|
+
}, { global: true });
|
|
1388
|
+
|
|
1389
|
+
// ── 后台任务可见集变化 → 重估挂起项("最后一个任务结束"的唤醒路径)────────
|
|
1390
|
+
try {
|
|
1391
|
+
const js = svc('jobs');
|
|
1392
|
+
if (js && typeof js.onJobsChanged === 'function') {
|
|
1393
|
+
ctx.effect(() => js.onJobsChanged(() => {
|
|
1394
|
+
try { reevaluatePending('jobs-changed'); } catch (e) {
|
|
1395
|
+
console.warn('[dsh-notify-say] jobs-changed reevaluate failed: ' + String((e && e.message) || e));
|
|
1396
|
+
}
|
|
1397
|
+
}), 'dsh-notify-say: jobs-changed');
|
|
1398
|
+
}
|
|
1399
|
+
} catch (e) {
|
|
1400
|
+
console.warn('[dsh-notify-say] jobs.onJobsChanged unavailable: ' + String((e && e.message) || e));
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// ── 卸载清理:挂起计时器与状态(监听器由插件 fiber 自动回收)──────────────
|
|
1404
|
+
ctx.effect(() => () => {
|
|
1405
|
+
for (const p of pendingDone.values()) clearTimeout(p.timer);
|
|
1406
|
+
pendingDone.clear();
|
|
1407
|
+
prevStatus.clear();
|
|
1408
|
+
lastTurnEnd.clear();
|
|
1409
|
+
}, 'dsh-notify-say: pending timers');
|
|
1410
|
+
|
|
1411
|
+
// ── 审批 waterfall:必须 next() 放行并透传返回值,绝不阻断、不改写 ──────
|
|
1412
|
+
// 接缝证据:dsh-user-approval/lib/index.js:176(fail-closed 默认保持官方语义)。
|
|
1413
|
+
// 主要提醒路径是 approval/asked;本 listener 是放行同刻的兜底提醒(asked 先于
|
|
1414
|
+
// waterfall 入账,正常情况下会被上面的去抖去重,只在 asked 不可观测时兜底)。
|
|
1415
|
+
ctx.on('approval/request', (req, next) => {
|
|
1416
|
+
try {
|
|
1417
|
+
const session = req && req.agent && req.agent.session;
|
|
1418
|
+
if (cfg.notifyOnApproval && shouldNotifySession(session)) {
|
|
1419
|
+
const toolName = req && req.toolName;
|
|
1420
|
+
const reason = req && req.reason ? String(req.reason) : '';
|
|
1421
|
+
const now = Date.now();
|
|
1422
|
+
const stKey = 'session:' + sessionKey(session) + '|' + (toolName || '未知');
|
|
1423
|
+
if (!isRecent(approvalSeen, stKey, now, cfg.approvalDebounceMs)) {
|
|
1424
|
+
rememberAt(approvalSeen, stKey, now);
|
|
1425
|
+
notify('approval', 'DSH 需要你确认', approvalBody(toolName, reason), session);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
} catch {
|
|
1429
|
+
// 通知侧任何失败都不影响审批链;next() 在 try 外调用
|
|
1430
|
+
}
|
|
1431
|
+
return next();
|
|
1432
|
+
}, { global: true });
|
|
1433
|
+
|
|
1434
|
+
// ── host → client 桥(两条 route,同一前缀)────────────────────────────────
|
|
1435
|
+
//
|
|
1436
|
+
// client 跑在 sandbox 渲染进程里(无 process、无 fs),既拿不到"刚才是哪个会话
|
|
1437
|
+
// 通知的",也改不了配置。官方正路:host 注册 /api/… route,client 用 fetch
|
|
1438
|
+
// (本机先例 dsh-our-free-model)。
|
|
1439
|
+
//
|
|
1440
|
+
// 1. session bridge:点 Toast 后跳到对应会话
|
|
1441
|
+
// 2. config route :设置卡片里直接读写音量/音色/开关(改完立即生效)
|
|
1442
|
+
//
|
|
1443
|
+
// 稳定性:`webServer` 是**可选依赖**,拿不到就整体跳过;
|
|
1444
|
+
// Toast 与语音完全不受影响 —— 这两条都是增强,不是主功能。
|
|
1445
|
+
try {
|
|
1446
|
+
if (ctx && typeof ctx.inject === 'function') {
|
|
1447
|
+
ctx.inject(['webServer'], (scoped) => {
|
|
1448
|
+
try {
|
|
1449
|
+
const server = scoped && scoped.webServer;
|
|
1450
|
+
if (!server || typeof server.register !== 'function') return;
|
|
1451
|
+
const reg = () => server.register(
|
|
1452
|
+
{ kind: 'prefix', path: ROUTE_PREFIX, handler: (req, res) => {
|
|
1453
|
+
// 同一前缀下按路径分流:`/config…` 走配置读写,其余走会话桥。
|
|
1454
|
+
//
|
|
1455
|
+
// ⚠️ 必须按**路径段**判断,不能用 `url.indexOf('/config') >= 0`:
|
|
1456
|
+
// 子串匹配会被查询串里的内容骗到(例如 `/consume?next=/config`)。
|
|
1457
|
+
// 这里取 pathname 再去掉前缀,只比较第一段。
|
|
1458
|
+
try {
|
|
1459
|
+
const raw = String((req && (req.url || req.path)) || '');
|
|
1460
|
+
const pathname = raw.split('?')[0].split('#')[0];
|
|
1461
|
+
const rest = pathname.startsWith(ROUTE_PREFIX)
|
|
1462
|
+
? pathname.slice(ROUTE_PREFIX.length)
|
|
1463
|
+
: pathname;
|
|
1464
|
+
const head = rest.replace(/^\/+/, '').split('/')[0];
|
|
1465
|
+
// ── 本地来源防护(v0.4.24)─────────────────────────────────
|
|
1466
|
+
//
|
|
1467
|
+
// ⚠️ 实测 2026-10-18:宿主 /api 网关有鉴权(无凭据 401),
|
|
1468
|
+
// 但插件 webServer.register 的路由**不在保护范围内** ——
|
|
1469
|
+
// 不带凭据 POST config/set 返回 200 且真的改了配置。
|
|
1470
|
+
// 恶意网页可用 <img>/<form> 触发本机回环请求(不触发 CORS 预检)。
|
|
1471
|
+
//
|
|
1472
|
+
// 防护逻辑见 lib/http-guard.js。这里做"分流前的总闸",
|
|
1473
|
+
// 各端点内部的限流用 throttle()。
|
|
1474
|
+
|
|
1475
|
+
const isWriteRoute = (head === 'config' && /\/set\b/.test(pathname))
|
|
1476
|
+
|| head === 'consume';
|
|
1477
|
+
const guard = checkLocalOrigin(req, { write: isWriteRoute });
|
|
1478
|
+
if (!guard.ok) {
|
|
1479
|
+
try {
|
|
1480
|
+
res.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
1481
|
+
res.end(JSON.stringify({ ok: false, reason: guard.reason }));
|
|
1482
|
+
} catch { /* 响应已发出 */ }
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
if (head === 'config') return configRoute.handler(req, res);
|
|
1487
|
+
|
|
1488
|
+
// ── 自测端点(v0.4.11)────────────────────────────────────
|
|
1489
|
+
//
|
|
1490
|
+
// 从 HTTP 直接触发一次真实 notify(),**绕开会话事件**。
|
|
1491
|
+
//
|
|
1492
|
+
// 为什么需要:2026-10-17 遇到"完成任务完全没反应、连弹窗都没有"。
|
|
1493
|
+
// 此时无法区分是
|
|
1494
|
+
// ① 会话事件没进插件(事件处理器丢了)
|
|
1495
|
+
// ② 事件进来了但 notify() 坏了
|
|
1496
|
+
// 有了这个端点就能二分:/selftest 能弹窗出声 → notify 好、问题在事件;
|
|
1497
|
+
// 否则 → notify 链路坏了。
|
|
1498
|
+
if (head === 'selftest') {
|
|
1499
|
+
// ── 限流(v0.4.24)──────────────────────────────────────────
|
|
1500
|
+
// preview / selftest 会真的出声、真的起子进程。恶意网页可以用
|
|
1501
|
+
// <img src=".../preview"> 反复触发 —— 既骚扰又是资源放大。
|
|
1502
|
+
// 3 秒一次:够用户手点试听,又不足以被刷。
|
|
1503
|
+
const gate = throttle(head === 'selftest' ? 'selftest' : 'preview', 3000)
|
|
1504
|
+
if (!gate.ok) {
|
|
1505
|
+
try {
|
|
1506
|
+
res.writeHead(429, {
|
|
1507
|
+
'content-type': 'application/json; charset=utf-8',
|
|
1508
|
+
'cache-control': 'no-store',
|
|
1509
|
+
'retry-after': String(Math.ceil((gate.retryAfterMs || 1000) / 1000)),
|
|
1510
|
+
})
|
|
1511
|
+
res.end(JSON.stringify({ ok: false, reason: 'throttled', retryAfterMs: gate.retryAfterMs }))
|
|
1512
|
+
} catch { /* 响应已发出 */ }
|
|
1513
|
+
return
|
|
1514
|
+
}
|
|
1515
|
+
try {
|
|
1516
|
+
const q = raw.split('?')[1] || '';
|
|
1517
|
+
const km = /kind=([a-z]+)/.exec(q);
|
|
1518
|
+
const tk = km ? km[1] : 'done';
|
|
1519
|
+
notify(tk, '插件自测', '看到这个弹窗说明 notify 通路正常', null);
|
|
1520
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
|
1521
|
+
res.end(JSON.stringify({ ok: true, kind: tk }));
|
|
1522
|
+
} catch (e) {
|
|
1523
|
+
try {
|
|
1524
|
+
res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
|
|
1525
|
+
res.end(JSON.stringify({ ok: false, error: String((e && e.message) || e) }));
|
|
1526
|
+
} catch { /* ignore */ }
|
|
1527
|
+
}
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// ── 试听端点(v0.4.17)──────────────────────────────────────
|
|
1532
|
+
//
|
|
1533
|
+
// **只出声,不弹窗**(ps1 的 -Preview)。用途:
|
|
1534
|
+
// · 调「语音包增益」时听效果 —— 不然只能盲调
|
|
1535
|
+
// · 换发音人/音色后确认是想要的
|
|
1536
|
+
// · 换自定义音频后确认选对了文件
|
|
1537
|
+
//
|
|
1538
|
+
// 与 /selftest 的区别:selftest 走完整 notify()(含 Toast),
|
|
1539
|
+
// 用来验证"通知通路";这里只验证"声音对不对"。
|
|
1540
|
+
//
|
|
1541
|
+
// 支持 ?kind=done|fail|question|approval|blocked|chime,
|
|
1542
|
+
// 默认 done。参数按**当前生效配置**组装,所以听到的就是
|
|
1543
|
+
// 真实播报的效果(含增益、发音人、按事件覆盖)。
|
|
1544
|
+
if (head === 'preview') {
|
|
1545
|
+
// ── 限流(v0.4.24)──────────────────────────────────────────
|
|
1546
|
+
// preview / selftest 会真的出声、真的起子进程。恶意网页可以用
|
|
1547
|
+
// <img src=".../preview"> 反复触发 —— 既骚扰又是资源放大。
|
|
1548
|
+
// 3 秒一次:够用户手点试听,又不足以被刷。
|
|
1549
|
+
const gate = throttle(head === 'selftest' ? 'selftest' : 'preview', 3000)
|
|
1550
|
+
if (!gate.ok) {
|
|
1551
|
+
try {
|
|
1552
|
+
res.writeHead(429, {
|
|
1553
|
+
'content-type': 'application/json; charset=utf-8',
|
|
1554
|
+
'cache-control': 'no-store',
|
|
1555
|
+
'retry-after': String(Math.ceil((gate.retryAfterMs || 1000) / 1000)),
|
|
1556
|
+
})
|
|
1557
|
+
res.end(JSON.stringify({ ok: false, reason: 'throttled', retryAfterMs: gate.retryAfterMs }))
|
|
1558
|
+
} catch { /* 响应已发出 */ }
|
|
1559
|
+
return
|
|
1560
|
+
}
|
|
1561
|
+
try {
|
|
1562
|
+
const q = raw.split('?')[1] || '';
|
|
1563
|
+
const km = /kind=([a-z]+)/.exec(q);
|
|
1564
|
+
const pk = km ? km[1] : 'done';
|
|
1565
|
+
refreshConfig();
|
|
1566
|
+
const aKind = audioKindOf(pk) || 'done';
|
|
1567
|
+
// 试听统一走 bundled 语义(选曲 + 增益),
|
|
1568
|
+
// 这样"调增益听效果"这件事对语音包最直接。
|
|
1569
|
+
// 若用户当前音源是 tts/custom,则按各自参数试听。
|
|
1570
|
+
const src = resolveSource(aKind, cfg);
|
|
1571
|
+
if (src === 'tts') {
|
|
1572
|
+
const line = ttsTextFor(pk);
|
|
1573
|
+
const vVoice = kindOr(cfg.kindTtsVoice, aKind, cfg.ttsVoice);
|
|
1574
|
+
const vRate = kindOr(cfg.kindTtsRate, aKind, cfg.ttsRate || 0);
|
|
1575
|
+
const ttsArgs = [
|
|
1576
|
+
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File',
|
|
1577
|
+
TTS_PATH, '-Text', line, '-Volume', String(cfg.volume), '-Rate', String(vRate),
|
|
1578
|
+
];
|
|
1579
|
+
if (vVoice) ttsArgs.push('-VoiceName', vVoice);
|
|
1580
|
+
spawnTts(ttsArgs);
|
|
1581
|
+
} else if (src === 'custom') {
|
|
1582
|
+
const file = resolveCustomFile(aKind, cfg);
|
|
1583
|
+
const a = baseNotifyArgs(pk, '试听', '', false);
|
|
1584
|
+
a.push('-Preview');
|
|
1585
|
+
if (file) {
|
|
1586
|
+
a.push('-CustomFile', file);
|
|
1587
|
+
if (cfg.normalizeCustomAudio === true) {
|
|
1588
|
+
a.push('-Gain', String(Number(cfg.voiceGain) > 1 ? Number(cfg.voiceGain) : 2.0));
|
|
1589
|
+
}
|
|
1590
|
+
} else {
|
|
1591
|
+
a.push('-SystemSound', 'auto');
|
|
1592
|
+
}
|
|
1593
|
+
spawnNotify(a);
|
|
1594
|
+
} else if (src === 'system') {
|
|
1595
|
+
const a = baseNotifyArgs(pk, '试听', '', false);
|
|
1596
|
+
a.push('-Preview', '-SystemSound', 'auto');
|
|
1597
|
+
spawnNotify(a);
|
|
1598
|
+
} else if (src === 'none') {
|
|
1599
|
+
// 静音模式:不折腾,直接告知
|
|
1600
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
|
1601
|
+
res.end(JSON.stringify({ ok: true, kind: pk, source: 'none', note: '当前设为不出声' }));
|
|
1602
|
+
return;
|
|
1603
|
+
} else if (src === 'bundled') {
|
|
1604
|
+
// 语音包:选曲 + 按峰值限幅的增益
|
|
1605
|
+
const a = baseNotifyArgs(pk, '试听', '', false);
|
|
1606
|
+
a.push('-Preview');
|
|
1607
|
+
const g = Number(cfg.voiceGain);
|
|
1608
|
+
if (Number.isFinite(g) && g > 1) a.push('-Gain', String(g));
|
|
1609
|
+
spawnNotify(a);
|
|
1610
|
+
} else {
|
|
1611
|
+
// 未知音源:退回系统提示音,绝不静默(试听没声音最让人困惑)
|
|
1612
|
+
const a = baseNotifyArgs(pk, '试听', '', false);
|
|
1613
|
+
a.push('-Preview', '-SystemSound', 'auto');
|
|
1614
|
+
spawnNotify(a);
|
|
1615
|
+
}
|
|
1616
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
|
1617
|
+
res.end(JSON.stringify({ ok: true, kind: pk, source: src, gain: cfg.voiceGain }));
|
|
1618
|
+
} catch (e) {
|
|
1619
|
+
try {
|
|
1620
|
+
res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
|
|
1621
|
+
res.end(JSON.stringify({ ok: false, error: String((e && e.message) || e) }));
|
|
1622
|
+
} catch { /* ignore */ }
|
|
1623
|
+
}
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
} catch { /* 分流失败就落到默认分支 */ }
|
|
1627
|
+
return bridge.handler(req, res);
|
|
1628
|
+
} },
|
|
1629
|
+
'dsh-notify-say: api',
|
|
1630
|
+
);
|
|
1631
|
+
if (scoped && typeof scoped.effect === 'function') scoped.effect(reg, 'dsh-notify-say: api');
|
|
1632
|
+
else reg();
|
|
1633
|
+
} catch (e) {
|
|
1634
|
+
console.warn('[dsh-notify-say] api route 注册失败(跳会话/设置卡片不可用,其余功能正常):'
|
|
1635
|
+
+ String((e && e.message) || e));
|
|
1636
|
+
}
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
} catch {
|
|
1640
|
+
// webServer 不存在 / inject 不支持:静默降级
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// ── 开发期自热重载(默认关闭)────────────────────────────────────────────
|
|
1644
|
+
//
|
|
1645
|
+
// 背景(2026-10-17 实测教训):宿主只在启动时加载一次本文件,之后改磁盘上的
|
|
1646
|
+
// 代码**不会**生效 —— 我改了 audioSource 默认值,磁盘是新的,宿主仍按旧逻辑
|
|
1647
|
+
// 走 bundled→system,导致每次完成都静默。那时唯一解是重启 DSH。
|
|
1648
|
+
//
|
|
1649
|
+
// 机制移植自本机 dsh-our-free-model 的 src/reload.js(详见 lib/dev-reload.js
|
|
1650
|
+
// 的模块注释,含"必须用 Map.prototype.delete"这个关键点)。
|
|
1651
|
+
//
|
|
1652
|
+
// **默认关闭**:
|
|
1653
|
+
// - 生产安装不会重写自己的文件,开着只是多一条可能出错的路径;
|
|
1654
|
+
// - 它依赖 ctx.loader.internal / ctx.registry / ctx.fiber 三个非公开对象,
|
|
1655
|
+
// DSH 升级可能变;关着就不会受影响。
|
|
1656
|
+
//
|
|
1657
|
+
// 开启方式:cordis.patch.yml 或 config.json 里设 `devReload: true`。
|
|
1658
|
+
//
|
|
1659
|
+
// ⚠️ 防无限循环:重载后新实例的 apply 会**再次**读到 devReload=true,
|
|
1660
|
+
// 若不做标记就会重载→重载→…。故用 globalThis 上的时间戳做冷却:
|
|
1661
|
+
// 30 秒内已重载过就跳过。key 带插件名,避免与其它插件冲突。
|
|
1662
|
+
try {
|
|
1663
|
+
if (cfg.devReload) {
|
|
1664
|
+
// ⚠️ 语义必须是「**改文件时**重载」,不是「启动后重载一次」。
|
|
1665
|
+
//
|
|
1666
|
+
// 第一版写成启动后 1.5 秒触发一次 —— 那是错的:没人改文件时重载毫无意义,
|
|
1667
|
+
// 而且每次启动都白跑一次高风险操作。真正的需求是监听源码变更。
|
|
1668
|
+
//
|
|
1669
|
+
// 用 watchPackage(lib/dev-reload.js)监听包目录,变更 → 防抖 → 重载。
|
|
1670
|
+
// 防抖 800ms:编辑器保存常触发多次事件,太灵敏会在写到一半时重载,
|
|
1671
|
+
// 拿到语法不完整的文件。
|
|
1672
|
+
const stop = watchPackage(PLUGIN_ROOT, {
|
|
1673
|
+
logger: console,
|
|
1674
|
+
debounceMs: 800,
|
|
1675
|
+
onChange: () => {
|
|
1676
|
+
const KEY = Symbol.for('dsh-notify-say.devReloadAt');
|
|
1677
|
+
const last = Number(globalThis[KEY]) || 0;
|
|
1678
|
+
// 冷却:一次保存可能触发多个文件事件,也防止重载后立刻又重载
|
|
1679
|
+
if (Date.now() - last < 3000) return;
|
|
1680
|
+
globalThis[KEY] = Date.now();
|
|
1681
|
+
|
|
1682
|
+
// ── 计算"磁盘上现在应该是什么"(v0.4.20)──────────────────────
|
|
1683
|
+
//
|
|
1684
|
+
// 重载必须能自证:换上来的代码 == 磁盘上的代码。
|
|
1685
|
+
// 只靠"拿到新模块对象"不够 —— 那可能只是入口换了、依赖还是旧的。
|
|
1686
|
+
//
|
|
1687
|
+
// 这里算出磁盘内容的指纹,连同代际标记一起交给 selfReload;
|
|
1688
|
+
// 它会在**刚加载的模块里**比对这个值,不一致就判失败并回滚。
|
|
1689
|
+
//
|
|
1690
|
+
// 注意:这个指纹是"内容哈希",而 index.js 导出的 fingerprint 是
|
|
1691
|
+
// "代际标记",两者用途不同,不能混。
|
|
1692
|
+
const diskFingerprint = computeFingerprint(PLUGIN_ROOT);
|
|
1693
|
+
selfReload(ctx, {
|
|
1694
|
+
logger: console,
|
|
1695
|
+
// 期望新模块导出一个非空 fingerprint(证明它被重新求值过)
|
|
1696
|
+
expectedFingerprint: undefined,
|
|
1697
|
+
// 但必须要求:新模块的 fingerprint 与**当前这个实例**不同
|
|
1698
|
+
differFrom: fingerprint,
|
|
1699
|
+
diskFingerprint,
|
|
1700
|
+
}).then((r) => {
|
|
1701
|
+
try {
|
|
1702
|
+
if (r.ok) {
|
|
1703
|
+
console.log(`[dsh-notify-say] 已热重载(${r.fibers} 个 fiber`
|
|
1704
|
+
+ `${r.busted ? ',走了代际绕过' : ''}`
|
|
1705
|
+
+ `${r.verified ? ',指纹校验通过' : ''})`);
|
|
1706
|
+
} else {
|
|
1707
|
+
console.warn(`[dsh-notify-say] 热重载失败:${r.error}`
|
|
1708
|
+
+ (r.restored ? '(旧代码仍在运行)' : '(回滚也失败,建议重启宿主)'));
|
|
1709
|
+
}
|
|
1710
|
+
} catch { /* ignore */ }
|
|
1711
|
+
}).catch(() => { /* selfReload 内部已兜底 */ });
|
|
1712
|
+
},
|
|
1713
|
+
});
|
|
1714
|
+
// 停掉监听挂到 effect 上:插件被卸载/重载时自动清理,否则旧实例的 watcher 会残留
|
|
1715
|
+
try {
|
|
1716
|
+
if (typeof ctx.effect === 'function') ctx.effect(() => stop, 'dsh-notify-say: dev-reload watcher');
|
|
1717
|
+
else process.once('exit', () => { try { stop() } catch { /* ignore */ } });
|
|
1718
|
+
} catch {
|
|
1719
|
+
try { stop(); } catch { /* ignore */ }
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
} catch { /* 热重载不可用不影响主功能 */ }
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// ── 官方 Config schema 导出(v0.4.0)─────────────────────────────────────────
|
|
1726
|
+
//
|
|
1727
|
+
// 这是「设置页 GUI」的官方入口:DSH 0.1.7+ 的 Loader 自动接管插件导出的 `Config`,
|
|
1728
|
+
// 在设置页生成表单,无需自建 client 半侧、无需 `slots`(未文档化、历史上被无声改过)。
|
|
1729
|
+
//
|
|
1730
|
+
// 源码侧契约(本机 app.asar 实证):
|
|
1731
|
+
// - `volatileForm(schema)` 为 undefined 时抛 `Plugin entry "<ns>" has no volatile fields`
|
|
1732
|
+
// - 写入非 volatile 路径时抛 `Config field "<path>" is not volatile`
|
|
1733
|
+
// → 故 lib/config-schema.js 里**每个字段**都调了 `.volatile()`。
|
|
1734
|
+
//
|
|
1735
|
+
// 关键:**schema 加载失败时不能导出坏对象**,否则插件可能整体加载失败。
|
|
1736
|
+
// 拿不到 schemastery 时导出 `undefined`(等价于「本插件不提供表单」),
|
|
1737
|
+
// 插件功能完全不受影响,只是设置页不出现。宁可少一个页面,不可少一个插件。
|
|
1738
|
+
const _schema = (() => {
|
|
1739
|
+
try {
|
|
1740
|
+
const z = loadSchema(process.env, { log: (m) => console.warn('[dsh-notify-say] ' + m) });
|
|
1741
|
+
if (!z) return undefined;
|
|
1742
|
+
return buildConfig(z);
|
|
1743
|
+
} catch (e) {
|
|
1744
|
+
try {
|
|
1745
|
+
console.warn('[dsh-notify-say] Config schema 构建失败,设置页将不出现(插件功能不受影响):'
|
|
1746
|
+
+ String((e && e.message) || e));
|
|
1747
|
+
} catch { /* 连日志都失败就算了 */ }
|
|
1748
|
+
return undefined;
|
|
1749
|
+
}
|
|
1750
|
+
})();
|
|
1751
|
+
|
|
1752
|
+
export const Config = _schema;
|
|
1753
|
+
|
|
1754
|
+
/**
|
|
1755
|
+
* 本模块所在代码的"代际指纹"(v0.4.20)。
|
|
1756
|
+
*
|
|
1757
|
+
* ## 为什么需要
|
|
1758
|
+
*
|
|
1759
|
+
* 热重载要能回答一个问题:**刚才那次重载,真的把新代码换上了吗?**
|
|
1760
|
+
*
|
|
1761
|
+
* 只检查"re-import 返回了新对象"是不够的 —— 那只证明**入口**被重新求值了,
|
|
1762
|
+
* 不证明它 import 的依赖也换了。2026-10-17 实测就出现过
|
|
1763
|
+
* "config-store.js 换了、index.js 没换"(或反之)的半新半旧状态,
|
|
1764
|
+
* 而重载照样报 ok:true,排查时被误导了好几轮。
|
|
1765
|
+
*
|
|
1766
|
+
* ## 它是什么
|
|
1767
|
+
*
|
|
1768
|
+
* 一个**在模块求值时就固定下来**的标记:
|
|
1769
|
+
* · 由本模块的 import.meta.url 派生(含代际查询串)
|
|
1770
|
+
* · 加上加载时间戳
|
|
1771
|
+
*
|
|
1772
|
+
* 关键在于:它是**模块顶层的 const**,一旦求值就冻结。
|
|
1773
|
+
* 所以:
|
|
1774
|
+
* · 如果重载真的重新求值了本模块 → 得到**新的** fingerprint
|
|
1775
|
+
* · 如果还是旧模块(缓存没清掉)→ 读到**旧的** fingerprint
|
|
1776
|
+
*
|
|
1777
|
+
* 重载流程拿它和"磁盘期望值"比对,两者一致才算真生效。
|
|
1778
|
+
*
|
|
1779
|
+
* 注意:它**不是**文件内容哈希(那要读盘,太重)。
|
|
1780
|
+
* 它只证伪"没换",不证明"内容对" —— 内容对不对由文件系统负责。
|
|
1781
|
+
*/
|
|
1782
|
+
/**
|
|
1783
|
+
* 本模块**代码内容**的指纹(v0.4.20)。
|
|
1784
|
+
*
|
|
1785
|
+
* ## 为什么需要
|
|
1786
|
+
*
|
|
1787
|
+
* 热重载要能回答:**刚才那次重载,真的把新代码换上了吗?**
|
|
1788
|
+
*
|
|
1789
|
+
* 只检查"re-import 返回了新对象"是不够的 —— 那只证明**入口**被重新求值了,
|
|
1790
|
+
* 不证明它 import 的依赖也换了。2026-10-17 实测就出现过
|
|
1791
|
+
* "config-store.js 换了、index.js 没换"(或反之)的半新半旧状态,
|
|
1792
|
+
* 而重载照样报 ok:true,排查时被误导了好几轮。
|
|
1793
|
+
*
|
|
1794
|
+
* ## 为什么是"源码哈希"而不是"代际标记"
|
|
1795
|
+
*
|
|
1796
|
+
* 第一版用的是 `import.meta.url`(含代际查询串),结果**判断力反了**:
|
|
1797
|
+
* 代际一变 URL 就变、指纹就变,于是"代码没改但重新求值了"也被判成
|
|
1798
|
+
* "新代码已生效" —— 校验形同虚设(S2 测试当场抓到)。
|
|
1799
|
+
*
|
|
1800
|
+
* 现在改为对**本模块文件自身的字节**做哈希:
|
|
1801
|
+
* · 代码没改 → 无论如何重新求值,指纹都一样 → 拒绝了假成功 ✅
|
|
1802
|
+
* · 代码改了 → 指纹不同 → 认可生效 ✅
|
|
1803
|
+
*
|
|
1804
|
+
* 读盘成本可接受:只在模块求值那一刻做一次(几 KB~几十 KB)。
|
|
1805
|
+
*
|
|
1806
|
+
* ## 依赖了什么
|
|
1807
|
+
*
|
|
1808
|
+
* `import.meta.url`(去掉查询串)+ `node:fs`。若沙箱读不到文件,
|
|
1809
|
+
* 退化为空串,`verifyReload` 会据此判失败(宁可报错也不假成功)。
|
|
1810
|
+
*/
|
|
1811
|
+
export const fingerprint = (() => {
|
|
1812
|
+
try {
|
|
1813
|
+
// 去掉 ?dns-reload= 之类查询串,拿到稳定的文件路径
|
|
1814
|
+
const filePath = fileURLToPath(import.meta.url);
|
|
1815
|
+
const buf = readFileSync(filePath);
|
|
1816
|
+
let hash = 5381;
|
|
1817
|
+
for (let i = 0; i < buf.length; i++) hash = ((hash * 33) ^ buf[i]) >>> 0;
|
|
1818
|
+
return hash.toString(16).padStart(8, '0');
|
|
1819
|
+
} catch {
|
|
1820
|
+
return '';
|
|
1821
|
+
}
|
|
1822
|
+
})();
|