dsh-plugin-notify-sounds 1.6.9 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -97,6 +97,18 @@ window.__ModuleLoader__.load({
97
97
  */
98
98
  const isMac = () => typeof navigator !== "undefined" && /Mac/i.test(`${navigator.platform ?? ""} ${navigator.userAgent ?? ""}`);
99
99
 
100
+ /**
101
+ * Whether the window-title mark has anywhere to appear.
102
+ *
103
+ * Only the macOS Desktop shell fails this, for the reason above. The
104
+ * row's switch is DISABLED rather than merely re-described: an enabled
105
+ * switch that the user can turn on and that then never does anything is
106
+ * worse than one that says it is not available yet — the old copy read
107
+ * like a working feature with a caveat, so people turned it on and
108
+ * waited.
109
+ */
110
+ const titleFlashUnavailable = () => isMac() && isDesktopShell();
111
+
100
112
  /** Stylesheet class names, one projection the components read. */
101
113
  const css = {
102
114
  section: "dsh-notify-sounds-section",
@@ -161,6 +173,8 @@ window.__ModuleLoader__.load({
161
173
  "question.description": "智能体向你提问时播放",
162
174
  "completion.title": "任务完成时",
163
175
  "completion.description": "会话由运行转为空闲时播放",
176
+ "goalFinalOnly.title": "目标模式下只在最终轮提醒",
177
+ "goalFinalOnly.description": "目标模式会自动进行多轮;开启后中间轮结束时保持安静,只在目标结束(或暂停、受阻)时响一次。普通对话不受影响",
164
178
  "subagent.title": "子智能体的提醒",
165
179
  "subagent.description": "子智能体(后台任务)完成或需要决策时也提醒;关闭后只提醒主对话",
166
180
  "onlyWhenHidden.title": "仅在页面不可见时提示",
@@ -178,7 +192,7 @@ window.__ModuleLoader__.load({
178
192
  "systemNotify.unsupported": "当前环境不支持系统通知",
179
193
  "titleFlash.title": "窗口标题提醒",
180
194
  "titleFlash.description": "页面处于后台时,在窗口标题前加文字标记(待处理 / 已完成),便于在任务栏察觉;回到页面后自动清除",
181
- "titleFlash.macUnsupported": "macOS 暂不支持:标题栏隐藏、Dock 也没有窗口标题,标记无处显示(只在「窗口」菜单 / Mission Control 里可见)",
195
+ "titleFlash.macUnsupported": "macOS 暂不支持,开关已禁用:标题栏隐藏、Dock 也没有窗口标题,标记无处显示(只在「窗口」菜单 / Mission Control 里可见)",
182
196
  "titleFlash.pending": "待处理",
183
197
  "titleFlash.done": "已完成",
184
198
  "renotify.title": "未处理决策重复提醒",
@@ -219,10 +233,21 @@ window.__ModuleLoader__.load({
219
233
  "update.available": "有新版",
220
234
  "update.failed": "检查失败(网络不可用)",
221
235
  "update.apply": "立即更新",
236
+ "update.desktopGuidance": "升级请走包管理器:设置 → 插件 → 卸载本插件,再重新添加 dsh-plugin-notify-sounds。安装由包管理器完成,升级才能留在新版(本插件不再自己替换文件)",
237
+ "update.webGuidance": "在终端执行 dsh plugin --profile {profile} add dsh-plugin-notify-sounds@{version}",
238
+ "update.unregistered": "这个安装没有被包管理器登记(记录仍为 {recorded}),下次安装其他插件时可能被退回。请按上面的方式重新安装一次。",
222
239
  "update.updating": "更新中…",
223
- "update.done": "更新完成",
224
- "update.restart": "重启 DSH 后生效",
240
+ "update.doneDetail": "更新完成:{from} → {to},重启 DSH 后生效",
225
241
  "update.applyFailed": "更新失败({stage}),已恢复原状",
242
+ "update.stage.check": "检查",
243
+ "update.stage.download": "下载",
244
+ "update.stage.verify": "校验",
245
+ "update.stage.unpack": "解包",
246
+ "update.stage.manifest": "核对包信息",
247
+ "update.stage.replace": "替换文件",
248
+ "update.stage.unknown": "未知阶段",
249
+ "update.retry": "通常是网络或代理的临时问题,稍后重试即可;若反复失败,可到 {url} 手动安装",
250
+ "update.reinstall": "可在 DSH 的 设置 → 插件 里先卸载本插件,再添加 dsh-plugin-notify-sounds 重新安装;仓库:{url}",
226
251
  };
227
252
 
228
253
  /** English dictionary, checked complete against the zh key set. */
@@ -257,7 +282,7 @@ window.__ModuleLoader__.load({
257
282
  "systemNotify.unsupported": "System notifications are unavailable in this environment",
258
283
  "titleFlash.title": "Window title alert",
259
284
  "titleFlash.description": "While the page is hidden, mark the window title as waiting for you or finished, so the taskbar shows it; cleared when you return",
260
- "titleFlash.macUnsupported": "Not supported on macOS: the title bar is hidden and the Dock shows no window title, so the mark has nowhere to appear (only the Window menu and Mission Control would show it)",
285
+ "titleFlash.macUnsupported": "Not supported on macOS, so this switch is disabled: the title bar is hidden and the Dock shows no window title, so the mark has nowhere to appear (only the Window menu and Mission Control would show it)",
261
286
  "titleFlash.pending": "Waiting for you",
262
287
  "titleFlash.done": "Finished",
263
288
  "renotify.title": "Repeat unanswered decisions",
@@ -266,6 +291,8 @@ window.__ModuleLoader__.load({
266
291
  "renotify.interval": "Interval",
267
292
  "renotify.minutes": "{n} min",
268
293
  "quiet.title": "Quiet hours",
294
+ "goalFinalOnly.title": "Only alert on the final goal round",
295
+ "goalFinalOnly.description": "A goal runs several automatic rounds; with this on, intermediate rounds stay silent and one chime marks the end of the goal (or a pause or block). Ordinary sessions are unaffected",
269
296
  "quiet.description": "No alerts during this daily time range (may wrap past midnight, e.g. 22:00 to 08:00)",
270
297
  "quiet.start": "Start time",
271
298
  "quiet.end": "End time",
@@ -298,10 +325,21 @@ window.__ModuleLoader__.load({
298
325
  "update.available": "New version available",
299
326
  "update.failed": "Check failed (no network)",
300
327
  "update.apply": "Update now",
328
+ "update.desktopGuidance": "Update through the package manager: Settings → Plugins → remove this plugin, then add dsh-plugin-notify-sounds again. The installer keeps the upgrade (this plugin no longer replaces its own files)",
329
+ "update.webGuidance": "Run dsh plugin --profile {profile} add dsh-plugin-notify-sounds@{version} in a terminal",
330
+ "update.unregistered": "This install is not registered with the package manager (it still records {recorded}), so a later plugin install may roll it back. Reinstall it with the step above.",
301
331
  "update.updating": "Updating…",
302
- "update.done": "Updated",
303
- "update.restart": "restart DSH to load it",
332
+ "update.doneDetail": "Updated: {from} → {to} — restart DSH to load it",
304
333
  "update.applyFailed": "Update failed ({stage}) — the previous version is intact",
334
+ "update.stage.check": "check",
335
+ "update.stage.download": "download",
336
+ "update.stage.verify": "checksum",
337
+ "update.stage.unpack": "unpack",
338
+ "update.stage.manifest": "package check",
339
+ "update.stage.replace": "file replacement",
340
+ "update.stage.unknown": "unknown step",
341
+ "update.retry": "This is usually a temporary network or proxy problem — try again later. If it keeps failing, install manually from {url}",
342
+ "update.reinstall": "Remove this plugin under Settings → Plugins, then add dsh-plugin-notify-sounds again. Repository: {url}",
305
343
  };
306
344
  //#endregion
307
345
 
@@ -443,19 +481,27 @@ window.__ModuleLoader__.load({
443
481
  * Peak-normalization gain for one decoded buffer: scales the loudest
444
482
  * sample toward full scale, boosting at most 4× so near-silent files
445
483
  * do not get blown up.
484
+ *
485
+ * Every sample is visited, deliberately. An earlier version stepped
486
+ * through the buffer taking every 8th frame and claimed the peak would
487
+ * be caught anyway because "neighbouring samples carry the same
488
+ * magnitude" — that only holds for a heavily oversampled signal. At
489
+ * 44.1 kHz a 10 kHz component has about 4.4 samples per period, so a
490
+ * stride of 8 aliases against it and can systematically miss the real
491
+ * peak; a tone near fs/8 is the extreme case. An underestimated peak
492
+ * means a boost that is too large, so the custom sound plays hot and
493
+ * can clip on its loudest transient.
494
+ *
495
+ * The cost is not on a playback path: this runs once per uploaded file,
496
+ * right after `decodeAudioData` (which costs far more), and the result
497
+ * is cached alongside the buffer in `customSoundCache`.
446
498
  */
447
499
  function normalizedGain(buffer) {
448
500
  try {
449
501
  let peak = 0;
450
- // Sample every 8th frame: a peak that would clip is still
451
- // caught by any reasonably strided scan (audio waveforms are
452
- // smooth enough that adjacent samples carry the same
453
- // magnitude), and this keeps a 30-second upload off the main
454
- // thread's critical path.
455
- const stride = 8;
456
502
  for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
457
503
  const data = buffer.getChannelData(channel);
458
- for (let i = 0; i < data.length; i += stride) {
504
+ for (let i = 0; i < data.length; i++) {
459
505
  const magnitude = Math.abs(data[i]);
460
506
  if (magnitude > peak) peak = magnitude;
461
507
  }
@@ -574,11 +620,25 @@ window.__ModuleLoader__.load({
574
620
  // Release the master gain once the tail has faded. Without
575
621
  // this, every play leaves a gain node permanently wired to
576
622
  // the destination — a slow audio-node leak on long sessions.
577
- setTimeout(() => {
623
+ const remaining = () => (t0 + tail + 0.1 - engine.currentTime) * 1000;
624
+ const release = (rechecked) => {
578
625
  try {
626
+ // The delay was computed when this was scheduled, and a
627
+ // suspended context freezes `currentTime`: without this
628
+ // second look, a timer that comes due during a freeze
629
+ // disconnects a tail that is still playing. Re-check
630
+ // once — not in a loop, so a clock that never advances
631
+ // (a test's frozen mock, a context suspended for good)
632
+ // still ends with the node released.
633
+ if (!rechecked && remaining() > 5) {
634
+ setTimeout(() => release(true), remaining() + 50);
635
+ return;
636
+ }
579
637
  master.disconnect();
580
638
  } catch {}
581
- }, Math.max(0, (t0 + tail + 0.1 - engine.currentTime) * 1000 + 50));
639
+ };
640
+ const timer = setTimeout(() => release(false), Math.max(0, remaining() + 50));
641
+ timer.unref?.();
582
642
  });
583
643
  }
584
644
 
@@ -814,8 +874,183 @@ window.__ModuleLoader__.load({
814
874
  /** Route the Host half answers the update check on; must match the Host half's UPDATE_CHECK_PATH. */
815
875
  const UPDATE_CHECK_PATH = "/notify-sounds/update/check";
816
876
 
817
- /** Route the Host half applies an update through; must match the Host half's UPDATE_APPLY_PATH. */
818
- const UPDATE_APPLY_PATH = "/notify-sounds/update/apply";
877
+
878
+ /**
879
+ * Where a user goes when the one-click update cannot finish.
880
+ *
881
+ * Shown as plain selectable text rather than a link: the Desktop shell
882
+ * is a `dsh-app://` page and the Settings primitives expose no external
883
+ * link component, so a URL the user can copy is the honest form — a
884
+ * button that silently did nothing here would be worse than no button.
885
+ */
886
+ const REPOSITORY_URL = "https://github.com/BillCx330/dsh-plugin-notify-sounds";
887
+
888
+ /**
889
+ * Every stage the Host can report, and whether a fresh install is the remedy.
890
+ *
891
+ * Keyed off the stage rather than off the message: telling someone whose
892
+ * download timed out to uninstall the plugin would be actively bad
893
+ * advice, and telling someone whose archive was rejected to "try again
894
+ * later" leaves them with no way forward at all. `true` means the
895
+ * payload itself could not be applied, which retrying will not change.
896
+ */
897
+ const UPDATE_STAGES = { check: false, download: false, verify: false, unpack: true, manifest: true, replace: true };
898
+
899
+ /** The localized word for a Host-side update stage. */
900
+ const stageWord = (t, stage) => t(`update.stage.${typeof stage === "string" && Object.hasOwn(UPDATE_STAGES, stage) ? stage : "unknown"}`);
901
+
902
+ /** `v1.2.3` for display, `?` when the Host reported no version. */
903
+ const formatVersion = (text) => (typeof text === "string" && text !== "" ? `v${text}` : "?");
904
+
905
+ /**
906
+ * The line reporting an installation that landed.
907
+ *
908
+ * Composed here rather than in JSX so the notice above the section and
909
+ * the row inside it say exactly the same thing, and so the whole
910
+ * sentence — punctuation included — lives in the dictionaries instead
911
+ * of being assembled from fragments at the call site (which is how the
912
+ * English UI ended up with full-width stops).
913
+ */
914
+ const updateAppliedText = (t, applied) => t("update.doneDetail", { from: formatVersion(applied?.from), to: formatVersion(applied?.to) });
915
+
916
+ /**
917
+ * The two lines a failed update shows: what happened, and what to do.
918
+ *
919
+ * The remedy is chosen from the stage the Host reported, never from the
920
+ * message: a download that timed out must not be answered with "uninstall
921
+ * and reinstall", and an archive that was refused must not be answered
922
+ * with "try again later".
923
+ */
924
+ const updateFailureText = (t, failed, current) => {
925
+ // Name both versions whenever the target is known: "the update
926
+ // failed" on its own cannot be acted on afterwards, and this is the
927
+ // line a user copies into a bug report.
928
+ const named = typeof failed?.to === "string" ? `${formatVersion(current)} → ${formatVersion(failed.to)} · ` : "";
929
+ return {
930
+ detail: `${named}${t("update.applyFailed", { stage: stageWord(t, failed?.stage) })}`,
931
+ advice: UPDATE_STAGES[failed?.stage] === true
932
+ ? t("update.reinstall", { url: REPOSITORY_URL })
933
+ : t("update.retry", { url: REPOSITORY_URL }),
934
+ };
935
+ };
936
+
937
+ /** Channel name and claim key shared by every page of this origin. */
938
+ const ALERT_CHANNEL = "dsh-notify-sounds:alerts";
939
+ const ALERT_LEADER_KEY = "dsh-notify-sounds:leader";
940
+ /** How often the leader re-asserts, and when a quiet claim is considered abandoned. */
941
+ const ALERT_HEARTBEAT_MS = 5000;
942
+ const ALERT_STALE_MS = 15000;
943
+
944
+ /**
945
+ * `localStorage`, when this page has one that answers.
946
+ *
947
+ * Even reading it can throw where storage is blocked (private mode, a
948
+ * sandboxed frame), and a page without it simply leads itself.
949
+ *
950
+ * @returns the storage, or undefined.
951
+ */
952
+ const safeAlertStorage = () => {
953
+ try {
954
+ if (typeof localStorage === "undefined") return undefined;
955
+ localStorage.getItem(ALERT_LEADER_KEY);
956
+ return localStorage;
957
+ } catch {
958
+ return undefined;
959
+ }
960
+ };
961
+
962
+ /**
963
+ * Elect one page among the tabs — and windows — of this GUI to be the one
964
+ * that speaks.
965
+ *
966
+ * Two pages are two independent subscribers to the same session facts:
967
+ * left alone, one decision rings the sound twice, raises two system
968
+ * notifications and runs two repeat reminders (the settings even say
969
+ * "at most one quiet check a day" while both of them check). The election
970
+ * is a claim in `localStorage`, which every page of this origin shares,
971
+ * refreshed by a heartbeat; a claim that stops being refreshed is taken
972
+ * over, so a closed, crashed or frozen tab hands the role on within a few
973
+ * seconds. `BroadcastChannel` only makes a claim heard sooner — the
974
+ * correctness rests on the stored claim, because whoever wrote it last
975
+ * owns the role and every page re-reads it rather than trusting a message.
976
+ *
977
+ * The title mark is deliberately NOT gated by this: each window and each
978
+ * tab has its own title, so marking them all is right, while a sound and
979
+ * a toast belong to the machine.
980
+ *
981
+ * Pages that can use neither mechanism — the smoke harness, a hardened
982
+ * browser context — lead themselves, which is the behaviour before this
983
+ * existed.
984
+ *
985
+ * @returns `{ isLeader, refresh, dispose }`, where `refresh` re-runs the
986
+ * election (the tests drive it instead of waiting for a heartbeat).
987
+ */
988
+ function createAlertLeader() {
989
+ const storage = safeAlertStorage();
990
+ const Channel = typeof BroadcastChannel === "function" ? BroadcastChannel : undefined;
991
+ if (storage === undefined || Channel === undefined) {
992
+ return { isLeader: () => true, refresh: () => {}, dispose: () => {} };
993
+ }
994
+ const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
995
+ let channel;
996
+ try {
997
+ channel = new Channel(ALERT_CHANNEL);
998
+ } catch {
999
+ return { isLeader: () => true, refresh: () => {}, dispose: () => {} };
1000
+ }
1001
+ let leader = false;
1002
+ const readClaim = () => {
1003
+ try {
1004
+ const parsed = JSON.parse(storage.getItem(ALERT_LEADER_KEY) ?? "null");
1005
+ return parsed !== null && typeof parsed?.id === "string" && Number.isFinite(parsed?.at) ? parsed : undefined;
1006
+ } catch {
1007
+ return undefined;
1008
+ }
1009
+ };
1010
+ const claim = () => {
1011
+ leader = true;
1012
+ try {
1013
+ storage.setItem(ALERT_LEADER_KEY, JSON.stringify({ id, at: Date.now() }));
1014
+ } catch {}
1015
+ try {
1016
+ channel.postMessage({ id });
1017
+ } catch {}
1018
+ };
1019
+ const refresh = () => {
1020
+ const current = readClaim();
1021
+ if (current === undefined || current.id === id) {
1022
+ claim();
1023
+ return;
1024
+ }
1025
+ // Somebody else holds a claim. While it is fresh this page stays
1026
+ // silent; once it goes stale its holder is gone and this page
1027
+ // takes over.
1028
+ if (Date.now() - current.at < ALERT_STALE_MS) {
1029
+ leader = false;
1030
+ return;
1031
+ }
1032
+ claim();
1033
+ };
1034
+ channel.onmessage = (event) => {
1035
+ if (event?.data?.id !== id) refresh();
1036
+ };
1037
+ const timer = setInterval(refresh, ALERT_HEARTBEAT_MS);
1038
+ timer.unref?.();
1039
+ refresh();
1040
+ return {
1041
+ isLeader: () => leader,
1042
+ refresh,
1043
+ dispose: () => {
1044
+ clearInterval(timer);
1045
+ try {
1046
+ if (leader && readClaim()?.id === id) storage.removeItem(ALERT_LEADER_KEY);
1047
+ } catch {}
1048
+ try {
1049
+ channel.close();
1050
+ } catch {}
1051
+ },
1052
+ };
1053
+ }
819
1054
 
820
1055
  /**
821
1056
  * Show one OS notification; every failure mode is silent.
@@ -981,8 +1216,7 @@ window.__ModuleLoader__.load({
981
1216
  .catch(() => {});
982
1217
  }, [autoCheck]);
983
1218
  if (applied === undefined || typeof applied.to !== "string") return null;
984
- const version = (text) => (typeof text === "string" && text !== "" ? `v${text}` : "?");
985
- return h("div", { className: css.hint, key: "text" }, `${t("update.done")}:${version(applied.from)} → ${version(applied.to)},${t("update.restart")}`);
1219
+ return h("div", { className: css.hint, key: "text" }, updateAppliedText(t, applied));
986
1220
  }
987
1221
 
988
1222
  function NotifySoundsSection({ useConfig, setField, preview, t }) {
@@ -1099,17 +1333,6 @@ window.__ModuleLoader__.load({
1099
1333
  },
1100
1334
  onPreview: (id) => preview(id, volume),
1101
1335
  }),
1102
- h(SwitchRow, {
1103
- key: "subagent",
1104
- t,
1105
- title: t("subagent.title"),
1106
- description: t("subagent.description"),
1107
- label: t("subagent.title"),
1108
- checked: value?.subagentAlerts === true,
1109
- disabled,
1110
- failed: failedField === "subagentAlerts",
1111
- onChange: (next) => write("subagentAlerts", next),
1112
- }),
1113
1336
  h(CustomSoundRow, {
1114
1337
  key: "custom",
1115
1338
  t,
@@ -1152,6 +1375,28 @@ window.__ModuleLoader__.load({
1152
1375
  },
1153
1376
  onPreview: () => preview("custom", volume),
1154
1377
  }),
1378
+ h(SwitchRow, {
1379
+ key: "subagent",
1380
+ t,
1381
+ title: t("subagent.title"),
1382
+ description: t("subagent.description"),
1383
+ label: t("subagent.title"),
1384
+ checked: value?.subagentAlerts === true,
1385
+ disabled,
1386
+ failed: failedField === "subagentAlerts",
1387
+ onChange: (next) => write("subagentAlerts", next),
1388
+ }),
1389
+ h(SwitchRow, {
1390
+ key: "goalFinalOnly",
1391
+ t,
1392
+ title: t("goalFinalOnly.title"),
1393
+ description: t("goalFinalOnly.description"),
1394
+ label: t("goalFinalOnly.title"),
1395
+ checked: value?.goalFinalOnly === true,
1396
+ disabled,
1397
+ failed: failedField === "goalFinalOnly",
1398
+ onChange: (next) => write("goalFinalOnly", next),
1399
+ }),
1155
1400
  h(SystemNotifyRow, {
1156
1401
  key: "systemNotify",
1157
1402
  t,
@@ -1170,10 +1415,10 @@ window.__ModuleLoader__.load({
1170
1415
  // document title like any other, so this is not
1171
1416
  // applied there. iOS Safari's UA also contains "like
1172
1417
  // Mac OS X" — `isDesktopShell` keeps it out.
1173
- description: isMac() && isDesktopShell() ? t("titleFlash.macUnsupported") : t("titleFlash.description"),
1418
+ description: titleFlashUnavailable() ? t("titleFlash.macUnsupported") : t("titleFlash.description"),
1174
1419
  label: t("titleFlash.title"),
1175
1420
  checked: value?.titleFlash === true,
1176
- disabled,
1421
+ disabled: disabled || titleFlashUnavailable(),
1177
1422
  failed: failedField === "titleFlash",
1178
1423
  onChange: (next) => write("titleFlash", next),
1179
1424
  }),
@@ -1709,67 +1954,51 @@ window.__ModuleLoader__.load({
1709
1954
  setState({ phase: "failed" });
1710
1955
  }
1711
1956
  };
1712
- const apply = async () => {
1713
- setState({ phase: "updating" });
1714
- try {
1715
- const response = await fetch(UPDATE_APPLY_PATH, { method: "POST" });
1716
- const result = await response.json();
1717
- setState({ phase: result?.ok === true ? (result.upToDate === true ? "done" : "updated") : "applyFailed", result });
1718
- } catch {
1719
- setState({ phase: "applyFailed" });
1720
- }
1721
- };
1722
1957
  // A cached answer makes this free; only a cold Host reaches out.
1723
1958
  // Same guard as the notice: `=== true` (not `!== false`), because
1724
1959
  // during the loading frame `undefined` must not be treated as
1725
- // permission to reach out. The button below works regardless.
1960
+ // permission to reach out.
1726
1961
  useEffect(() => {
1727
1962
  if (autoCheck !== true) return;
1728
1963
  check(false)();
1729
1964
  }, [autoCheck]);
1730
1965
  const payload = state.payload;
1731
- const version = (text) => (typeof text === "string" && text !== "" ? `v${text}` : "?");
1732
- // The Host remembers the last install and repeats it with every
1733
- // answer, and this row prefers that: an update swaps the client
1734
- // half's own file, so the page may reload and drop this row's
1735
- // state mid-message — the news has to survive that. The Host keeps
1736
- // it until DSH restarts, which is exactly as long as "restart to
1737
- // load it" is true.
1738
- const applied = payload?.applied ?? (state.phase === "updated" ? state.result : undefined);
1739
- const appliedDone = applied !== undefined && (applied.ok === true || typeof applied.to === "string");
1966
+ const latest = payload?.latest === undefined ? undefined : formatVersion(payload.latest);
1740
1967
  let status = payload === undefined
1741
1968
  ? t("update.idle")
1742
1969
  : payload.updateAvailable === true
1743
- ? `${t("update.available")} ${version(payload.latest)}`
1970
+ ? `${t("update.available")} ${latest}`
1744
1971
  : t("update.uptodate");
1745
1972
  if (state.phase === "checking") status = t("update.checking");
1746
- if (state.phase === "updating") status = t("update.updating");
1747
- let detail = payload === undefined ? status : `${t("update.current")} ${version(payload.current)} · ${status}`;
1748
- if (appliedDone) {
1749
- detail = `${t("update.done")}:${version(applied.from)} → ${version(applied.to)},${t("update.restart")}`;
1750
- } else if (state.phase === "applyFailed") {
1751
- detail = t("update.applyFailed", { stage: String(state.result?.stage ?? "?") });
1973
+ const detail = payload === undefined ? status : `${t("update.current")} ${formatVersion(payload.current)} · ${status}`;
1974
+ // The install belongs to the package manager now: this plugin only
1975
+ // checks, and every route below installs THROUGH pnpm so the
1976
+ // profile's lockfile moves with the files. The two shells reach it
1977
+ // differently, so the row spells out the one that applies here.
1978
+ const lines = [];
1979
+ if (payload?.unregistered === true) {
1980
+ lines.push(h("div", { className: css.hint, key: "unregistered" }, t("update.unregistered", {
1981
+ recorded: formatVersion(payload.recorded),
1982
+ })));
1983
+ }
1984
+ if (payload?.updateAvailable === true && latest !== undefined) {
1985
+ lines.push(h("div", { className: css.hint, key: "howto" }, isDesktopShell()
1986
+ ? t("update.desktopGuidance")
1987
+ : t("update.webGuidance", { profile: payload.profile ?? "web", version: latest })));
1752
1988
  }
1753
- const busy = state.phase === "checking" || state.phase === "updating";
1754
1989
  return h("div", { className: css.row }, [
1755
1990
  h("div", { key: "text" }, [
1756
1991
  h("div", { className: css.rowTitle, key: "title" }, t("update.title")),
1757
1992
  h("div", { className: css.rowDescription, key: "description" }, t("update.description")),
1758
1993
  h("div", { className: css.hint, key: "status" }, detail),
1994
+ ...lines,
1759
1995
  ]),
1760
1996
  h("div", { className: css.stackControl, key: "control" }, [
1761
- payload?.updateAvailable === true && !appliedDone ? h(Button, {
1762
- key: "apply",
1763
- variant: "outline",
1764
- size: "sm",
1765
- disabled: busy,
1766
- onClick: apply,
1767
- }, t("update.apply")) : null,
1768
1997
  h(Button, {
1769
1998
  key: "check",
1770
1999
  variant: "outline",
1771
2000
  size: "sm",
1772
- disabled: busy,
2001
+ disabled: state.phase === "checking",
1773
2002
  onClick: check(true),
1774
2003
  }, t("update.check")),
1775
2004
  ]),
@@ -1893,6 +2122,12 @@ window.__ModuleLoader__.load({
1893
2122
  let panelObserver = null;
1894
2123
  let observedPanel = null;
1895
2124
  const scan = (root) => {
2125
+ // One panel-level probe before the walk. This observer fires on
2126
+ // every change anywhere in the settings tree — a conversation
2127
+ // streaming next to an open panel included — and once this
2128
+ // section's cell carries the glyph there is nothing left to do;
2129
+ // the per-cell checks below would only re-establish that.
2130
+ if (root.querySelector(`[${NAV_ICON_MARKER}]`) !== null) return;
1896
2131
  const label = t("nav");
1897
2132
  for (const cell of root.querySelectorAll("button")) swapNavIcon(cell, label);
1898
2133
  };
@@ -1961,6 +2196,11 @@ window.__ModuleLoader__.load({
1961
2196
  */
1962
2197
  function apply(ctx) {
1963
2198
  insertStyles();
2199
+ // One page per browser profile speaks (see createAlertLeader): a
2200
+ // sound and a toast belong to the machine, and a second tab of the
2201
+ // same GUI must not raise them a second time.
2202
+ const alertLeader = createAlertLeader();
2203
+ ctx.effect(() => alertLeader.dispose, "notify-sounds: alert leader");
1964
2204
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "notify-sounds: dictionaries");
1965
2205
  const t = ctx.locale.bind(NS);
1966
2206
  // The platform's own resolver for package text, which may be a plain
@@ -1995,7 +2235,8 @@ window.__ModuleLoader__.load({
1995
2235
  const cooldown = new Map();
1996
2236
 
1997
2237
  /**
1998
- * The session list rows, if this runtime exposes them.
2238
+ * One snapshot of the session list rows, or `undefined` when this
2239
+ * runtime exposes none.
1999
2240
  *
2000
2241
  * The status facts carry no classification at all — an entry is
2001
2242
  * only `{ running, pendingInteraction, completionUnread }` — so
@@ -2003,24 +2244,20 @@ window.__ModuleLoader__.load({
2003
2244
  * session list, whose rows carry the same `origin`/`parentId` the
2004
2245
  * sidebar uses to keep sub-agents out of the ordinary list.
2005
2246
  *
2247
+ * Callers read ONCE per pass and hand the result down: the previous
2248
+ * shape called this per session inside the loops below, so a status
2249
+ * update covering ten sessions took ten snapshots, and the pass
2250
+ * scope was implied by "the same millisecond" rather than stated.
2251
+ *
2006
2252
  * @returns the `byId` row map, or undefined when unavailable.
2007
2253
  */
2008
- // Cache one snapshot per synchronous block: `isNotifiable` is
2009
- // called per session in read()/trackPending loops, and each call
2010
- // would otherwise take a fresh snapshot (O8).
2011
- let cachedRows;
2012
- let cachedRowsAt = 0;
2013
- const sessionRows = () => {
2014
- const now = Date.now();
2015
- if (cachedRows !== undefined && now === cachedRowsAt) return cachedRows;
2254
+ const readSessionRows = () => {
2016
2255
  try {
2017
- cachedRows = ctx.sessions?.list?.getSnapshot?.()?.byId
2256
+ return ctx.sessions?.list?.getSnapshot?.()?.byId
2018
2257
  ?? ctx.uiSession?.sessions?.list?.getSnapshot?.()?.byId;
2019
2258
  } catch {
2020
- cachedRows = undefined;
2259
+ return undefined;
2021
2260
  }
2022
- cachedRowsAt = now;
2023
- return cachedRows;
2024
2261
  };
2025
2262
 
2026
2263
  /**
@@ -2028,10 +2265,11 @@ window.__ModuleLoader__.load({
2028
2265
  * sub-agent, a teammate — rather than one of the user's own.
2029
2266
  *
2030
2267
  * @param sessionId - the session to classify.
2268
+ * @param rows - the pass's session rows, when the runtime has them.
2031
2269
  * @returns whether it is a spawned child session.
2032
2270
  */
2033
- const isSubagentSession = (sessionId) => {
2034
- const row = sessionRows()?.[sessionId];
2271
+ const isSubagentSession = (sessionId, rows) => {
2272
+ const row = rows?.[sessionId];
2035
2273
  // Deliberately ONLY `origin`, the same test the sidebar and the
2036
2274
  // sub-agent UI use. `parentId` alone is not enough: a forked
2037
2275
  // conversation carries one too and is still the user's own.
@@ -2048,12 +2286,13 @@ window.__ModuleLoader__.load({
2048
2286
  * not something they want a chime for.
2049
2287
  *
2050
2288
  * @param sessionId - the session to test.
2289
+ * @param rows - the pass's session rows, when the runtime has them.
2051
2290
  * @returns whether its events should alert.
2052
2291
  */
2053
- const isNotifiable = (sessionId) => {
2292
+ const isNotifiable = (sessionId, rows) => {
2054
2293
  const value = form.getSnapshot().value;
2055
2294
  if (value?.subagentAlerts === true) return true;
2056
- return !isSubagentSession(sessionId);
2295
+ return !isSubagentSession(sessionId, rows);
2057
2296
  };
2058
2297
 
2059
2298
  /**
@@ -2075,6 +2314,10 @@ window.__ModuleLoader__.load({
2075
2314
  * reminder is not recorded as sent.
2076
2315
  */
2077
2316
  const notify = (kind, interaction, sessionId, options = {}) => {
2317
+ // The elected page is the only one that makes a noise or raises
2318
+ // a notification; every page still keeps its own title mark,
2319
+ // because a title belongs to its own window.
2320
+ if (!alertLeader.isLeader()) return false;
2078
2321
  const value = form.getSnapshot().value;
2079
2322
  if (value?.enabled === false) return false;
2080
2323
  if (value?.quietHours === true && inQuietHours(new Date(), value?.quietStart, value?.quietEnd)) return false;
@@ -2096,8 +2339,17 @@ window.__ModuleLoader__.load({
2096
2339
  // Evict the oldest entry rather than clearing the whole
2097
2340
  // table: one session hitting the cap must not silence
2098
2341
  // every other session's cooldown.
2099
- if (cooldown.size >= 96) cooldown.delete(cooldown.keys().next().value);
2100
- cooldown.set(`${kind}:${sessionId}`, now);
2342
+ //
2343
+ // LRU, not FIFO: `Map.set` on a key that is already there
2344
+ // updates the value and KEEPS the original position, so
2345
+ // plain eviction would throw away whichever entry is
2346
+ // touched most often — precisely the session whose
2347
+ // two-second dedupe is doing work. Deleting first moves it
2348
+ // to the back on re-insertion.
2349
+ const key = `${kind}:${sessionId}`;
2350
+ if (cooldown.has(key)) cooldown.delete(key);
2351
+ else if (cooldown.size >= 96) cooldown.delete(cooldown.keys().next().value);
2352
+ cooldown.set(key, now);
2101
2353
  }
2102
2354
  const fallback = kind === "completion" ? "ding" : kind === "question" ? "chime" : "bell";
2103
2355
  let id = typeof value?.[kind + "Sound"] === "string" ? value[kind + "Sound"] : fallback;
@@ -2156,12 +2408,96 @@ window.__ModuleLoader__.load({
2156
2408
  return value?.quietHours === true && inQuietHours(new Date(), value?.quietStart, value?.quietEnd);
2157
2409
  };
2158
2410
  /** Whether any session is currently running. */
2159
- const anyRunning = () => {
2411
+ const anyRunning = (rows) => {
2160
2412
  for (const [sessionId, status] of source.getSnapshot()) {
2161
- if (status.running === true && isNotifiable(sessionId)) return true;
2413
+ if (status.running === true && isNotifiable(sessionId, rows)) return true;
2162
2414
  }
2163
2415
  return false;
2164
2416
  };
2417
+ /**
2418
+ * The goal projection's phase for one session, when this runtime
2419
+ * carries the goal packages.
2420
+ *
2421
+ * DSH's goal mode drives several rounds inside one session, and
2422
+ * every round ends with a running→idle edge — so an unguarded
2423
+ * completion chime fires once per round. The phase is the host's
2424
+ * own vocabulary: `active` keeps going on its own, `complete`
2425
+ * finished, and `paused`/`blocked` wait for the user.
2426
+ *
2427
+ * Every step is optional: a runtime without the goal projection
2428
+ * simply answers `undefined`, which reads as "no goal".
2429
+ */
2430
+ const goalPhaseOf = (sessionId) => {
2431
+ try {
2432
+ const sessions = ctx.sessions ?? ctx.uiSession?.sessions;
2433
+ const store = sessions?.get?.(sessionId)?.projections;
2434
+ const snapshot = store?.get?.("goal") ?? store?.faceOf?.("goal")?.getSnapshot?.();
2435
+ const phase = snapshot?.goal?.phase;
2436
+ return typeof phase === "string" ? phase : undefined;
2437
+ } catch {
2438
+ return undefined;
2439
+ }
2440
+ };
2441
+ /** Whether the completion edge for a session is worth an alert now. */
2442
+ const completionIsFinal = (sessionId, value) => {
2443
+ if (value?.goalFinalOnly !== true) return true;
2444
+ // A session with no goal, or with one that will not continue on
2445
+ // its own (finished, paused, blocked), ends this edge for the
2446
+ // user; one that is still `active` drives another round.
2447
+ return goalPhaseOf(sessionId) !== "active";
2448
+ };
2449
+ /** Longest a goal may stay `active` before its watcher gives up. */
2450
+ const GOAL_SETTLE_TIMEOUT_MS = 30 * 60 * 1000;
2451
+ /** Settings whose session already has a watcher waiting for its goal. */
2452
+ const goalWatchers = new Map();
2453
+ /**
2454
+ * Serve the single completion chime a goal earns when it ends.
2455
+ *
2456
+ * The edge that armed this watcher was an intermediate round (the
2457
+ * phase was still `active`). The next round starts at quiescence,
2458
+ * so the projection is re-read on every change: running again means
2459
+ * the edge was intermediate and stays silent, while any non-active
2460
+ * phase means the goal ended and earns the one chime.
2461
+ */
2462
+ const watchGoalCompletion = (sessionId) => {
2463
+ if (goalWatchers.has(sessionId)) return;
2464
+ let settled = false;
2465
+ let subscriptionOff = () => {};
2466
+ let timer;
2467
+ const settle = (chime) => {
2468
+ if (settled) return;
2469
+ settled = true;
2470
+ goalWatchers.delete(sessionId);
2471
+ try {
2472
+ subscriptionOff();
2473
+ } catch {}
2474
+ clearTimeout(timer);
2475
+ if (!chime) return;
2476
+ if (!pageIsWatched() && !quietNow()) unreadCompletion = true;
2477
+ notify("completion", undefined, sessionId);
2478
+ refreshFlash();
2479
+ };
2480
+ try {
2481
+ const sessions = ctx.sessions ?? ctx.uiSession?.sessions;
2482
+ const face = sessions?.get?.(sessionId)?.projections?.faceOf?.("goal");
2483
+ if (typeof face?.subscribe === "function") {
2484
+ const subscription = face.subscribe(() => {
2485
+ if (source.getSnapshot().get(sessionId)?.running === true) {
2486
+ settle(false);
2487
+ return;
2488
+ }
2489
+ const phase = goalPhaseOf(sessionId);
2490
+ if (phase === undefined || phase !== "active") settle(true);
2491
+ });
2492
+ if (typeof subscription === "function") subscriptionOff = subscription;
2493
+ }
2494
+ } catch {}
2495
+ // A goal that never resolves must not pin this entry forever;
2496
+ // dropping it silently beats a stray chime minutes later.
2497
+ timer = setTimeout(() => settle(false), GOAL_SETTLE_TIMEOUT_MS);
2498
+ timer?.unref?.();
2499
+ goalWatchers.set(sessionId, () => settle(false));
2500
+ };
2165
2501
  /**
2166
2502
  * Which alert the title should carry, if any.
2167
2503
  *
@@ -2180,7 +2516,7 @@ window.__ModuleLoader__.load({
2180
2516
  // the quiet window ends.
2181
2517
  if (record.quiet !== true) return "pending";
2182
2518
  }
2183
- if (anyRunning()) return undefined;
2519
+ if (anyRunning(readSessionRows())) return undefined;
2184
2520
  return unreadCompletion ? "done" : undefined;
2185
2521
  };
2186
2522
  /** Refresh the title alert from the current facts. */
@@ -2190,12 +2526,12 @@ window.__ModuleLoader__.load({
2190
2526
  applyTitleFlash(wantedTitleState(), titleAlerts(t));
2191
2527
  };
2192
2528
  /** Rebuild the tracker from one snapshot, then refresh the flash. */
2193
- const trackPending = (snapshot) => {
2529
+ const trackPending = (snapshot, rows) => {
2194
2530
  const next = new Map();
2195
2531
  for (const [sessionId, status] of snapshot) {
2196
2532
  // A filtered session is invisible to the whole package:
2197
2533
  // it gets no reminder and no title mark either.
2198
- if (!isNotifiable(sessionId)) continue;
2534
+ if (!isNotifiable(sessionId, rows)) continue;
2199
2535
  const interaction = status.pendingInteraction;
2200
2536
  if (interaction === undefined) continue;
2201
2537
  const record = pending.get(sessionId);
@@ -2222,11 +2558,14 @@ window.__ModuleLoader__.load({
2222
2558
  };
2223
2559
  const read = () => {
2224
2560
  const snapshot = source.getSnapshot();
2561
+ // One row snapshot for this whole pass, handed to every
2562
+ // check below instead of being taken per session.
2563
+ const rows = readSessionRows();
2225
2564
  if (previous !== undefined) {
2226
2565
  for (const [sessionId, status] of snapshot) {
2227
2566
  // Sub-agent sessions stay out of it unless asked for:
2228
2567
  // the user wants the main conversation's alerts.
2229
- if (!isNotifiable(sessionId)) continue;
2568
+ if (!isNotifiable(sessionId, rows)) continue;
2230
2569
  const before = previous.get(sessionId);
2231
2570
  const beforePending = before === undefined ? undefined : before.pendingInteraction;
2232
2571
  if (status.pendingInteraction !== undefined && status.pendingInteraction !== beforePending) {
@@ -2241,18 +2580,25 @@ window.__ModuleLoader__.load({
2241
2580
  notify(status.pendingInteraction.kind === "question" ? "question" : "decision", status.pendingInteraction, sessionId);
2242
2581
  }
2243
2582
  if (before !== undefined && before.running === true && status.running === false) {
2244
- // A finish that happens while the user is away is
2245
- // news; one they watch happen is not — and one
2246
- // that happens during quiet hours is forgotten
2247
- // rather than kept for later, so nothing about it
2248
- // shows up when the quiet window ends.
2249
- if (!pageIsWatched() && !quietNow()) unreadCompletion = true;
2250
- notify("completion", undefined, sessionId);
2583
+ const value = form.getSnapshot().value;
2584
+ if (!completionIsFinal(sessionId, value)) {
2585
+ // An intermediate goal round: stay silent and let
2586
+ // the goal's own end deliver the single chime.
2587
+ watchGoalCompletion(sessionId);
2588
+ } else {
2589
+ // A finish that happens while the user is away is
2590
+ // news; one they watch happen is not — and one
2591
+ // that happens during quiet hours is forgotten
2592
+ // rather than kept for later, so nothing about it
2593
+ // shows up when the quiet window ends.
2594
+ if (!pageIsWatched() && !quietNow()) unreadCompletion = true;
2595
+ notify("completion", undefined, sessionId);
2596
+ }
2251
2597
  }
2252
2598
  }
2253
2599
  }
2254
2600
  previous = snapshot;
2255
- trackPending(snapshot);
2601
+ trackPending(snapshot, rows);
2256
2602
  };
2257
2603
  // Returning to the page clears the alert even while the
2258
2604
  // decision stays pending — and marks every pending decision
@@ -2332,6 +2678,7 @@ window.__ModuleLoader__.load({
2332
2678
  unsubscribeForm();
2333
2679
  unsubscribeLocale();
2334
2680
  clearInterval(renotifyTimer);
2681
+ for (const stop of [...goalWatchers.values()]) stop();
2335
2682
  if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisibility);
2336
2683
  applyTitleFlash(undefined, titleAlerts(t));
2337
2684
  };
@@ -2372,9 +2719,17 @@ window.__ModuleLoader__.load({
2372
2719
  let timer;
2373
2720
  const tick = () => {
2374
2721
  try {
2375
- if (form.getSnapshot().value?.updateAutoCheck === false) return;
2376
- fetch(UPDATE_CHECK_PATH, { method: "GET" }).catch(() => {});
2722
+ if (form.getSnapshot().value?.updateAutoCheck !== false) {
2723
+ fetch(UPDATE_CHECK_PATH, { method: "GET" }).catch(() => {});
2724
+ }
2377
2725
  } catch {}
2726
+ // Rescheduled unconditionally, and that is the fix rather
2727
+ // than a detail: returning early here — which is what the
2728
+ // chain used to do when the preference was off — ended the
2729
+ // self-scheduling for good. Turning the switch off once, or
2730
+ // simply loading the page with it off, meant no check ever
2731
+ // ran again until a full reload, while the row went on
2732
+ // promising "at most one check a day".
2378
2733
  timer = setTimeout(tick, 24 * 60 * 60 * 1000);
2379
2734
  timer.unref?.();
2380
2735
  };
@@ -2424,7 +2779,21 @@ window.__ModuleLoader__.load({
2424
2779
  exports.SOUND_IDS = SOUND_IDS;
2425
2780
  exports.FOCUS_PATH = FOCUS_PATH;
2426
2781
  exports.UPDATE_CHECK_PATH = UPDATE_CHECK_PATH;
2427
- exports.UPDATE_APPLY_PATH = UPDATE_APPLY_PATH;
2782
+ // Which update stages a retry can fix and which need a fresh install:
2783
+ // the Host names a stage in every failure it reports, so this map is the
2784
+ // one place the advice comes from, and the tests cross-check it against
2785
+ // the stage words the dictionaries carry.
2786
+ exports.UPDATE_STAGES = UPDATE_STAGES;
2787
+ // The two composed messages, so the tests can assert the sentences a
2788
+ // user actually reads rather than only the dictionary keys behind them.
2789
+ exports.updateAppliedText = updateAppliedText;
2790
+ exports.updateFailureText = updateFailureText;
2791
+ // The alert election, so the tests can drive two pages against a stubbed
2792
+ // storage and channel instead of hoping two real tabs cooperate.
2793
+ exports.createAlertLeader = createAlertLeader;
2794
+ // The peak scan, so a test can build the one buffer that tells a full
2795
+ // scan apart from a strided one.
2796
+ exports.normalizedGain = normalizedGain;
2428
2797
  exports.timeOfDayToMinutes = timeOfDayToMinutes;
2429
2798
  return module.exports;
2430
2799
  },