dsh-plugin-notify-sounds 1.9.0 → 1.9.3
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/README.md +13 -9
- package/README.zh.md +13 -9
- package/lib/client.js +242 -20
- package/lib/index.js +22 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,12 +11,12 @@ English | [中文](README.zh.md)
|
|
|
11
11
|
|
|
12
12
|
**A soft chime when the agent is waiting for you — or when it is done.**
|
|
13
13
|
|
|
14
|
-
Walk away from the window without missing anything: an OS notification
|
|
14
|
+
Walk away from the window without missing anything: an OS notification whether the window is minimised, covered, or merely unfocused (**click it to jump back**), and a "Waiting for you" / "Finished" mark on the taskbar title. Once you have seen it, the plugin goes quiet — it will not nag you like an alarm clock.
|
|
15
15
|
|
|
16
16
|
**Install**: DSH → **Plugins** in the left sidebar → Add plugin, paste the line below (keep the version — a release younger than 24 hours is resolved back to the previous one by pnpm's `minimumReleaseAge`) and press **Install**:
|
|
17
17
|
|
|
18
18
|
```
|
|
19
|
-
dsh-plugin-notify-sounds@1.9.
|
|
19
|
+
dsh-plugin-notify-sounds@1.9.3
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
---
|
|
@@ -31,11 +31,11 @@ Every visual detail of the settings section comes from DSH's own design system:
|
|
|
31
31
|
|---|---|
|
|
32
32
|
| **Your decision is needed** | A chime when the agent raises an approval, a plan review, or a question — separate sounds for approvals and questions |
|
|
33
33
|
| **Task finished** | A chime when a session goes from running to idle |
|
|
34
|
-
| **Aware even when you are away** | An OS notification
|
|
34
|
+
| **Aware even when you are away** | An OS notification whenever the window is minimised, covered, or merely unfocused (body is the approval reason or the question itself; **click to return to the window**) plus a taskbar title mark |
|
|
35
35
|
| **Missed it? It reminds again** | Unanswered decisions re-ping every 1/5/15/30 minutes — and **stop for good once you have seen them**, even if you switch away again |
|
|
36
36
|
| **Only what you care about** | Sub-agents (background tasks) are completely silent by default — only your own conversation is announced |
|
|
37
37
|
| **Quiet when it should be** | Quiet hours (e.g. 22:00–08:00, wrapping past midnight), notify-only-when-hidden, and a master switch |
|
|
38
|
-
| **Your sound, your way** | Nine built-in sounds; **up to three of your own clips**,
|
|
38
|
+
| **Your sound, your way** | Nine built-in sounds, levelled to each other; **up to three of your own clips**, named and assignable to any alert (mp3 / wav / ogg / m4a, up to 15s each); volume — each feature with its own toggle |
|
|
39
39
|
| **Update checks** | Checks npm for a newer version and points at the route that installs it — the **Plugins** panel in the sidebar (uninstall, then add again), the marketplace, or `dsh plugin add` on a web profile; the row also flags an install the profile's lockfile does not match |
|
|
40
40
|
|
|
41
41
|
## 💡 Why
|
|
@@ -48,7 +48,7 @@ This plugin turns "it is waiting for you" and "it is done" into sound. With syst
|
|
|
48
48
|
|
|
49
49
|
Bell · Chime · Ding · Drop · Pulse · Arc · Coin · Success · Knock — nine built-in sounds, all synthesized live with Web Audio: no audio files, no network requests.
|
|
50
50
|
|
|
51
|
-
Or upload your own
|
|
51
|
+
Or upload your own audio (mp3 / wav / ogg / m4a, up to 15s and 3MB each, validated and loudness-normalized), or pick "None" for notifications only. Each trigger (approval / question / completion) gets its own sound, with preview right in the settings.
|
|
52
52
|
|
|
53
53
|
## ⚙️ Settings
|
|
54
54
|
|
|
@@ -66,7 +66,7 @@ Everything applies immediately — no restart needed.
|
|
|
66
66
|
2. Paste the package name **with a version** (this plugin is published on [npm](https://www.npmjs.com/package/dsh-plugin-notify-sounds)):
|
|
67
67
|
|
|
68
68
|
```
|
|
69
|
-
dsh-plugin-notify-sounds@1.9.
|
|
69
|
+
dsh-plugin-notify-sounds@1.9.3
|
|
70
70
|
```
|
|
71
71
|
|
|
72
72
|
Do not drop the version: pnpm's `minimumReleaseAge` policy keeps a release younger than 24 hours out of automatic resolution, so the bare name resolves to the previous one. The current version is on [npm](https://www.npmjs.com/package/dsh-plugin-notify-sounds?activeTab=versions) and in [Releases](https://github.com/BillCx330/dsh-plugin-notify-sounds/releases).
|
|
@@ -100,7 +100,7 @@ dsh plugin --profile <your profile> add github:BillCx330/dsh-plugin-notify-sound
|
|
|
100
100
|
**Release archive** — download the `.tgz` from [Releases](https://github.com/BillCx330/dsh-plugin-notify-sounds/releases) and install it by its **path on disk**:
|
|
101
101
|
|
|
102
102
|
```sh
|
|
103
|
-
dsh plugin --profile <your profile> add ./dsh-plugin-notify-sounds-1.9.
|
|
103
|
+
dsh plugin --profile <your profile> add ./dsh-plugin-notify-sounds-1.9.3.tgz
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
(`dsh plugin add` hands its argument straight to the package manager, so it takes a local file path — a download URL will not work.)
|
|
@@ -122,7 +122,8 @@ The command line writes `dsh.profile.bundles` and installs the dependency for yo
|
|
|
122
122
|
- **Upgrades are installed by the package manager, not by the plugin**: 1.8.0 removed the self-installer (writing files in place never updated the profile's lockfile, so the next plugin install silently restored the old version). The update row still checks npm and reports a newer version, then points at the route that installs — the **Plugins** panel in the sidebar (uninstall, then add again), the marketplace, or `dsh plugin add` on a web profile.
|
|
123
123
|
- **Versions up to 1.7.0** reach 1.8.0 through their own updater (1.8.0 keeps the same seven filenames), but that arrival is not registered with pnpm: reinstall once through the package manager as above and every later upgrade stays in place. Versions up to 1.6.9 cannot install a release that adds a file — their fixed whitelist refuses it safely; the same one-time reinstall fixes that too.
|
|
124
124
|
- **Multiple windows** elect one page to speak (since 1.7.0): sounds, system notifications and repeat reminders therefore fire once for the whole GUI, not once per window. Title marks stay per-window, because each window has its own title. If the speaking page closes or crashes, another takes over within seconds.
|
|
125
|
-
- **Click-to-return** is fully implemented and tested on Windows; macOS / Linux fall back to the system deep link and are unverified.
|
|
125
|
+
- **Click-to-return** is fully implemented and tested on Windows; macOS / Linux fall back to the system deep link and are unverified.
|
|
126
|
+
- **Third-party desktop wrappers** (e.g. DSH NEXT) register no `dsh://` protocol and expose no focus API, so clicking a notification cannot return you to the window there — the gap is on the host side, not something a plugin can close.
|
|
126
127
|
- **The window is found by its title** — deliberately without inspecting other processes (that trips security software). If DSH ever changes its title format, click-to-return silently stops working; sounds and notifications are unaffected.
|
|
127
128
|
- **The nav glyph** depends on the settings panel's DOM structure and silently falls back to the official gear after a DSH redesign — cosmetic only.
|
|
128
129
|
- **Notification permission** is granted on the Desktop; under restrictive policies the row shows how to allow it manually, everything else keeps working.
|
|
@@ -145,10 +146,13 @@ For anyone hacking on the code (exact details live in the source comments):
|
|
|
145
146
|
|
|
146
147
|
## 📜 Changelog
|
|
147
148
|
|
|
149
|
+
- **v1.9.3** (2026-11-02) **A visible-but-unfocused window now raises a system notification.** Notifications used to fire only while the page was hidden, but a window sitting in plain sight behind another app — or on a second screen while you type elsewhere — still reports `visible`, so that whole case was silent ([#5](https://github.com/BillCx330/dsh-plugin-notify-sounds/issues/5), thanks @myzc1000). The gate is now "hidden **or** unfocused". Minimised, tray-hidden and fully covered windows behave as before (Chromium's occlusion tracking marks those hidden).Two small fixes came along: a refused write while adding a clip left its uploaded file behind (it is deleted now), and a multi-line conversation title broke the toast title across lines (it is flattened to one).
|
|
150
|
+
- **v1.9.2** (2026-10-30) **System notifications are titled with the conversation name.** They used to read "Task finished / The agent has finished the current task", so with several conversations open you had to click through to learn which one. The title now carries the conversation's name (from the `title` already on DSH's session rows, falling back to the workspace folder — and treating DSH's own "Working directory: …" fallback as unnamed, so only the folder segment shows), and the body is unchanged; a decision toast prefixes its body with the event so nothing is lost. Titles are trimmed at 48 characters, an untitled session keeps the old wording, and a raw session id is never shown. These fields exist since DSH 0.2.0 — not a new API — and when they are missing the behaviour is exactly what it was. **One cross-platform fix came along**: on Windows two windows uploading the same clip at once made the second report "not audio" (415) — POSIX lets `rename` overwrite, Windows refuses — and that refusal is now treated as success. ([#4](https://github.com/BillCx330/dsh-plugin-notify-sounds/issues/4), thanks @AdySnowflake)
|
|
151
|
+
- **v1.9.1** (2026-10-29) **Fixed "too quiet": the nine presets are now levelled by loudness, not by peak.** A user reported that even 100% was quiet. Two things were stacked: the presets had never been matched to each other (`coin` peaks at 0.105, `ding` at 0.315 — 9.5 dB apart), and all of them sat about 12 dB below a decoded clip (a preset played at -16 dBFS at 100%, an imported file at -4). The same slider number meant two loudnesses. The first cut levelled them by peak, which made the coin sound louder still: a square carries 3 dB more energy than a sine at the same peak (RMS/peak 1.0 vs 0.707). Levels now use a crest-corrected peak — sines and squares land on the same energy, and triangles are capped at the ceiling rather than pushed past it. The same number is about 12 dB louder, and the nine sounds agree with each other. **The saved volume is re-scaled once on upgrade**: at the new scale the same number is ~10 dB louder, so the stored value is halved (60 → 30, 100 → 50) — about 3.5 dB above where you were. Turn it up for the new headroom. ([#3](https://github.com/BillCx330/dsh-plugin-notify-sounds/issues/3), thanks @sink-er)
|
|
148
152
|
- **v1.9.0** (2026-10-27) **Your own sounds become a small library: three at most, each renameable and assignable.** All three alerts used to share a single "Custom" upload, so giving "finished" its own sound while "approval needed" kept a preset was impossible. The "Custom sound" row now holds everything: press **Add audio**, and the file is listed underneath (three at most) where it can be renamed, previewed or removed; the three alert pickers list your sounds by name, so each alert plays the one you point it at. **The bytes moved out of the config**: a 512KB clip used to become 699,052 characters of base64 inside `cordis.patch.yml` — 277× the whole file, rewritten on every settings change — so audio is now stored under `profiles/<profile>/notify-sounds/sounds/<hash>.<ext>` with only the name in the config. Three gaps closed as well: **3MB and 15 seconds per sound** (a long clip turns an alert into background music and can cost tens of MB once decoded); **silent and near-zero-length files are refused at upload**; and **anything unreadable falls back to that alert's default sound** — a damaged payload used to silence all three with nothing in the UI to explain it. The list marks a file that has gone missing, and one no alert uses yet. Existing audio from an older release is moved into the library on first run; if that fails it stays where it was and keeps playing.
|
|
149
153
|
- **v1.8.6** (2026-10-23) **A full audit, paid off — no new features.** Fixed: with goal-only alerting on, turning the switch **off** while the watcher waited still produced the chime it was holding at the end of the goal (the watcher now re-reads the preference); the update check cached a FAILURE for the full day, so one network blip left the row reading "check failed" until tomorrow (failures are now cached for a minute, and the row no longer blames the network — it says neither source returned a usable answer); a `pnpm-lock.yaml` key carrying a peer suffix (`pkg@1.8.4(@scope/peer@3.18.4)`) was read as a version, which flagged a perfectly good install as unregistered and told the user to reinstall it; an unreadable current version rendered as "This version ? · Up to date", which is now its own message; the repeat-interval control displayed a hand-edited 7 as "5 min" while reminders fired every seven (display and timer now share one snap); identical quiet-hours bounds were silently treated as "no quiet" and the description now says so; the client's time parser accepted `9:05` while the Host schema rejected it (both are strict now); a custom-sound read failure was reported as "could not decode"; and one refused notification construction left the row permanently "unsupported" (a successful one now clears it). **Update guidance**: on the Desktop the plugin manager is a **sidebar panel named Plugins**, not "Settings → Plugins" — 1.8.5 got that wrong; every install command now pins the version (it rendered as `@v1.8.5`, and the display prefix does not belong in a command a package manager parses), and the copy explains that a release younger than pnpm's `minimumReleaseAge` window resolves back to the previous one. **Cleanup**: removed the 527-line unreachable self-installer from 1.8.0 (the Host file drops from 1553 to 1023 lines, along with 10 imports that existed only for it), the update notice that could never render, eight dead dictionary keys, and the tests that pinned the dead code; all three route paths are now cross-checked between the halves (`/notify-sounds/goal` was missing, and it is the one that has already drifted once). The README's "multiple windows fire independently" claim contradicted the speaking-page election shipped in 1.7.0 and is corrected.
|
|
150
154
|
- **v1.8.5** (2026-10-20) **The wording catches up with the mechanism.** The update row still said a newer version could be installed "in one click" — it cannot since 1.8.0: the row only checks and points at the package manager. The row now says exactly that, and its in-app guidance carries the version (`dsh-plugin-notify-sounds@{version}`) so a fresh release is not silently resolved to the previous one by pnpm's `minimumReleaseAge` policy. The README feature table, settings summary and install step no longer promise one-click installs; historical changelog entries are left untouched.
|
|
151
|
-
- **v1.8.4** (2026-10-20) **A click always finds the window.** A trace of a real click showed the native raise giving up with "no window found" while the window existed: a hidden or repainting window can transiently answer with an empty title, and the search had nothing else to match on. The window search now also matches the process that owns the window (the Desktop shell is this Host's parent), and the foreground request is retried three times before the deep-link fallback is paid for.
|
|
155
|
+
- **v1.8.4** (2026-10-20) **A click always finds the window.** A trace of a real click showed the native raise giving up with "no window found" while the window existed: a hidden or repainting window can transiently answer with an empty title, and the search had nothing else to match on. The window search now also matches the process that owns the window (the Desktop shell is this Host's parent), and the foreground request is retried three times before the deep-link fallback is paid for. ([#2](https://github.com/BillCx330/dsh-plugin-notify-sounds/issues/2), thanks @hxwd94666)
|
|
152
156
|
- **v1.8.3** (2026-10-20) **Click-to-return works when the window is closed to the tray.** The Win32 raise searched only *visible* windows, so a DSH window hidden to the tray was never found and the click fell back to the slow deep-link route — which reads as "clicked the notification, nothing happened". The search now also remembers an invisible window whose title matches, and the raise shows it (`SW_SHOW`) before restoring and focusing it; the deep link stays as the last resort.
|
|
153
157
|
- **v1.8.2** (2026-10-20) **Actually fixes goal-mode alerting.** The 1.8.1 host route read `stateOf(session, "goal")` but took `current.phase`, while the projection value nests the goal: `current.goal.phase`. Every read therefore came back empty and every round chimed. The route now reads the nested field (with a fallback for a flattened shape), and a Host-side debug trace confirmed `current.goal.phase` is `active` on intermediate rounds and `complete` on the final one.
|
|
154
158
|
- **v1.8.1** (2026-10-20) **Fixes goal-mode alerting.** The 1.8.0 switch read the phase from the browser's own session projection, which only carries sessions that page has opened — for a goal in a session it never opened the read came back empty, which the code treated as "no goal", so every round chimed again. The browser half now asks the Host's new read-only `/notify-sounds/goal` route, whose projection store is authoritative, and re-checks it while the goal is still `active` (the single chime lands when the goal completes, pauses or blocks; a session that starts running again stays silent). A Host that does not answer the route keeps the old behaviour.
|
package/README.zh.md
CHANGED
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
|
|
12
12
|
**智能体在等你、或干完活时,轻轻响一声。**
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
人不在窗口前也不怕:最小化、被遮挡、或只是切到别的窗口,都会弹系统通知(**点击即回到窗口**),任务栏标题亮出「待处理 / 已完成」;你回来看过一眼之后,它就闭嘴——不会像闹钟一样反复催你。
|
|
15
15
|
|
|
16
16
|
**安装**:DSH → 左侧栏「插件」→ 添加插件,粘贴下面这行(**带上版本号**,否则发布不满 24 小时的版本会被 pnpm 的 `minimumReleaseAge` 解析回上一版),点「安装」:
|
|
17
17
|
|
|
18
18
|
```
|
|
19
|
-
dsh-plugin-notify-sounds@1.9.
|
|
19
|
+
dsh-plugin-notify-sounds@1.9.3
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
---
|
|
@@ -31,11 +31,11 @@ dsh-plugin-notify-sounds@1.9.0
|
|
|
31
31
|
|---|---|
|
|
32
32
|
| **需要你决策** | 智能体发起审批、计划确认、提问时提示音;审批与提问可分别设音效 |
|
|
33
33
|
| **任务完成** | 会话由运行转为空闲时提示音 |
|
|
34
|
-
| **人不在窗口前也能感知** |
|
|
34
|
+
| **人不在窗口前也能感知** | 最小化、被遮挡或只是没在用它时都弹系统通知(正文就是审批理由或提问原文,点击回到窗口);任务栏标题显示「待处理 / 已完成」 |
|
|
35
35
|
| **没看到会再提醒** | 未处理决策按 1/5/15/30 分钟重复提醒 |
|
|
36
36
|
| **只提醒你关心的** | 子智能体(后台任务)默认完全静默,只提醒主对话,可开关 |
|
|
37
37
|
| **该安静时安静** | 静音时段(如 22:00–08:00,可跨午夜)、仅页面不可见时提示、总开关一键全静音 |
|
|
38
|
-
| **声音随你** |
|
|
38
|
+
| **声音随你** | 九种内置音效(彼此响度已对齐);**最多 3 个自己的音频**,可改名、指派给任意提醒(mp3 / wav / ogg / m4a,每个 ≤15 秒);音量调节;每项独立开关 |
|
|
39
39
|
| **检测更新** | 设置里检查 npm 新版本,并指向真正负责安装的入口——**左侧栏「插件」**(卸载后再装)、市场,或 web profile 上的 `dsh plugin add`;若当前安装未被 lockfile 记录,会在行内提示 |
|
|
40
40
|
|
|
41
41
|
## 💡 为什么需要它
|
|
@@ -48,7 +48,7 @@ DSH 的状态提示只有视觉——状态点、侧栏标记。你一切到别
|
|
|
48
48
|
|
|
49
49
|
双音铃 · 风铃 · 单音叮 · 水滴 · 脉冲 · 上行琶音 · 金币 · 达成 · 敲门 —— 九种内置音效全部由 Web Audio 现场合成,不含任何音频文件、不发一次网络请求。
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
也可以换上你自己的音频(mp3 / wav / ogg / m4a,每个 ≤15 秒、3MB,自动校验并做响度归一化),或者干脆选「无声」只留通知。三种触发(审批 / 提问 / 完成)各选各的音效,设置里随时试听。
|
|
52
52
|
|
|
53
53
|
## ⚙️ 设置
|
|
54
54
|
|
|
@@ -66,7 +66,7 @@ DSH 的状态提示只有视觉——状态点、侧栏标记。你一切到别
|
|
|
66
66
|
2. 在输入框粘贴包名**和版本号**(本插件已发布在 [npm](https://www.npmjs.com/package/dsh-plugin-notify-sounds)):
|
|
67
67
|
|
|
68
68
|
```
|
|
69
|
-
dsh-plugin-notify-sounds@1.9.
|
|
69
|
+
dsh-plugin-notify-sounds@1.9.3
|
|
70
70
|
```
|
|
71
71
|
|
|
72
72
|
版本号不要省:pnpm 的 `minimumReleaseAge` 策略会把刚发布不到 24 小时的版本挡在外面,只写包名会解析回上一个版本。想看最新版本号可到 [npm](https://www.npmjs.com/package/dsh-plugin-notify-sounds?activeTab=versions) 或 [Releases](https://github.com/BillCx330/dsh-plugin-notify-sounds/releases) 查。
|
|
@@ -100,7 +100,7 @@ dsh plugin --profile <你的 profile> add github:BillCx330/dsh-plugin-notify-sou
|
|
|
100
100
|
**Release 压缩包** —— 从 [Releases](https://github.com/BillCx330/dsh-plugin-notify-sounds/releases) 下载 `.tgz`,用它**在磁盘上的路径**安装:
|
|
101
101
|
|
|
102
102
|
```sh
|
|
103
|
-
dsh plugin --profile <你的 profile> add ./dsh-plugin-notify-sounds-1.9.
|
|
103
|
+
dsh plugin --profile <你的 profile> add ./dsh-plugin-notify-sounds-1.9.3.tgz
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
(`dsh plugin add` 把参数原样交给包管理器,所以要给本地文件路径,不能填下载地址。)
|
|
@@ -122,7 +122,8 @@ dsh plugin --profile <你的 profile> add ./dsh-plugin-notify-sounds
|
|
|
122
122
|
- **升级由包管理器安装,不再由插件自己完成**:1.8.0 移除了自安装器(原地写文件从不会更新 profile 的 lockfile,于是下一次插件安装会静默把旧版本装回来)。更新行仍会检查 npm、报告新版,然后指向真正负责安装的入口——**左侧栏「插件」**(卸载后再装)、市场,或 web profile 上的 `dsh plugin add`。
|
|
123
123
|
- **1.7.0 及更早版本**可以通过它们自带的自更新到达 1.8.0(1.8.0 保持同样的七个文件名),但这次到达不会被 pnpm 登记:按上面的方式用包管理器重装一次,此后所有升级都会留下。1.6.9 及更早无法安装「新增了文件」的版本——固定白名单会安全拒绝;同样的一次性重装即可解决。
|
|
124
124
|
- **多窗口**:提示音、系统通知和重复提醒由同一个 GUI 的所有页面选出一个「发声页」来发(1.7.0 起),所以同一个事件只响一次;窗口标题标记仍是每个窗口各自一份,因为它们各自有标题。发声页关闭或崩溃后几秒内由其他页面接管。
|
|
125
|
-
- **点击通知回到窗口**:Windows 完整实现并实测;macOS / Linux 走系统深链,未验证。
|
|
125
|
+
- **点击通知回到窗口**:Windows 完整实现并实测;macOS / Linux 走系统深链,未验证。
|
|
126
|
+
- **第三方封装的桌面客户端**(如 DSH NEXT):唤窗依赖官方壳的 Win32 能力与 `dsh://` 协议注册,封装版没有这两样,点击通知不会回到窗口——那是宿主侧缺的,插件无法补。
|
|
126
127
|
- **唤窗按窗口标题定位主窗口**:刻意不读取其他进程信息(那会触发杀软主动防御)。若将来 DSH 改变标题格式,唤窗会静默失效——提示音与通知本身不受影响。
|
|
127
128
|
- **导航小图标**:依赖设置面板的 DOM 结构,若DSH 改版后自动回落为官方齿轮,仅影响外观。
|
|
128
129
|
- **通知权限**:桌面端放行;受限环境(某些浏览器策略)下被拒时,行内会引导手动允许,其余功能不受影响。
|
|
@@ -144,10 +145,13 @@ dsh plugin --profile <你的 profile> add ./dsh-plugin-notify-sounds
|
|
|
144
145
|
|
|
145
146
|
## 📜 更新记录
|
|
146
147
|
|
|
148
|
+
- **v1.9.3**(2026-11-02)**窗口可见但没焦点时也会弹系统通知。** 此前只在页面不可见时发通知;而窗口被别的程序盖住、或你切到别的窗口打字、DSH 仍完整显示在屏幕上时,页面报告的仍是 `visible`,这类「看得见但没在用」的情况完全收不到通知([#5](https://github.com/BillCx330/dsh-plugin-notify-sounds/issues/5),感谢 @myzc1000)。现在条件改为「不可见 **或** 没有焦点」。最小化、托盘、被完全遮挡(Chromium 的遮挡检测会把窗口标记为 hidden)行为不变。顺带修了两处小问题:上传音频时若写入被拒,已经存好的文件会残留(现在会一并清掉);对话标题里含换行时会把通知标题撑成两行(现在折叠成一行)。
|
|
149
|
+
- **v1.9.2**(2026-10-30)**系统通知的标题改成对话名。** 此前通知固定写「任务完成 / 智能体已完成当前任务」,同时开着几个对话时,得点进去才知道是哪一个。现在标题用该对话的名字(读的是 DSH 会话列表里已有的 `title`;没有标题、或标题只是「Working directory: 路径」这类兜底串时,只显示目录名),正文不变;决策通知的正文会带上「需要你的决策:」,把原来写在标题里的事件补回来。标题超过 48 字会截断,未命名且无目录时保持原来的文案——**不会显示裸会话 id**。字段从 DSH 0.2.0 起就有,不是新 API,取不到时行为与之前完全一致。**顺带修了一个跨平台问题**:Windows 下两个窗口同时上传同一段音频时,后到的那个会被误报「不是音频」(415)——POSIX 上 `rename` 能覆盖、Windows 上会抛错;现在这种情况按成功处理。([#4](https://github.com/BillCx330/dsh-plugin-notify-sounds/issues/4),感谢 @AdySnowflake)
|
|
150
|
+
- **v1.9.1**(2026-10-29)**修好「声音太小」:九个内置音效按实际响度对齐,而不是按峰值。** 有用户反馈 100% 也偏小。查下来是两件事:九个预设从未互相对齐(`coin` 的叠加峰值 0.105、`ding` 0.315,差 9.5 dB);预设整体又比自定义音频轻约 12 dB(预设 100% 时约 -16 dBFS,导入的音频约 -4 dBFS)——同一个音量数字,两种响度。第一版修复按**峰值**对齐,结果金币反而更响:方波在同等峰值下能量比正弦高 3 dB(RMS/峰值 1.0 对 0.707)。现在按**波峰因数折算后的峰值**对齐——正弦和方波落在同一能量上,三角波因为峰值已经触顶不再加推。同样的音量数字比以前响约 12 dB,九个音效之间也一致了。**升级时音量会自动折算**:新刻度下同一个数字比以前响约 10 dB,所以首次启动会把已存的音量减半(60 → 30、100 → 50),实际只比原来高约 3.5 dB;想更大声再往上调即可。([#3](https://github.com/BillCx330/dsh-plugin-notify-sounds/issues/3),感谢 @sink-er)
|
|
147
151
|
- **v1.9.0**(2026-10-27)**自定义音频变成一个小库:最多 3 个,可以改名、指派。** 此前三个提醒共用同一个「自定义」上传——想给「完成」配一个、同时让「决策」用内置音,做不到。现在「自定义音效」只有一行:点「添加音频」选文件,加进去的会列在它下面(最多 3 个),每个都能改名、试听、移除;三个提醒的音效下拉里会直接列出这些音频的名字,挑哪个就响哪个。**存放位置也换了**:音频不再以 base64 塞进 `cordis.patch.yml`(一个 512KB 的音频就是 699,052 个字符,是整份配置的 277 倍,而且每次改设置都要整份重新广播),改为存到 `profiles/<profile>/notify-sounds/sounds/<哈希>.<后缀>`,配置里只留名字。顺带补了三件一直缺的:**上限 3MB、每个 15 秒**(太长的音频会变成背景音,解码后还可能占几十 MB 内存);**没有声音、或短到听不见的文件,在上传时就被拒绝**;**读不到音频时改用该项的默认音**——此前配置里的音频一旦损坏,三个提醒会全部静默,界面上还什么都不说。列表里也会标出「文件找不到了」和「还没有提醒使用它」。老用户升级时,原来的音频会自动搬进这个库;搬不进去就原样留着,照旧能响。
|
|
148
152
|
- **v1.8.6**(2026-10-23)**把一次全量审计翻出来的问题收干净,没有新功能。** 修复:目标模式下,若用户在等到目标结束之前关掉「只在最终轮提醒」,目标结束后仍会补响一次——等待器现在会复查这个开关;更新检查把「失败」也缓存满 24 小时,一次网络抖动就让整行显示一整天「检查失败」,现在失败只缓存 1 分钟,并且不再断言原因是网络,而是如实说两个源都没给出可用答复;`pnpm-lock.yaml` 里带 peer 后缀的键(`pkg@1.8.4(@scope/peer@3.18.4)`)会被当成版本号,让正常的安装被误报「未被包管理器登记」并劝用户重装;当前版本读不出来时,界面会显示「当前版本 ? · 已是最新」,现在单独说明无法读取;「重复提醒间隔」把手改的 7 分钟显示成 5 分钟、实际按 7 分钟响,现在显示与计时共用同一次吸附;静音时段起止相同被当作「不静音」,描述已写明;客户端的时间解析接受 `9:05` 而宿主 schema 拒绝,现在两边一样严;自定义音效读取文件失败时误报「无法解码」,现在分开说;系统通知构造失败一次后整页永久显示「不支持」,现在成功一次即恢复。**更新指引**:桌面端的插件管理器是**左侧栏「插件」**(不是「设置 → 插件」),1.8.5 写错了,已改正;安装命令一律带版本号(此前渲染成 `@v1.8.5`,多了一个 `v`,复制进包管理器未必能解析),并说明发布不满 24 小时的版本会被 pnpm 的 `minimumReleaseAge` 挡掉。**清理**:移除 1.8.0 留下的 527 行不可达自安装器(宿主文件从 1553 行降到 1023 行,连同 10 个只服务于它的导入)、永远返回 null 的更新通知组件、8 个死字典键,以及只测死代码的测试;三条路由的拼写现在都在跨半契约测试里对齐(`/notify-sounds/goal` 此前漏了,而它正是漂移过一次的那条)。README 里「多窗口各自响一次」的说法与 1.7.0 起的发声页选举矛盾,已改正。
|
|
149
153
|
- **v1.8.5**(2026-10-20)**文案跟上机制。** 更新行此前仍写着「发现新版可一键安装」——从 1.8.0 起已不支持:这一行只负责检查,并指向包管理器。现在行内如实说明,且引导里带上版本号(`dsh-plugin-notify-sounds@{version}`),避免刚发布的版本被 pnpm 的 `minimumReleaseAge` 策略静默解析成上一个版本。README 的功能表、设置摘要与安装步骤不再承诺一键安装;历史更新记录保持原样。
|
|
150
|
-
- **v1.8.4**(2026-10-20)**点击通知一定能找到窗口。** 真实点击的日志显示:窗口明明存在,原生唤窗却报 "no window found"
|
|
154
|
+
- **v1.8.4**(2026-10-20)**点击通知一定能找到窗口。** 真实点击的日志显示:窗口明明存在,原生唤窗却报 "no window found"——隐藏或正在重绘的窗口可能瞬时返回空标题,而当时的搜索只认标题。现在窗口搜索同时按「拥有窗口的进程」匹配(桌面壳是本宿主进程的父进程),前台请求连续重试三次之后才走深链兜底。([#2](https://github.com/BillCx330/dsh-plugin-notify-sounds/issues/2),感谢 @hxwd94666)
|
|
151
155
|
- **v1.8.3**(2026-10-20)**窗口关到托盘时,点击通知也能回到 DSH 了。** 原先的 Win32 唤窗只搜**可见**窗口,关到托盘的 DSH 窗口永远找不到,只能退到很慢的深链兜底——表现就是「点了通知没反应」。现在会记住标题匹配的不可见窗口,并在置前之前先 `SW_SHOW` 让它显示出来;深链仍作为最后兜底。
|
|
152
156
|
- **v1.8.2**(2026-10-20)**真正修好目标模式的提醒。** 1.8.1 的宿主路由读了 `stateOf(session, "goal")`,却取 `current.phase`,而投影值把 goal 嵌在里面:`current.goal.phase`。于是每次读取都为空、每轮照响。现在改为读嵌套字段(并保留扁平形状的兜底),宿主侧调试记录已确认:中间轮 `current.goal.phase` 是 `active`,最后一轮是 `complete`。
|
|
153
157
|
- **v1.8.1**(2026-10-20)**修复目标模式的提醒。** 1.8.0 的开关从浏览器自己的会话投影读 phase,而那份投影只包含这个页面打开过的会话——目标所在的会话没被打开过时读到空值,代码把它当成「没有目标」,于是每轮又照响。浏览器半现在改问宿主新增的只读路由 `/notify-sounds/goal`(宿主的投影存储才是权威的),并在目标仍为 `active` 时持续复查:目标完成、暂停或受阻时响唯一一次;会话重新开始运行则保持安静。宿主不回答该路由时退回旧行为。
|
package/lib/client.js
CHANGED
|
@@ -206,7 +206,7 @@ window.__ModuleLoader__.load({
|
|
|
206
206
|
"custom.unreadable": "读不到这个文件:它可能已被移动,或没有读取权限,请重新选择",
|
|
207
207
|
"custom.missing": "音频文件找不到了(可能被删除或被杀毒软件隔离),会改用默认音。请重新添加",
|
|
208
208
|
"systemNotify.title": "系统通知",
|
|
209
|
-
"systemNotify.description": "
|
|
209
|
+
"systemNotify.description": "页面处于后台、或窗口失去焦点时弹出系统通知,标题用当前对话的名字,点击可回到窗口",
|
|
210
210
|
"systemNotify.granted": "已授权系统通知",
|
|
211
211
|
"systemNotify.denied": "通知权限被拒绝,请在浏览器或系统设置中允许后重试",
|
|
212
212
|
"systemNotify.unsupported": "当前环境不支持系统通知",
|
|
@@ -239,6 +239,7 @@ window.__ModuleLoader__.load({
|
|
|
239
239
|
"sound.custom": "自定义",
|
|
240
240
|
"notify.decision.title": "需要你的决策",
|
|
241
241
|
"notify.decision.body": "智能体正在等待你的决定",
|
|
242
|
+
"notify.decision.event": "需要你的决策:{detail}",
|
|
242
243
|
"notify.completion.title": "任务完成",
|
|
243
244
|
"notify.completion.body": "智能体已完成当前任务",
|
|
244
245
|
"update.title": "更新",
|
|
@@ -298,7 +299,7 @@ window.__ModuleLoader__.load({
|
|
|
298
299
|
"custom.unreadable": "Could not read this file: it may have been moved, or permission was revoked — choose it again",
|
|
299
300
|
"custom.missing": "The audio file is missing (deleted, or quarantined by antivirus), so the default sound plays instead. Add it again",
|
|
300
301
|
"systemNotify.title": "System notifications",
|
|
301
|
-
"systemNotify.description": "Show an OS notification while the page is hidden; click it to focus the window",
|
|
302
|
+
"systemNotify.description": "Show an OS notification while the page is hidden or the window has lost focus — titled with the conversation name; click it to focus the window",
|
|
302
303
|
"systemNotify.granted": "Notification permission granted",
|
|
303
304
|
"systemNotify.denied": "Denied — allow notifications in the browser or system settings and retry",
|
|
304
305
|
"systemNotify.unsupported": "System notifications are unavailable in this environment",
|
|
@@ -333,6 +334,7 @@ window.__ModuleLoader__.load({
|
|
|
333
334
|
"sound.custom": "Custom",
|
|
334
335
|
"notify.decision.title": "Your decision is needed",
|
|
335
336
|
"notify.decision.body": "The agent is waiting for your decision",
|
|
337
|
+
"notify.decision.event": "Your decision is needed: {detail}",
|
|
336
338
|
"notify.completion.title": "Task finished",
|
|
337
339
|
"notify.completion.body": "The agent has finished the current task",
|
|
338
340
|
"update.title": "Updates",
|
|
@@ -456,11 +458,14 @@ window.__ModuleLoader__.load({
|
|
|
456
458
|
/** Bumped when a preview starts, so a decode still in flight knows it lost. */
|
|
457
459
|
let previewToken = 0;
|
|
458
460
|
|
|
459
|
-
/** Remember one scheduled source and forget it once it ends. */
|
|
460
|
-
function trackSource(source) {
|
|
461
|
+
/** Remember one scheduled source, run its cleanup, and forget it once it ends. */
|
|
462
|
+
function trackSource(source, cleanup) {
|
|
461
463
|
liveSources.add(source);
|
|
462
464
|
try {
|
|
463
|
-
source.onended = () =>
|
|
465
|
+
source.onended = () => {
|
|
466
|
+
liveSources.delete(source);
|
|
467
|
+
cleanup?.();
|
|
468
|
+
};
|
|
464
469
|
} catch {
|
|
465
470
|
// A source that refuses the handler still plays; it just stays
|
|
466
471
|
// tracked until the next stop clears the set.
|
|
@@ -488,6 +493,90 @@ window.__ModuleLoader__.load({
|
|
|
488
493
|
liveSources.clear();
|
|
489
494
|
}
|
|
490
495
|
|
|
496
|
+
/**
|
|
497
|
+
* Peak of one preset's summed voices, measured once at load.
|
|
498
|
+
*
|
|
499
|
+
* The presets were written with `note.gain * 0.25` per voice and nothing
|
|
500
|
+
* held them to each other: `coin` peaked at 0.105 while `ding` reached
|
|
501
|
+
* 0.315, a 9.5 dB spread, and every one of them sat far below a decoded
|
|
502
|
+
* clip (whose peak is normalized to 1). At 100% a preset played at
|
|
503
|
+
* -16 dBFS while the user's own file played at -4 — the same slider
|
|
504
|
+
* value was two different loudnesses.
|
|
505
|
+
*
|
|
506
|
+
* This walks each preset's envelope at 2 ms resolution and returns the
|
|
507
|
+
* loudest sum, so every voice can be scaled to one target instead.
|
|
508
|
+
*
|
|
509
|
+
* @param preset - one entry of SOUND_PRESETS.
|
|
510
|
+
* @returns the peak the summed envelopes reach, before any level.
|
|
511
|
+
*/
|
|
512
|
+
function presetPeak(preset) {
|
|
513
|
+
const ATTACK = 0.012;
|
|
514
|
+
const value = (note, t) => {
|
|
515
|
+
if (t < note.at) return 0;
|
|
516
|
+
const peak = note.gain * 0.25;
|
|
517
|
+
const attackEnd = note.at + ATTACK;
|
|
518
|
+
if (t <= attackEnd) {
|
|
519
|
+
// exponentialRampToValueAtTime from 0.0001, same as playback
|
|
520
|
+
return 0.0001 * Math.pow(peak / 0.0001, (t - note.at) / ATTACK);
|
|
521
|
+
}
|
|
522
|
+
const end = note.at + note.dur;
|
|
523
|
+
if (t >= end) return 0;
|
|
524
|
+
return peak * Math.pow(0.0001 / peak, (t - attackEnd) / (end - attackEnd));
|
|
525
|
+
};
|
|
526
|
+
let peak = 0;
|
|
527
|
+
for (let t = 0; t < 2; t += 0.002) {
|
|
528
|
+
let sum = 0;
|
|
529
|
+
for (const note of preset.notes) sum += value(note, t);
|
|
530
|
+
if (sum > peak) peak = sum;
|
|
531
|
+
}
|
|
532
|
+
return peak;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* RMS-to-peak ratio of each waveform, at the frequencies these presets use.
|
|
537
|
+
*
|
|
538
|
+
* A sine peaks at √2 times its RMS; a square's RMS is its peak. So at
|
|
539
|
+
* the same peak a square carries 3 dB more energy — which is exactly why
|
|
540
|
+
* `coin` (two square notes) sounded louder than `ding` (two sines) once
|
|
541
|
+
* both were normalized to one peak. Triangle sits below the sine, but it
|
|
542
|
+
* is not scaled up: that would push its peak through the ceiling.
|
|
543
|
+
*/
|
|
544
|
+
const WAVEFORM_RMS = { sine: Math.SQRT1_2, triangle: Math.sqrt(1 / 3), square: 1 };
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* One level per preset, chosen so every preset lands at the same
|
|
548
|
+
* loudness rather than the same peak.
|
|
549
|
+
*
|
|
550
|
+
* The headline number is still a peak ceiling (0.95), because that is
|
|
551
|
+
* what keeps the output from clipping; the waveform's own RMS ratio is
|
|
552
|
+
* then folded in as a crest-factor correction. Anything quieter per peak
|
|
553
|
+
* (triangle) is capped at the peak target instead of being pushed past
|
|
554
|
+
* it, so the correction only ever pulls a preset down.
|
|
555
|
+
*/
|
|
556
|
+
const PRESET_LEVEL = Object.fromEntries(
|
|
557
|
+
Object.entries(SOUND_PRESETS).map(([id, preset]) => {
|
|
558
|
+
const crest = Math.max(
|
|
559
|
+
...preset.notes.map((note) => (WAVEFORM_RMS[note.type ?? "sine"] ?? Math.SQRT1_2) / Math.SQRT1_2),
|
|
560
|
+
);
|
|
561
|
+
return [id, 0.95 / (presetPeak(preset) * Math.max(1, crest))];
|
|
562
|
+
}),
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Build the output end of one playback.
|
|
567
|
+
*
|
|
568
|
+
* @param engine - the page's AudioContext.
|
|
569
|
+
* @param loudness - 0–1, already clamped by the caller.
|
|
570
|
+
* @param level - the preset's own leveling factor (1 for clips).
|
|
571
|
+
* @returns the node the sources connect to, and every node to release.
|
|
572
|
+
*/
|
|
573
|
+
function outputChain(engine, loudness, level) {
|
|
574
|
+
const master = engine.createGain();
|
|
575
|
+
master.gain.value = loudness * 0.6 * level;
|
|
576
|
+
master.connect(engine.destination);
|
|
577
|
+
return { master, nodes: [master] };
|
|
578
|
+
}
|
|
579
|
+
|
|
491
580
|
/** Play one decoded buffer at a loudness in percent, through a peak-normalized gain. */
|
|
492
581
|
function playBuffer(engine, buffer, normGain, volumePercent) {
|
|
493
582
|
const percent = Number.isFinite(volumePercent) ? volumePercent : DEFAULT_VOLUME;
|
|
@@ -496,10 +585,19 @@ window.__ModuleLoader__.load({
|
|
|
496
585
|
const source = engine.createBufferSource();
|
|
497
586
|
const gain = engine.createGain();
|
|
498
587
|
source.buffer = buffer;
|
|
499
|
-
gain.gain.value = normGain
|
|
588
|
+
gain.gain.value = normGain;
|
|
589
|
+
const { master, nodes } = outputChain(engine, loudness, 1);
|
|
500
590
|
source.connect(gain);
|
|
501
|
-
gain.connect(
|
|
502
|
-
|
|
591
|
+
gain.connect(master);
|
|
592
|
+
// A clip's nodes outlive its buffer unless something disconnects
|
|
593
|
+
// them; the Web Audio graph keeps every connected node alive.
|
|
594
|
+
trackSource(source, () => {
|
|
595
|
+
for (const node of [gain, ...nodes]) {
|
|
596
|
+
try {
|
|
597
|
+
node.disconnect();
|
|
598
|
+
} catch {}
|
|
599
|
+
}
|
|
600
|
+
});
|
|
503
601
|
source.start(engine.currentTime + 0.02);
|
|
504
602
|
}
|
|
505
603
|
|
|
@@ -701,9 +799,9 @@ window.__ModuleLoader__.load({
|
|
|
701
799
|
const loudness = Math.max(0, Math.min(100, percent)) / 100;
|
|
702
800
|
if (loudness === 0) return;
|
|
703
801
|
whenRunning(engine, () => {
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
master
|
|
802
|
+
// Every preset is scaled to the same peak, so a slider value
|
|
803
|
+
// means one loudness whichever sound is selected.
|
|
804
|
+
const { master, nodes } = outputChain(engine, loudness, PRESET_LEVEL[id] ?? 1);
|
|
707
805
|
const t0 = engine.currentTime + 0.02;
|
|
708
806
|
let tail = 0;
|
|
709
807
|
for (const note of preset.notes) {
|
|
@@ -743,7 +841,7 @@ window.__ModuleLoader__.load({
|
|
|
743
841
|
setTimeout(() => release(true), remaining() + 50);
|
|
744
842
|
return;
|
|
745
843
|
}
|
|
746
|
-
|
|
844
|
+
for (const node of nodes) node.disconnect();
|
|
747
845
|
} catch {}
|
|
748
846
|
};
|
|
749
847
|
const timer = setTimeout(() => release(false), Math.max(0, remaining() + 50));
|
|
@@ -784,6 +882,25 @@ window.__ModuleLoader__.load({
|
|
|
784
882
|
return typeof document === "undefined" || document.visibilityState === "visible";
|
|
785
883
|
}
|
|
786
884
|
|
|
885
|
+
/**
|
|
886
|
+
* Whether the user is away from this window right now.
|
|
887
|
+
*
|
|
888
|
+
* `visibilityState` alone covers minimized, tray-hidden and — through
|
|
889
|
+
* Chromium's occlusion tracking — a window completely covered by another
|
|
890
|
+
* app. What it does NOT cover is a window sitting in plain sight while
|
|
891
|
+
* the user types in a different app: that still reports "visible", so a
|
|
892
|
+
* notification gated on visibility alone never fires. That is exactly
|
|
893
|
+
* the case reported on issue #5 ("only fires once DSH is in the
|
|
894
|
+
* background"), and the reason this predicate looks at focus too.
|
|
895
|
+
*
|
|
896
|
+
* @returns whether the window is hidden or merely unfocused.
|
|
897
|
+
*/
|
|
898
|
+
function pageIsAway() {
|
|
899
|
+
if (typeof document === "undefined") return false;
|
|
900
|
+
if (document.visibilityState !== "visible") return true;
|
|
901
|
+
return typeof document.hasFocus === "function" && document.hasFocus() === false;
|
|
902
|
+
}
|
|
903
|
+
|
|
787
904
|
/**
|
|
788
905
|
* Text the title alert writes in front of the window title, per state.
|
|
789
906
|
*
|
|
@@ -1246,6 +1363,60 @@ window.__ModuleLoader__.load({
|
|
|
1246
1363
|
*/
|
|
1247
1364
|
let notificationConstructionFailed = false;
|
|
1248
1365
|
|
|
1366
|
+
/** Longest conversation title a toast shows before it is trimmed. */
|
|
1367
|
+
const NOTIFICATION_TITLE_MAX = 48;
|
|
1368
|
+
|
|
1369
|
+
/**
|
|
1370
|
+
* The conversation's own name, for a notification title.
|
|
1371
|
+
*
|
|
1372
|
+
* Every DSH release since 0.2.0 puts both fields on the session row we
|
|
1373
|
+
* already read: `title` is the persisted name (empty until one is set),
|
|
1374
|
+
* `displayTitle` falls back to the workspace folder and then the raw id.
|
|
1375
|
+
* A toast that says only "任务完成" makes the user open the window to
|
|
1376
|
+
* learn WHICH conversation finished; this is the name they already know
|
|
1377
|
+
* from the sidebar.
|
|
1378
|
+
*
|
|
1379
|
+
* Falls back to "" — the caller then keeps its own event title — so a
|
|
1380
|
+
* runtime that renames or drops these fields loses the nicety rather
|
|
1381
|
+
* than printing a raw id.
|
|
1382
|
+
*
|
|
1383
|
+
* @param sessionId - the session the alert is about.
|
|
1384
|
+
* @param rows - the pass's session rows, when the runtime has them.
|
|
1385
|
+
* @returns the name to show, or "" when there is none.
|
|
1386
|
+
*/
|
|
1387
|
+
function conversationTitle(sessionId, rows) {
|
|
1388
|
+
const row = rows?.[sessionId];
|
|
1389
|
+
if (row === null || typeof row !== "object") return "";
|
|
1390
|
+
const named = typeof row.title === "string" ? row.title.trim() : "";
|
|
1391
|
+
const cwd = typeof row.cwd === "string" ? row.cwd : "";
|
|
1392
|
+
const shown = typeof row.displayTitle === "string" ? row.displayTitle.trim() : "";
|
|
1393
|
+
// DSH's own fallback names a session after its working directory
|
|
1394
|
+
// ("Working directory: ..."). Some sessions keep that string as their
|
|
1395
|
+
// title; it is 40+ characters of path that says no more than the folder
|
|
1396
|
+
// name and puts the path on the lock screen, so it counts as unnamed —
|
|
1397
|
+
// the folder's last segment is the useful part.
|
|
1398
|
+
const lower = named.toLowerCase();
|
|
1399
|
+
const restatesCwd = named === "" || named === cwd || lower.startsWith("working directory:");
|
|
1400
|
+
const folder = cwd === "" ? "" : cwd.split(/[\\/]/u).filter(Boolean).pop() ?? "";
|
|
1401
|
+
// Prefer the name the sidebar shows, but never hand back the same
|
|
1402
|
+
// restatement we just rejected; the folder alone is shorter and says
|
|
1403
|
+
// the same thing.
|
|
1404
|
+
const fallback = shown !== "" && shown !== sessionId && shown !== named ? shown : folder;
|
|
1405
|
+
const name = restatesCwd ? fallback : named;
|
|
1406
|
+
if (name === "") return "";
|
|
1407
|
+
// A title taken from the first message can be several lines long (a
|
|
1408
|
+
// pasted prompt) or carry tabs; a toast title is one line, so the
|
|
1409
|
+
// whitespace is collapsed rather than shown as ragged breaks.
|
|
1410
|
+
const flat = name.replace(/\s+/gu, " ").trim();
|
|
1411
|
+
if (flat.length <= NOTIFICATION_TITLE_MAX) return flat;
|
|
1412
|
+
// Do not cut between a surrogate pair: half an emoji renders as a
|
|
1413
|
+
// replacement character in the toast.
|
|
1414
|
+
let cut = NOTIFICATION_TITLE_MAX - 1;
|
|
1415
|
+
const code = flat.charCodeAt(cut - 1);
|
|
1416
|
+
if (code >= 0xd800 && code <= 0xdbff) cut -= 1;
|
|
1417
|
+
return `${flat.slice(0, cut)}…`;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1249
1420
|
/**
|
|
1250
1421
|
* Human-readable summary of one pending interaction, for notification
|
|
1251
1422
|
* bodies: the approval's localized reason first, then its raw reason,
|
|
@@ -2560,13 +2731,16 @@ window.__ModuleLoader__.load({
|
|
|
2560
2731
|
const slot = CUSTOM_SLOTS.find((candidate) => soundFileOf(value, candidate) === "");
|
|
2561
2732
|
if (slot === undefined) throw new Error("sound library is full");
|
|
2562
2733
|
const file = await storeSound(bytes);
|
|
2563
|
-
await persist(`${slot}File`, file);
|
|
2564
2734
|
try {
|
|
2735
|
+
await persist(`${slot}File`, file);
|
|
2565
2736
|
await persist(`${slot}Name`, name);
|
|
2566
2737
|
} catch (error) {
|
|
2567
2738
|
// One sound is one record: a half-written slot would show a
|
|
2568
|
-
// recording with no name and no way to remove it.
|
|
2739
|
+
// recording with no name and no way to remove it. The upload
|
|
2740
|
+
// already landed, so it is deleted too — otherwise every
|
|
2741
|
+
// refused write leaves a file nothing points at.
|
|
2569
2742
|
await form.set(`${slot}File`, "").catch(() => {});
|
|
2743
|
+
void forgetSound(file, slot);
|
|
2570
2744
|
throw error;
|
|
2571
2745
|
}
|
|
2572
2746
|
return slot;
|
|
@@ -2618,6 +2792,42 @@ window.__ModuleLoader__.load({
|
|
|
2618
2792
|
}
|
|
2619
2793
|
};
|
|
2620
2794
|
|
|
2795
|
+
/**
|
|
2796
|
+
* Re-scale the stored volume once, for the release that changed what
|
|
2797
|
+
* the number means.
|
|
2798
|
+
*
|
|
2799
|
+
* 1.9.1 made every preset about 10 dB louder at the same setting. A
|
|
2800
|
+
* user who left the slider at 60 would hear a jump they never asked
|
|
2801
|
+
* for, so the stored value is halved: at half the number the new
|
|
2802
|
+
* scale lands about 3.5 dB above where they were — noticeable, not
|
|
2803
|
+
* startling — and the range above it is free for anyone who wants
|
|
2804
|
+
* the new headroom.
|
|
2805
|
+
*
|
|
2806
|
+
* The marker is written BEFORE the volume, deliberately. If the
|
|
2807
|
+
* second write is refused the user keeps their old (loud) setting
|
|
2808
|
+
* and can turn it down; the reverse order would halve the number
|
|
2809
|
+
* again on the next launch.
|
|
2810
|
+
*/
|
|
2811
|
+
let volumeMigrating = false;
|
|
2812
|
+
const migrateVolume = async () => {
|
|
2813
|
+
if (volumeMigrating) return;
|
|
2814
|
+
const value = form.getSnapshot()?.value;
|
|
2815
|
+
if (value === undefined || value === null) return;
|
|
2816
|
+
if (value.volumeRev === 1) return;
|
|
2817
|
+
volumeMigrating = true;
|
|
2818
|
+
const current = Number.isFinite(value?.volume) ? value.volume : 60;
|
|
2819
|
+
const scaled = Math.max(5, Math.min(100, Math.round(current / 2)));
|
|
2820
|
+
try {
|
|
2821
|
+
const marked = await form.set("volumeRev", 1);
|
|
2822
|
+
if (marked !== true) return;
|
|
2823
|
+
await form.set("volume", scaled);
|
|
2824
|
+
} catch {
|
|
2825
|
+
// Nothing to undo: the marker only says the scale is current.
|
|
2826
|
+
} finally {
|
|
2827
|
+
volumeMigrating = false;
|
|
2828
|
+
}
|
|
2829
|
+
};
|
|
2830
|
+
|
|
2621
2831
|
/**
|
|
2622
2832
|
* Bring sounds from an older release into the library.
|
|
2623
2833
|
*
|
|
@@ -2698,9 +2908,11 @@ window.__ModuleLoader__.load({
|
|
|
2698
2908
|
ctx.effect(() => {
|
|
2699
2909
|
let running = false;
|
|
2700
2910
|
const attempt = () => {
|
|
2911
|
+
const current = form.getSnapshot()?.value;
|
|
2912
|
+
if (current === undefined || current === null) return;
|
|
2913
|
+
if (current.volumeRev !== 1) void migrateVolume();
|
|
2701
2914
|
if (running) return;
|
|
2702
|
-
const value =
|
|
2703
|
-
if (value === undefined || value === null) return;
|
|
2915
|
+
const value = current;
|
|
2704
2916
|
const hasInline = typeof value.customSound === "string" && value.customSound !== "";
|
|
2705
2917
|
const hasPerTrigger = ["Decision", "Question", "Completion"].some((suffix) => typeof value[`custom${suffix}File`] === "string" && value[`custom${suffix}File`] !== "");
|
|
2706
2918
|
if (!hasInline && !hasPerTrigger) return;
|
|
@@ -2851,12 +3063,20 @@ window.__ModuleLoader__.load({
|
|
|
2851
3063
|
// One entry point for "which sound": the trigger's own file,
|
|
2852
3064
|
// the legacy inline payload, or the preset its picker names.
|
|
2853
3065
|
playAlert(kind, value, volume);
|
|
2854
|
-
if (value?.systemNotify === true &&
|
|
3066
|
+
if (value?.systemNotify === true && pageIsAway()) {
|
|
3067
|
+
// The name the user knows this conversation by, when the
|
|
3068
|
+
// runtime exposes one. Naming it only helps if the toast
|
|
3069
|
+
// still says what happened, so for a decision the event
|
|
3070
|
+
// moves into the body instead of being dropped.
|
|
3071
|
+
const conversation = conversationTitle(sessionId, readSessionRows());
|
|
3072
|
+
const eventTitle = t(kind === "completion" ? "notify.completion.title" : "notify.decision.title");
|
|
3073
|
+
const title = conversation !== "" ? conversation : eventTitle;
|
|
2855
3074
|
if (kind === "completion") {
|
|
2856
|
-
showSystemNotification(
|
|
3075
|
+
showSystemNotification(title, t("notify.completion.body"), "dsh-notify-sounds:completion");
|
|
2857
3076
|
} else {
|
|
2858
|
-
const
|
|
2859
|
-
|
|
3077
|
+
const detail = interactionSummary(interaction, resolvePackageText) ?? t("notify.decision.body");
|
|
3078
|
+
const body = conversation !== "" ? t("notify.decision.event", { detail }) : detail;
|
|
3079
|
+
showSystemNotification(title, body, "dsh-notify-sounds:decision");
|
|
2860
3080
|
}
|
|
2861
3081
|
}
|
|
2862
3082
|
return true;
|
|
@@ -3267,6 +3487,8 @@ window.__ModuleLoader__.load({
|
|
|
3267
3487
|
exports.GOAL_PHASE_PATH = GOAL_PHASE_PATH;
|
|
3268
3488
|
exports.SOUND_PATH = SOUND_ROUTE;
|
|
3269
3489
|
exports.SOUND_MAX_BYTES = SOUND_MAX_BYTES;
|
|
3490
|
+
exports.PRESET_LEVEL = PRESET_LEVEL;
|
|
3491
|
+
exports.SOUND_PRESETS = SOUND_PRESETS;
|
|
3270
3492
|
// The alert election, so the tests can drive two pages against a stubbed
|
|
3271
3493
|
// storage and channel instead of hoping two real tabs cooperate.
|
|
3272
3494
|
exports.createAlertLeader = createAlertLeader;
|
package/lib/index.js
CHANGED
|
@@ -783,7 +783,20 @@ export function writeSoundFile(bytes) {
|
|
|
783
783
|
mkdirSync(directory, { recursive: true });
|
|
784
784
|
const temporary = `${target}.tmp`;
|
|
785
785
|
writeFileSync(temporary, buffer);
|
|
786
|
-
|
|
786
|
+
try {
|
|
787
|
+
renameSync(temporary, target);
|
|
788
|
+
} catch (error) {
|
|
789
|
+
// POSIX renames straight over an existing file; Windows refuses with
|
|
790
|
+
// EPERM/EEXIST. Two windows uploading the same bytes race here: the
|
|
791
|
+
// other one already wrote this exact content-addressed file, so a
|
|
792
|
+
// refusal is the outcome we wanted, not a failure.
|
|
793
|
+
if (existsSync(target)) {
|
|
794
|
+
rmSync(temporary, { force: true });
|
|
795
|
+
return fileName;
|
|
796
|
+
}
|
|
797
|
+
rmSync(temporary, { force: true });
|
|
798
|
+
throw error;
|
|
799
|
+
}
|
|
787
800
|
return fileName;
|
|
788
801
|
} catch (error) {
|
|
789
802
|
// A read-only profile or a full disk must not take the route down: the
|
|
@@ -1039,6 +1052,14 @@ export const Config = z.object({
|
|
|
1039
1052
|
enabled: z.boolean().default(true).volatile(),
|
|
1040
1053
|
/** Playback loudness in percent. */
|
|
1041
1054
|
volume: z.number().step(1).min(0).max(100).default(60).volatile(),
|
|
1055
|
+
/**
|
|
1056
|
+
* Which loudness scale the stored `volume` speaks.
|
|
1057
|
+
*
|
|
1058
|
+
* 1.9.1 made every preset ~10 dB louder at the same number, so the page
|
|
1059
|
+
* re-scales a stored value once and sets this to 1. A config without it has
|
|
1060
|
+
* never been re-scaled, including one written by an older release.
|
|
1061
|
+
*/
|
|
1062
|
+
volumeRev: z.number().default(0).volatile(),
|
|
1042
1063
|
/** Preset played when a Session gains a pending approval or plan review. */
|
|
1043
1064
|
decisionSound: z.union(SOUND_IDS).default("bell").volatile(),
|
|
1044
1065
|
/** Preset played when a Session gains a pending question. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-notify-sounds",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.3",
|
|
4
4
|
"description": "DeepSeek Harness 原生风格提示音插件:需要决策或任务完成时播放提示音,点击系统通知可回到窗口;支持重复提醒、静音时段,以及目标模式下只在最终轮提醒。 · Native-style notification sounds for DeepSeek Harness: a chime when the agent needs your decision or finishes a task, click a system notification to jump back, plus repeat reminders, quiet hours, and goal-mode alerts that wait for the final round.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|