dsh-notify-me 1.1.4 → 1.1.8

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +54 -1
  2. package/lib/client.js +1162 -815
  3. package/package.json +95 -95
package/lib/client.js CHANGED
@@ -1,815 +1,1162 @@
1
- // dsh-notify-me — browser half (client plugin bundle).
2
- //
3
- // Loaded by dsh-client-modules at /plugins/dsh-notify-me/client.js and
4
- // executed through the vendored cordis Loader's lazy-CJS module table
5
- // (window.__ModuleLoader__.load). The factory body is plain CJS with
6
- // require() resolved against the shell's module table — the same shape the
7
- // shipped ui-* packages' tsdown bundles emit. react is part of the platform
8
- // base, so require("react") resolves to the shell-seeded instance (same as
9
- // every shipped ui-* bundle); the settings page is skipped when react or the
10
- // slots/locale services are absent, keeping the reminder core self-contained.
11
- //
12
- // What this plugin does (all client-side, no settings transport needed):
13
- // - watches the "sessions" service (SessionRuntime): the selected
14
- // session's ConversationSnapshot (running / pending interactions) and the
15
- // session-list summaries of every other session;
16
- // - fires a Windows desktop Notification (+ optional sound) when the agent
17
- // needs the user's input — pending interaction kinds 'approval',
18
- // 'plan-review' or 'question' — and when a reply/turn finishes;
19
- // - registers a "通知提醒 / Notify me" page into DSH Settings
20
- // (settings.section slot) exposing an on/off master switch, per-channel
21
- // toggles, volume and the notification language (follow-interface /
22
- // Simplified Chinese / English);
23
- // - preferences persist in localStorage (origin-scoped, like other
24
- // third-party client plugins) and are tweakable on window.__dshNotifyMe.
25
-
26
- window.__ModuleLoader__.load({
27
- id: "dsh-notify-me",
28
- factory: (require) => {
29
- var module = { exports: {} };
30
- var exports = module.exports;
31
- Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
32
-
33
- // react is externalized into the loader module table; never required by
34
- // the reminder core, only by the optional Settings page.
35
- var React = null;
36
- try { React = require("react") || null; } catch (e) { React = null; }
37
-
38
- // ───────────────────────── configuration ─────────────────────────
39
- var LS_CONFIG = "dshNotifyMe.config";
40
- var DEFAULTS = {
41
- enabled: true, // master switch: off = no toast/sound/title marker at all
42
- // language of the notification copy: 'auto' follows the DSH UI locale,
43
- // 'zh' and 'en' pin Simplified Chinese / English.
44
- language: "auto",
45
- // toast + sound are only useful while the user is elsewhere; the tab
46
- // being hidden is the reliable "away" signal.
47
- attentionHiddenOnly: false, // needs-input alerts even while the page is visible
48
- doneHiddenOnly: true, // reply-finished alerts only when the page is hidden
49
- toast: true,
50
- sound: true,
51
- volume: 0.5,
52
- autoFocus: true // clicking the toast focuses the DSH window
53
- };
54
- function readConfig() {
55
- var cfg = {};
56
- try {
57
- var raw = localStorage.getItem(LS_CONFIG);
58
- if (raw) cfg = JSON.parse(raw);
59
- } catch (e) { /* corrupt storage -> defaults */ }
60
- for (var k in DEFAULTS) if (!(k in cfg)) cfg[k] = DEFAULTS[k];
61
- return cfg;
62
- }
63
- function writeConfig(patch) {
64
- var cfg = readConfig();
65
- for (var k in patch) cfg[k] = patch[k];
66
- try { localStorage.setItem(LS_CONFIG, JSON.stringify(cfg)); } catch (e) {}
67
- return cfg;
68
- }
69
- var config = readConfig();
70
- // Persist a patch, swap the live module config and re-apply any active
71
- // title marker (its prefix follows the notification language).
72
- function setConfigLocal(patch) {
73
- config = writeConfig(patch);
74
- applyTitle();
75
- return config;
76
- }
77
-
78
- // ───────────────────────── copy tables ─────────────────────────
79
- // Notification copy, keyed by language id. Selected through
80
- // effectiveLang() so a pinned language stays pinned even while the DSH UI
81
- // speaks the other one.
82
- var TXT = {
83
- zh: {
84
- kind: { approval: "审批请求", "plan-review": "方案待确认", question: "提问" },
85
- kindDefault: "需要操作",
86
- bodyKind: { approval: "有审批待处理", "plan-review": "方案待确认", question: "有提问待回答" },
87
- bodyDefault: "需要你操作",
88
- current: "当前对话",
89
- doneTitle: "DSH · 回复完成",
90
- doneBody: "回复已完成",
91
- background: "后台会话",
92
- marker: "🔔 需要你 · ",
93
- sep: ":",
94
- testAttention: "这是一条测试提醒:DSH 需要你的操作",
95
- testDone: "测试提醒 · 回复完成示例"
96
- },
97
- en: {
98
- kind: { approval: "Approval request", "plan-review": "Plan review", question: "Question" },
99
- kindDefault: "Action needed",
100
- bodyKind: {
101
- approval: "An approval is waiting for you",
102
- "plan-review": "A plan awaits your review",
103
- question: "A question is waiting for you"
104
- },
105
- bodyDefault: "Your input is needed",
106
- current: "Current conversation",
107
- doneTitle: "DSH · Reply finished",
108
- doneBody: "Reply finished",
109
- background: "Background session",
110
- marker: "🔔 Action needed · ",
111
- sep: ": ",
112
- testAttention: "This is a test alert: DSH is waiting for your input",
113
- testDone: "Test alert · reply-finished sample"
114
- }
115
- };
116
- // Locale service handle (registered on the client context by dsh-client-locale).
117
- var localeSvc = null;
118
- // Current DSH UI language: 'zh' | 'en' | null (unknown). Used by the
119
- // 'auto' notification-language mode.
120
- function currentUILang() {
121
- try {
122
- if (localeSvc && typeof localeSvc.getLocale === "function") {
123
- var a = localeSvc.getLocale().active;
124
- if (typeof a === "string" && a) {
125
- if (a.indexOf("zh") === 0) return "zh";
126
- if (a.indexOf("en") === 0) return "en";
127
- }
128
- }
129
- } catch (e) {}
130
- try {
131
- var dl = (document.documentElement && document.documentElement.lang) || "";
132
- if (dl.indexOf("zh") === 0) return "zh";
133
- if (dl.indexOf("en") === 0) return "en";
134
- } catch (e) {}
135
- return null;
136
- }
137
- function effectiveLang() {
138
- if (!config.language || config.language === "auto") return currentUILang() || "zh";
139
- return config.language === "en" ? "en" : "zh";
140
- }
141
- function texts() { return TXT[effectiveLang()] || TXT.zh; }
142
-
143
- // ─────────────────────── notification plumbing ───────────────────
144
- var permAsked = false;
145
- function requestPermission() {
146
- if (!("Notification" in window) || permAsked) return;
147
- permAsked = true;
148
- try {
149
- if (Notification.permission === "default") {
150
- Notification.requestPermission().catch(function () {});
151
- }
152
- } catch (e) {}
153
- }
154
- function canToast() {
155
- return config.toast && "Notification" in window && Notification.permission === "granted";
156
- }
157
- function showToast(kind, title, body) {
158
- if (!canToast()) return;
159
- try {
160
- var n = new Notification(title, {
161
- body: body || "",
162
- tag: "dsh-notify-me-" + kind,
163
- renotify: true,
164
- icon: (location.origin || "") + "/favicon.svg"
165
- });
166
- if (config.autoFocus) {
167
- n.onclick = function () {
168
- try { window.focus(); } catch (e) {}
169
- n.close();
170
- };
171
- }
172
- setTimeout(function () { n.close(); }, 15000);
173
- } catch (e) {}
174
- }
175
- // Sound: WebAudio beeps (no asset needed). The context only runs after a
176
- // user gesture; we unlock it on the first pointer/key event.
177
- var audioCtx = null;
178
- function unlockAudio() {
179
- try {
180
- if (!audioCtx) {
181
- var AC = window.AudioContext || window.webkitAudioContext;
182
- if (!AC) return;
183
- audioCtx = new AC();
184
- }
185
- if (audioCtx.state === "suspended") audioCtx.resume().catch(function () {});
186
- } catch (e) {}
187
- }
188
- function tone(freq, startIn, durMs, vol) {
189
- if (!audioCtx || audioCtx.state !== "running") return;
190
- try {
191
- var t0 = audioCtx.currentTime + startIn;
192
- var osc = audioCtx.createOscillator();
193
- var gain = audioCtx.createGain();
194
- osc.type = "sine";
195
- osc.frequency.value = freq;
196
- gain.gain.setValueAtTime(0.0001, t0);
197
- gain.gain.exponentialRampToValueAtTime(vol, t0 + 0.02);
198
- gain.gain.exponentialRampToValueAtTime(0.0001, t0 + durMs / 1000);
199
- osc.connect(gain).connect(audioCtx.destination);
200
- osc.start(t0);
201
- osc.stop(t0 + durMs / 1000 + 0.05);
202
- } catch (e) {}
203
- }
204
- function playAttentionSound() {
205
- if (!config.sound) return;
206
- unlockAudio();
207
- tone(880, 0.0, 140, config.volume);
208
- tone(1174, 0.16, 140, config.volume);
209
- tone(1568, 0.32, 200, config.volume);
210
- }
211
- function playDoneSound() {
212
- if (!config.sound) return;
213
- unlockAudio();
214
- tone(659, 0.0, 150, config.volume);
215
- tone(988, 0.18, 260, config.volume);
216
- }
217
- function unlockOnce() {
218
- unlockAudio();
219
- requestPermission();
220
- }
221
- function onFirstGesture() {
222
- unlockOnce();
223
- window.removeEventListener("pointerdown", onFirstGesture, true);
224
- window.removeEventListener("keydown", onFirstGesture, true);
225
- }
226
- window.addEventListener("pointerdown", onFirstGesture, true);
227
- window.addEventListener("keydown", onFirstGesture, true);
228
-
229
- // ────────────────────────── title marker ─────────────────────────
230
- var originalTitle = null;
231
- var attentionActive = 0;
232
- function applyTitle() {
233
- try {
234
- if (originalTitle === null) originalTitle = document.title;
235
- document.title = (config.enabled && attentionActive > 0)
236
- ? texts().marker + originalTitle
237
- : originalTitle;
238
- } catch (e) {}
239
- }
240
- function acquireTitleMark() {
241
- attentionActive += 1;
242
- applyTitle();
243
- }
244
- function releaseTitleMark() {
245
- if (attentionActive > 0) attentionActive -= 1;
246
- applyTitle();
247
- }
248
-
249
- // ─────────────────────── shared alert core ───────────────────────
250
- var attentionKeysFired = Object.create(null); // dedupe per pending key
251
- var lastAttentionAt = 0;
252
-
253
- function hidden() { return document.hidden || document.visibilityState === "hidden"; }
254
-
255
- // Deliver one alert regardless of visibility rules (used by the test
256
- // buttons); respects the master switch and permission state.
257
- function deliver(kind, title, body) {
258
- if (!config.enabled) return;
259
- showToast(kind, title, body);
260
- if (kind === "attention") {
261
- playAttentionSound();
262
- acquireTitleMark();
263
- } else {
264
- playDoneSound();
265
- }
266
- try {
267
- if (window.__dshNotifyMe && window.__dshNotifyMe.onEvent) {
268
- window.__dshNotifyMe.onEvent(kind, { title: title, body: body });
269
- }
270
- } catch (e) {}
271
- }
272
- function fire(kind, title, body) {
273
- if (!config.enabled) return;
274
- if (kind === "attention" && config.attentionHiddenOnly && !hidden()) return;
275
- if (kind === "done" && config.doneHiddenOnly && !hidden()) return;
276
- deliver(kind, title, body);
277
- }
278
- // A display label for a session row ("current" maps to the localized
279
- // "当前对话 / Current conversation" placeholder).
280
- function sessionLabelText(row) {
281
- var raw = row && row.label;
282
- if (!raw || raw === "current") return texts().current;
283
- return raw;
284
- }
285
- function fireAttention(kind, sessionLabel, detail) {
286
- var d = texts();
287
- var label = d.kind[kind] || d.kindDefault;
288
- var content = detail || d.bodyKind[kind] || d.bodyDefault;
289
- var who = sessionLabel || "";
290
- fire("attention", "DSH · " + label, (who ? who + d.sep : "") + content);
291
- }
292
- function fireDone(sessionLabel, snippet) {
293
- var d = texts();
294
- var body = sessionLabel || d.doneBody;
295
- if (snippet) body = body + "\n" + snippet;
296
- fire("done", d.doneTitle, body);
297
- }
298
- // Test alerts deliberately ignore the visibility rules (attentionHiddenOnly
299
- // / doneHiddenOnly): the whole point of a test button is to prove the
300
- // channel works while you are looking at the page. Only the master switch
301
- // (and browser notification permission) can stop them. The attention test
302
- // also drops its title marker again shortly after, so a test never leaves a
303
- // standing "waiting on you" mark in the tab.
304
- var TEST_MARK_RELEASE_MS = 6000;
305
- function testAlert(kind) {
306
- var d = texts();
307
- if (kind === "attention") {
308
- deliver("attention", "DSH · " + d.kind.question, d.testAttention);
309
- setTimeout(function () { releaseTitleMark(); }, TEST_MARK_RELEASE_MS);
310
- } else {
311
- deliver("done", d.doneTitle, d.testDone);
312
- }
313
- }
314
-
315
- // ───────────────────────── state machine ─────────────────────────
316
- // prevById: per-session-id baseline of what we have already reported.
317
- // { running: bool, pendingKeys: string[] , doneArmed: bool, seeded: bool }
318
- var prevById = Object.create(null);
319
- var faceSub = null; // current session face unsubscribe
320
- var currentFaceId = null;
321
- var listStore = null;
322
- var rootCtx = null; // client cordis context, captured at apply()
323
-
324
- function textFromPayload(payload) {
325
- if (!payload || typeof payload !== "object") return "";
326
- var keys = ["text", "question", "prompt", "title", "summary", "description", "message", "operation", "name", "tool"];
327
- for (var i = 0; i < keys.length; i++) {
328
- var v = payload[keys[i]];
329
- if (typeof v === "string" && v.trim()) return v.length > 160 ? v.slice(0, 160) + "…" : v;
330
- if (typeof v === "object" && v !== null && typeof v.text === "string" && v.text.trim()) {
331
- var s = v.text;
332
- return s.length > 160 ? s.slice(0, 160) + "…" : s;
333
- }
334
- }
335
- return "";
336
- }
337
- function snippetOfLastAssistant(snap) {
338
- try {
339
- var nodes = snap.nodes || [];
340
- for (var i = nodes.length - 1; i >= 0; i--) {
341
- var nd = nodes[i];
342
- if (nd && nd.kind === "assistant" && nd.blocks) {
343
- for (var b = nd.blocks.length - 1; b >= 0; b--) {
344
- if (nd.blocks[b] && nd.blocks[b].kind === "text" && nd.blocks[b].text) {
345
- var t = nd.blocks[b].text.replace(/\s+/g, " ").trim();
346
- if (t) return t.length > 80 ? t.slice(0, 80) + "…" : t;
347
- }
348
- }
349
- }
350
- }
351
- } catch (e) {}
352
- return "";
353
- }
354
- // Evaluate one row/face observation. row: {id, running, pending:[{key,kind,payload}], completed, label, face:bool}
355
- function evaluateRow(row) {
356
- var prev = prevById[row.id];
357
- var runningNow = !!row.running;
358
- var pendingNow = row.pending || [];
359
- var prevPendingKeys = prev ? prev.pendingKeys : [];
360
- var wasRunning = prev ? prev.running : false;
361
- var newKeys = [];
362
- for (var i = 0; i < pendingNow.length; i++) {
363
- var pk = pendingNow[i].key;
364
- if (prevPendingKeys.indexOf(pk) === -1 && !attentionKeysFired[pk]) {
365
- newKeys.push(pendingNow[i]);
366
- attentionKeysFired[pk] = true;
367
- }
368
- }
369
- var hasPendingNow = pendingNow.length > 0;
370
-
371
- // 1) a pending interaction just appeared -> the agent is waiting on the user.
372
- // First evaluation of a session also lands here (prevPendingKeys empty),
373
- // so a wait that already existed when the plugin (or page) mounted still
374
- // alerts once.
375
- if (newKeys.length > 0) {
376
- var it = newKeys[0];
377
- var detail = textFromPayload(it.payload);
378
- if (!detail) detail = "";
379
- fireAttention(it.kind, sessionLabelText(row), detail);
380
- lastAttentionAt = Date.now();
381
- }
382
-
383
- // 2) a non-selected session finished while we were away (sidebar "done")
384
- var notified = false;
385
- if (!row.face && row.completed && prev && !prev.completed && prev.seeded) {
386
- fireDone(sessionLabelText(row), "");
387
- notified = true;
388
- }
389
-
390
- // 3) running true -> false on a session we were watching: a reply finished
391
- if (!notified && prev && prev.seeded && wasRunning && !runningNow && !hasPendingNow) {
392
- // ignore the edge if it happened in the same tick as an attention alert
393
- if (Date.now() - lastAttentionAt < 300) {
394
- /* answered by an attention alert above */
395
- } else {
396
- fireDone(sessionLabelText(row), row.face ? snippetOfLastAssistant(row.faceSnap) : "");
397
- }
398
- }
399
-
400
- // store the next baseline
401
- prevById[row.id] = {
402
- running: runningNow,
403
- pendingKeys: pendingNow.map(function (p) { return p.key; }),
404
- completed: !!row.completed,
405
- seeded: true
406
- };
407
-
408
- // a wait we alerted about has been resolved -> drop the title marker
409
- if (prev && prevPendingKeys.length > 0 && pendingNow.length === 0) {
410
- releaseTitleMark();
411
- }
412
- }
413
-
414
- // face -> row adapter (selected session, rich data)
415
- function evalSessionFace(sessionId, snap) {
416
- if (sessionId !== currentFaceId) return;
417
- var pend = (snap.pending || []).map(function (p) {
418
- return { key: p.key || (p.kind + ":" + sessionId), kind: p.kind, payload: p.payload || null };
419
- });
420
- evaluateRow({
421
- id: sessionId,
422
- running: !!snap.running,
423
- pending: pend,
424
- completed: false,
425
- face: true,
426
- faceSnap: snap,
427
- label: "current"
428
- });
429
- }
430
- // list row -> adapter (every other listed session)
431
- function evalListRow(sid, sum, currentId) {
432
- evaluateRow({
433
- id: sid,
434
- running: !!sum.running,
435
- pending: sum.pendingInteraction ? [{ key: "list:" + sid + ":" + sum.pendingInteraction, kind: sum.pendingInteraction, payload: null }] : [],
436
- completed: !!sum.completed,
437
- face: false,
438
- label: sum.displayTitle || sum.title || sid
439
- });
440
- }
441
-
442
- // ─────────────────────── subscription wiring ─────────────────────
443
- function bindCurrentFace() {
444
- if (!listStore) return;
445
- try {
446
- var st = listStore.getSnapshot();
447
- var cur = st && st.current;
448
- if (!cur) { detachFace(); return; }
449
- if (cur === currentFaceId && faceSub) return;
450
- detachFace();
451
- currentFaceId = cur;
452
- var b = rootCtx.sessions.binding(cur);
453
- if (!b || !b.session || typeof b.session.subscribe !== "function") {
454
- // binding not minted yet — the retry timer will re-check
455
- return;
456
- }
457
- faceSub = b.session.subscribe(function () {
458
- try {
459
- evalSessionFace(cur, b.session.getSnapshot());
460
- } catch (e) {}
461
- });
462
- // First evaluation seeds the baseline (no false completion edges) while
463
- // still alerting for interactions that were already pending on bind.
464
- try {
465
- evalSessionFace(cur, b.session.getSnapshot());
466
- } catch (e) {}
467
- } catch (e) {}
468
- }
469
- function detachFace() {
470
- if (faceSub) { try { faceSub(); } catch (e) {} faceSub = null; }
471
- currentFaceId = null;
472
- }
473
- function onListChanged() {
474
- if (!listStore) return;
475
- try {
476
- var st = listStore.getSnapshot();
477
- var cur = st && st.current;
478
- bindCurrentFace();
479
- var byId = (st && st.byId) || {};
480
- for (var sid in byId) {
481
- if (sid === cur) continue; // current handled by the face watcher
482
- var sum = byId[sid];
483
- if (!sum) continue;
484
- evalListRow(sid, sum, cur);
485
- }
486
- // sessions that vanished: keep baseline (they may reappear) - no action.
487
- } catch (e) {}
488
- }
489
-
490
- // ──────────────────────── global control API ─────────────────────
491
- var api = {
492
- // keep in sync with package.json "version" on every release
493
- version: "1.1.4",
494
- get config() { return readConfig(); },
495
- setConfig: function (patch) { return setConfigLocal(patch); },
496
- resetConfig: function () {
497
- try { localStorage.removeItem(LS_CONFIG); } catch (e) {}
498
- config = readConfig();
499
- return config;
500
- },
501
- // manual test; run it after clicking anywhere so permission can be granted.
502
- // Test alerts fire regardless of the page-open visibility rules.
503
- test: function (kind) {
504
- kind = kind === "attention" ? "attention" : "done";
505
- unlockOnce();
506
- testAlert(kind);
507
- return "sent " + kind + " (lang=" + effectiveLang() + ", permission=" + ("Notification" in window ? Notification.permission : "n/a") + ")";
508
- }
509
- };
510
- window.__dshNotifyMe = api;
511
-
512
- // ────────────────── Settings page (react only, optional) ─────────
513
- // Registered when the web profile provides react + slots + locale; the
514
- // reminder core above never depends on any of it.
515
- var NS = "dsh-notify-me";
516
- var UI_zh = {
517
- nav: "通知提醒",
518
- blurb: "当模型停下来需要你操作(审批 / 方案待确认 / 提问),或回复在后台完成时,用系统通知、提示音和标签页标题提醒你。",
519
- enable: "启用提醒",
520
- enableHint: "总开关:关闭后不再弹系统通知、不播放提示音,也不再改动标签页标题。",
521
- toast: "系统通知",
522
- toastHint: "经浏览器弹出系统 Toast(Windows 通知中心);需要已授予通知权限。",
523
- sound: "提示音",
524
- soundHint: "WebAudio 合成音,「需要你」与「回复完成」使用不同音型。",
525
- attentionVisible: "页面打开时也提醒「需要你」",
526
- attentionVisibleHint: "默认开启:模型在等你审批 / 确认方案 / 回答问题(含提问弹窗)时,即使页面在最前也会提醒。",
527
- doneVisible: "页面打开时也提醒「回复完成」",
528
- doneVisibleHint: "默认关闭:「完成」只在页面隐藏或切到后台时才提醒,避免打扰。",
529
- autoFocus: "点击通知把 DSH 切回前台",
530
- volume: "音量",
531
- language: "通知语言",
532
- langAuto: "跟随界面",
533
- langZh: "简体中文",
534
- langEn: "English",
535
- langAutoNote: "(跟随界面)",
536
- current: "界面当前语言",
537
- testTitle: "快速测试",
538
- testHint: "用当前设置各发一条测试提醒(不受上面「页面打开时也提醒」开关限制)。",
539
- testAttention: "测试「需要你」",
540
- testDone: "测试「回复完成」",
541
- reset: "恢复默认设置",
542
- resetDone: "已恢复默认设置。",
543
- sent: "已发送测试提醒 · 通知语言:",
544
- permHint: "通知权限尚未授予:先在页面上点击一次并允许通知,系统通知才能弹出(提示音不受影响)。",
545
- offHint: "已关闭提醒:先打开「启用提醒」再测试。"
546
- };
547
- var UI_en = {
548
- nav: "Notify me",
549
- blurb: "When the agent stops and needs your input (approval / plan review / question) or a reply finishes in the background, get a system notification, a sound, and a tab-title marker.",
550
- enable: "Enable reminders",
551
- enableHint: "Master switch: when off, no toasts, no sounds, and the tab title is never modified.",
552
- toast: "System notifications",
553
- toastHint: "Native toasts via the browser (Windows notification center); requires notification permission.",
554
- sound: "Sound",
555
- soundHint: "WebAudio beeps — \"needs you\" and \"reply finished\" use different patterns.",
556
- attentionVisible: "Also alert \"needs you\" while the page is open",
557
- attentionVisibleHint: "On by default: alert even in the foreground when the model waits for approval / plan review / a question.",
558
- doneVisible: "Also alert \"reply finished\" while the page is open",
559
- doneVisibleHint: "Off by default: \"done\" only alerts while the page is hidden or backgrounded, to avoid noise.",
560
- autoFocus: "Click a toast to focus the DSH window",
561
- volume: "Volume",
562
- language: "Notification language",
563
- langAuto: "Follow the interface",
564
- langZh: "简体中文",
565
- langEn: "English",
566
- langAutoNote: " (follows the interface)",
567
- current: "Current interface language",
568
- testTitle: "Quick test",
569
- testHint: "Send one test alert with the current settings (not limited by the \"while the page is open\" toggles above).",
570
- testAttention: "Test \"needs you\"",
571
- testDone: "Test \"reply finished\"",
572
- reset: "Restore defaults",
573
- resetDone: "Defaults restored.",
574
- sent: "Test alert sent · notification language: ",
575
- permHint: "Notification permission is not granted yet — click once on the page and allow notifications so toasts can appear (sound is unaffected).",
576
- offHint: "Reminders are off: enable them first, then test."
577
- };
578
- // Small element helper to keep the hand-written page readable.
579
- function h(tag, props) {
580
- var args = [tag, props || null];
581
- for (var i = 2; i < arguments.length; i++) args.push(arguments[i]);
582
- return React.createElement.apply(React, args);
583
- }
584
- // css classes (namespaced to avoid collisions)
585
- var UI_CSS = "" +
586
- ".dnm-wrap{width:100%;max-width:720px;display:flex;flex-direction:column;gap:14px;color:var(--dsw-alias-label-primary,rgba(20,20,20,.9))}" +
587
- ".dnm-blurb{margin:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9))}" +
588
- ".dnm-card{border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35));background:var(--dsw-alias-bg-layer-3,rgba(128,128,128,.05));border-radius:10px;overflow:hidden}" +
589
- ".dnm-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 14px;min-height:46px;box-sizing:border-box}" +
590
- ".dnm-row+.dnm-row{border-top:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35))}" +
591
- ".dnm-rowText{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}" +
592
- ".dnm-rowLabel{font-size:13px;font-weight:600;line-height:20px;color:var(--dsw-alias-label-primary,rgba(20,20,20,.9))}" +
593
- ".dnm-rowHint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9))}" +
594
- ".dnm-switch{flex:none;position:relative;width:34px;height:20px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.45));background:var(--dsw-alias-bg-layer-1,rgba(128,128,128,.2));cursor:pointer;padding:0;transition:background .15s ease}" +
595
- ".dnm-switch.on{background:var(--dsw-alias-state-business-primary,#3b82f6);border-color:transparent}" +
596
- ".dnm-knob{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;transition:left .15s ease}" +
597
- ".dnm-switch.on .dnm-knob{left:16px}" +
598
- ".dnm-range{width:150px;accent-color:var(--dsw-alias-state-business-primary,#3b82f6)}" +
599
- ".dnm-select{border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));background:var(--dsw-alias-bg-layer-1,rgba(255,255,255,.03));color:var(--dsw-alias-label-primary,rgba(20,20,20,.9));border-radius:8px;padding:5px 8px;font-size:13px;font-family:inherit}" +
600
- ".dnm-btn{border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));background:transparent;color:var(--dsw-alias-label-primary,rgba(20,20,20,.9));border-radius:8px;padding:5px 12px;font-size:13px;cursor:pointer;font-family:inherit}" +
601
- ".dnm-btn:hover{background:var(--dsw-alias-bg-layer-1,rgba(128,128,128,.1))}" +
602
- ".dnm-btn[disabled]{opacity:.45;cursor:default}" +
603
- ".dnm-btnRow{display:flex;align-items:center;gap:10px;flex-wrap:wrap}" +
604
- ".dnm-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9))}" +
605
- ".dnm-feedback{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9));min-height:18px}";
606
- (function injectStyle() {
607
- try {
608
- if (typeof document === "undefined" || typeof document.createElement !== "function") return;
609
- var tagId = "dsh-notify-me/settings";
610
- if (document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]")) return;
611
- var s = document.createElement("style");
612
- s.dataset.plugin = "dsh-notify-me";
613
- s.dataset.pluginCss = tagId;
614
- s.textContent = UI_CSS;
615
- (document.head || document.documentElement).appendChild(s);
616
- } catch (e) {}
617
- })();
618
-
619
- function rowSwitch(key, labelText, hintText, checked, onChange) {
620
- return h("div", { className: "dnm-row" }, [
621
- h("div", { className: "dnm-rowText" }, [
622
- h("div", { className: "dnm-rowLabel" }, labelText),
623
- hintText ? h("div", { className: "dnm-rowHint" }, hintText) : null
624
- ]),
625
- h("button", {
626
- type: "button",
627
- role: "switch",
628
- "aria-checked": checked ? "true" : "false",
629
- className: "dnm-switch" + (checked ? " on" : ""),
630
- onClick: function () { onChange(!checked); }
631
- }, h("span", { className: "dnm-knob" }))
632
- ]);
633
- }
634
- function card(children) {
635
- return h("div", { className: "dnm-card" }, children);
636
- }
637
-
638
- function NotifySettingsView(props) {
639
- var ctx = props.ctx;
640
- var t = props.t;
641
- // Keep the page copy in sync with the active DSH UI language.
642
- React.useSyncExternalStore(function (cb) { return ctx.locale.subscribe(cb); }, function () { return ctx.locale.getSnapshot(); });
643
- var cfgState = React.useState(function () { return readConfig(); });
644
- var cfg = cfgState[0];
645
- var setCfg = cfgState[1];
646
- var feedbackState = React.useState("");
647
- var feedback = feedbackState[0];
648
- var setFeedback = feedbackState[1];
649
- function patch(p) {
650
- setCfg(setConfigLocal(p));
651
- }
652
- function runTest(kind) {
653
- if (!config.enabled) { setFeedback(t("offHint")); return; }
654
- unlockOnce();
655
- // Bypasses the visibility rules on purpose: a test must fire while the
656
- // page is open, otherwise the button proves nothing.
657
- testAlert(kind);
658
- var langName = effectiveLang() === "en" ? t("langEn") : t("langZh");
659
- setFeedback(t("sent") + langName);
660
- }
661
- var permOK = ("Notification" in window) ? Notification.permission === "granted" : false;
662
- var resolvedLang = effectiveLang();
663
- var resolvedLangName = resolvedLang === "en" ? t("langEn") : t("langZh");
664
- return h("div", { className: "dnm-wrap" }, [
665
- h("p", { className: "dnm-blurb" }, t("blurb")),
666
- card([
667
- rowSwitch("enabled", t("enable"), t("enableHint"), cfg.enabled, function (v) {
668
- patch({ enabled: v });
669
- if (!v && attentionActive > 0) { attentionActive = 0; applyTitle(); }
670
- })
671
- ]),
672
- card([
673
- rowSwitch("toast", t("toast"), t("toastHint"), cfg.toast, function (v) { patch({ toast: v }); }),
674
- rowSwitch("sound", t("sound"), t("soundHint"), cfg.sound, function (v) { patch({ sound: v }); }),
675
- rowSwitch("attentionVisible", t("attentionVisible"), t("attentionVisibleHint"), !cfg.attentionHiddenOnly, function (v) { patch({ attentionHiddenOnly: !v }); }),
676
- rowSwitch("doneVisible", t("doneVisible"), t("doneVisibleHint"), !cfg.doneHiddenOnly, function (v) { patch({ doneHiddenOnly: !v }); }),
677
- rowSwitch("autoFocus", t("autoFocus"), null, cfg.autoFocus, function (v) { patch({ autoFocus: v }); }),
678
- h("div", { className: "dnm-row" }, [
679
- h("div", { className: "dnm-rowText" }, [
680
- h("div", { className: "dnm-rowLabel" }, t("volume")),
681
- h("div", { className: "dnm-rowHint" }, String(Math.round(cfg.volume * 100)) + "%")
682
- ]),
683
- h("input", {
684
- type: "range",
685
- className: "dnm-range",
686
- min: 0, max: 100, step: 5,
687
- value: Math.round(cfg.volume * 100),
688
- "aria-label": t("volume"),
689
- onChange: function (e) { patch({ volume: Number(e.currentTarget.value) / 100 }); }
690
- })
691
- ])
692
- ]),
693
- card([
694
- h("div", { className: "dnm-row" }, [
695
- h("div", { className: "dnm-rowText" }, [
696
- h("div", { className: "dnm-rowLabel" }, t("language")),
697
- h("div", { className: "dnm-rowHint" }, resolvedLangName + (cfg.language === "auto" ? t("langAutoNote") : ""))
698
- ]),
699
- h("select", {
700
- className: "dnm-select",
701
- value: cfg.language,
702
- "aria-label": t("language"),
703
- onChange: function (e) { patch({ language: e.currentTarget.value }); }
704
- }, [
705
- h("option", { value: "auto" }, t("langAuto")),
706
- h("option", { value: "zh" }, t("langZh")),
707
- h("option", { value: "en" }, t("langEn"))
708
- ])
709
- ]),
710
- h("div", { className: "dnm-row" }, [
711
- h("div", { className: "dnm-rowText" }, [
712
- h("div", { className: "dnm-rowLabel" }, t("testTitle")),
713
- h("div", { className: "dnm-rowHint" }, t("testHint"))
714
- ]),
715
- h("div", { className: "dnm-btnRow" }, [
716
- h("button", { type: "button", className: "dnm-btn", disabled: !cfg.enabled, onClick: function () { runTest("attention"); } }, t("testAttention")),
717
- h("button", { type: "button", className: "dnm-btn", disabled: !cfg.enabled, onClick: function () { runTest("done"); } }, t("testDone"))
718
- ])
719
- ]),
720
- h("div", { className: "dnm-row" }, [
721
- h("div", { className: "dnm-rowText" }, [
722
- h("div", { className: "dnm-rowLabel" }, t("reset")),
723
- h("div", { className: "dnm-rowHint" }, " ")
724
- ]),
725
- h("button", { type: "button", className: "dnm-btn", onClick: function () {
726
- try { localStorage.removeItem(LS_CONFIG); } catch (e) {}
727
- config = readConfig();
728
- applyTitle();
729
- setCfg(config);
730
- setFeedback(t("resetDone"));
731
- } }, t("reset"))
732
- ]),
733
- !permOK ? h("div", { className: "dnm-row" }, [
734
- h("div", { className: "dnm-rowText" }, [h("div", { className: "dnm-rowHint" }, t("permHint"))])
735
- ]) : null
736
- ]),
737
- h("p", { className: "dnm-feedback" }, feedback)
738
- ]);
739
- }
740
-
741
- // ────────────────────────── plugin body ──────────────────────────
742
- var inject = ["sessions", "locale", "slots"];
743
-
744
- /**
745
- * Client plugin body: watch the sessions service and raise desktop
746
- * notifications + sounds when the agent needs input or a reply finishes;
747
- * also contributes a Settings page (settings.section slot).
748
- * @param ctx - client root context.
749
- */
750
- function apply(ctx) {
751
- rootCtx = ctx;
752
- try {
753
- if (ctx.locale && typeof ctx.locale.getLocale === "function") localeSvc = ctx.locale;
754
- } catch (e) {}
755
- // Settings UI — only when the full web profile (react + slots + locale)
756
- // is present. The reminder core above never depends on this.
757
- try {
758
- if (React && React.createElement && ctx.slots && ctx.locale && typeof ctx.locale.register === "function" && typeof ctx.slots.inject === "function") {
759
- ctx.effect(function () {
760
- ctx.locale.register(NS, { zh: UI_zh, en: UI_en });
761
- }, "dsh-notify-me: settings dictionaries");
762
- var t = ctx.locale.bind(NS);
763
- ctx.slots.inject("settings.section", function () {
764
- return ctx.slots.register({
765
- name: "settings.section",
766
- id: "dsh-notify-me",
767
- order: 45,
768
- label: function () { return t("nav"); },
769
- locale: NS,
770
- inject: function () { return { t: t }; }
771
- }, function (ownerProps) {
772
- return React.createElement(NotifySettingsView, { ctx: ctx, t: t });
773
- });
774
- });
775
- }
776
- } catch (e) {
777
- try { console.error("[dsh-notify-me] settings page unavailable", e); } catch (e2) {}
778
- }
779
- ctx.effect(function () {
780
- var disposed = false;
781
- var unsubs = [];
782
- var retryTimer = null;
783
- try {
784
- var sessionsSvc = ctx.sessions;
785
- if (!sessionsSvc || !sessionsSvc.list || typeof sessionsSvc.list.subscribe !== "function") {
786
- try { console.log("[dsh-notify-me] sessions service unavailable — watchers idle"); } catch (e) {}
787
- return;
788
- }
789
- listStore = sessionsSvc.list;
790
- unsubs.push(sessionsSvc.list.subscribe(onListChanged));
791
- // initial scan (baseline only; no alerts for pre-existing state)
792
- onListChanged();
793
- // binding may resolve a tick later; re-check periodically while idle
794
- retryTimer = setInterval(function () {
795
- if (disposed) return;
796
- try { bindCurrentFace(); } catch (e) {}
797
- }, 1500);
798
- try { console.log("[dsh-notify-me] active — window.__dshNotifyMe 可配置提醒 (enabled=" + config.enabled + ", lang=" + effectiveLang() + ", toast=" + config.toast + ", sound=" + config.sound + ")"); } catch (e) {}
799
- } catch (e) {
800
- try { console.error("[dsh-notify-me] init failed", e); } catch (e2) {}
801
- }
802
- return function () {
803
- disposed = true;
804
- if (retryTimer) clearInterval(retryTimer);
805
- for (var i = 0; i < unsubs.length; i++) { try { unsubs[i](); } catch (e) {} }
806
- detachFace();
807
- };
808
- }, "dsh-notify-me: watchers");
809
- }
810
-
811
- exports.inject = inject;
812
- exports.apply = apply;
813
- return module.exports;
814
- }
815
- });
1
+ // dsh-notify-me — browser half (client plugin bundle).
2
+ //
3
+ // Loaded by dsh-client-modules at /plugins/dsh-notify-me/client.js and
4
+ // executed through the vendored cordis Loader's lazy-CJS module table
5
+ // (window.__ModuleLoader__.load). The factory body is plain CJS with
6
+ // require() resolved against the shell's module table — the same shape the
7
+ // shipped ui-* packages' tsdown bundles emit. react is part of the platform
8
+ // base, so require("react") resolves to the shell-seeded instance (same as
9
+ // every shipped ui-* bundle); the settings page is skipped when react or the
10
+ // slots/locale services are absent, keeping the reminder core self-contained.
11
+ //
12
+ // What this plugin does (all client-side, no settings transport needed):
13
+ // - watches two services, because pending interactions and the running bit
14
+ // have different owners on current hosts:
15
+ // * "uiSession" -> pendingInteractions (a Map<sessionId, interaction>
16
+ // snapshot whose values carry kind 'approval' | 'plan-review' |
17
+ // 'question'). Present since DSH 0.1.2-alpha.2; the controller
18
+ // snapshots stopped carrying `pending` in the same release, so this is
19
+ // the only live source of "the agent is waiting on you" on 0.1.2+.
20
+ // It is resolved through ctx.get() — cordis throws on a plain
21
+ // ctx.uiSession read for any name absent from the plugin's inject map;
22
+ // * "sessions" -> the selected session's ConversationSnapshot (running)
23
+ // and the session-list summaries of every other session (running /
24
+ // completed), which carry the "reply finished" edge on every version;
25
+ // - fires a Windows desktop Notification (+ optional sound) when the agent
26
+ // needs the user's input — pending interaction kinds 'approval',
27
+ // 'plan-review' or 'question' — and when a reply/turn finishes;
28
+ // - registers a "通知提醒 / Notify me" page into DSH Settings
29
+ // (settings.section slot) exposing an on/off master switch, per-channel
30
+ // toggles, volume and the notification language (follow-interface /
31
+ // Simplified Chinese / English);
32
+ // - preferences persist in localStorage (origin-scoped, like other
33
+ // third-party client plugins) and are tweakable on window.__dshNotifyMe.
34
+
35
+ window.__ModuleLoader__.load({
36
+ id: "dsh-notify-me",
37
+ factory: (require) => {
38
+ var module = { exports: {} };
39
+ var exports = module.exports;
40
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
41
+
42
+ // react is externalized into the loader module table; never required by
43
+ // the reminder core, only by the optional Settings page.
44
+ var React = null;
45
+ try { React = require("react") || null; } catch (e) { React = null; }
46
+
47
+ // ───────────────────────── configuration ─────────────────────────
48
+ var LS_CONFIG = "dshNotifyMe.config";
49
+ var DEFAULTS = {
50
+ enabled: true, // master switch: off = no toast/sound/title marker at all
51
+ // language of the notification copy: 'auto' follows the DSH UI locale,
52
+ // 'zh' and 'en' pin Simplified Chinese / English.
53
+ language: "auto",
54
+ // toast + sound are only useful while the user is elsewhere; the tab
55
+ // being hidden is the reliable "away" signal.
56
+ attentionHiddenOnly: false, // needs-input alerts even while the page is visible
57
+ // A wait inside the conversation already on screen needs no toast: the
58
+ // approval card is right there, and a toast lands on top of it. Leaving
59
+ // the page brings that alert back (see flushQuieted).
60
+ currentHiddenOnly: true, // the visible session's own waits stay silent
61
+ doneHiddenOnly: true, // reply-finished alerts only when the page is hidden
62
+ toast: true,
63
+ sound: true,
64
+ volume: 0.5,
65
+ autoFocus: true // clicking the toast focuses the DSH window
66
+ };
67
+ function readConfig() {
68
+ var cfg = {};
69
+ try {
70
+ var raw = localStorage.getItem(LS_CONFIG);
71
+ if (raw) cfg = JSON.parse(raw);
72
+ } catch (e) { /* corrupt storage -> defaults */ }
73
+ for (var k in DEFAULTS) if (!(k in cfg)) cfg[k] = DEFAULTS[k];
74
+ return cfg;
75
+ }
76
+ function writeConfig(patch) {
77
+ var cfg = readConfig();
78
+ for (var k in patch) cfg[k] = patch[k];
79
+ try { localStorage.setItem(LS_CONFIG, JSON.stringify(cfg)); } catch (e) {}
80
+ return cfg;
81
+ }
82
+ var config = readConfig();
83
+ // Persist a patch, swap the live module config and re-apply any active
84
+ // title marker (its prefix follows the notification language).
85
+ function setConfigLocal(patch) {
86
+ config = writeConfig(patch);
87
+ applyTitle();
88
+ return config;
89
+ }
90
+
91
+ // ───────────────────────── copy tables ─────────────────────────
92
+ // Notification copy, keyed by language id. Selected through
93
+ // effectiveLang() so a pinned language stays pinned even while the DSH UI
94
+ // speaks the other one.
95
+ var TXT = {
96
+ zh: {
97
+ kind: { approval: "审批请求", "plan-review": "方案待确认", question: "提问" },
98
+ kindDefault: "需要操作",
99
+ bodyKind: { approval: "有审批待处理", "plan-review": "方案待确认", question: "有提问待回答" },
100
+ bodyDefault: "需要你操作",
101
+ current: "当前对话",
102
+ doneTitle: "DSH · 回复完成",
103
+ doneBody: "回复已完成",
104
+ background: "后台会话",
105
+ marker: "🔔 需要你 · ",
106
+ sep: ":",
107
+ testAttention: "这是一条测试提醒:DSH 需要你的操作",
108
+ testDone: "测试提醒 · 回复完成示例"
109
+ },
110
+ en: {
111
+ kind: { approval: "Approval request", "plan-review": "Plan review", question: "Question" },
112
+ kindDefault: "Action needed",
113
+ bodyKind: {
114
+ approval: "An approval is waiting for you",
115
+ "plan-review": "A plan awaits your review",
116
+ question: "A question is waiting for you"
117
+ },
118
+ bodyDefault: "Your input is needed",
119
+ current: "Current conversation",
120
+ doneTitle: "DSH · Reply finished",
121
+ doneBody: "Reply finished",
122
+ background: "Background session",
123
+ marker: "🔔 Action needed · ",
124
+ sep: ": ",
125
+ testAttention: "This is a test alert: DSH is waiting for your input",
126
+ testDone: "Test alert · reply-finished sample"
127
+ }
128
+ };
129
+ // Locale service handle (registered on the client context by dsh-client-locale).
130
+ var localeSvc = null;
131
+ // Current DSH UI language: 'zh' | 'en' | null (unknown). Used by the
132
+ // 'auto' notification-language mode.
133
+ function currentUILang() {
134
+ try {
135
+ if (localeSvc && typeof localeSvc.getLocale === "function") {
136
+ var a = localeSvc.getLocale().active;
137
+ if (typeof a === "string" && a) {
138
+ if (a.indexOf("zh") === 0) return "zh";
139
+ if (a.indexOf("en") === 0) return "en";
140
+ }
141
+ }
142
+ } catch (e) {}
143
+ try {
144
+ var dl = (document.documentElement && document.documentElement.lang) || "";
145
+ if (dl.indexOf("zh") === 0) return "zh";
146
+ if (dl.indexOf("en") === 0) return "en";
147
+ } catch (e) {}
148
+ return null;
149
+ }
150
+ function effectiveLang() {
151
+ if (!config.language || config.language === "auto") return currentUILang() || "zh";
152
+ return config.language === "en" ? "en" : "zh";
153
+ }
154
+ function texts() { return TXT[effectiveLang()] || TXT.zh; }
155
+
156
+ // ─────────────────────── notification plumbing ───────────────────
157
+ var permAsked = false;
158
+ function requestPermission() {
159
+ if (!("Notification" in window) || permAsked) return;
160
+ permAsked = true;
161
+ try {
162
+ if (Notification.permission === "default") {
163
+ Notification.requestPermission().catch(function () {});
164
+ }
165
+ } catch (e) {}
166
+ }
167
+ function canToast() {
168
+ if (!config.toast) return false;
169
+ try {
170
+ return "Notification" in window && Notification.permission === "granted";
171
+ } catch (e) { return false; } // a policy-disabled Notification must not break delivery
172
+ }
173
+ // Bring the window forward and make the alerted session the current one,
174
+ // so a click on the toast lands on the conversation that needs the user
175
+ // instead of just the app. Every failure here is non-fatal: a session that
176
+ // left the list, an older host without `open()`, or a cordis service that
177
+ // cannot be resolved must still leave a usable window in front.
178
+ function focusSession(sessionId) {
179
+ try { window.focus(); } catch (e) {}
180
+ if (!sessionId) return;
181
+ try {
182
+ var svc = lookupService("sessions");
183
+ if (!svc || typeof svc.open !== "function") return;
184
+ var st = listStore && listStore.getSnapshot();
185
+ if (st && st.byId && !st.byId[sessionId] && st.current !== sessionId) return;
186
+ svc.open(sessionId);
187
+ } catch (e) {}
188
+ }
189
+ function showToast(kind, title, body, sessionId) {
190
+ if (!canToast()) return;
191
+ try {
192
+ var n = new Notification(title, {
193
+ body: body || "",
194
+ tag: "dsh-notify-me-" + kind,
195
+ renotify: true,
196
+ icon: (location.origin || "") + "/favicon.svg"
197
+ });
198
+ if (config.autoFocus) {
199
+ n.onclick = function () {
200
+ focusSession(sessionId);
201
+ n.close();
202
+ };
203
+ }
204
+ setTimeout(function () { n.close(); }, 15000);
205
+ } catch (e) {}
206
+ }
207
+ // Sound: WebAudio beeps (no asset needed). The context only runs after a
208
+ // user gesture; we unlock it on the first pointer/key event.
209
+ var audioCtx = null;
210
+ function unlockAudio() {
211
+ try {
212
+ if (!audioCtx) {
213
+ var AC = window.AudioContext || window.webkitAudioContext;
214
+ if (!AC) return;
215
+ audioCtx = new AC();
216
+ }
217
+ if (audioCtx.state === "suspended") audioCtx.resume().catch(function () {});
218
+ } catch (e) {}
219
+ }
220
+ function tone(freq, startIn, durMs, vol) {
221
+ if (!audioCtx || audioCtx.state !== "running") return;
222
+ try {
223
+ var t0 = audioCtx.currentTime + startIn;
224
+ var osc = audioCtx.createOscillator();
225
+ var gain = audioCtx.createGain();
226
+ osc.type = "sine";
227
+ osc.frequency.value = freq;
228
+ gain.gain.setValueAtTime(0.0001, t0);
229
+ gain.gain.exponentialRampToValueAtTime(vol, t0 + 0.02);
230
+ gain.gain.exponentialRampToValueAtTime(0.0001, t0 + durMs / 1000);
231
+ osc.connect(gain).connect(audioCtx.destination);
232
+ osc.start(t0);
233
+ osc.stop(t0 + durMs / 1000 + 0.05);
234
+ } catch (e) {}
235
+ }
236
+ function playAttentionSound() {
237
+ if (!config.sound) return;
238
+ unlockAudio();
239
+ tone(880, 0.0, 140, config.volume);
240
+ tone(1174, 0.16, 140, config.volume);
241
+ tone(1568, 0.32, 200, config.volume);
242
+ }
243
+ function playDoneSound() {
244
+ if (!config.sound) return;
245
+ unlockAudio();
246
+ tone(659, 0.0, 150, config.volume);
247
+ tone(988, 0.18, 260, config.volume);
248
+ }
249
+ function unlockOnce() {
250
+ unlockAudio();
251
+ requestPermission();
252
+ }
253
+ function onFirstGesture() {
254
+ unlockOnce();
255
+ window.removeEventListener("pointerdown", onFirstGesture, true);
256
+ window.removeEventListener("keydown", onFirstGesture, true);
257
+ }
258
+ window.addEventListener("pointerdown", onFirstGesture, true);
259
+ window.addEventListener("keydown", onFirstGesture, true);
260
+
261
+ // ────────────────────────── title marker ─────────────────────────
262
+ // The host owns document.title (it tracks the session/workspace name), so
263
+ // the base title is re-read on every apply instead of being frozen once —
264
+ // and the exact string we last wrote is remembered, so the host taking the
265
+ // title over (or an earlier load leaving a marker behind) can never make us
266
+ // mistake our own marker for the base title.
267
+ var attentionActive = 0;
268
+ var hostBase = null; // document.title as the host last left it
269
+ var writtenTitle = null; // exact string this plugin last put into document.title
270
+ // Our own marker prefix never belongs to the base title — strip a leftover
271
+ // one (an earlier load that was closed while a wait was pending, or a title
272
+ // the host tracked while a marker was up) before storing the base.
273
+ function cleanBase(t) {
274
+ var d = texts();
275
+ var prefixes = [d.marker, TXT.zh.marker, TXT.en.marker];
276
+ for (var i = 0; i < prefixes.length; i++) {
277
+ while (t.indexOf(prefixes[i]) === 0) t = t.slice(prefixes[i].length);
278
+ }
279
+ return t;
280
+ }
281
+ function applyTitle() {
282
+ try {
283
+ var current = String(document.title || "");
284
+ if (current !== writtenTitle) hostBase = cleanBase(current);
285
+ var next = (config.enabled && attentionActive > 0) ? texts().marker + hostBase : hostBase;
286
+ writtenTitle = next;
287
+ if (current !== next) document.title = next;
288
+ } catch (e) {}
289
+ }
290
+ function acquireTitleMark() {
291
+ attentionActive += 1;
292
+ applyTitle();
293
+ }
294
+ function releaseTitleMark() {
295
+ if (attentionActive > 0) attentionActive -= 1;
296
+ applyTitle();
297
+ }
298
+ // Title marker ownership: on hosts that publish pending interactions
299
+ // (uiSession) that source owns the marker, but whichever source saw the wait
300
+ // first must still mark it (the other one may never report that key at all),
301
+ // so the mark is acquired by the first reporter and released only by its
302
+ // owner. Without this, the two sources would either double-mark or never
303
+ // clear the mark.
304
+ function markAttention(key) {
305
+ if (attentionKeysMarked[key]) return;
306
+ attentionKeysMarked[key] = true;
307
+ acquireTitleMark();
308
+ }
309
+ function unmarkAttention(key) {
310
+ delete quietedAttention[key]; // the wait is over; a queued alert dies with it
311
+ if (!attentionKeysMarked[key]) return;
312
+ delete attentionKeysMarked[key];
313
+ releaseTitleMark();
314
+ }
315
+
316
+ // ─────────────────────── shared alert core ───────────────────────
317
+ var attentionKeysFired = Object.create(null); // dedupe per pending key
318
+ var attentionKeysMarked = Object.create(null); // keys currently holding a title mark
319
+ // Waits silenced because their session is the one on screen, keyed by
320
+ // interaction key so an entry dies with the wait it belongs to (the mark
321
+ // release path deletes it) and is re-delivered once the page is backgrounded.
322
+ var quietedAttention = Object.create(null);
323
+ var lastAttentionAt = 0;
324
+
325
+ function hidden() { return document.hidden || document.visibilityState === "hidden"; }
326
+
327
+ // Deliver one alert regardless of visibility rules (used by the test
328
+ // buttons); respects the master switch and permission state. The title
329
+ // marker is NOT touched here — its ownership belongs to the watchers
330
+ // (markAttention / unmarkAttention), which keeps the counter balanced.
331
+ function deliver(kind, title, body, sessionId) {
332
+ if (!config.enabled) return;
333
+ showToast(kind, title, body, sessionId);
334
+ if (kind === "attention") {
335
+ playAttentionSound();
336
+ } else {
337
+ playDoneSound();
338
+ }
339
+ try {
340
+ if (window.__dshNotifyMe && window.__dshNotifyMe.onEvent) {
341
+ window.__dshNotifyMe.onEvent(kind, { title: title, body: body, sessionId: sessionId || null });
342
+ }
343
+ } catch (e) {}
344
+ }
345
+ function fire(kind, title, body, isCurrent, sessionId) {
346
+ if (!config.enabled) return false;
347
+ if (kind === "attention" && config.attentionHiddenOnly && !hidden()) return false;
348
+ // The wait belongs to the conversation the user is looking at right now:
349
+ // the approval card is already on screen, so a toast would only cover the
350
+ // thing it is asking about. The caller keeps the tab marker, and
351
+ // flushQuieted() re-delivers the alert the moment the page is backgrounded.
352
+ if (kind === "attention" && quietedByVisibleSession(isCurrent)) return false;
353
+ if (kind === "done" && config.doneHiddenOnly && !hidden()) return false;
354
+ deliver(kind, title, body, sessionId);
355
+ return true;
356
+ }
357
+ // True when this alert must stay silent purely because it is about the
358
+ // session already on screen. Kept separate from fire() so the caller can
359
+ // queue exactly this case and nothing else.
360
+ function quietedByVisibleSession(isCurrent) {
361
+ return !!(isCurrent && config.currentHiddenOnly && !hidden());
362
+ }
363
+ // Queued copies of the alerts that rule suppressed. Delivered by key so a
364
+ // wait that got answered while the user was still on the page never fires.
365
+ function queueQuieted(key, copy) {
366
+ if (key && copy) quietedAttention[key] = copy;
367
+ }
368
+ function flushQuieted() {
369
+ if (!config.enabled || !hidden()) return;
370
+ var keys = Object.keys(quietedAttention);
371
+ for (var i = 0; i < keys.length; i++) {
372
+ var copy = quietedAttention[keys[i]];
373
+ delete quietedAttention[keys[i]];
374
+ fire("attention", copy.title, copy.body, false, copy.sessionId);
375
+ }
376
+ }
377
+ // A display label for a session row ("current" maps to the localized
378
+ // "当前对话 / Current conversation" placeholder).
379
+ function sessionLabelText(row) {
380
+ var raw = row && row.label;
381
+ if (!raw || raw === "current") return texts().current;
382
+ return raw;
383
+ }
384
+ // The session the user is looking at, or null while the list is not ready.
385
+ function currentSessionId() {
386
+ try {
387
+ var st = listStore && listStore.getSnapshot();
388
+ return (st && st.current) || null;
389
+ } catch (e) { return null; }
390
+ }
391
+ // Display label for a session id we only know from the interaction map:
392
+ // the list row's title, the localized "current" placeholder for the selected
393
+ // session, the raw id as a last resort.
394
+ function labelForSessionId(sid) {
395
+ try {
396
+ var st = listStore && listStore.getSnapshot();
397
+ if (st) {
398
+ var sum = st.byId && st.byId[sid];
399
+ if (sum) return sessionLabelText({ label: sum.displayTitle || sum.title || "" });
400
+ if (st.current === sid) return texts().current;
401
+ }
402
+ } catch (e) {}
403
+ return sid;
404
+ }
405
+ // Returns null once the alert went out, or the copy it would have shown
406
+ // when only the visible-session rule held it back — the caller queues that
407
+ // copy by interaction key so it can still fire from the background.
408
+ function fireAttention(kind, sessionLabel, detail, isCurrent, sessionId) {
409
+ var d = texts();
410
+ var label = d.kind[kind] || d.kindDefault;
411
+ var content = detail || d.bodyKind[kind] || d.bodyDefault;
412
+ var who = sessionLabel || "";
413
+ var title = "DSH · " + label;
414
+ var body = (who ? who + d.sep : "") + content;
415
+ if (quietedByVisibleSession(isCurrent)) return { title: title, body: body, sessionId: sessionId || null };
416
+ fire("attention", title, body, isCurrent, sessionId);
417
+ return null;
418
+ }
419
+ function fireDone(sessionLabel, snippet, sessionId) {
420
+ var d = texts();
421
+ var body = sessionLabel || d.doneBody;
422
+ if (snippet) body = body + "\n" + snippet;
423
+ fire("done", d.doneTitle, body, false, sessionId);
424
+ }
425
+ // Test alerts deliberately ignore the visibility rules (attentionHiddenOnly
426
+ // / doneHiddenOnly): the whole point of a test button is to prove the
427
+ // channel works while you are looking at the page. Only the master switch
428
+ // (and browser notification permission) can stop them. The attention test
429
+ // also drops its title marker again shortly after, so a test never leaves a
430
+ // standing "waiting on you" mark in the tab.
431
+ var TEST_MARK_RELEASE_MS = 6000;
432
+ var TEST_MARK_KEY = "test:attention";
433
+ function testAlert(kind) {
434
+ var d = texts();
435
+ if (kind === "attention") {
436
+ deliver("attention", "DSH · " + d.kind.question, d.testAttention);
437
+ markAttention(TEST_MARK_KEY);
438
+ setTimeout(function () { unmarkAttention(TEST_MARK_KEY); }, TEST_MARK_RELEASE_MS);
439
+ } else {
440
+ deliver("done", d.doneTitle, d.testDone);
441
+ }
442
+ }
443
+
444
+ // ───────────────────────── state machine ─────────────────────────
445
+ // prevById: per-session-id baseline of what we have already reported.
446
+ // { running: bool, pendingKeys: string[] , doneArmed: bool, seeded: bool }
447
+ var prevById = Object.create(null);
448
+ var faceSub = null; // current session face unsubscribe
449
+ var currentFaceId = null;
450
+ var listStore = null;
451
+ var rootCtx = null; // client cordis context, captured at apply()
452
+ // uiSession pending-interaction watcher (DSH >= 0.1.2-alpha.2)
453
+ var uiPending = false; // true once the interaction snapshot is readable
454
+ var uiSub = null;
455
+ var uiStore = null; // the interaction store we hold a subscription on
456
+ var uiBindNote = "not attempted"; // last lookup outcome, read by __dshNotifyMe.debug()
457
+ var uiWarned = false; // one unreachable-service warning per page load
458
+ var uiBaseline = Object.create(null); // sessionId -> { key: string }
459
+
460
+ // One-line detail for a pending interaction. Two shapes reach this:
461
+ // * the pre-0.1.2 controller payload (approval/question requested frames)
462
+ // — flat, its text under `text`/`question`/…;
463
+ // * the 0.1.2+ uiSession interaction objects — class instances whose own
464
+ // fields are the detail (PendingApproval: toolName/reason/callId,
465
+ // PendingQuestion: questions[]).
466
+ function textFromPayload(payload) {
467
+ if (!payload || typeof payload !== "object") return "";
468
+ function clip(s) {
469
+ s = String(s).replace(/\s+/g, " ").trim();
470
+ if (!s) return "";
471
+ return s.length > 160 ? s.slice(0, 160) + "…" : s;
472
+ }
473
+ // scalar fields (covers the flat pre-0.1.2 payloads and PendingApproval)
474
+ var keys = ["text", "question", "prompt", "title", "summary", "description", "message", "operation", "toolName", "reason", "name", "tool"];
475
+ for (var i = 0; i < keys.length; i++) {
476
+ var v = payload[keys[i]];
477
+ if (typeof v === "string") {
478
+ var s = clip(v);
479
+ if (s) {
480
+ // PendingApproval carries both the tool and the human-readable
481
+ // reason — "pwsh" alone says nothing useful in a toast.
482
+ if (keys[i] === "toolName" && typeof payload.reason === "string" && clip(payload.reason)) {
483
+ return s + " · " + clip(payload.reason);
484
+ }
485
+ return s;
486
+ }
487
+ } else if (v && typeof v === "object" && typeof v.text === "string" && clip(v.text)) {
488
+ return clip(v.text);
489
+ }
490
+ }
491
+ // nested batches (PendingQuestion.questions -> { question, detail, options })
492
+ var qs = payload.questions;
493
+ if (Array.isArray(qs) && qs.length > 0) {
494
+ var q = qs[0];
495
+ if (typeof q === "string") return clip(q);
496
+ if (q && typeof q === "object") {
497
+ var qk = ["question", "title", "text", "detail"];
498
+ for (var j = 0; j < qk.length; j++) {
499
+ if (typeof q[qk[j]] === "string" && clip(q[qk[j]])) return clip(q[qk[j]]);
500
+ }
501
+ }
502
+ }
503
+ return "";
504
+ }
505
+ function snippetOfLastAssistant(snap) {
506
+ try {
507
+ var nodes = snap.nodes || [];
508
+ for (var i = nodes.length - 1; i >= 0; i--) {
509
+ var nd = nodes[i];
510
+ if (nd && nd.kind === "assistant" && nd.blocks) {
511
+ for (var b = nd.blocks.length - 1; b >= 0; b--) {
512
+ if (nd.blocks[b] && nd.blocks[b].kind === "text" && nd.blocks[b].text) {
513
+ var t = nd.blocks[b].text.replace(/\s+/g, " ").trim();
514
+ if (t) return t.length > 80 ? t.slice(0, 80) + "…" : t;
515
+ }
516
+ }
517
+ }
518
+ }
519
+ } catch (e) {}
520
+ return "";
521
+ }
522
+ // Evaluate one row/face observation. row: {id, running, pending:[{key,kind,payload}], completed, label, face:bool}
523
+ function evaluateRow(row) {
524
+ var prev = prevById[row.id];
525
+ var runningNow = !!row.running;
526
+ var pendingNow = row.pending || [];
527
+ var prevPendingKeys = prev ? prev.pendingKeys : [];
528
+ var wasRunning = prev ? prev.running : false;
529
+ var newKeys = [];
530
+ for (var i = 0; i < pendingNow.length; i++) {
531
+ var pk = pendingNow[i].key;
532
+ if (prevPendingKeys.indexOf(pk) === -1 && !attentionKeysFired[pk]) {
533
+ newKeys.push(pendingNow[i]);
534
+ attentionKeysFired[pk] = true;
535
+ }
536
+ }
537
+ var hasPendingNow = pendingNow.length > 0;
538
+
539
+ // 1) a pending interaction just appeared -> the agent is waiting on the user.
540
+ // First evaluation of a session also lands here (prevPendingKeys empty),
541
+ // so a wait that already existed when the plugin (or page) mounted still
542
+ // alerts once.
543
+ // On hosts that expose uiSession this is the fallback path only: the
544
+ // interaction snapshot there is the authoritative source, and it drives
545
+ // the title marker itself (see the uiSession watcher below).
546
+ if (newKeys.length > 0) {
547
+ var it = newKeys[0];
548
+ var copy = fireAttention(it.kind, sessionLabelText(row), textFromPayload(it.payload), !!row.face, row.id);
549
+ if (copy) queueQuieted(it.key, copy);
550
+ lastAttentionAt = Date.now();
551
+ markAttention(it.key);
552
+ }
553
+
554
+ // 2) a non-selected session finished while we were away (sidebar "done")
555
+ var notified = false;
556
+ if (!row.face && row.completed && prev && !prev.completed && prev.seeded) {
557
+ fireDone(sessionLabelText(row), "", row.id);
558
+ notified = true;
559
+ }
560
+
561
+ // 3) running true -> false on a session we were watching: a reply finished
562
+ if (!notified && prev && prev.seeded && wasRunning && !runningNow && !hasPendingNow) {
563
+ // ignore the edge if it happened in the same tick as an attention alert
564
+ if (Date.now() - lastAttentionAt < 300) {
565
+ /* answered by an attention alert above */
566
+ } else {
567
+ fireDone(sessionLabelText(row), row.face ? snippetOfLastAssistant(row.faceSnap) : "", row.id);
568
+ }
569
+ }
570
+
571
+ // store the next baseline
572
+ prevById[row.id] = {
573
+ running: runningNow,
574
+ pendingKeys: pendingNow.map(function (p) { return p.key; }),
575
+ completed: !!row.completed,
576
+ seeded: true
577
+ };
578
+
579
+ // a wait we alerted about has been resolved -> drop the title marker
580
+ if (!uiPending && prev && prevPendingKeys.length > 0 && pendingNow.length === 0) {
581
+ for (var r = 0; r < prevPendingKeys.length; r++) unmarkAttention(prevPendingKeys[r]);
582
+ }
583
+ }
584
+
585
+ // face -> row adapter (selected session, rich data)
586
+ function evalSessionFace(sessionId, snap) {
587
+ if (sessionId !== currentFaceId) return;
588
+ var pend = (snap.pending || []).map(function (p) {
589
+ return { key: p.key || (p.kind + ":" + sessionId), kind: p.kind, payload: p.payload || null };
590
+ });
591
+ evaluateRow({
592
+ id: sessionId,
593
+ running: !!snap.running,
594
+ pending: pend,
595
+ completed: false,
596
+ face: true,
597
+ faceSnap: snap,
598
+ label: "current"
599
+ });
600
+ }
601
+ // list row -> adapter (every other listed session)
602
+ function evalListRow(sid, sum, currentId) {
603
+ evaluateRow({
604
+ id: sid,
605
+ running: !!sum.running,
606
+ pending: sum.pendingInteraction ? [{ key: "list:" + sid + ":" + sum.pendingInteraction, kind: sum.pendingInteraction, payload: null }] : [],
607
+ completed: !!sum.completed,
608
+ face: false,
609
+ label: sum.displayTitle || sum.title || sid
610
+ });
611
+ }
612
+
613
+ // ─────────────────────── subscription wiring ─────────────────────
614
+ function bindCurrentFace() {
615
+ if (!listStore) return;
616
+ try {
617
+ var st = listStore.getSnapshot();
618
+ var cur = st && st.current;
619
+ if (!cur) { detachFace(); return; }
620
+ if (cur === currentFaceId && faceSub) return;
621
+ detachFace();
622
+ currentFaceId = cur;
623
+ var b = rootCtx.sessions.binding(cur);
624
+ if (!b || !b.session || typeof b.session.subscribe !== "function") {
625
+ // binding not minted yet — the retry timer will re-check
626
+ return;
627
+ }
628
+ faceSub = b.session.subscribe(function () {
629
+ try {
630
+ evalSessionFace(cur, b.session.getSnapshot());
631
+ } catch (e) {}
632
+ });
633
+ // First evaluation seeds the baseline (no false completion edges) while
634
+ // still alerting for interactions that were already pending on bind.
635
+ try {
636
+ evalSessionFace(cur, b.session.getSnapshot());
637
+ } catch (e) {}
638
+ } catch (e) {}
639
+ }
640
+ function detachFace() {
641
+ if (faceSub) { try { faceSub(); } catch (e) {} faceSub = null; }
642
+ currentFaceId = null;
643
+ }
644
+ function onListChanged() {
645
+ if (!listStore) return;
646
+ try {
647
+ var st = listStore.getSnapshot();
648
+ var cur = st && st.current;
649
+ bindCurrentFace();
650
+ bindUiSession();
651
+ var byId = (st && st.byId) || {};
652
+ for (var sid in byId) {
653
+ if (sid === cur) continue; // current handled by the face watcher
654
+ var sum = byId[sid];
655
+ if (!sum) continue;
656
+ evalListRow(sid, sum, cur);
657
+ }
658
+ // sessions that vanished: keep baseline (they may reappear) - no action.
659
+ } catch (e) {}
660
+ }
661
+
662
+ // ─────────────── uiSession pending-interaction watcher ───────────
663
+ // DSH >= 0.1.2-alpha.2 moved pending interactions out of the Controller
664
+ // snapshots into their own store (uiSession.pendingInteractions), a
665
+ // Map<sessionId, interaction> whose values carry kind + key + sessionId and
666
+ // the domain's own detail fields. It is the only live "the agent is waiting
667
+ // on you" source on those hosts.
668
+ //
669
+ // The service is looked up lazily rather than declared in `inject`:
670
+ // a cordis `inject` entry is a hard gate (the entry never activates while
671
+ // the service is missing, and a never-activated entry makes the whole web
672
+ // boot audit fail), while hosts older than 0.1.2-alpha.2 have no uiSession
673
+ // at all and must keep working through the controller path above.
674
+ //
675
+ // ⚠️ Reading `ctx.uiSession` directly does NOT work here, and failing to
676
+ // notice that is exactly how this watcher stayed dead on every 0.1.2+ host:
677
+ // cordis resolves a service property only for names declared in the plugin's
678
+ // `inject` map and throws `cannot get property "uiSession" without inject`
679
+ // for every other name — sibling loader entries never leak their service
680
+ // into an ancestor store, so the old try/catch around that read silently
681
+ // produced `null` forever. ctx.get() reads the reflector store with no
682
+ // inject requirement, which is the only lazy lookup that actually resolves
683
+ // the service; ctx.reflect.get() and a plain property read stay as
684
+ // fallbacks for contexts that predate that API.
685
+ function lookupService(name) {
686
+ var svc = null;
687
+ try {
688
+ if (rootCtx && typeof rootCtx.get === "function") svc = rootCtx.get(name) || null;
689
+ } catch (e) { svc = null; }
690
+ if (svc) return svc;
691
+ try {
692
+ if (rootCtx && rootCtx.reflect && typeof rootCtx.reflect.get === "function") svc = rootCtx.reflect.get(name) || null;
693
+ } catch (e) { svc = null; }
694
+ if (svc) return svc;
695
+ try { if (rootCtx && name in rootCtx) svc = rootCtx[name] || null; } catch (e) { svc = null; }
696
+ return svc;
697
+ }
698
+ // Did some entry on this host ever provide the service? Separates the
699
+ // normal "older host has no interaction store" case (stay silent, the
700
+ // controller path still works there) from "provided but unreachable",
701
+ // which is a real defect and deserves one console warning.
702
+ function serviceWasProvided(name) {
703
+ try {
704
+ var props = rootCtx && rootCtx.reflect && rootCtx.reflect.props;
705
+ return !!(props && props[name]);
706
+ } catch (e) { return false; }
707
+ }
708
+ function noteUiBind(note) {
709
+ if (uiBindNote === note) return;
710
+ uiBindNote = note;
711
+ if (note === "bound" || uiWarned || !serviceWasProvided("uiSession")) return;
712
+ uiWarned = true;
713
+ try {
714
+ console.warn("[dsh-notify-me] uiSession is provided but not reachable (" + note + ") — approval alerts fall back to the legacy controller path, which stopped reporting pending interactions in DSH 0.1.2+");
715
+ } catch (e) {}
716
+ }
717
+ function bindUiSession() {
718
+ var svc = lookupService("uiSession");
719
+ var store = svc && svc.pendingInteractions;
720
+ if (!store || typeof store.getSnapshot !== "function" || typeof store.subscribe !== "function") {
721
+ noteUiBind(svc ? "uiSession has no pendingInteractions store" : "uiSession not visible");
722
+ return;
723
+ }
724
+ if (uiSub && uiStore === store) { noteUiBind("bound"); return; }
725
+ // the service instance was swapped (plugin HMR): drop the stale
726
+ // subscription and its marks, then bind the live store
727
+ if (uiSub) detachUiSession();
728
+ uiStore = store;
729
+ uiPending = true; // this watcher now owns the title marker
730
+ uiSub = store.subscribe(function () { try { evalUiPending(); } catch (e) {} });
731
+ noteUiBind("bound");
732
+ evalUiPending(); // seed: a wait that predates this plugin still alerts once
733
+ }
734
+ function evalUiPending() {
735
+ var svc = lookupService("uiSession");
736
+ var store = svc && svc.pendingInteractions;
737
+ if (!store || typeof store.getSnapshot !== "function") return;
738
+ var snap = store.getSnapshot(); // Map<sessionId, interaction>
739
+ if (!snap || typeof snap.forEach !== "function") return;
740
+ var seen = Object.create(null);
741
+ snap.forEach(function (it, sid) {
742
+ if (!it) return;
743
+ seen[sid] = true;
744
+ var key = it.key || (it.kind + ":" + sid);
745
+ var prev = uiBaseline[sid];
746
+ // new interactions alert once; a wait that already existed on bind
747
+ // (no baseline yet for that session) alerts here too.
748
+ if (prev && prev.key === key) return;
749
+ uiBaseline[sid] = { key: key };
750
+ // a wait replaced by a newer one in the same session: the old mark goes
751
+ // with it, otherwise the tab would keep one stale marker per replacement
752
+ if (prev) unmarkAttention(prev.key);
753
+ if (attentionKeysFired[key]) { markAttention(key); return; } // already alerted, still needs its mark
754
+ attentionKeysFired[key] = true;
755
+ var copy = fireAttention(it.kind, labelForSessionId(sid), textFromPayload(it), currentSessionId() === sid, sid);
756
+ if (copy) queueQuieted(key, copy);
757
+ lastAttentionAt = Date.now();
758
+ markAttention(key);
759
+ });
760
+ for (var sid2 in uiBaseline) {
761
+ if (seen[sid2]) continue;
762
+ // interaction answered, cancelled, or its session went away
763
+ unmarkAttention(uiBaseline[sid2].key);
764
+ delete uiBaseline[sid2];
765
+ }
766
+ }
767
+ function detachUiSession() {
768
+ if (uiSub) { try { uiSub(); } catch (e) {} uiSub = null; }
769
+ uiStore = null;
770
+ // drop every mark this watcher holds; hosts without the interaction store
771
+ // go back to the controller marker path
772
+ for (var s in uiBaseline) unmarkAttention(uiBaseline[s].key);
773
+ uiBaseline = Object.create(null);
774
+ uiPending = false;
775
+ }
776
+
777
+ // ──────────────────────── global control API ─────────────────────
778
+ // Expose the control API. Merge instead of replace: a consumer (or a test
779
+ // harness) may have registered onEvent before the plugin applied, and that
780
+ // hook must survive — binding the interaction store can deliver an alert
781
+ // synchronously, in the middle of apply().
782
+ var api = window.__dshNotifyMe;
783
+ if (!api || typeof api !== "object") api = {};
784
+ try {
785
+ Object.defineProperty(api, "config", {
786
+ get: function () { return readConfig(); },
787
+ configurable: true
788
+ });
789
+ } catch (e) {}
790
+ // keep in sync with package.json "version" on every release
791
+ api.version = "1.1.8";
792
+ api.setConfig = function (patch) { return setConfigLocal(patch); };
793
+ api.resetConfig = function () {
794
+ try { localStorage.removeItem(LS_CONFIG); } catch (e) {}
795
+ config = readConfig();
796
+ return config;
797
+ };
798
+ // manual test; run it after clicking anywhere so permission can be granted.
799
+ // Test alerts fire regardless of the page-open visibility rules.
800
+ api.test = function (kind) {
801
+ kind = kind === "attention" ? "attention" : "done";
802
+ unlockOnce();
803
+ testAlert(kind);
804
+ return "sent " + kind + " (lang=" + effectiveLang() + ", permission=" + ("Notification" in window ? Notification.permission : "n/a") + ")";
805
+ };
806
+ // Live diagnostics for the "why did nothing pop" case. The needs-input
807
+ // channel has two possible owners on current hosts (uiSession on 0.1.2+,
808
+ // the controller snapshots before that) and both used to fail silently, so
809
+ // the binding state has to be readable from the page itself.
810
+ api.debug = function () {
811
+ return {
812
+ version: api.version,
813
+ enabled: config.enabled,
814
+ language: effectiveLang(),
815
+ attentionHiddenOnly: config.attentionHiddenOnly,
816
+ currentHiddenOnly: config.currentHiddenOnly,
817
+ doneHiddenOnly: config.doneHiddenOnly,
818
+ toast: config.toast,
819
+ sound: config.sound,
820
+ permission: ("Notification" in window) ? Notification.permission : "n/a",
821
+ visibility: hidden() ? "hidden" : "visible",
822
+ uiSession: uiPending ? "bound" : "unbound",
823
+ uiSessionNote: uiBindNote,
824
+ sessionBaselines: Object.keys(prevById).length,
825
+ pendingBaselines: Object.keys(uiBaseline).length,
826
+ firedKeys: Object.keys(attentionKeysFired),
827
+ markedKeys: Object.keys(attentionKeysMarked),
828
+ quietedKeys: Object.keys(quietedAttention),
829
+ lastAttentionAt: lastAttentionAt ? new Date(lastAttentionAt).toISOString() : null
830
+ };
831
+ };
832
+ window.__dshNotifyMe = api;
833
+
834
+ // ────────────────── Settings page (react only, optional) ─────────
835
+ // Registered when the web profile provides react + slots + locale; the
836
+ // reminder core above never depends on any of it.
837
+ var NS = "dsh-notify-me";
838
+ var UI_zh = {
839
+ nav: "通知提醒",
840
+ blurb: "当模型停下来需要你操作(审批 / 方案待确认 / 提问),或回复在后台完成时,用系统通知、提示音和标签页标题提醒你。",
841
+ enable: "启用提醒",
842
+ enableHint: "总开关:关闭后不再弹系统通知、不播放提示音,也不再改动标签页标题。",
843
+ toast: "系统通知",
844
+ toastHint: "经浏览器弹出系统 Toast(Windows 通知中心);需要已授予通知权限。",
845
+ sound: "提示音",
846
+ soundHint: "WebAudio 合成音,「需要你」与「回复完成」使用不同音型。",
847
+ attentionVisible: "页面打开时也提醒「需要你」",
848
+ attentionVisibleHint: "默认开启:模型在等你审批 / 确认方案 / 回答问题(含提问弹窗)时,即使页面在最前也会提醒。",
849
+ currentQuiet: "当前对话不弹通知",
850
+ currentQuietHint: "默认开启:等待就发生在你正看着的这个对话里时,只留标签页标记,不弹系统通知、不响提示音,免得挡住审批卡片;页面切到后台后照常提醒。",
851
+ doneVisible: "页面打开时也提醒「回复完成」",
852
+ doneVisibleHint: "默认关闭:「完成」只在页面隐藏或切到后台时才提醒,避免打扰。",
853
+ autoFocus: "点击通知回到对应对话",
854
+ autoFocusHint: "默认开启:点系统通知会把 DSH 带到前台,并直接切到这条提醒所属的对话;关掉后点击通知不做任何事。",
855
+ volume: "音量",
856
+ language: "通知语言",
857
+ langAuto: "跟随界面",
858
+ langZh: "简体中文",
859
+ langEn: "English",
860
+ langAutoNote: "(跟随界面)",
861
+ current: "界面当前语言",
862
+ testTitle: "快速测试",
863
+ testHint: "用当前设置各发一条测试提醒(不受上面「页面打开时也提醒」开关限制)。",
864
+ testAttention: "测试「需要你」",
865
+ testDone: "测试「回复完成」",
866
+ reset: "恢复默认设置",
867
+ resetDone: "已恢复默认设置。",
868
+ sent: "已发送测试提醒 · 通知语言:",
869
+ permHint: "通知权限尚未授予:先在页面上点击一次并允许通知,系统通知才能弹出(提示音不受影响)。",
870
+ offHint: "已关闭提醒:先打开「启用提醒」再测试。"
871
+ };
872
+ var UI_en = {
873
+ nav: "Notify me",
874
+ blurb: "When the agent stops and needs your input (approval / plan review / question) or a reply finishes in the background, get a system notification, a sound, and a tab-title marker.",
875
+ enable: "Enable reminders",
876
+ enableHint: "Master switch: when off, no toasts, no sounds, and the tab title is never modified.",
877
+ toast: "System notifications",
878
+ toastHint: "Native toasts via the browser (Windows notification center); requires notification permission.",
879
+ sound: "Sound",
880
+ soundHint: "WebAudio beeps — \"needs you\" and \"reply finished\" use different patterns.",
881
+ attentionVisible: "Also alert \"needs you\" while the page is open",
882
+ attentionVisibleHint: "On by default: alert even in the foreground when the model waits for approval / plan review / a question.",
883
+ currentQuiet: "Stay quiet for the conversation on screen",
884
+ currentQuietHint: "On by default: a wait inside the conversation you are looking at keeps only the tab marker — no toast or sound landing on the approval card. Backgrounding the page puts the alert back.",
885
+ doneVisible: "Also alert \"reply finished\" while the page is open",
886
+ doneVisibleHint: "Off by default: \"done\" only alerts while the page is hidden or backgrounded, to avoid noise.",
887
+ autoFocus: "Click a toast to open that conversation",
888
+ autoFocusHint: "On by default: clicking a toast brings DSH forward and switches to the conversation the alert came from. When off, clicking a toast does nothing.",
889
+ volume: "Volume",
890
+ language: "Notification language",
891
+ langAuto: "Follow the interface",
892
+ langZh: "简体中文",
893
+ langEn: "English",
894
+ langAutoNote: " (follows the interface)",
895
+ current: "Current interface language",
896
+ testTitle: "Quick test",
897
+ testHint: "Send one test alert with the current settings (not limited by the \"while the page is open\" toggles above).",
898
+ testAttention: "Test \"needs you\"",
899
+ testDone: "Test \"reply finished\"",
900
+ reset: "Restore defaults",
901
+ resetDone: "Defaults restored.",
902
+ sent: "Test alert sent · notification language: ",
903
+ permHint: "Notification permission is not granted yet — click once on the page and allow notifications so toasts can appear (sound is unaffected).",
904
+ offHint: "Reminders are off: enable them first, then test."
905
+ };
906
+ // Small element helper to keep the hand-written page readable.
907
+ function h(tag, props) {
908
+ var args = [tag, props || null];
909
+ for (var i = 2; i < arguments.length; i++) args.push(arguments[i]);
910
+ return React.createElement.apply(React, args);
911
+ }
912
+ // css classes (namespaced to avoid collisions)
913
+ var UI_CSS = "" +
914
+ ".dnm-wrap{width:100%;max-width:720px;display:flex;flex-direction:column;gap:14px;color:var(--dsw-alias-label-primary,rgba(20,20,20,.9))}" +
915
+ ".dnm-blurb{margin:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9))}" +
916
+ ".dnm-card{border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35));background:var(--dsw-alias-bg-layer-3,rgba(128,128,128,.05));border-radius:10px;overflow:hidden}" +
917
+ ".dnm-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 14px;min-height:46px;box-sizing:border-box}" +
918
+ ".dnm-row+.dnm-row{border-top:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35))}" +
919
+ ".dnm-rowText{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}" +
920
+ ".dnm-rowLabel{font-size:13px;font-weight:600;line-height:20px;color:var(--dsw-alias-label-primary,rgba(20,20,20,.9))}" +
921
+ ".dnm-rowHint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9))}" +
922
+ ".dnm-switch{flex:none;position:relative;width:34px;height:20px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.45));background:var(--dsw-alias-bg-layer-1,rgba(128,128,128,.2));cursor:pointer;padding:0;transition:background .15s ease}" +
923
+ ".dnm-switch.on{background:var(--dsw-alias-state-business-primary,#3b82f6);border-color:transparent}" +
924
+ ".dnm-knob{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;transition:left .15s ease}" +
925
+ ".dnm-switch.on .dnm-knob{left:16px}" +
926
+ ".dnm-range{width:150px;accent-color:var(--dsw-alias-state-business-primary,#3b82f6)}" +
927
+ ".dnm-select{border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));background:var(--dsw-alias-bg-layer-1,rgba(255,255,255,.03));color:var(--dsw-alias-label-primary,rgba(20,20,20,.9));border-radius:8px;padding:5px 8px;font-size:13px;font-family:inherit}" +
928
+ ".dnm-btn{border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));background:transparent;color:var(--dsw-alias-label-primary,rgba(20,20,20,.9));border-radius:8px;padding:5px 12px;font-size:13px;cursor:pointer;font-family:inherit}" +
929
+ ".dnm-btn:hover{background:var(--dsw-alias-bg-layer-1,rgba(128,128,128,.1))}" +
930
+ ".dnm-btn[disabled]{opacity:.45;cursor:default}" +
931
+ ".dnm-btnRow{display:flex;align-items:center;gap:10px;flex-wrap:wrap}" +
932
+ ".dnm-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9))}" +
933
+ ".dnm-feedback{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9));min-height:18px}";
934
+ (function injectStyle() {
935
+ try {
936
+ if (typeof document === "undefined" || typeof document.createElement !== "function") return;
937
+ var tagId = "dsh-notify-me/settings";
938
+ if (document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]")) return;
939
+ var s = document.createElement("style");
940
+ s.dataset.plugin = "dsh-notify-me";
941
+ s.dataset.pluginCss = tagId;
942
+ s.textContent = UI_CSS;
943
+ (document.head || document.documentElement).appendChild(s);
944
+ } catch (e) {}
945
+ })();
946
+
947
+ function rowSwitch(key, labelText, hintText, checked, onChange) {
948
+ return h("div", { className: "dnm-row" }, [
949
+ h("div", { className: "dnm-rowText" }, [
950
+ h("div", { className: "dnm-rowLabel" }, labelText),
951
+ hintText ? h("div", { className: "dnm-rowHint" }, hintText) : null
952
+ ]),
953
+ h("button", {
954
+ type: "button",
955
+ role: "switch",
956
+ "aria-checked": checked ? "true" : "false",
957
+ className: "dnm-switch" + (checked ? " on" : ""),
958
+ onClick: function () { onChange(!checked); }
959
+ }, h("span", { className: "dnm-knob" }))
960
+ ]);
961
+ }
962
+ function card(children) {
963
+ return h("div", { className: "dnm-card" }, children);
964
+ }
965
+
966
+ function NotifySettingsView(props) {
967
+ var ctx = props.ctx;
968
+ var t = props.t;
969
+ // Keep the page copy in sync with the active DSH UI language.
970
+ React.useSyncExternalStore(function (cb) { return ctx.locale.subscribe(cb); }, function () { return ctx.locale.getSnapshot(); });
971
+ var cfgState = React.useState(function () { return readConfig(); });
972
+ var cfg = cfgState[0];
973
+ var setCfg = cfgState[1];
974
+ var feedbackState = React.useState("");
975
+ var feedback = feedbackState[0];
976
+ var setFeedback = feedbackState[1];
977
+ function patch(p) {
978
+ setCfg(setConfigLocal(p));
979
+ }
980
+ function runTest(kind) {
981
+ if (!config.enabled) { setFeedback(t("offHint")); return; }
982
+ unlockOnce();
983
+ // Bypasses the visibility rules on purpose: a test must fire while the
984
+ // page is open, otherwise the button proves nothing.
985
+ testAlert(kind);
986
+ var langName = effectiveLang() === "en" ? t("langEn") : t("langZh");
987
+ setFeedback(t("sent") + langName);
988
+ }
989
+ var permOK = ("Notification" in window) ? Notification.permission === "granted" : false;
990
+ var resolvedLang = effectiveLang();
991
+ var resolvedLangName = resolvedLang === "en" ? t("langEn") : t("langZh");
992
+ return h("div", { className: "dnm-wrap" }, [
993
+ h("p", { className: "dnm-blurb" }, t("blurb")),
994
+ card([
995
+ rowSwitch("enabled", t("enable"), t("enableHint"), cfg.enabled, function (v) {
996
+ patch({ enabled: v });
997
+ if (!v && attentionActive > 0) { attentionActive = 0; applyTitle(); }
998
+ })
999
+ ]),
1000
+ card([
1001
+ rowSwitch("toast", t("toast"), t("toastHint"), cfg.toast, function (v) { patch({ toast: v }); }),
1002
+ rowSwitch("sound", t("sound"), t("soundHint"), cfg.sound, function (v) { patch({ sound: v }); }),
1003
+ rowSwitch("attentionVisible", t("attentionVisible"), t("attentionVisibleHint"), !cfg.attentionHiddenOnly, function (v) { patch({ attentionHiddenOnly: !v }); }),
1004
+ rowSwitch("currentQuiet", t("currentQuiet"), t("currentQuietHint"), cfg.currentHiddenOnly, function (v) { patch({ currentHiddenOnly: v }); }),
1005
+ rowSwitch("doneVisible", t("doneVisible"), t("doneVisibleHint"), !cfg.doneHiddenOnly, function (v) { patch({ doneHiddenOnly: !v }); }),
1006
+ rowSwitch("autoFocus", t("autoFocus"), t("autoFocusHint"), cfg.autoFocus, function (v) { patch({ autoFocus: v }); }),
1007
+ h("div", { className: "dnm-row" }, [
1008
+ h("div", { className: "dnm-rowText" }, [
1009
+ h("div", { className: "dnm-rowLabel" }, t("volume")),
1010
+ h("div", { className: "dnm-rowHint" }, String(Math.round(cfg.volume * 100)) + "%")
1011
+ ]),
1012
+ h("input", {
1013
+ type: "range",
1014
+ className: "dnm-range",
1015
+ min: 0, max: 100, step: 5,
1016
+ value: Math.round(cfg.volume * 100),
1017
+ "aria-label": t("volume"),
1018
+ onChange: function (e) { patch({ volume: Number(e.currentTarget.value) / 100 }); }
1019
+ })
1020
+ ])
1021
+ ]),
1022
+ card([
1023
+ h("div", { className: "dnm-row" }, [
1024
+ h("div", { className: "dnm-rowText" }, [
1025
+ h("div", { className: "dnm-rowLabel" }, t("language")),
1026
+ h("div", { className: "dnm-rowHint" }, resolvedLangName + (cfg.language === "auto" ? t("langAutoNote") : ""))
1027
+ ]),
1028
+ h("select", {
1029
+ className: "dnm-select",
1030
+ value: cfg.language,
1031
+ "aria-label": t("language"),
1032
+ onChange: function (e) { patch({ language: e.currentTarget.value }); }
1033
+ }, [
1034
+ h("option", { value: "auto" }, t("langAuto")),
1035
+ h("option", { value: "zh" }, t("langZh")),
1036
+ h("option", { value: "en" }, t("langEn"))
1037
+ ])
1038
+ ]),
1039
+ h("div", { className: "dnm-row" }, [
1040
+ h("div", { className: "dnm-rowText" }, [
1041
+ h("div", { className: "dnm-rowLabel" }, t("testTitle")),
1042
+ h("div", { className: "dnm-rowHint" }, t("testHint"))
1043
+ ]),
1044
+ h("div", { className: "dnm-btnRow" }, [
1045
+ h("button", { type: "button", className: "dnm-btn", disabled: !cfg.enabled, onClick: function () { runTest("attention"); } }, t("testAttention")),
1046
+ h("button", { type: "button", className: "dnm-btn", disabled: !cfg.enabled, onClick: function () { runTest("done"); } }, t("testDone"))
1047
+ ])
1048
+ ]),
1049
+ h("div", { className: "dnm-row" }, [
1050
+ h("div", { className: "dnm-rowText" }, [
1051
+ h("div", { className: "dnm-rowLabel" }, t("reset")),
1052
+ h("div", { className: "dnm-rowHint" }, " ")
1053
+ ]),
1054
+ h("button", { type: "button", className: "dnm-btn", onClick: function () {
1055
+ try { localStorage.removeItem(LS_CONFIG); } catch (e) {}
1056
+ config = readConfig();
1057
+ applyTitle();
1058
+ setCfg(config);
1059
+ setFeedback(t("resetDone"));
1060
+ } }, t("reset"))
1061
+ ]),
1062
+ !permOK ? h("div", { className: "dnm-row" }, [
1063
+ h("div", { className: "dnm-rowText" }, [h("div", { className: "dnm-rowHint" }, t("permHint"))])
1064
+ ]) : null
1065
+ ]),
1066
+ h("p", { className: "dnm-feedback" }, feedback)
1067
+ ]);
1068
+ }
1069
+
1070
+ // ────────────────────────── plugin body ──────────────────────────
1071
+ // Hard service requirements only. `uiSession` is deliberately NOT listed:
1072
+ // a cordis inject entry is a hard gate (the entry stays pending while the
1073
+ // service is missing, and the web boot audit fails on entries that never
1074
+ // activate), while hosts older than 0.1.2-alpha.2 have no uiSession at all.
1075
+ // It is therefore looked up lazily in bindUiSession(), with the controller
1076
+ // path above as the fallback. The bundle entry
1077
+ // "@deepseek-ai/dsh-client-ui-session" stays in package.json's
1078
+ // dsh.client.inject so the load order keeps the bundle in the graph.
1079
+ var inject = ["sessions", "locale", "slots"];
1080
+
1081
+ /**
1082
+ * Client plugin body: watch the sessions service and raise desktop
1083
+ * notifications + sounds when the agent needs input or a reply finishes;
1084
+ * also contributes a Settings page (settings.section slot).
1085
+ * @param ctx - client root context.
1086
+ */
1087
+ function apply(ctx) {
1088
+ rootCtx = ctx;
1089
+ try {
1090
+ if (ctx.locale && typeof ctx.locale.getLocale === "function") localeSvc = ctx.locale;
1091
+ } catch (e) {}
1092
+ // Settings UI — only when the full web profile (react + slots + locale)
1093
+ // is present. The reminder core above never depends on this.
1094
+ try {
1095
+ if (React && React.createElement && ctx.slots && ctx.locale && typeof ctx.locale.register === "function" && typeof ctx.slots.inject === "function") {
1096
+ ctx.effect(function () {
1097
+ ctx.locale.register(NS, { zh: UI_zh, en: UI_en });
1098
+ }, "dsh-notify-me: settings dictionaries");
1099
+ var t = ctx.locale.bind(NS);
1100
+ ctx.slots.inject("settings.section", function () {
1101
+ return ctx.slots.register({
1102
+ name: "settings.section",
1103
+ id: "dsh-notify-me",
1104
+ order: 45,
1105
+ label: function () { return t("nav"); },
1106
+ locale: NS,
1107
+ inject: function () { return { t: t }; }
1108
+ }, function (ownerProps) {
1109
+ return React.createElement(NotifySettingsView, { ctx: ctx, t: t });
1110
+ });
1111
+ });
1112
+ }
1113
+ } catch (e) {
1114
+ try { console.error("[dsh-notify-me] settings page unavailable", e); } catch (e2) {}
1115
+ }
1116
+ ctx.effect(function () {
1117
+ var disposed = false;
1118
+ var unsubs = [];
1119
+ var retryTimer = null;
1120
+ try {
1121
+ var sessionsSvc = ctx.sessions;
1122
+ if (!sessionsSvc || !sessionsSvc.list || typeof sessionsSvc.list.subscribe !== "function") {
1123
+ try { console.log("[dsh-notify-me] sessions service unavailable — watchers idle"); } catch (e) {}
1124
+ return;
1125
+ }
1126
+ listStore = sessionsSvc.list;
1127
+ unsubs.push(sessionsSvc.list.subscribe(onListChanged));
1128
+ // A wait kept quiet because it was already on screen still has to
1129
+ // reach the user the moment they leave the page.
1130
+ var onVisibility = function () { try { flushQuieted(); } catch (e) {} };
1131
+ try { document.addEventListener("visibilitychange", onVisibility); } catch (e) {}
1132
+ unsubs.push(function () { try { document.removeEventListener("visibilitychange", onVisibility); } catch (e) {} });
1133
+ // Interaction source first: it owns the title marker once present, so
1134
+ // binding it before the controller scan keeps a single mark per wait.
1135
+ bindUiSession();
1136
+ // initial scan (baseline only; no alerts for pre-existing sessions)
1137
+ onListChanged();
1138
+ // binding may resolve a tick later; re-check periodically while idle
1139
+ retryTimer = setInterval(function () {
1140
+ if (disposed) return;
1141
+ try { bindCurrentFace(); } catch (e) {}
1142
+ try { bindUiSession(); } catch (e) {}
1143
+ }, 1500);
1144
+ try { console.log("[dsh-notify-me] active — window.__dshNotifyMe 可配置提醒 (enabled=" + config.enabled + ", lang=" + effectiveLang() + ", toast=" + config.toast + ", sound=" + config.sound + ")"); } catch (e) {}
1145
+ } catch (e) {
1146
+ try { console.error("[dsh-notify-me] init failed", e); } catch (e2) {}
1147
+ }
1148
+ return function () {
1149
+ disposed = true;
1150
+ if (retryTimer) clearInterval(retryTimer);
1151
+ for (var i = 0; i < unsubs.length; i++) { try { unsubs[i](); } catch (e) {} }
1152
+ detachFace();
1153
+ detachUiSession();
1154
+ };
1155
+ }, "dsh-notify-me: watchers");
1156
+ }
1157
+
1158
+ exports.inject = inject;
1159
+ exports.apply = apply;
1160
+ return module.exports;
1161
+ }
1162
+ });