dsh-plugin-notify-sounds 1.7.0 → 1.8.4
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 +18 -5
- package/README.zh.md +18 -5
- package/lib/client.js +120 -52
- package/lib/index.js +137 -57
- package/package.json +62 -62
package/README.md
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# dsh-plugin-notify-sounds
|
|
2
2
|
|
|
3
|
+
[](https://awesome-dsh-plugin.com)
|
|
4
|
+
[](https://dsh-plugin.org/plugins/billcx330/dsh-plugin-notify-sounds)
|
|
5
|
+
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
3
6
|
[](https://www.npmjs.com/package/dsh-plugin-notify-sounds)
|
|
4
7
|
[](https://www.npmjs.com/package/dsh-plugin-notify-sounds)
|
|
5
8
|
[](LICENSE)
|
|
6
|
-
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
7
9
|
|
|
8
10
|
English | [中文](README.zh.md)
|
|
9
11
|
|
|
@@ -74,7 +76,13 @@ The "install source" dropdown above the box picks the download mirror (mainland
|
|
|
74
76
|
|
|
75
77
|
### ⬆️ Upgrading
|
|
76
78
|
|
|
77
|
-
|
|
79
|
+
**Upgrades are installed by the package manager**, so the profile's lockfile moves with the files — a DSH plugin cannot install its own update durably (the manager refuses a package that is already declared, and its remove-then-install step unloads the very code that would finish the install). The plugin therefore only *checks*, and tells you where to install:
|
|
80
|
+
|
|
81
|
+
- **Inside DSH (recommended)**: Settings → Plugins → remove this plugin → **Add plugin** → paste `dsh-plugin-notify-sounds` → Install → restart DSH.
|
|
82
|
+
- **With the marketplace plugin** ([dshmarket](https://www.npmjs.com/package/dshmarket)): update it in one click there.
|
|
83
|
+
- **On a `web` profile**: `dsh plugin --profile <your profile> add dsh-plugin-notify-sounds@<version>`.
|
|
84
|
+
|
|
85
|
+
The update row also warns when the running version is not what the profile's lockfile records — the state left behind by the pre-1.8.0 self-updater. One reinstall through the package manager fixes it for good.
|
|
78
86
|
|
|
79
87
|
### 🛠️ Other ways
|
|
80
88
|
|
|
@@ -87,7 +95,7 @@ dsh plugin --profile <your profile> add github:BillCx330/dsh-plugin-notify-sound
|
|
|
87
95
|
**Release archive** — download the `.tgz` from [Releases](https://github.com/BillCx330/dsh-plugin-notify-sounds/releases) and install it by its **path on disk**:
|
|
88
96
|
|
|
89
97
|
```sh
|
|
90
|
-
dsh plugin --profile <your profile> add ./dsh-plugin-notify-sounds-1.
|
|
98
|
+
dsh plugin --profile <your profile> add ./dsh-plugin-notify-sounds-1.8.4.tgz
|
|
91
99
|
```
|
|
92
100
|
|
|
93
101
|
(`dsh plugin add` hands its argument straight to the package manager, so it takes a local file path — a download URL will not work.)
|
|
@@ -105,8 +113,8 @@ The command line writes `dsh.profile.bundles` and installs the dependency for yo
|
|
|
105
113
|
## ⚠️ Known limitations
|
|
106
114
|
|
|
107
115
|
- **Hand-edited config**: preferences live in the profile's `cordis.patch.yml`; values must match the schema (volume 0–100, a preset id, `HH:MM` times) or the plugin declines to load — just fix the value. The settings UI only ever writes valid values.
|
|
108
|
-
- **
|
|
109
|
-
- **Versions up to 1.
|
|
116
|
+
- **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 — Settings → Plugins (remove, then add again), the marketplace, or `dsh plugin add` on a web profile.
|
|
117
|
+
- **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.
|
|
110
118
|
- **Multiple windows** fire independently: the same event may chime once per window; turn on notify-only-when-hidden to avoid it.
|
|
111
119
|
- **Click-to-return** is fully implemented and tested on Windows; macOS / Linux fall back to the system deep link and are unverified.
|
|
112
120
|
- **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.
|
|
@@ -131,6 +139,11 @@ For anyone hacking on the code (exact details live in the source comments):
|
|
|
131
139
|
|
|
132
140
|
## 📜 Changelog
|
|
133
141
|
|
|
142
|
+
- **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.
|
|
143
|
+
- **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.
|
|
144
|
+
- **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.
|
|
145
|
+
- **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.
|
|
146
|
+
- **v1.8.0** (2026-10-20) **The plugin stops installing itself; the package manager owns upgrades.** Writing files in place never updated the profile's lockfile, so the next plugin install — or a market upgrade — silently restored the version pnpm had recorded: a 1.7.0 install could roll back to 1.6.9 with no warning. DSH 0.2.1 makes a durable self-install impossible from inside a plugin (the manager refuses a package that is already declared, and its remove-then-install step unloads the very code that would finish the install), so the self-installer is gone: the update row checks npm, reports the newer version, and points at the route that installs — Settings → Plugins (remove, then add again), the marketplace, or `dsh plugin add` on a web profile. It also compares the running version with the version the profile's lockfile would restore and **says so when they differ**, turning the silent rollback into a self-service fix. **New: goal-mode alerts wait for the goal to end** (issue #2) — a goal runs several automatic rounds and every round used to chime; the new switch **Only alert on the final goal round** stays silent on intermediate rounds and chimes once when the goal completes, pauses or blocks (ordinary sessions are unaffected; off by default). The release keeps the same seven filenames, so 1.6.9 / 1.7.0 installations still reach it through their own updater — after one reinstall through the package manager the upgrade is permanent.
|
|
134
147
|
- **v1.7.0** (2026-10-16) **The update mechanism turns a corner**: one-click update is no longer limited by a fixed list of seven filenames. It now accepts any regular file inside the package directory, governed by rules instead of an inventory — path traversal, absolute paths, symlinks, non-file entries, reserved names (its own backup/temp names and its record file, `.git`, `node_modules`, `.npmrc`), paths containing `:`, names colliding only by case, and a file that differs from the entry point `package.json` declares only by case are all still refused, with the file count and the decompressed size capped (enforced while inflating). **1.7.0 and later update cleanly into a release that adds or drops any number of files**: a file a later release drops is now actually deleted, based on the `.update-manifest.json` record this updater leaves behind — **only files an update installed are ever deleted**, anything you put in that directory yourself is left alone, and installations older than the first record are never guessed at. **1.6.9 and earlier will fail** (safely — nothing is written, the installed version keeps working) if a newer release adds a file, and the README says to reinstall once. Fixed: turning auto-check off ended the daily check chain for good, even after turning it back on; clicking a notification did nothing when the window title changed between the two Win32 reads; prerelease versions compared as strings (`rc.10` looked older than `rc.9`); **two tabs — or two windows — of the same GUI each rang the sound, raised their own system notification and ran their own repeat reminder** (one page is now elected to speak, via BroadcastChannel plus a shared claim, and the others stay silent until it disappears; per-window title marks are unaffected); and custom-sound normalization now visits **every sample** (taking every 8th frame aliased against content near fs/8 and systematically underestimated the peak). A failed update now gives **stage-appropriate advice** — retry for network failures, and the repository plus the uninstall/reinstall steps when the payload itself could not be applied, naming both versions — `package.json` is written **last** (a crash mid-swap leaves the old version number over the new code, so the next check still offers the update instead of declaring success), and the archive's manifest is verified to be this package at the version the registry named. The custom-sound row moved to sit **directly under the three trigger sounds** it is used by, and the smaller fixes on top: the cooldown table is a real LRU rather than FIFO (the session that fired most often was evicted first), one session-list snapshot per pass instead of per session, the macOS Desktop's window-title switch is now disabled rather than merely re-described, the full-width punctuation that leaked into the English update message is gone, and the README's release-archive instructions now give a command that actually works.
|
|
135
148
|
- **v1.6.9** (2026-10-12) Fixes a README filename compatibility issue in v1.6.8: reverted to `README.md` (English) + `README.zh.md` (Chinese) so the update whitelist in every prior version can recognise them. A new filename would be refused by old users' updates — the whitelist is one-directional because the update runs old code.
|
|
136
149
|
- **v1.6.8** (2026-10-12) Review fixes and optimizations: the auto-check first-frame timing gap is closed (`undefined` is no longer treated as permission to reach out); an orphaned JSDoc is removed; the teardown assertion now actually verifies listener removal; `normalizedGain` samples every 8th frame (2.6M → 330K iterations for a 30-second upload); the master gain disconnects after playback ends (no more audio-node leak); `swapNavIcon` is wrapped in try/catch and preserves the original node for restore on dispose (React re-renders can no longer crash it); the cooldown table evicts the oldest entry instead of clearing all; and `sessionRows` caches one snapshot per synchronous block.
|
package/README.zh.md
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# dsh-plugin-notify-sounds
|
|
2
2
|
|
|
3
|
+
[](https://awesome-dsh-plugin.com)
|
|
4
|
+
[](https://dsh-plugin.org/plugins/billcx330/dsh-plugin-notify-sounds)
|
|
5
|
+
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
3
6
|
[](https://www.npmjs.com/package/dsh-plugin-notify-sounds)
|
|
4
7
|
[](https://www.npmjs.com/package/dsh-plugin-notify-sounds)
|
|
5
8
|
[](LICENSE)
|
|
6
|
-
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
7
9
|
|
|
8
10
|
[English](README.md) | 中文
|
|
9
11
|
|
|
@@ -74,7 +76,13 @@ DSH 的状态提示只有视觉——状态点、侧栏标记。你一切到别
|
|
|
74
76
|
|
|
75
77
|
### ⬆️ 升级
|
|
76
78
|
|
|
77
|
-
|
|
79
|
+
**升级由包管理器安装**,这样 profile 的 lockfile 会跟着一起更新——DSH 插件无法自己持久地更新自己(管理器拒绝已声明的包,而它的「先移除再安装」会先卸载掉正要完成安装的代码)。所以插件只负责**检查**,并告诉你从哪里装:
|
|
80
|
+
|
|
81
|
+
- **在 DSH 里(推荐)**:设置 → 插件 → 卸载本插件 → **添加插件** → 粘贴 `dsh-plugin-notify-sounds` → 安装 → 重启 DSH。
|
|
82
|
+
- **装了市场插件**([dshmarket](https://www.npmjs.com/package/dshmarket)):在市场里一键更新。
|
|
83
|
+
- **`web` profile**:`dsh plugin --profile <你的 profile> add dsh-plugin-notify-sounds@<版本>`。
|
|
84
|
+
|
|
85
|
+
更新行还会提示「当前版本与 lockfile 记录不一致」——那是 1.8.0 之前的自更新留下的状态,按上面方式重装一次即可永久修好。
|
|
78
86
|
|
|
79
87
|
### 🛠️ 其他安装方式
|
|
80
88
|
|
|
@@ -87,7 +95,7 @@ dsh plugin --profile <你的 profile> add github:BillCx330/dsh-plugin-notify-sou
|
|
|
87
95
|
**Release 压缩包** —— 从 [Releases](https://github.com/BillCx330/dsh-plugin-notify-sounds/releases) 下载 `.tgz`,用它**在磁盘上的路径**安装:
|
|
88
96
|
|
|
89
97
|
```sh
|
|
90
|
-
dsh plugin --profile <你的 profile> add ./dsh-plugin-notify-sounds-1.
|
|
98
|
+
dsh plugin --profile <你的 profile> add ./dsh-plugin-notify-sounds-1.8.4.tgz
|
|
91
99
|
```
|
|
92
100
|
|
|
93
101
|
(`dsh plugin add` 把参数原样交给包管理器,所以要给本地文件路径,不能填下载地址。)
|
|
@@ -105,8 +113,8 @@ dsh plugin --profile <你的 profile> add ./dsh-plugin-notify-sounds
|
|
|
105
113
|
## ⚠️ 已知边界
|
|
106
114
|
|
|
107
115
|
- **手改配置**:偏好写在 profile 的 `cordis.patch.yml`,值必须符合 schema(音量 0–100、音效为预设之一、时间 `HH:MM`);写错会让插件不加载,改回即可。设置界面只会写入合法值。
|
|
108
|
-
-
|
|
109
|
-
- **1.
|
|
116
|
+
- **升级由包管理器安装,不再由插件自己完成**:1.8.0 移除了自安装器(原地写文件从不会更新 profile 的 lockfile,于是下一次插件安装会静默把旧版本装回来)。更新行仍会检查 npm、报告新版,然后指向真正负责安装的入口——设置 → 插件(卸载后再装)、市场,或 web profile 上的 `dsh plugin add`。
|
|
117
|
+
- **1.7.0 及更早版本**可以通过它们自带的自更新到达 1.8.0(1.8.0 保持同样的七个文件名),但这次到达不会被 pnpm 登记:按上面的方式用包管理器重装一次,此后所有升级都会留下。1.6.9 及更早无法安装「新增了文件」的版本——固定白名单会安全拒绝;同样的一次性重装即可解决。
|
|
110
118
|
- **多窗口**:各窗口独立触发,同一事件可能各响一次;打开「仅在页面不可见时提示」即可避免。
|
|
111
119
|
- **点击通知回到窗口**:Windows 完整实现并实测;macOS / Linux 走系统深链,未验证。
|
|
112
120
|
- **唤窗按窗口标题定位主窗口**:刻意不读取其他进程信息(那会触发杀软主动防御)。若将来 DSH 改变标题格式,唤窗会静默失效——提示音与通知本身不受影响。
|
|
@@ -130,6 +138,11 @@ dsh plugin --profile <你的 profile> add ./dsh-plugin-notify-sounds
|
|
|
130
138
|
|
|
131
139
|
## 📜 更新记录
|
|
132
140
|
|
|
141
|
+
- **v1.8.4**(2026-10-20)**点击通知一定能找到窗口。** 真实点击的日志显示:窗口明明存在,原生唤窗却报 "no window found"——隐藏或正在重绘的窗口可能瞬时返回空标题,而当时的搜索只认标题。现在窗口搜索同时按「拥有窗口的进程」匹配(桌面壳是本宿主进程的父进程),前台请求连续重试三次之后才走深链兜底。
|
|
142
|
+
- **v1.8.3**(2026-10-20)**窗口关到托盘时,点击通知也能回到 DSH 了。** 原先的 Win32 唤窗只搜**可见**窗口,关到托盘的 DSH 窗口永远找不到,只能退到很慢的深链兜底——表现就是「点了通知没反应」。现在会记住标题匹配的不可见窗口,并在置前之前先 `SW_SHOW` 让它显示出来;深链仍作为最后兜底。
|
|
143
|
+
- **v1.8.2**(2026-10-20)**真正修好目标模式的提醒。** 1.8.1 的宿主路由读了 `stateOf(session, "goal")`,却取 `current.phase`,而投影值把 goal 嵌在里面:`current.goal.phase`。于是每次读取都为空、每轮照响。现在改为读嵌套字段(并保留扁平形状的兜底),宿主侧调试记录已确认:中间轮 `current.goal.phase` 是 `active`,最后一轮是 `complete`。
|
|
144
|
+
- **v1.8.1**(2026-10-20)**修复目标模式的提醒。** 1.8.0 的开关从浏览器自己的会话投影读 phase,而那份投影只包含这个页面打开过的会话——目标所在的会话没被打开过时读到空值,代码把它当成「没有目标」,于是每轮又照响。浏览器半现在改问宿主新增的只读路由 `/notify-sounds/goal`(宿主的投影存储才是权威的),并在目标仍为 `active` 时持续复查:目标完成、暂停或受阻时响唯一一次;会话重新开始运行则保持安静。宿主不回答该路由时退回旧行为。
|
|
145
|
+
- **v1.8.0**(2026-10-20)**插件不再自己安装更新,改由包管理器负责。** 原地写文件从不会更新 profile 的 lockfile,于是下一次插件安装(或市场升级)会静默把 pnpm 记录的版本装回来:1.7.0 的安装可能一声不响退回 1.6.9。DSH 0.2.1 让插件无法从内部持久地自安装(管理器拒绝已声明的包,而它的「先移除再安装」会先卸载掉正要完成安装的代码),所以自安装器被移除:更新行只检查 npm、报告新版,并指向真正负责安装的入口——设置 → 插件(卸载后再装)、市场,或 web profile 上的 `dsh plugin add`。它还会把「运行版本」与「lockfile 会恢复的版本」做比较,**不一致时明确提示**,把静默回退变成用户可自助修复的状态。**新增:目标模式下等目标结束再提醒**(issue #2)——目标模式会自动进行多轮,原先每轮结束都会响;新开关「目标模式下只在最终轮提醒」让中间轮保持安静,只在目标完成、暂停或受阻时响一次(普通对话不受影响;默认关闭)。本版保持同样的七个文件名,1.6.9 / 1.7.0 的安装仍可通过它们自带的自更新到达 1.8.0——之后用包管理器重装一次,升级即永久生效。
|
|
133
146
|
- **v1.7.0**(2026-10-16)**更新机制的转折点**:一键更新不再受「固定七个文件名」的白名单约束,改为按规则接受包目录下的任何普通文件——路径穿越、绝对路径、符号链接、非普通文件、保留名字(更新器自己的备份/临时名与记录文件、`.git`、`node_modules`、`.npmrc`)、含 `:` 的路径段,以及只有大小写不同的重名或**与 `package.json` 声明的入口只差大小写**的文件,依然一律拒绝,文件数与解压体积也都有上限(解压时即受 `gunzip` 上限约束)。**1.7.0 及以后可以正常更新到任意增减文件的新版**:某个版本去掉的文件现在会真的被删掉(依据更新器自己留下的 `.update-manifest.json` 记录,**只删它自己装过的文件**,用户放进目录里的东西一律不动;第一条记录之前的安装不做任何猜测)。**1.6.9 及更早**遇到「新增了文件」的新版会更新失败(安全失败:一个字节都不写,已装版本继续正常工作),README 里说明了重装一次的办法。修复:关掉「自动检查更新」会让每日轮询永久停摆(重新打开也不恢复);窗口标题在两次 Win32 读取之间变短时,点通知唤窗会静默失效;预发布版本号按字符串比较(`rc.10` 被误判为比 `rc.9` 旧);**同一个 GUI 开多个标签页或多个窗口时提示音、系统通知、重复提醒全部翻倍**(现在由 BroadcastChannel + 本地记录选出一个「发声页」,其余页保持安静,只在发声页消失后接管;窗口标题标记各窗口独立,不受影响);自定义音效的峰值归一化改为**逐样本**扫描(原先每 8 帧抽样会与接近 fs/8 的高频混叠、系统性低估峰值)。更新失败时给出**按阶段区分的建议**(网络类提示重试;解包/替换类给出手动重装步骤与仓库地址,并列出失败的两个版本号),写入顺序改为**最后写 `package.json`**(中途崩溃时检查仍会提示更新,用户再点一次即可自愈),并会校验归档里的清单确实是本包、确实是 registry 报的那个版本。设置里「自定义音效」移到**三种提示音之后**(它们才是会用到它的三处);`settings` 相关的小修:冷却表改为真正的 LRU(原先是 FIFO,最常触发的会话反而最先被淘汰)、会话列表每轮只取一次快照、macOS 桌面版的「窗口标题提醒」开关改为**禁用**(原先能打开但标记无处显示)、英文界面更新提示里残留的全角标点修掉、README 的 Release 安装说明改为可照做的本地路径写法。
|
|
134
147
|
- **v1.6.9**(2026-10-12)修正 v1.6.8 的 README 文件名兼容性:改回 `README.md`(英文默认)+ `README.zh.md`(中文),确保旧版本更新白名单能识别。新文件名会让旧用户的更新被拒——白名单是单向兼容的,更新时跑的是旧代码。
|
|
135
148
|
- **v1.6.8**(2026-10-12)修复复核细节 + 优化:`autoCheck` 首帧时序补上(`undefined` 不再被当成允许联网);删孤立 JSDoc;teardown 断言改为真正验证监听器移除;`normalizedGain` 抽样(每 8 帧,260 万→33 万次);`master` gain 播完自动断连(不再泄漏音频节点);`swapNavIcon` try/catch + 保留原节点、dispose 时还原(React 重排不再炸);冷却表 LRU 淘汰(不再全清);`sessionRows` 同步块级快照缓存。
|
package/lib/client.js
CHANGED
|
@@ -173,6 +173,8 @@ window.__ModuleLoader__.load({
|
|
|
173
173
|
"question.description": "智能体向你提问时播放",
|
|
174
174
|
"completion.title": "任务完成时",
|
|
175
175
|
"completion.description": "会话由运行转为空闲时播放",
|
|
176
|
+
"goalFinalOnly.title": "目标模式下只在最终轮提醒",
|
|
177
|
+
"goalFinalOnly.description": "目标模式会自动进行多轮;开启后中间轮结束时保持安静,只在目标结束(或暂停、受阻)时响一次。普通对话不受影响",
|
|
176
178
|
"subagent.title": "子智能体的提醒",
|
|
177
179
|
"subagent.description": "子智能体(后台任务)完成或需要决策时也提醒;关闭后只提醒主对话",
|
|
178
180
|
"onlyWhenHidden.title": "仅在页面不可见时提示",
|
|
@@ -231,6 +233,9 @@ window.__ModuleLoader__.load({
|
|
|
231
233
|
"update.available": "有新版",
|
|
232
234
|
"update.failed": "检查失败(网络不可用)",
|
|
233
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}),下次安装其他插件时可能被退回。请按上面的方式重新安装一次。",
|
|
234
239
|
"update.updating": "更新中…",
|
|
235
240
|
"update.doneDetail": "更新完成:{from} → {to},重启 DSH 后生效",
|
|
236
241
|
"update.applyFailed": "更新失败({stage}),已恢复原状",
|
|
@@ -286,6 +291,8 @@ window.__ModuleLoader__.load({
|
|
|
286
291
|
"renotify.interval": "Interval",
|
|
287
292
|
"renotify.minutes": "{n} min",
|
|
288
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",
|
|
289
296
|
"quiet.description": "No alerts during this daily time range (may wrap past midnight, e.g. 22:00 to 08:00)",
|
|
290
297
|
"quiet.start": "Start time",
|
|
291
298
|
"quiet.end": "End time",
|
|
@@ -318,6 +325,9 @@ window.__ModuleLoader__.load({
|
|
|
318
325
|
"update.available": "New version available",
|
|
319
326
|
"update.failed": "Check failed (no network)",
|
|
320
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.",
|
|
321
331
|
"update.updating": "Updating…",
|
|
322
332
|
"update.doneDetail": "Updated: {from} → {to} — restart DSH to load it",
|
|
323
333
|
"update.applyFailed": "Update failed ({stage}) — the previous version is intact",
|
|
@@ -864,8 +874,6 @@ window.__ModuleLoader__.load({
|
|
|
864
874
|
/** Route the Host half answers the update check on; must match the Host half's UPDATE_CHECK_PATH. */
|
|
865
875
|
const UPDATE_CHECK_PATH = "/notify-sounds/update/check";
|
|
866
876
|
|
|
867
|
-
/** Route the Host half applies an update through; must match the Host half's UPDATE_APPLY_PATH. */
|
|
868
|
-
const UPDATE_APPLY_PATH = "/notify-sounds/update/apply";
|
|
869
877
|
|
|
870
878
|
/**
|
|
871
879
|
* Where a user goes when the one-click update cannot finish.
|
|
@@ -1378,6 +1386,17 @@ window.__ModuleLoader__.load({
|
|
|
1378
1386
|
failed: failedField === "subagentAlerts",
|
|
1379
1387
|
onChange: (next) => write("subagentAlerts", next),
|
|
1380
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
|
+
}),
|
|
1381
1400
|
h(SystemNotifyRow, {
|
|
1382
1401
|
key: "systemNotify",
|
|
1383
1402
|
t,
|
|
@@ -1935,73 +1954,52 @@ window.__ModuleLoader__.load({
|
|
|
1935
1954
|
setState({ phase: "failed" });
|
|
1936
1955
|
}
|
|
1937
1956
|
};
|
|
1938
|
-
const apply = async () => {
|
|
1939
|
-
setState({ phase: "updating" });
|
|
1940
|
-
try {
|
|
1941
|
-
const response = await fetch(UPDATE_APPLY_PATH, { method: "POST" });
|
|
1942
|
-
const result = await response.json();
|
|
1943
|
-
setState({ phase: result?.ok === true ? (result.upToDate === true ? "done" : "updated") : "applyFailed", result });
|
|
1944
|
-
} catch {
|
|
1945
|
-
setState({ phase: "applyFailed" });
|
|
1946
|
-
}
|
|
1947
|
-
};
|
|
1948
1957
|
// A cached answer makes this free; only a cold Host reaches out.
|
|
1949
1958
|
// Same guard as the notice: `=== true` (not `!== false`), because
|
|
1950
1959
|
// during the loading frame `undefined` must not be treated as
|
|
1951
|
-
// permission to reach out.
|
|
1960
|
+
// permission to reach out.
|
|
1952
1961
|
useEffect(() => {
|
|
1953
1962
|
if (autoCheck !== true) return;
|
|
1954
1963
|
check(false)();
|
|
1955
1964
|
}, [autoCheck]);
|
|
1956
1965
|
const payload = state.payload;
|
|
1957
|
-
|
|
1958
|
-
// answer, and this row prefers that: an update swaps the client
|
|
1959
|
-
// half's own file, so the page may reload and drop this row's
|
|
1960
|
-
// state mid-message — the news has to survive that. The Host keeps
|
|
1961
|
-
// it until DSH restarts, which is exactly as long as "restart to
|
|
1962
|
-
// load it" is true.
|
|
1963
|
-
const applied = payload?.applied ?? (state.phase === "updated" ? state.result : undefined);
|
|
1964
|
-
const appliedDone = applied !== undefined && (applied.ok === true || typeof applied.to === "string");
|
|
1966
|
+
const latest = payload?.latest === undefined ? undefined : formatVersion(payload.latest);
|
|
1965
1967
|
let status = payload === undefined
|
|
1966
1968
|
? t("update.idle")
|
|
1967
1969
|
: payload.updateAvailable === true
|
|
1968
|
-
? `${t("update.available")} ${
|
|
1970
|
+
? `${t("update.available")} ${latest}`
|
|
1969
1971
|
: t("update.uptodate");
|
|
1970
1972
|
if (state.phase === "checking") status = t("update.checking");
|
|
1971
|
-
if (state.phase === "
|
|
1972
|
-
|
|
1973
|
-
//
|
|
1974
|
-
//
|
|
1975
|
-
//
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1973
|
+
if (state.phase === "failed") status = t("update.failed");
|
|
1974
|
+
const detail = payload === undefined ? status : `${t("update.current")} ${formatVersion(payload.current)} · ${status}`;
|
|
1975
|
+
// The install belongs to the package manager now: this plugin only
|
|
1976
|
+
// checks, and every route below installs THROUGH pnpm so the
|
|
1977
|
+
// profile's lockfile moves with the files. The two shells reach it
|
|
1978
|
+
// differently, so the row spells out the one that applies here.
|
|
1979
|
+
const lines = [];
|
|
1980
|
+
if (payload?.unregistered === true) {
|
|
1981
|
+
lines.push(h("div", { className: css.hint, key: "unregistered" }, t("update.unregistered", {
|
|
1982
|
+
recorded: formatVersion(payload.recorded),
|
|
1983
|
+
})));
|
|
1984
|
+
}
|
|
1985
|
+
if (payload?.updateAvailable === true && latest !== undefined) {
|
|
1986
|
+
lines.push(h("div", { className: css.hint, key: "howto" }, isDesktopShell()
|
|
1987
|
+
? t("update.desktopGuidance")
|
|
1988
|
+
: t("update.webGuidance", { profile: payload.profile ?? "web", version: latest })));
|
|
1983
1989
|
}
|
|
1984
|
-
const busy = state.phase === "checking" || state.phase === "updating";
|
|
1985
1990
|
return h("div", { className: css.row }, [
|
|
1986
1991
|
h("div", { key: "text" }, [
|
|
1987
1992
|
h("div", { className: css.rowTitle, key: "title" }, t("update.title")),
|
|
1988
1993
|
h("div", { className: css.rowDescription, key: "description" }, t("update.description")),
|
|
1989
1994
|
h("div", { className: css.hint, key: "status" }, detail),
|
|
1990
|
-
|
|
1995
|
+
...lines,
|
|
1991
1996
|
]),
|
|
1992
1997
|
h("div", { className: css.stackControl, key: "control" }, [
|
|
1993
|
-
payload?.updateAvailable === true && !appliedDone ? h(Button, {
|
|
1994
|
-
key: "apply",
|
|
1995
|
-
variant: "outline",
|
|
1996
|
-
size: "sm",
|
|
1997
|
-
disabled: busy,
|
|
1998
|
-
onClick: apply,
|
|
1999
|
-
}, t("update.apply")) : null,
|
|
2000
1998
|
h(Button, {
|
|
2001
1999
|
key: "check",
|
|
2002
2000
|
variant: "outline",
|
|
2003
2001
|
size: "sm",
|
|
2004
|
-
disabled:
|
|
2002
|
+
disabled: state.phase === "checking",
|
|
2005
2003
|
onClick: check(true),
|
|
2006
2004
|
}, t("update.check")),
|
|
2007
2005
|
]),
|
|
@@ -2416,6 +2414,82 @@ window.__ModuleLoader__.load({
|
|
|
2416
2414
|
if (status.running === true && isNotifiable(sessionId, rows)) return true;
|
|
2417
2415
|
}
|
|
2418
2416
|
return false;
|
|
2417
|
+
};
|
|
2418
|
+
/** Route the Host answers a session's goal phase on. */
|
|
2419
|
+
const GOAL_PHASE_PATH = "/notify-sounds/goal";
|
|
2420
|
+
/** How often an active goal's phase is re-read while waiting for its end. */
|
|
2421
|
+
const GOAL_POLL_MS = 2000;
|
|
2422
|
+
/** Longest a goal may stay `active` before its watcher gives up. */
|
|
2423
|
+
const GOAL_SETTLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
2424
|
+
/**
|
|
2425
|
+
* One goal phase from the Host, or undefined when it cannot be read.
|
|
2426
|
+
*
|
|
2427
|
+
* The Host owns the durable projection; this page's own store only
|
|
2428
|
+
* carries sessions it has opened, and a completion edge can belong to a
|
|
2429
|
+
* session this page never opened. Undefined keeps the old behaviour
|
|
2430
|
+
* (alert) for any runtime that does not answer the route.
|
|
2431
|
+
*/
|
|
2432
|
+
const readGoalPhase = async (sessionId) => {
|
|
2433
|
+
try {
|
|
2434
|
+
const response = await fetch(GOAL_PHASE_PATH + "?session=" + encodeURIComponent(sessionId), { method: "GET" });
|
|
2435
|
+
if (!response.ok) return undefined;
|
|
2436
|
+
const payload = await response.json();
|
|
2437
|
+
return typeof payload?.phase === "string" ? payload.phase : undefined;
|
|
2438
|
+
} catch {
|
|
2439
|
+
return undefined;
|
|
2440
|
+
}
|
|
2441
|
+
};
|
|
2442
|
+
/** Deliver the completion alert this edge earns. */
|
|
2443
|
+
const alertCompletion = (sessionId) => {
|
|
2444
|
+
if (!pageIsWatched() && !quietNow()) unreadCompletion = true;
|
|
2445
|
+
notify("completion", undefined, sessionId);
|
|
2446
|
+
refreshFlash();
|
|
2447
|
+
};
|
|
2448
|
+
/** Sessions whose completion alert is waiting for their goal to end. */
|
|
2449
|
+
const goalWatchers = new Map();
|
|
2450
|
+
/**
|
|
2451
|
+
* Resolve a running→idle edge against the goal projection.
|
|
2452
|
+
*
|
|
2453
|
+
* A goal drives several rounds and every round ends with this edge; with
|
|
2454
|
+
* `goalFinalOnly` on, the edge is only an alert when the goal will not
|
|
2455
|
+
* continue: no goal, completed, paused or blocked. A goal still `active`
|
|
2456
|
+
* gets one watcher that re-reads the phase until it ends (the single
|
|
2457
|
+
* chime) or the session starts running again (silent intermediate round).
|
|
2458
|
+
*/
|
|
2459
|
+
const handleCompletion = (sessionId, value) => {
|
|
2460
|
+
if (value?.goalFinalOnly !== true) {
|
|
2461
|
+
alertCompletion(sessionId);
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
if (goalWatchers.has(sessionId)) return;
|
|
2465
|
+
let settled = false;
|
|
2466
|
+
let pollTimer;
|
|
2467
|
+
let deadline;
|
|
2468
|
+
const finish = (chime) => {
|
|
2469
|
+
if (settled) return;
|
|
2470
|
+
settled = true;
|
|
2471
|
+
goalWatchers.delete(sessionId);
|
|
2472
|
+
clearTimeout(pollTimer);
|
|
2473
|
+
clearTimeout(deadline);
|
|
2474
|
+
if (chime) alertCompletion(sessionId);
|
|
2475
|
+
};
|
|
2476
|
+
goalWatchers.set(sessionId, () => finish(false));
|
|
2477
|
+
const poll = async () => {
|
|
2478
|
+
if (source.getSnapshot().get(sessionId)?.running === true) {
|
|
2479
|
+
finish(false);
|
|
2480
|
+
return;
|
|
2481
|
+
}
|
|
2482
|
+
const phase = await readGoalPhase(sessionId);
|
|
2483
|
+
if (settled) return;
|
|
2484
|
+
if (phase !== "active") {
|
|
2485
|
+
finish(true);
|
|
2486
|
+
return;
|
|
2487
|
+
}
|
|
2488
|
+
pollTimer = setTimeout(poll, GOAL_POLL_MS);
|
|
2489
|
+
};
|
|
2490
|
+
deadline = setTimeout(() => finish(true), GOAL_SETTLE_TIMEOUT_MS);
|
|
2491
|
+
deadline?.unref?.();
|
|
2492
|
+
void poll();
|
|
2419
2493
|
};
|
|
2420
2494
|
/**
|
|
2421
2495
|
* Which alert the title should carry, if any.
|
|
@@ -2499,13 +2573,7 @@ window.__ModuleLoader__.load({
|
|
|
2499
2573
|
notify(status.pendingInteraction.kind === "question" ? "question" : "decision", status.pendingInteraction, sessionId);
|
|
2500
2574
|
}
|
|
2501
2575
|
if (before !== undefined && before.running === true && status.running === false) {
|
|
2502
|
-
|
|
2503
|
-
// news; one they watch happen is not — and one
|
|
2504
|
-
// that happens during quiet hours is forgotten
|
|
2505
|
-
// rather than kept for later, so nothing about it
|
|
2506
|
-
// shows up when the quiet window ends.
|
|
2507
|
-
if (!pageIsWatched() && !quietNow()) unreadCompletion = true;
|
|
2508
|
-
notify("completion", undefined, sessionId);
|
|
2576
|
+
handleCompletion(sessionId, form.getSnapshot().value);
|
|
2509
2577
|
}
|
|
2510
2578
|
}
|
|
2511
2579
|
}
|
|
@@ -2590,6 +2658,7 @@ window.__ModuleLoader__.load({
|
|
|
2590
2658
|
unsubscribeForm();
|
|
2591
2659
|
unsubscribeLocale();
|
|
2592
2660
|
clearInterval(renotifyTimer);
|
|
2661
|
+
for (const stop of [...goalWatchers.values()]) stop();
|
|
2593
2662
|
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisibility);
|
|
2594
2663
|
applyTitleFlash(undefined, titleAlerts(t));
|
|
2595
2664
|
};
|
|
@@ -2690,7 +2759,6 @@ window.__ModuleLoader__.load({
|
|
|
2690
2759
|
exports.SOUND_IDS = SOUND_IDS;
|
|
2691
2760
|
exports.FOCUS_PATH = FOCUS_PATH;
|
|
2692
2761
|
exports.UPDATE_CHECK_PATH = UPDATE_CHECK_PATH;
|
|
2693
|
-
exports.UPDATE_APPLY_PATH = UPDATE_APPLY_PATH;
|
|
2694
2762
|
// Which update stages a retry can fix and which need a fresh install:
|
|
2695
2763
|
// the Host names a stage in every failure it reports, so this map is the
|
|
2696
2764
|
// one place the advice comes from, and the tests cross-check it against
|
package/lib/index.js
CHANGED
|
@@ -62,9 +62,14 @@ const DESKTOP_APP_TITLE = "DeepSeek Harness";
|
|
|
62
62
|
|
|
63
63
|
/** `ShowWindow` command: restore a minimized or maximized window to its prior size. */
|
|
64
64
|
const SW_RESTORE = 9;
|
|
65
|
+
/** `ShowWindow` command: show a window without asking for the foreground. */
|
|
66
|
+
const SW_SHOW = 5;
|
|
65
67
|
|
|
66
68
|
/** Virtual key and flag for the ALT tap that satisfies the foreground lock. */
|
|
67
69
|
const VK_MENU = 0x12;
|
|
70
|
+
|
|
71
|
+
/** The shell owns the window: this Host is its child process. */
|
|
72
|
+
const SHELL_PID = process.ppid;
|
|
68
73
|
const KEYEVENTF_KEYUP = 0x2;
|
|
69
74
|
|
|
70
75
|
//#region native window raise
|
|
@@ -164,6 +169,7 @@ function loadNativeRaise() {
|
|
|
164
169
|
AllowSetForegroundWindow: user32.func("bool __stdcall AllowSetForegroundWindow(int pid)"),
|
|
165
170
|
GetForegroundWindow: user32.func("void * __stdcall GetForegroundWindow()"),
|
|
166
171
|
GetWindowThreadProcessId: user32.func("uint32 __stdcall GetWindowThreadProcessId(void *h, void *pid)"),
|
|
172
|
+
GetWindowPid: user32.func("uint32 __stdcall GetWindowThreadProcessId(void *h, _Out_ uint32 *pid)"),
|
|
167
173
|
AttachThreadInput: user32.func("bool __stdcall AttachThreadInput(uint32 attach, uint32 attachTo, bool attachFlag)"),
|
|
168
174
|
keybd_event: user32.func("void __stdcall keybd_event(uint8 vk, uint8 scan, uint32 flags, uintptr_t extra)"),
|
|
169
175
|
GetCurrentThreadId: kernel32.func("uint32 __stdcall GetCurrentThreadId()"),
|
|
@@ -243,14 +249,36 @@ export function isDesktopTitle(title) {
|
|
|
243
249
|
* @param native - the bound Win32 calls.
|
|
244
250
|
* @returns the window handle, or undefined.
|
|
245
251
|
*/
|
|
246
|
-
function findDesktopWindow(native) {
|
|
247
|
-
let
|
|
252
|
+
export function findDesktopWindow(native) {
|
|
253
|
+
let byTitleVisible;
|
|
254
|
+
let byTitleHidden;
|
|
255
|
+
let byPidVisible;
|
|
256
|
+
let byPidHidden;
|
|
257
|
+
const pidBuffer = new Uint32Array(1);
|
|
248
258
|
const visit = native.koffi.register((handle) => {
|
|
249
259
|
try {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
260
|
+
const visible = native.IsWindowVisible(handle);
|
|
261
|
+
const title = windowTitle(native, handle);
|
|
262
|
+
if (isDesktopTitle(title)) {
|
|
263
|
+
if (visible) {
|
|
264
|
+
byTitleVisible = handle;
|
|
265
|
+
return false; // stop at the first visible title match
|
|
266
|
+
}
|
|
267
|
+
if (byTitleHidden === undefined) byTitleHidden = handle;
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
// A hidden or repainting window can transiently answer with no
|
|
271
|
+
// title; the shell process owns the window, so match that too.
|
|
272
|
+
if (SHELL_PID === undefined || SHELL_PID === 0) return true;
|
|
273
|
+
if (typeof native.GetWindowPid !== "function") return true;
|
|
274
|
+
pidBuffer[0] = 0;
|
|
275
|
+
native.GetWindowPid(handle, pidBuffer);
|
|
276
|
+
if (pidBuffer[0] !== SHELL_PID) return true;
|
|
277
|
+
if (visible) {
|
|
278
|
+
if (byPidVisible === undefined) byPidVisible = handle;
|
|
279
|
+
} else if (byPidHidden === undefined) {
|
|
280
|
+
byPidHidden = handle;
|
|
281
|
+
}
|
|
254
282
|
} catch {
|
|
255
283
|
// A window that cannot be inspected simply does not match.
|
|
256
284
|
}
|
|
@@ -265,7 +293,7 @@ function findDesktopWindow(native) {
|
|
|
265
293
|
native.koffi.unregister(visit);
|
|
266
294
|
} catch {}
|
|
267
295
|
}
|
|
268
|
-
return
|
|
296
|
+
return byTitleVisible ?? byTitleHidden ?? byPidVisible ?? byPidHidden;
|
|
269
297
|
}
|
|
270
298
|
|
|
271
299
|
/**
|
|
@@ -281,6 +309,9 @@ function raiseDesktopWindowNative() {
|
|
|
281
309
|
// Restore first: `ShowWindow(SW_RESTORE)` is not a foreground request, so it
|
|
282
310
|
// is granted even to a background process. Its return value is the window's
|
|
283
311
|
// PREVIOUS visibility, not a success flag, so it is deliberately ignored.
|
|
312
|
+
// SW_SHOW is not a foreground request, so a background process is granted
|
|
313
|
+
// it; it is what makes a tray-hidden window visible again.
|
|
314
|
+
if (!native.IsWindowVisible(handle)) native.ShowWindow(handle, SW_SHOW);
|
|
284
315
|
native.ShowWindow(handle, SW_RESTORE);
|
|
285
316
|
native.BringWindowToTop(handle);
|
|
286
317
|
// The foreground request is the part Windows polices: a background process
|
|
@@ -435,45 +466,32 @@ const warned = new Set();
|
|
|
435
466
|
*
|
|
436
467
|
* Every failure mode leaves the click a harmless no-op.
|
|
437
468
|
*/
|
|
438
|
-
function
|
|
469
|
+
function tryRaiseNative() {
|
|
470
|
+
try {
|
|
471
|
+
if (process.platform !== "win32") return false;
|
|
472
|
+
return raiseDesktopWindowNative();
|
|
473
|
+
} catch (error) {
|
|
474
|
+
warn(`native raise failed: ${error?.message ?? error}`);
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Raise the window through the app's own deep link; last resort. */
|
|
480
|
+
function focusDesktopWindowDeepLink() {
|
|
439
481
|
try {
|
|
440
482
|
if (process.platform === "win32") {
|
|
441
|
-
|
|
442
|
-
try {
|
|
443
|
-
raised = raiseDesktopWindowNative();
|
|
444
|
-
} catch (error) {
|
|
445
|
-
warn(`native raise failed: ${error?.message ?? error}`);
|
|
446
|
-
}
|
|
447
|
-
if (raised) {
|
|
448
|
-
log("window raised");
|
|
449
|
-
} else {
|
|
450
|
-
log("native raise did not take the foreground; falling back to the deep link");
|
|
451
|
-
raiseDesktopWindowDeepLink();
|
|
452
|
-
}
|
|
483
|
+
raiseDesktopWindowDeepLink();
|
|
453
484
|
} else if (process.platform === "darwin") {
|
|
454
|
-
// Activate the app by bundle id first: it needs no URL scheme to
|
|
455
|
-
// have been registered, which a packaged .app only gets if its
|
|
456
|
-
// Info.plist declares CFBundleURLTypes — something this project
|
|
457
|
-
// cannot verify from source. The deep link stays as the fallback,
|
|
458
|
-
// and a launch that failed is now named in the log instead of
|
|
459
|
-
// looking exactly like one that succeeded.
|
|
460
485
|
log("raising via open -b");
|
|
461
486
|
launcher("open", ["-b", MACOS_BUNDLE_ID], (ok) => {
|
|
462
|
-
if (ok)
|
|
463
|
-
log("bundle-id activation did not take; falling back to the deep link");
|
|
464
|
-
launcher("open", [FOCUS_DEEP_LINK]);
|
|
487
|
+
if (!ok) launcher("open", [FOCUS_DEEP_LINK]);
|
|
465
488
|
});
|
|
466
489
|
} else {
|
|
467
|
-
// No desktop shell ships for other platforms today — the shell
|
|
468
|
-
// itself refuses anything but win32/darwin, and a browser click
|
|
469
|
-
// never reaches this function. `xdg-open` is the correct opener
|
|
470
|
-
// should a Linux shell ever appear; until then this line is
|
|
471
|
-
// unreachable rather than misleading.
|
|
472
490
|
log("raising via xdg-open + deep link");
|
|
473
491
|
launcher("xdg-open", [FOCUS_DEEP_LINK]);
|
|
474
492
|
}
|
|
475
493
|
} catch (error) {
|
|
476
|
-
warn(`focus failed: ${error?.message ?? error}`);
|
|
494
|
+
warn(`focus fallback failed: ${error?.message ?? error}`);
|
|
477
495
|
}
|
|
478
496
|
}
|
|
479
497
|
|
|
@@ -482,6 +500,9 @@ function focusDesktopWindow() {
|
|
|
482
500
|
/** Route the browser half reads the update check through. */
|
|
483
501
|
export const UPDATE_CHECK_PATH = "/notify-sounds/update/check";
|
|
484
502
|
|
|
503
|
+
/** Route the browser half reads a session's goal phase through. */
|
|
504
|
+
export const GOAL_PHASE_PATH = "/notify-sounds/goal";
|
|
505
|
+
|
|
485
506
|
/** The npm package this plugin publishes as — the only name the check ever asks for. */
|
|
486
507
|
const UPDATE_PACKAGE = "dsh-plugin-notify-sounds";
|
|
487
508
|
|
|
@@ -683,6 +704,41 @@ async function readCappedBytes(response, cap) {
|
|
|
683
704
|
* @returns `{ ok, current, latest, updateAvailable, checkedAt }`, or
|
|
684
705
|
* `{ ok: false, current, error }` when no registry answered.
|
|
685
706
|
*/
|
|
707
|
+
/**
|
|
708
|
+
* How the running package relates to what the profile's package manager
|
|
709
|
+
* recorded for it.
|
|
710
|
+
*
|
|
711
|
+
* This plugin no longer installs its own releases (as of 1.8.0): a release
|
|
712
|
+
* reaches a profile through the package manager, which also owns the
|
|
713
|
+
* lockfile. A tree that arrived by any other route looks identical on disk
|
|
714
|
+
* while the lockfile still pins an older version — and the next
|
|
715
|
+
* `pnpm install` in that profile silently restores the old one. Reading the
|
|
716
|
+
* version the lockfile would restore lets the Settings row say so instead of
|
|
717
|
+
* leaving the user to discover it after the next plugin operation.
|
|
718
|
+
*
|
|
719
|
+
* Every part is optional: no profile directory, no lockfile or an unreadable
|
|
720
|
+
* one simply reports nothing, and the row keeps its normal wording.
|
|
721
|
+
*
|
|
722
|
+
* @param current - the version actually on disk.
|
|
723
|
+
* @returns the fields the check payload mirrors to the row.
|
|
724
|
+
*/
|
|
725
|
+
function installationProvenance(current) {
|
|
726
|
+
try {
|
|
727
|
+
const profileDir = process.env.DSH_PROFILE_DIR;
|
|
728
|
+
if (profileDir === undefined) return {};
|
|
729
|
+
const lock = readFileSync(join(profileDir, "pnpm-lock.yaml"), "utf8");
|
|
730
|
+
const recorded = new RegExp(`${UPDATE_PACKAGE}@([0-9][^:\\s"']*)`).exec(lock)?.[1];
|
|
731
|
+
return {
|
|
732
|
+
recorded,
|
|
733
|
+
unregistered: current !== undefined && recorded !== undefined && recorded !== current,
|
|
734
|
+
profile: process.env.DSH_PROFILE,
|
|
735
|
+
};
|
|
736
|
+
} catch {
|
|
737
|
+
return {};
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
export { installationProvenance };
|
|
741
|
+
|
|
686
742
|
export async function checkForUpdate(options = {}) {
|
|
687
743
|
const now = Date.now();
|
|
688
744
|
if (options.force !== true && updateCheck.result !== undefined && now - updateCheck.checkedAt < UPDATE_CHECK_TTL_MS) {
|
|
@@ -704,7 +760,7 @@ export async function checkForUpdate(options = {}) {
|
|
|
704
760
|
const current = readOwnVersion(options.root);
|
|
705
761
|
const answer = await fetchRegistryAnswer();
|
|
706
762
|
if (answer === undefined) {
|
|
707
|
-
const result = { ok: false, current, error: "no registry answered", applied: updateCheck.applied };
|
|
763
|
+
const result = { ok: false, current, error: "no registry answered", applied: updateCheck.applied, ...installationProvenance(current) };
|
|
708
764
|
updateCheck.checkedAt = now;
|
|
709
765
|
updateCheck.result = result;
|
|
710
766
|
return result;
|
|
@@ -717,6 +773,7 @@ export async function checkForUpdate(options = {}) {
|
|
|
717
773
|
updateAvailable: Number.isFinite(ordering) && ordering < 0,
|
|
718
774
|
checkedAt: now,
|
|
719
775
|
applied: updateCheck.applied,
|
|
776
|
+
...installationProvenance(current),
|
|
720
777
|
};
|
|
721
778
|
updateCheck.checkedAt = now;
|
|
722
779
|
updateCheck.result = result;
|
|
@@ -770,7 +827,11 @@ async function fetchRegistryAnswer() {
|
|
|
770
827
|
|
|
771
828
|
//#endregion
|
|
772
829
|
|
|
773
|
-
//#region update apply
|
|
830
|
+
//#region update apply — UNREACHABLE since 1.8.0: no route calls it any more.
|
|
831
|
+
// The Settings row sends the user to the plugin manager / package manager
|
|
832
|
+
// instead, so this plugin never writes its own files. The code below stays
|
|
833
|
+
// only until its tests retire with a later release; nothing in apply() can
|
|
834
|
+
// reach it.
|
|
774
835
|
|
|
775
836
|
/** Route the browser half asks for an update to be applied through. */
|
|
776
837
|
export const UPDATE_APPLY_PATH = "/notify-sounds/update/apply";
|
|
@@ -1334,6 +1395,8 @@ export const Config = z.object({
|
|
|
1334
1395
|
quietStart: z.string().pattern(TIME_OF_DAY).default("22:00").volatile(),
|
|
1335
1396
|
/** Quiet range end, "HH:MM" 24-hour. */
|
|
1336
1397
|
quietEnd: z.string().pattern(TIME_OF_DAY).default("08:00").volatile(),
|
|
1398
|
+
/** In goal mode, stay silent on intermediate rounds and chime once when the goal ends. */
|
|
1399
|
+
goalFinalOnly: z.boolean().default(false).volatile(),
|
|
1337
1400
|
});
|
|
1338
1401
|
|
|
1339
1402
|
/**
|
|
@@ -1397,7 +1460,14 @@ export function apply(ctx) {
|
|
|
1397
1460
|
res.end();
|
|
1398
1461
|
log(`focus click (fromBrowser=${fromBrowser})`);
|
|
1399
1462
|
if (!fromBrowser) {
|
|
1400
|
-
|
|
1463
|
+
// Foreground requests can be refused while another app owns
|
|
1464
|
+
// the input queue; retry before paying for a deep-link launch.
|
|
1465
|
+
let raised = false;
|
|
1466
|
+
for (let attempt = 0; attempt < 3 && !raised; attempt += 1) {
|
|
1467
|
+
raised = tryRaiseNative();
|
|
1468
|
+
if (!raised) await new Promise((resolve) => setTimeout(resolve, 180));
|
|
1469
|
+
}
|
|
1470
|
+
if (!raised) focusDesktopWindowDeepLink();
|
|
1401
1471
|
}
|
|
1402
1472
|
},
|
|
1403
1473
|
}), "notify-sounds: focus route");
|
|
@@ -1436,37 +1506,47 @@ export function apply(ctx) {
|
|
|
1436
1506
|
},
|
|
1437
1507
|
}), "notify-sounds: update check route");
|
|
1438
1508
|
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1509
|
+
});
|
|
1510
|
+
// The goal phase route (1.8.1): the browser cannot rely on its own
|
|
1511
|
+
// session projections for a session it never opened, so goal mode asks
|
|
1512
|
+
// the Host, whose projection store is authoritative. Read-only, one
|
|
1513
|
+
// session id, fenced like every other route here.
|
|
1514
|
+
ctx.inject(["webServer", "connection", "sessions", "sessionProjections"], (child) => {
|
|
1515
|
+
/** The goal phase for one session, or undefined when there is none. */
|
|
1516
|
+
const phaseOf = (sessionId) => {
|
|
1517
|
+
try {
|
|
1518
|
+
const session = child.sessions.get(sessionId);
|
|
1519
|
+
if (session === undefined) return undefined;
|
|
1520
|
+
const projection = child.sessionProjections.stateOf(session, "goal");
|
|
1521
|
+
if (projection === undefined || projection.failure != null) return undefined;
|
|
1522
|
+
const phase = projection.current?.goal?.phase ?? projection.current?.phase;
|
|
1523
|
+
return typeof phase === "string" ? phase : undefined;
|
|
1524
|
+
} catch {
|
|
1525
|
+
return undefined;
|
|
1526
|
+
}
|
|
1527
|
+
};
|
|
1443
1528
|
child.effect(() => child.webServer.register({
|
|
1444
1529
|
kind: "exact",
|
|
1445
|
-
path:
|
|
1446
|
-
handler:
|
|
1530
|
+
path: GOAL_PHASE_PATH,
|
|
1531
|
+
handler: (req, res) => {
|
|
1447
1532
|
const rejection = child.connection.requestRejection(req);
|
|
1448
1533
|
if (rejection !== undefined) {
|
|
1449
1534
|
res.statusCode = rejection;
|
|
1450
1535
|
res.end();
|
|
1451
1536
|
return;
|
|
1452
1537
|
}
|
|
1453
|
-
if (req.method !== "
|
|
1538
|
+
if (req.method !== "GET") {
|
|
1454
1539
|
res.statusCode = 405;
|
|
1455
|
-
res.setHeader("allow", "
|
|
1540
|
+
res.setHeader("allow", "GET");
|
|
1456
1541
|
res.end();
|
|
1457
1542
|
return;
|
|
1458
1543
|
}
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
} catch (error) {
|
|
1465
|
-
warn(`update apply failed: ${error?.message ?? error}`);
|
|
1466
|
-
res.statusCode = 500;
|
|
1467
|
-
res.end();
|
|
1468
|
-
}
|
|
1544
|
+
const sessionId = new URL(req.url ?? "/", "http://localhost").searchParams.get("session") ?? "";
|
|
1545
|
+
const phase = sessionId === "" ? undefined : phaseOf(sessionId);
|
|
1546
|
+
res.statusCode = 200;
|
|
1547
|
+
res.setHeader("content-type", "application/json");
|
|
1548
|
+
res.end(JSON.stringify({ phase: phase ?? null }));
|
|
1469
1549
|
},
|
|
1470
|
-
}), "notify-sounds:
|
|
1550
|
+
}), "notify-sounds: goal phase route");
|
|
1471
1551
|
});
|
|
1472
1552
|
}
|
package/package.json
CHANGED
|
@@ -1,62 +1,62 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-plugin-notify-sounds",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Notification sounds for DeepSeek Harness — a soft chime when the agent needs your decision or finishes a task, with a Settings section styled like DSH's own · 需要决策或任务完成时播放提示音,点击系统通知可回到窗口,设置面板可调音量与音效",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "lib/index.js",
|
|
7
|
-
"exports": {
|
|
8
|
-
".": {
|
|
9
|
-
"default": "./lib/index.js"
|
|
10
|
-
},
|
|
11
|
-
"./client": {
|
|
12
|
-
"default": "./lib/client.js"
|
|
13
|
-
},
|
|
14
|
-
"./package.json": "./package.json",
|
|
15
|
-
"./cordis.patch.yml": "./cordis.patch.yml"
|
|
16
|
-
},
|
|
17
|
-
"files": [
|
|
18
|
-
"lib/index.js",
|
|
19
|
-
"lib/client.js",
|
|
20
|
-
"cordis.patch.yml",
|
|
21
|
-
"README.zh.md"
|
|
22
|
-
],
|
|
23
|
-
"license": "MIT",
|
|
24
|
-
"repository": {
|
|
25
|
-
"type": "git",
|
|
26
|
-
"url": "git+https://github.com/BillCx330/dsh-plugin-notify-sounds.git"
|
|
27
|
-
},
|
|
28
|
-
"bugs": {
|
|
29
|
-
"url": "https://github.com/BillCx330/dsh-plugin-notify-sounds/issues"
|
|
30
|
-
},
|
|
31
|
-
"homepage": "https://github.com/BillCx330/dsh-plugin-notify-sounds#readme",
|
|
32
|
-
"keywords": [
|
|
33
|
-
"deepseek",
|
|
34
|
-
"harness",
|
|
35
|
-
"dsh",
|
|
36
|
-
"dsh-plugin",
|
|
37
|
-
"notification",
|
|
38
|
-
"sound"
|
|
39
|
-
],
|
|
40
|
-
"peerDependencies": {
|
|
41
|
-
"@deepseek-ai/schemastery": "^3.18.1"
|
|
42
|
-
},
|
|
43
|
-
"devDependencies": {
|
|
44
|
-
"@deepseek-ai/schemastery": "^3.18.4"
|
|
45
|
-
},
|
|
46
|
-
"dsh": {
|
|
47
|
-
"bundle": {
|
|
48
|
-
"patch": "./cordis.patch.yml"
|
|
49
|
-
},
|
|
50
|
-
"client": {
|
|
51
|
-
"inject": [
|
|
52
|
-
"@deepseek-ai/dsh-client-locale",
|
|
53
|
-
"@deepseek-ai/dsh-api-session-controller",
|
|
54
|
-
"@deepseek-ai/dsh-client-ui-session",
|
|
55
|
-
"@deepseek-ai/dsh-client-ui-settings",
|
|
56
|
-
"@deepseek-ai/dsh-client-ui-settings-general",
|
|
57
|
-
"@deepseek-ai/dsh-client-ui-primitives"
|
|
58
|
-
],
|
|
59
|
-
"platform": "web"
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-plugin-notify-sounds",
|
|
3
|
+
"version": "1.8.4",
|
|
4
|
+
"description": "Notification sounds for DeepSeek Harness — a soft chime when the agent needs your decision or finishes a task, with a Settings section styled like DSH's own · 需要决策或任务完成时播放提示音,点击系统通知可回到窗口,设置面板可调音量与音效",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"default": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./client": {
|
|
12
|
+
"default": "./lib/client.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json",
|
|
15
|
+
"./cordis.patch.yml": "./cordis.patch.yml"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"lib/index.js",
|
|
19
|
+
"lib/client.js",
|
|
20
|
+
"cordis.patch.yml",
|
|
21
|
+
"README.zh.md"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/BillCx330/dsh-plugin-notify-sounds.git"
|
|
27
|
+
},
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/BillCx330/dsh-plugin-notify-sounds/issues"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/BillCx330/dsh-plugin-notify-sounds#readme",
|
|
32
|
+
"keywords": [
|
|
33
|
+
"deepseek",
|
|
34
|
+
"harness",
|
|
35
|
+
"dsh",
|
|
36
|
+
"dsh-plugin",
|
|
37
|
+
"notification",
|
|
38
|
+
"sound"
|
|
39
|
+
],
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@deepseek-ai/schemastery": "^3.18.4"
|
|
45
|
+
},
|
|
46
|
+
"dsh": {
|
|
47
|
+
"bundle": {
|
|
48
|
+
"patch": "./cordis.patch.yml"
|
|
49
|
+
},
|
|
50
|
+
"client": {
|
|
51
|
+
"inject": [
|
|
52
|
+
"@deepseek-ai/dsh-client-locale",
|
|
53
|
+
"@deepseek-ai/dsh-api-session-controller",
|
|
54
|
+
"@deepseek-ai/dsh-client-ui-session",
|
|
55
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
56
|
+
"@deepseek-ai/dsh-client-ui-settings-general",
|
|
57
|
+
"@deepseek-ai/dsh-client-ui-primitives"
|
|
58
|
+
],
|
|
59
|
+
"platform": "web"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|