dsh-plugin-notify-sounds 1.6.8 → 1.7.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",
@@ -178,7 +190,7 @@ window.__ModuleLoader__.load({
178
190
  "systemNotify.unsupported": "当前环境不支持系统通知",
179
191
  "titleFlash.title": "窗口标题提醒",
180
192
  "titleFlash.description": "页面处于后台时,在窗口标题前加文字标记(待处理 / 已完成),便于在任务栏察觉;回到页面后自动清除",
181
- "titleFlash.macUnsupported": "macOS 暂不支持:标题栏隐藏、Dock 也没有窗口标题,标记无处显示(只在「窗口」菜单 / Mission Control 里可见)",
193
+ "titleFlash.macUnsupported": "macOS 暂不支持,开关已禁用:标题栏隐藏、Dock 也没有窗口标题,标记无处显示(只在「窗口」菜单 / Mission Control 里可见)",
182
194
  "titleFlash.pending": "待处理",
183
195
  "titleFlash.done": "已完成",
184
196
  "renotify.title": "未处理决策重复提醒",
@@ -220,9 +232,17 @@ window.__ModuleLoader__.load({
220
232
  "update.failed": "检查失败(网络不可用)",
221
233
  "update.apply": "立即更新",
222
234
  "update.updating": "更新中…",
223
- "update.done": "更新完成",
224
- "update.restart": "重启 DSH 后生效",
235
+ "update.doneDetail": "更新完成:{from} → {to},重启 DSH 后生效",
225
236
  "update.applyFailed": "更新失败({stage}),已恢复原状",
237
+ "update.stage.check": "检查",
238
+ "update.stage.download": "下载",
239
+ "update.stage.verify": "校验",
240
+ "update.stage.unpack": "解包",
241
+ "update.stage.manifest": "核对包信息",
242
+ "update.stage.replace": "替换文件",
243
+ "update.stage.unknown": "未知阶段",
244
+ "update.retry": "通常是网络或代理的临时问题,稍后重试即可;若反复失败,可到 {url} 手动安装",
245
+ "update.reinstall": "可在 DSH 的 设置 → 插件 里先卸载本插件,再添加 dsh-plugin-notify-sounds 重新安装;仓库:{url}",
226
246
  };
227
247
 
228
248
  /** English dictionary, checked complete against the zh key set. */
@@ -257,7 +277,7 @@ window.__ModuleLoader__.load({
257
277
  "systemNotify.unsupported": "System notifications are unavailable in this environment",
258
278
  "titleFlash.title": "Window title alert",
259
279
  "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)",
280
+ "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
281
  "titleFlash.pending": "Waiting for you",
262
282
  "titleFlash.done": "Finished",
263
283
  "renotify.title": "Repeat unanswered decisions",
@@ -299,9 +319,17 @@ window.__ModuleLoader__.load({
299
319
  "update.failed": "Check failed (no network)",
300
320
  "update.apply": "Update now",
301
321
  "update.updating": "Updating…",
302
- "update.done": "Updated",
303
- "update.restart": "restart DSH to load it",
322
+ "update.doneDetail": "Updated: {from} → {to} — restart DSH to load it",
304
323
  "update.applyFailed": "Update failed ({stage}) — the previous version is intact",
324
+ "update.stage.check": "check",
325
+ "update.stage.download": "download",
326
+ "update.stage.verify": "checksum",
327
+ "update.stage.unpack": "unpack",
328
+ "update.stage.manifest": "package check",
329
+ "update.stage.replace": "file replacement",
330
+ "update.stage.unknown": "unknown step",
331
+ "update.retry": "This is usually a temporary network or proxy problem — try again later. If it keeps failing, install manually from {url}",
332
+ "update.reinstall": "Remove this plugin under Settings → Plugins, then add dsh-plugin-notify-sounds again. Repository: {url}",
305
333
  };
306
334
  //#endregion
307
335
 
@@ -443,19 +471,27 @@ window.__ModuleLoader__.load({
443
471
  * Peak-normalization gain for one decoded buffer: scales the loudest
444
472
  * sample toward full scale, boosting at most 4× so near-silent files
445
473
  * do not get blown up.
474
+ *
475
+ * Every sample is visited, deliberately. An earlier version stepped
476
+ * through the buffer taking every 8th frame and claimed the peak would
477
+ * be caught anyway because "neighbouring samples carry the same
478
+ * magnitude" — that only holds for a heavily oversampled signal. At
479
+ * 44.1 kHz a 10 kHz component has about 4.4 samples per period, so a
480
+ * stride of 8 aliases against it and can systematically miss the real
481
+ * peak; a tone near fs/8 is the extreme case. An underestimated peak
482
+ * means a boost that is too large, so the custom sound plays hot and
483
+ * can clip on its loudest transient.
484
+ *
485
+ * The cost is not on a playback path: this runs once per uploaded file,
486
+ * right after `decodeAudioData` (which costs far more), and the result
487
+ * is cached alongside the buffer in `customSoundCache`.
446
488
  */
447
489
  function normalizedGain(buffer) {
448
490
  try {
449
491
  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
492
  for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
457
493
  const data = buffer.getChannelData(channel);
458
- for (let i = 0; i < data.length; i += stride) {
494
+ for (let i = 0; i < data.length; i++) {
459
495
  const magnitude = Math.abs(data[i]);
460
496
  if (magnitude > peak) peak = magnitude;
461
497
  }
@@ -574,11 +610,25 @@ window.__ModuleLoader__.load({
574
610
  // Release the master gain once the tail has faded. Without
575
611
  // this, every play leaves a gain node permanently wired to
576
612
  // the destination — a slow audio-node leak on long sessions.
577
- setTimeout(() => {
613
+ const remaining = () => (t0 + tail + 0.1 - engine.currentTime) * 1000;
614
+ const release = (rechecked) => {
578
615
  try {
616
+ // The delay was computed when this was scheduled, and a
617
+ // suspended context freezes `currentTime`: without this
618
+ // second look, a timer that comes due during a freeze
619
+ // disconnects a tail that is still playing. Re-check
620
+ // once — not in a loop, so a clock that never advances
621
+ // (a test's frozen mock, a context suspended for good)
622
+ // still ends with the node released.
623
+ if (!rechecked && remaining() > 5) {
624
+ setTimeout(() => release(true), remaining() + 50);
625
+ return;
626
+ }
579
627
  master.disconnect();
580
628
  } catch {}
581
- }, Math.max(0, (t0 + tail + 0.1 - engine.currentTime) * 1000 + 50));
629
+ };
630
+ const timer = setTimeout(() => release(false), Math.max(0, remaining() + 50));
631
+ timer.unref?.();
582
632
  });
583
633
  }
584
634
 
@@ -817,6 +867,183 @@ window.__ModuleLoader__.load({
817
867
  /** Route the Host half applies an update through; must match the Host half's UPDATE_APPLY_PATH. */
818
868
  const UPDATE_APPLY_PATH = "/notify-sounds/update/apply";
819
869
 
870
+ /**
871
+ * Where a user goes when the one-click update cannot finish.
872
+ *
873
+ * Shown as plain selectable text rather than a link: the Desktop shell
874
+ * is a `dsh-app://` page and the Settings primitives expose no external
875
+ * link component, so a URL the user can copy is the honest form — a
876
+ * button that silently did nothing here would be worse than no button.
877
+ */
878
+ const REPOSITORY_URL = "https://github.com/BillCx330/dsh-plugin-notify-sounds";
879
+
880
+ /**
881
+ * Every stage the Host can report, and whether a fresh install is the remedy.
882
+ *
883
+ * Keyed off the stage rather than off the message: telling someone whose
884
+ * download timed out to uninstall the plugin would be actively bad
885
+ * advice, and telling someone whose archive was rejected to "try again
886
+ * later" leaves them with no way forward at all. `true` means the
887
+ * payload itself could not be applied, which retrying will not change.
888
+ */
889
+ const UPDATE_STAGES = { check: false, download: false, verify: false, unpack: true, manifest: true, replace: true };
890
+
891
+ /** The localized word for a Host-side update stage. */
892
+ const stageWord = (t, stage) => t(`update.stage.${typeof stage === "string" && Object.hasOwn(UPDATE_STAGES, stage) ? stage : "unknown"}`);
893
+
894
+ /** `v1.2.3` for display, `?` when the Host reported no version. */
895
+ const formatVersion = (text) => (typeof text === "string" && text !== "" ? `v${text}` : "?");
896
+
897
+ /**
898
+ * The line reporting an installation that landed.
899
+ *
900
+ * Composed here rather than in JSX so the notice above the section and
901
+ * the row inside it say exactly the same thing, and so the whole
902
+ * sentence — punctuation included — lives in the dictionaries instead
903
+ * of being assembled from fragments at the call site (which is how the
904
+ * English UI ended up with full-width stops).
905
+ */
906
+ const updateAppliedText = (t, applied) => t("update.doneDetail", { from: formatVersion(applied?.from), to: formatVersion(applied?.to) });
907
+
908
+ /**
909
+ * The two lines a failed update shows: what happened, and what to do.
910
+ *
911
+ * The remedy is chosen from the stage the Host reported, never from the
912
+ * message: a download that timed out must not be answered with "uninstall
913
+ * and reinstall", and an archive that was refused must not be answered
914
+ * with "try again later".
915
+ */
916
+ const updateFailureText = (t, failed, current) => {
917
+ // Name both versions whenever the target is known: "the update
918
+ // failed" on its own cannot be acted on afterwards, and this is the
919
+ // line a user copies into a bug report.
920
+ const named = typeof failed?.to === "string" ? `${formatVersion(current)} → ${formatVersion(failed.to)} · ` : "";
921
+ return {
922
+ detail: `${named}${t("update.applyFailed", { stage: stageWord(t, failed?.stage) })}`,
923
+ advice: UPDATE_STAGES[failed?.stage] === true
924
+ ? t("update.reinstall", { url: REPOSITORY_URL })
925
+ : t("update.retry", { url: REPOSITORY_URL }),
926
+ };
927
+ };
928
+
929
+ /** Channel name and claim key shared by every page of this origin. */
930
+ const ALERT_CHANNEL = "dsh-notify-sounds:alerts";
931
+ const ALERT_LEADER_KEY = "dsh-notify-sounds:leader";
932
+ /** How often the leader re-asserts, and when a quiet claim is considered abandoned. */
933
+ const ALERT_HEARTBEAT_MS = 5000;
934
+ const ALERT_STALE_MS = 15000;
935
+
936
+ /**
937
+ * `localStorage`, when this page has one that answers.
938
+ *
939
+ * Even reading it can throw where storage is blocked (private mode, a
940
+ * sandboxed frame), and a page without it simply leads itself.
941
+ *
942
+ * @returns the storage, or undefined.
943
+ */
944
+ const safeAlertStorage = () => {
945
+ try {
946
+ if (typeof localStorage === "undefined") return undefined;
947
+ localStorage.getItem(ALERT_LEADER_KEY);
948
+ return localStorage;
949
+ } catch {
950
+ return undefined;
951
+ }
952
+ };
953
+
954
+ /**
955
+ * Elect one page among the tabs — and windows — of this GUI to be the one
956
+ * that speaks.
957
+ *
958
+ * Two pages are two independent subscribers to the same session facts:
959
+ * left alone, one decision rings the sound twice, raises two system
960
+ * notifications and runs two repeat reminders (the settings even say
961
+ * "at most one quiet check a day" while both of them check). The election
962
+ * is a claim in `localStorage`, which every page of this origin shares,
963
+ * refreshed by a heartbeat; a claim that stops being refreshed is taken
964
+ * over, so a closed, crashed or frozen tab hands the role on within a few
965
+ * seconds. `BroadcastChannel` only makes a claim heard sooner — the
966
+ * correctness rests on the stored claim, because whoever wrote it last
967
+ * owns the role and every page re-reads it rather than trusting a message.
968
+ *
969
+ * The title mark is deliberately NOT gated by this: each window and each
970
+ * tab has its own title, so marking them all is right, while a sound and
971
+ * a toast belong to the machine.
972
+ *
973
+ * Pages that can use neither mechanism — the smoke harness, a hardened
974
+ * browser context — lead themselves, which is the behaviour before this
975
+ * existed.
976
+ *
977
+ * @returns `{ isLeader, refresh, dispose }`, where `refresh` re-runs the
978
+ * election (the tests drive it instead of waiting for a heartbeat).
979
+ */
980
+ function createAlertLeader() {
981
+ const storage = safeAlertStorage();
982
+ const Channel = typeof BroadcastChannel === "function" ? BroadcastChannel : undefined;
983
+ if (storage === undefined || Channel === undefined) {
984
+ return { isLeader: () => true, refresh: () => {}, dispose: () => {} };
985
+ }
986
+ const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
987
+ let channel;
988
+ try {
989
+ channel = new Channel(ALERT_CHANNEL);
990
+ } catch {
991
+ return { isLeader: () => true, refresh: () => {}, dispose: () => {} };
992
+ }
993
+ let leader = false;
994
+ const readClaim = () => {
995
+ try {
996
+ const parsed = JSON.parse(storage.getItem(ALERT_LEADER_KEY) ?? "null");
997
+ return parsed !== null && typeof parsed?.id === "string" && Number.isFinite(parsed?.at) ? parsed : undefined;
998
+ } catch {
999
+ return undefined;
1000
+ }
1001
+ };
1002
+ const claim = () => {
1003
+ leader = true;
1004
+ try {
1005
+ storage.setItem(ALERT_LEADER_KEY, JSON.stringify({ id, at: Date.now() }));
1006
+ } catch {}
1007
+ try {
1008
+ channel.postMessage({ id });
1009
+ } catch {}
1010
+ };
1011
+ const refresh = () => {
1012
+ const current = readClaim();
1013
+ if (current === undefined || current.id === id) {
1014
+ claim();
1015
+ return;
1016
+ }
1017
+ // Somebody else holds a claim. While it is fresh this page stays
1018
+ // silent; once it goes stale its holder is gone and this page
1019
+ // takes over.
1020
+ if (Date.now() - current.at < ALERT_STALE_MS) {
1021
+ leader = false;
1022
+ return;
1023
+ }
1024
+ claim();
1025
+ };
1026
+ channel.onmessage = (event) => {
1027
+ if (event?.data?.id !== id) refresh();
1028
+ };
1029
+ const timer = setInterval(refresh, ALERT_HEARTBEAT_MS);
1030
+ timer.unref?.();
1031
+ refresh();
1032
+ return {
1033
+ isLeader: () => leader,
1034
+ refresh,
1035
+ dispose: () => {
1036
+ clearInterval(timer);
1037
+ try {
1038
+ if (leader && readClaim()?.id === id) storage.removeItem(ALERT_LEADER_KEY);
1039
+ } catch {}
1040
+ try {
1041
+ channel.close();
1042
+ } catch {}
1043
+ },
1044
+ };
1045
+ }
1046
+
820
1047
  /**
821
1048
  * Show one OS notification; every failure mode is silent.
822
1049
  *
@@ -981,8 +1208,7 @@ window.__ModuleLoader__.load({
981
1208
  .catch(() => {});
982
1209
  }, [autoCheck]);
983
1210
  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")}`);
1211
+ return h("div", { className: css.hint, key: "text" }, updateAppliedText(t, applied));
986
1212
  }
987
1213
 
988
1214
  function NotifySoundsSection({ useConfig, setField, preview, t }) {
@@ -1099,17 +1325,6 @@ window.__ModuleLoader__.load({
1099
1325
  },
1100
1326
  onPreview: (id) => preview(id, volume),
1101
1327
  }),
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
1328
  h(CustomSoundRow, {
1114
1329
  key: "custom",
1115
1330
  t,
@@ -1152,6 +1367,17 @@ window.__ModuleLoader__.load({
1152
1367
  },
1153
1368
  onPreview: () => preview("custom", volume),
1154
1369
  }),
1370
+ h(SwitchRow, {
1371
+ key: "subagent",
1372
+ t,
1373
+ title: t("subagent.title"),
1374
+ description: t("subagent.description"),
1375
+ label: t("subagent.title"),
1376
+ checked: value?.subagentAlerts === true,
1377
+ disabled,
1378
+ failed: failedField === "subagentAlerts",
1379
+ onChange: (next) => write("subagentAlerts", next),
1380
+ }),
1155
1381
  h(SystemNotifyRow, {
1156
1382
  key: "systemNotify",
1157
1383
  t,
@@ -1170,10 +1396,10 @@ window.__ModuleLoader__.load({
1170
1396
  // document title like any other, so this is not
1171
1397
  // applied there. iOS Safari's UA also contains "like
1172
1398
  // Mac OS X" — `isDesktopShell` keeps it out.
1173
- description: isMac() && isDesktopShell() ? t("titleFlash.macUnsupported") : t("titleFlash.description"),
1399
+ description: titleFlashUnavailable() ? t("titleFlash.macUnsupported") : t("titleFlash.description"),
1174
1400
  label: t("titleFlash.title"),
1175
1401
  checked: value?.titleFlash === true,
1176
- disabled,
1402
+ disabled: disabled || titleFlashUnavailable(),
1177
1403
  failed: failedField === "titleFlash",
1178
1404
  onChange: (next) => write("titleFlash", next),
1179
1405
  }),
@@ -1728,7 +1954,6 @@ window.__ModuleLoader__.load({
1728
1954
  check(false)();
1729
1955
  }, [autoCheck]);
1730
1956
  const payload = state.payload;
1731
- const version = (text) => (typeof text === "string" && text !== "" ? `v${text}` : "?");
1732
1957
  // The Host remembers the last install and repeats it with every
1733
1958
  // answer, and this row prefers that: an update swaps the client
1734
1959
  // half's own file, so the page may reload and drop this row's
@@ -1740,15 +1965,21 @@ window.__ModuleLoader__.load({
1740
1965
  let status = payload === undefined
1741
1966
  ? t("update.idle")
1742
1967
  : payload.updateAvailable === true
1743
- ? `${t("update.available")} ${version(payload.latest)}`
1968
+ ? `${t("update.available")} ${formatVersion(payload.latest)}`
1744
1969
  : t("update.uptodate");
1745
1970
  if (state.phase === "checking") status = t("update.checking");
1746
1971
  if (state.phase === "updating") status = t("update.updating");
1747
- let detail = payload === undefined ? status : `${t("update.current")} ${version(payload.current)} · ${status}`;
1972
+ let detail = payload === undefined ? status : `${t("update.current")} ${formatVersion(payload.current)} · ${status}`;
1973
+ // Advice is a second line rather than more words in the first one:
1974
+ // the failure line is the part a user scans, and the remedy only
1975
+ // applies to some stages. `undefined` renders nothing at all.
1976
+ let advice;
1748
1977
  if (appliedDone) {
1749
- detail = `${t("update.done")}:${version(applied.from)} → ${version(applied.to)},${t("update.restart")}`;
1978
+ detail = updateAppliedText(t, applied);
1750
1979
  } else if (state.phase === "applyFailed") {
1751
- detail = t("update.applyFailed", { stage: String(state.result?.stage ?? "?") });
1980
+ const failure = updateFailureText(t, state.result, payload?.current);
1981
+ detail = failure.detail;
1982
+ advice = failure.advice;
1752
1983
  }
1753
1984
  const busy = state.phase === "checking" || state.phase === "updating";
1754
1985
  return h("div", { className: css.row }, [
@@ -1756,6 +1987,7 @@ window.__ModuleLoader__.load({
1756
1987
  h("div", { className: css.rowTitle, key: "title" }, t("update.title")),
1757
1988
  h("div", { className: css.rowDescription, key: "description" }, t("update.description")),
1758
1989
  h("div", { className: css.hint, key: "status" }, detail),
1990
+ advice === undefined ? null : h("div", { className: css.hint, key: "advice" }, advice),
1759
1991
  ]),
1760
1992
  h("div", { className: css.stackControl, key: "control" }, [
1761
1993
  payload?.updateAvailable === true && !appliedDone ? h(Button, {
@@ -1893,6 +2125,12 @@ window.__ModuleLoader__.load({
1893
2125
  let panelObserver = null;
1894
2126
  let observedPanel = null;
1895
2127
  const scan = (root) => {
2128
+ // One panel-level probe before the walk. This observer fires on
2129
+ // every change anywhere in the settings tree — a conversation
2130
+ // streaming next to an open panel included — and once this
2131
+ // section's cell carries the glyph there is nothing left to do;
2132
+ // the per-cell checks below would only re-establish that.
2133
+ if (root.querySelector(`[${NAV_ICON_MARKER}]`) !== null) return;
1896
2134
  const label = t("nav");
1897
2135
  for (const cell of root.querySelectorAll("button")) swapNavIcon(cell, label);
1898
2136
  };
@@ -1961,6 +2199,11 @@ window.__ModuleLoader__.load({
1961
2199
  */
1962
2200
  function apply(ctx) {
1963
2201
  insertStyles();
2202
+ // One page per browser profile speaks (see createAlertLeader): a
2203
+ // sound and a toast belong to the machine, and a second tab of the
2204
+ // same GUI must not raise them a second time.
2205
+ const alertLeader = createAlertLeader();
2206
+ ctx.effect(() => alertLeader.dispose, "notify-sounds: alert leader");
1964
2207
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "notify-sounds: dictionaries");
1965
2208
  const t = ctx.locale.bind(NS);
1966
2209
  // The platform's own resolver for package text, which may be a plain
@@ -1995,7 +2238,8 @@ window.__ModuleLoader__.load({
1995
2238
  const cooldown = new Map();
1996
2239
 
1997
2240
  /**
1998
- * The session list rows, if this runtime exposes them.
2241
+ * One snapshot of the session list rows, or `undefined` when this
2242
+ * runtime exposes none.
1999
2243
  *
2000
2244
  * The status facts carry no classification at all — an entry is
2001
2245
  * only `{ running, pendingInteraction, completionUnread }` — so
@@ -2003,24 +2247,20 @@ window.__ModuleLoader__.load({
2003
2247
  * session list, whose rows carry the same `origin`/`parentId` the
2004
2248
  * sidebar uses to keep sub-agents out of the ordinary list.
2005
2249
  *
2250
+ * Callers read ONCE per pass and hand the result down: the previous
2251
+ * shape called this per session inside the loops below, so a status
2252
+ * update covering ten sessions took ten snapshots, and the pass
2253
+ * scope was implied by "the same millisecond" rather than stated.
2254
+ *
2006
2255
  * @returns the `byId` row map, or undefined when unavailable.
2007
2256
  */
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;
2257
+ const readSessionRows = () => {
2016
2258
  try {
2017
- cachedRows = ctx.sessions?.list?.getSnapshot?.()?.byId
2259
+ return ctx.sessions?.list?.getSnapshot?.()?.byId
2018
2260
  ?? ctx.uiSession?.sessions?.list?.getSnapshot?.()?.byId;
2019
2261
  } catch {
2020
- cachedRows = undefined;
2262
+ return undefined;
2021
2263
  }
2022
- cachedRowsAt = now;
2023
- return cachedRows;
2024
2264
  };
2025
2265
 
2026
2266
  /**
@@ -2028,10 +2268,11 @@ window.__ModuleLoader__.load({
2028
2268
  * sub-agent, a teammate — rather than one of the user's own.
2029
2269
  *
2030
2270
  * @param sessionId - the session to classify.
2271
+ * @param rows - the pass's session rows, when the runtime has them.
2031
2272
  * @returns whether it is a spawned child session.
2032
2273
  */
2033
- const isSubagentSession = (sessionId) => {
2034
- const row = sessionRows()?.[sessionId];
2274
+ const isSubagentSession = (sessionId, rows) => {
2275
+ const row = rows?.[sessionId];
2035
2276
  // Deliberately ONLY `origin`, the same test the sidebar and the
2036
2277
  // sub-agent UI use. `parentId` alone is not enough: a forked
2037
2278
  // conversation carries one too and is still the user's own.
@@ -2048,12 +2289,13 @@ window.__ModuleLoader__.load({
2048
2289
  * not something they want a chime for.
2049
2290
  *
2050
2291
  * @param sessionId - the session to test.
2292
+ * @param rows - the pass's session rows, when the runtime has them.
2051
2293
  * @returns whether its events should alert.
2052
2294
  */
2053
- const isNotifiable = (sessionId) => {
2295
+ const isNotifiable = (sessionId, rows) => {
2054
2296
  const value = form.getSnapshot().value;
2055
2297
  if (value?.subagentAlerts === true) return true;
2056
- return !isSubagentSession(sessionId);
2298
+ return !isSubagentSession(sessionId, rows);
2057
2299
  };
2058
2300
 
2059
2301
  /**
@@ -2075,6 +2317,10 @@ window.__ModuleLoader__.load({
2075
2317
  * reminder is not recorded as sent.
2076
2318
  */
2077
2319
  const notify = (kind, interaction, sessionId, options = {}) => {
2320
+ // The elected page is the only one that makes a noise or raises
2321
+ // a notification; every page still keeps its own title mark,
2322
+ // because a title belongs to its own window.
2323
+ if (!alertLeader.isLeader()) return false;
2078
2324
  const value = form.getSnapshot().value;
2079
2325
  if (value?.enabled === false) return false;
2080
2326
  if (value?.quietHours === true && inQuietHours(new Date(), value?.quietStart, value?.quietEnd)) return false;
@@ -2096,8 +2342,17 @@ window.__ModuleLoader__.load({
2096
2342
  // Evict the oldest entry rather than clearing the whole
2097
2343
  // table: one session hitting the cap must not silence
2098
2344
  // every other session's cooldown.
2099
- if (cooldown.size >= 96) cooldown.delete(cooldown.keys().next().value);
2100
- cooldown.set(`${kind}:${sessionId}`, now);
2345
+ //
2346
+ // LRU, not FIFO: `Map.set` on a key that is already there
2347
+ // updates the value and KEEPS the original position, so
2348
+ // plain eviction would throw away whichever entry is
2349
+ // touched most often — precisely the session whose
2350
+ // two-second dedupe is doing work. Deleting first moves it
2351
+ // to the back on re-insertion.
2352
+ const key = `${kind}:${sessionId}`;
2353
+ if (cooldown.has(key)) cooldown.delete(key);
2354
+ else if (cooldown.size >= 96) cooldown.delete(cooldown.keys().next().value);
2355
+ cooldown.set(key, now);
2101
2356
  }
2102
2357
  const fallback = kind === "completion" ? "ding" : kind === "question" ? "chime" : "bell";
2103
2358
  let id = typeof value?.[kind + "Sound"] === "string" ? value[kind + "Sound"] : fallback;
@@ -2156,9 +2411,9 @@ window.__ModuleLoader__.load({
2156
2411
  return value?.quietHours === true && inQuietHours(new Date(), value?.quietStart, value?.quietEnd);
2157
2412
  };
2158
2413
  /** Whether any session is currently running. */
2159
- const anyRunning = () => {
2414
+ const anyRunning = (rows) => {
2160
2415
  for (const [sessionId, status] of source.getSnapshot()) {
2161
- if (status.running === true && isNotifiable(sessionId)) return true;
2416
+ if (status.running === true && isNotifiable(sessionId, rows)) return true;
2162
2417
  }
2163
2418
  return false;
2164
2419
  };
@@ -2180,7 +2435,7 @@ window.__ModuleLoader__.load({
2180
2435
  // the quiet window ends.
2181
2436
  if (record.quiet !== true) return "pending";
2182
2437
  }
2183
- if (anyRunning()) return undefined;
2438
+ if (anyRunning(readSessionRows())) return undefined;
2184
2439
  return unreadCompletion ? "done" : undefined;
2185
2440
  };
2186
2441
  /** Refresh the title alert from the current facts. */
@@ -2190,12 +2445,12 @@ window.__ModuleLoader__.load({
2190
2445
  applyTitleFlash(wantedTitleState(), titleAlerts(t));
2191
2446
  };
2192
2447
  /** Rebuild the tracker from one snapshot, then refresh the flash. */
2193
- const trackPending = (snapshot) => {
2448
+ const trackPending = (snapshot, rows) => {
2194
2449
  const next = new Map();
2195
2450
  for (const [sessionId, status] of snapshot) {
2196
2451
  // A filtered session is invisible to the whole package:
2197
2452
  // it gets no reminder and no title mark either.
2198
- if (!isNotifiable(sessionId)) continue;
2453
+ if (!isNotifiable(sessionId, rows)) continue;
2199
2454
  const interaction = status.pendingInteraction;
2200
2455
  if (interaction === undefined) continue;
2201
2456
  const record = pending.get(sessionId);
@@ -2222,11 +2477,14 @@ window.__ModuleLoader__.load({
2222
2477
  };
2223
2478
  const read = () => {
2224
2479
  const snapshot = source.getSnapshot();
2480
+ // One row snapshot for this whole pass, handed to every
2481
+ // check below instead of being taken per session.
2482
+ const rows = readSessionRows();
2225
2483
  if (previous !== undefined) {
2226
2484
  for (const [sessionId, status] of snapshot) {
2227
2485
  // Sub-agent sessions stay out of it unless asked for:
2228
2486
  // the user wants the main conversation's alerts.
2229
- if (!isNotifiable(sessionId)) continue;
2487
+ if (!isNotifiable(sessionId, rows)) continue;
2230
2488
  const before = previous.get(sessionId);
2231
2489
  const beforePending = before === undefined ? undefined : before.pendingInteraction;
2232
2490
  if (status.pendingInteraction !== undefined && status.pendingInteraction !== beforePending) {
@@ -2252,7 +2510,7 @@ window.__ModuleLoader__.load({
2252
2510
  }
2253
2511
  }
2254
2512
  previous = snapshot;
2255
- trackPending(snapshot);
2513
+ trackPending(snapshot, rows);
2256
2514
  };
2257
2515
  // Returning to the page clears the alert even while the
2258
2516
  // decision stays pending — and marks every pending decision
@@ -2372,9 +2630,17 @@ window.__ModuleLoader__.load({
2372
2630
  let timer;
2373
2631
  const tick = () => {
2374
2632
  try {
2375
- if (form.getSnapshot().value?.updateAutoCheck === false) return;
2376
- fetch(UPDATE_CHECK_PATH, { method: "GET" }).catch(() => {});
2633
+ if (form.getSnapshot().value?.updateAutoCheck !== false) {
2634
+ fetch(UPDATE_CHECK_PATH, { method: "GET" }).catch(() => {});
2635
+ }
2377
2636
  } catch {}
2637
+ // Rescheduled unconditionally, and that is the fix rather
2638
+ // than a detail: returning early here — which is what the
2639
+ // chain used to do when the preference was off — ended the
2640
+ // self-scheduling for good. Turning the switch off once, or
2641
+ // simply loading the page with it off, meant no check ever
2642
+ // ran again until a full reload, while the row went on
2643
+ // promising "at most one check a day".
2378
2644
  timer = setTimeout(tick, 24 * 60 * 60 * 1000);
2379
2645
  timer.unref?.();
2380
2646
  };
@@ -2425,6 +2691,21 @@ window.__ModuleLoader__.load({
2425
2691
  exports.FOCUS_PATH = FOCUS_PATH;
2426
2692
  exports.UPDATE_CHECK_PATH = UPDATE_CHECK_PATH;
2427
2693
  exports.UPDATE_APPLY_PATH = UPDATE_APPLY_PATH;
2694
+ // Which update stages a retry can fix and which need a fresh install:
2695
+ // the Host names a stage in every failure it reports, so this map is the
2696
+ // one place the advice comes from, and the tests cross-check it against
2697
+ // the stage words the dictionaries carry.
2698
+ exports.UPDATE_STAGES = UPDATE_STAGES;
2699
+ // The two composed messages, so the tests can assert the sentences a
2700
+ // user actually reads rather than only the dictionary keys behind them.
2701
+ exports.updateAppliedText = updateAppliedText;
2702
+ exports.updateFailureText = updateFailureText;
2703
+ // The alert election, so the tests can drive two pages against a stubbed
2704
+ // storage and channel instead of hoping two real tabs cooperate.
2705
+ exports.createAlertLeader = createAlertLeader;
2706
+ // The peak scan, so a test can build the one buffer that tells a full
2707
+ // scan apart from a strided one.
2708
+ exports.normalizedGain = normalizedGain;
2428
2709
  exports.timeOfDayToMinutes = timeOfDayToMinutes;
2429
2710
  return module.exports;
2430
2711
  },