dsh-turn-ding 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arron
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,176 @@
1
- # Temporary Holding Version
1
+ # dsh-turn-ding
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ DSH 的「会话跑完了」提醒插件:**Windows 任务栏图标右上角显示未读数字徽标 + 可选提示音**,效果等同于 Codex 的完成提醒。
4
+
5
+ > 结论先行:DSH 目前(0.2.1-alpha.2 / 桌面版 0.2.2-rc.1)**没有**这个功能。主进程不暴露任何 badge/托盘计数 IPC,官方插件目录里也没有相关插件。本插件补上这个缺口。
6
+
7
+ ---
8
+
9
+ ## 一、它做了什么
10
+
11
+ | 能力 | 实现位置 | 说明 |
12
+ | --- | --- | --- |
13
+ | 数字徽标 | `lib/badge-server.ps1` | `ITaskbarList3.SetOverlayIcon` 把一张 32×32 的红底白字圆图贴到 DSH 任务栏按钮右上角 |
14
+ | 提示音 | `lib/badge-server.ps1` | `System.Media.SoundPlayer` 播放指定 `.wav` |
15
+ | 判定「完成」 | `lib/index.js` | 监听 `session/event`,只认 `turn/end` 且 `reason.kind === 'completed'` |
16
+ | 每个会话完成都计数 | `lib/badge-server.ps1` | DSH 是一个窗口装多个会话,"窗口在前台"不等于"你正在看这个会话",所以每次都加 1 |
17
+ | 回到前台自动清零 | `lib/badge-server.ps1` | 500ms 轮询,检测到「后台→前台」的切换就清徽标;一直盯着屏幕时徽标约 10s 后自动淡出 |
18
+ | 自愈 | `lib/badge-server.ps1` | 每 ~6s 重绘一次,Explorer 重启/窗口隐藏后徽标不会丢 |
19
+ | 句柄重绑定 | `lib/badge-server.ps1` | 窗口句柄失效自动重新查找并补画 |
20
+
21
+ `aborted` / `error` / `max-tokens` / `interrupted` / `forked` 这几种"中途结束"**不会**计数;子会话(subagent)默认也忽略。
22
+
23
+ ## 二、安装(给使用者)
24
+
25
+ ### 方式 A:插件页直接装(推荐,最简单)
26
+
27
+ 在 DSH 侧栏打开「插件」→「安装第三方插件 / 添加插件」,输入包名点安装:
28
+
29
+ ```
30
+ dsh-turn-ding
31
+ ```
32
+
33
+ 桌面版需**完全退出** DSH 后再装/更新,装好重新打开。等价于命令行:
34
+
35
+ ```bash
36
+ dsh plugin --profile desktop add dsh-turn-ding
37
+ dsh plugin --profile web add dsh-turn-ding
38
+ ```
39
+
40
+ > 终端里没有 `dsh` 的话,先在桌面版菜单选「管理 dsh 命令」。
41
+
42
+ ### 方式 B:在「发现」里浏览安装(0.2.2 实验功能)
43
+
44
+ DSH `0.2.2` 及以上可在「插件 → 实验性插件」启用**自定义插件索引**(`0.2.1` 系列叫「自定义插件市场」),启用后插件页多出「已安装」「发现」两个标签。在「发现」里「管理索引源 / 添加索引源」填入本仓库的 `index.json` 地址,列表里点安装即可。索引只是省掉手输包名这一步,装的还是同一个 npm 包。
45
+
46
+ ```
47
+ https://raw.githubusercontent.com/<你的GitHub用户名>/dsh-turn-ding/main/index.json
48
+ ```
49
+
50
+ GitHub 直连不通时可用 jsDelivr CDN 镜像(可能比最新发布晚约 12 小时):
51
+
52
+ ```
53
+ https://cdn.jsdelivr.net/gh/<你的GitHub用户名>/dsh-turn-ding@main/index.json
54
+ ```
55
+
56
+ ### 方式 C:离线 / 本地目录
57
+
58
+ ```bash
59
+ dsh plugin --profile desktop add "D:/path/to/dsh-turn-ding"
60
+ # 或把 npm pack 出来的 tgz 当包名:
61
+ dsh plugin --profile desktop add "file:D:/path/to/dsh-turn-ding-0.1.0.tgz"
62
+ ```
63
+
64
+ ### 本地开发时的安装记录(本机 desktop profile)
65
+
66
+ 本机是通过本地 `link:` 装进 desktop profile 的:
67
+
68
+ ```bash
69
+ DSH="D:/Users/saiwa/AppData/Local/Programs/DeepSeek Harness/resources/runtime/cli/bin/dsh.cmd"
70
+ "$DSH" plugin --profile desktop add "D:/projects/arron/DSH-Plugins/dsh-turn-ding"
71
+ ```
72
+
73
+ 注意:`plugin` 子命令必须在**桌面应用完全退出**时运行(它要改 profile 的 `package.json` 并跑 pnpm)。`--dump-config`、boot 这类子命令对 `desktop` profile 是被硬拒的(`profile "desktop" is managed exclusively by the Electron application`),`plugin` 子命令例外放行。
74
+
75
+ 装完的效果写在这两个文件里:
76
+
77
+ - `C:\\Users\\saiwa\\.dsh\\profiles\\desktop\\package.json`
78
+ - `dependencies` 里加 `"dsh-turn-ding": "link:D:\\\\projects\\\\arron\\\\DSH-Plugins\\\\dsh-turn-ding"`
79
+ - `dsh.profile.bundles` 末尾加 `"dsh-turn-ding"`
80
+ - `C:\\Users\\saiwa\\.dsh\\profiles\\desktop\\pnpm-lock.yaml` 里的 `dsh-turn-ding: version: link:...`
81
+
82
+ 回滚:把 `package.json.bak-turn-ding` 覆盖回去即可(同一目录下已备份)。
83
+
84
+ ## 三、配置
85
+
86
+ 在 `C:\\Users\\saiwa\\.dsh\\profiles\\desktop\\cordis.patch.yml` 里给这一行加 `config`:
87
+
88
+ ```yaml
89
+ - id: turn-ding
90
+ name: 'dsh-turn-ding'
91
+ config:
92
+ sound: true
93
+ soundFile: 'C:\\Windows\\Media\\Windows Notify System Generic.wav'
94
+ badge: true
95
+ ignoreSubagents: true
96
+ ```
97
+
98
+ | 字段 | 默认 | 说明 |
99
+ | --- | --- | --- |
100
+ | `sound` | `true` | 是否播放提示音 |
101
+ | `soundFile` | `C:\\Windows\\Media\\Windows Notify System Generic.wav` | 任意本地 `.wav` |
102
+ | `badge` | `true` | 是否画数字徽标 |
103
+ | `ignoreSubagents` | `true` | 是否忽略子会话的 turn 结束 |
104
+
105
+ 常用替代音效:`Windows Notify.wav`、`Windows Ding.wav`、`ding.wav`、`chimes.wav`、`Windows Notify Email.wav`,都在 `C:\\Windows\\Media\\`。
106
+
107
+ ## 四、为什么是这个架构
108
+
109
+ 排查过三条路,只有第三条通:
110
+
111
+ 1. **Electron 主进程通道** —— 不通。`apps/desktop/src/ipc.ts:8-34` 的 `DESKTOP_IPC` 白名单里没有 badge/tray/overlay/notification 任何一项;preload 只暴露 `browser / deviceInfo / keyboard / shortcuts / updates`,且仅 `dsh-app://app` origin 可用。已安装的 `app.asar` 里 `setBadgeCount` / `setOverlayIcon` 出现次数为 **0**。
112
+ 2. **渲染层 `navigator.setAppBadge()`** —— 理论可行(该 Electron 构建确实含 Chromium BadgeService),但无法在本机实机验证,且窗口隐藏到托盘时任务栏按钮消失、徽标也随之失效。
113
+ 3. **host 侧 Win32**(采用)—— host 是独立 Node 子进程(`host-process.ts:186-201`,`ELECTRON_RUN_AS_NODE=1`),拿不到 `BrowserWindow`,但**能按进程找到目标窗口的 HWND**,于是可以调 `ITaskbarList3.SetOverlayIcon`。这条路已用真实截图逐像素验证。
114
+
115
+ `lib/index.js` 只负责「监听事件 + 通过 stdin 行协议驱动辅助进程」;所有 Win32/COM 调用都在 `lib/badge-server.ps1` 里。辅助进程常驻(不是每次完成都起一个),有 `ready` 握手和 pending 队列,启动失败会降级成「没有徽标」而不是报错。
116
+
117
+ ## 五、验证记录
118
+
119
+ | 测试 | 命令 | 结果 |
120
+ | --- | --- | --- |
121
+ | host 半边逻辑 | `node test/host-harness.mjs` | 只注册 `session/event`;aborted/error/subagent 全部忽略;两次 completed 各触发一次;dispose 干净 |
122
+ | 辅助进程协议 | `node test/server-proto.mjs` | `ready` → `ping/pong` → `set`/`notify`/`clear` → `bye`,exit 0 |
123
+ | 端到端 | `node test/e2e-badge.mjs` | 真 ctx + 真 PowerShell + 真任务栏:idle → 1 次完成 → 2 次完成,徽标依次变 `1`、`2`;再灌 aborted/error/subagent 三个事件,**像素零变化** |
124
+ | 自动清零 | `node test/autoclear.mjs` | 把目标指向当前前台进程,`set 4` 后 3.5s 内回 `autoclear` |
125
+ | 前台也计数(回归) | `node test/focus-regression.mjs` | 窗口在前台时连发两次 `notify` → `count=1`、`count=2`,截图确认徽标「2」 |
126
+ | 对照截图 | `node test/proof-badge.mjs` | clear 与 badge 两张任务栏截图 diff bbox = `(1781, 11, 1813, 42)`,正好 32px 宽的徽标区 |
127
+ | 整 profile boot | `node test/verify-desktop-profile.mjs` | 用真实 desktop 配置克隆 boot,web 服务正常起来,唯一失败项是既有的 `experimental-inspector`(与本插件无关);进程列表里抓到插件真正拉起了 `badge-server.ps1` |
128
+ | 插件市场索引 | 用实装 `parseMarketIndex()` 校验 `index.json` | `VALID`,安装目标解析为 `dsh-turn-ding@0.1.0` |
129
+
130
+ 截图见 `test/e2e-2-two.png`(徽标「2」)、`test/proof-badge3.png`(徽标「3」)。
131
+
132
+ ## 六、发布(给维护者)
133
+
134
+ 本插件就是一个普通的 npm 包,`dsh.bundle.patch` 是「这是 DSH 插件」的唯一标记,`package.json` 里**没有** `private: true`。发布流程:
135
+
136
+ ```bash
137
+ # 0. 首次:登录 npm(本机默认源是 npmmirror,publish 需显式指定官方源)
138
+ npm login --registry=https://registry.npmjs.org/
139
+
140
+ # 1. 改版本号、确认打包内容(files 白名单:lib/、cordis.patch.yml、README.md、LICENSE)
141
+ npm version patch # 0.1.0 → 0.1.1
142
+ npm pack --dry-run
143
+
144
+ # 2. 发布
145
+ npm publish --registry=https://registry.npmjs.org/ --access public
146
+
147
+ # 3. 同步刷新 index.json 里对应版本的 version / publishTimestamp(Unix 秒),
148
+ # 让「发现」里出现新版本;改完用官方校验器复验一次。
149
+ ```
150
+
151
+ `publishTimestamp` 用 `date +%s` 取当前 Unix 秒,必须落在 `[0, 10^11)`;`version` 必须是精确 SemVer。改完 `index.json` 后,用 `@deepseek-ai/dsh-plugin-market-index` 的 `parseMarketIndex(doc, indexUrl)` 校验,通过再推到 GitHub。
152
+
153
+ `os: ["win32"]` 会阻止在非 Windows 机器上安装,避免用户装了却没有效果。`engines.dsh` 仅作声明(安装器实际只校验 `peerDependencies`,本包故意不写)。
154
+
155
+ ## 七、已知限制
156
+
157
+ - **Windows only**。macOS/Linux 需要另一套实现(`app.dock.setBadge` 在 host 侧拿不到,得换方案)。`os: ["win32"]` 已经把这一点写进 manifest。
158
+ - **需要重启 DSH** 才会加载(同名包换版本也要重启,host 会报 restart-required)。
159
+ - **关闭到托盘时**任务栏按钮不存在,徽标自然看不见;重新显示窗口后会自愈重绘。
160
+ - 依赖 `powershell.exe`。PowerShell 5.1 即可(本机已验证),不需要 `pwsh` 7。
161
+ - 徽标是 **overlay 图标**,跟 Windows 通知中心里那个数字不是同一个东西——后者需要 Toast,本插件不做。
162
+
163
+ ## 八、文件
164
+
165
+ ```
166
+ dsh-turn-ding/
167
+ ├── package.json # dsh.bundle.patch 是"这是 DSH 插件"的唯一标记
168
+ ├── cordis.patch.yml # - insert: [{ id: turn-ding, name: dsh-turn-ding }]
169
+ ├── index.json # 插件市场「发现」用的格式 1 索引(发布后托管到可访问的 URL)
170
+ ├── LICENSE # MIT
171
+ ├── README.md
172
+ ├── lib/
173
+ │ ├── index.js # host 半边:事件监听 + 辅助进程生命周期
174
+ │ └── badge-server.ps1 # Win32 半边:COM overlay + 提示音 + 轮询维护
175
+ └── test/ # 上面那张表里的验证脚本与截图
176
+ ```
@@ -0,0 +1,4 @@
1
+ # dsh-turn-ding: one row, no surface of its own.
2
+ - insert:
3
+ - id: turn-ding
4
+ name: 'dsh-turn-ding'
package/index.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "format": 1,
3
+ "title": {
4
+ "en": "DSH Completion Reminders",
5
+ "zh": "DSH 完成提醒插件索引"
6
+ },
7
+ "description": {
8
+ "en": "Community plugin index for dsh-turn-ding — taskbar badge and completion chime for DeepSeek Harness.",
9
+ "zh": "dsh-turn-ding 的社区插件索引——为 DeepSeek Harness 提供任务栏数字徽标与完成提示音。"
10
+ },
11
+ "metadata": {
12
+ "labels": {
13
+ "windows-only": {
14
+ "title": { "en": "Windows only", "zh": "仅限 Windows" }
15
+ }
16
+ }
17
+ },
18
+ "plugins": [
19
+ {
20
+ "name": "dsh-turn-ding",
21
+ "title": {
22
+ "en": "Turn Ding — completion badge & chime",
23
+ "zh": "完成提示 · 任务栏徽标与提示音"
24
+ },
25
+ "description": {
26
+ "en": "Shows an unread number badge on the Windows taskbar icon and plays a chime whenever a DeepSeek Harness session finishes.",
27
+ "zh": "每当一个 DeepSeek Harness 会话完成,就在 Windows 任务栏图标右上角显示未读数字徽标,并播放提示音。"
28
+ },
29
+ "author": "Arron",
30
+ "metadata": {
31
+ "keywords": ["badge", "notification", "taskbar", "windows", "completion"],
32
+ "labels": ["windows-only"]
33
+ },
34
+ "versions": [
35
+ {
36
+ "version": "0.1.0",
37
+ "publishTimestamp": 1794631126,
38
+ "source": {
39
+ "type": "npm",
40
+ "registry": "https://registry.npmjs.org/"
41
+ },
42
+ "engines": {
43
+ "dsh": ">=0.2.1-alpha.2 <0.4.0"
44
+ }
45
+ }
46
+ ]
47
+ }
48
+ ]
49
+ }
@@ -0,0 +1,243 @@
1
+ param(
2
+ [Parameter(Mandatory = $true)][int]$TargetPid,
3
+ [string]$Sound = 'C:\Windows\Media\Windows Notify System Generic.wav',
4
+ [int]$ClearOnFocus = 1,
5
+ [int]$SoundEnabled = 1,
6
+ [int]$BadgeEnabled = 1
7
+ )
8
+
9
+ $ErrorActionPreference = 'Stop'
10
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
11
+
12
+ Add-Type -ReferencedAssemblies System.Drawing -TypeDefinition @'
13
+ using System;
14
+ using System.Drawing;
15
+ using System.Drawing.Drawing2D;
16
+ using System.Runtime.InteropServices;
17
+
18
+ namespace DshBadge {
19
+ [ComImport, Guid("56FDF344-FD6D-11d0-958A-006097C9A090")]
20
+ public class TaskbarListCoclass { }
21
+
22
+ [ComImport, Guid("ea1afb91-9e28-4b86-90e9-9e9f8a5eefaf"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
23
+ public interface ITaskbarList3 {
24
+ void HrInit();
25
+ void AddTab(IntPtr hwnd);
26
+ void DeleteTab(IntPtr hwnd);
27
+ void ActivateTab(IntPtr hwnd);
28
+ void SetActiveAlt(IntPtr hwnd);
29
+ void MarkFullscreenWindow(IntPtr hwnd, bool fFullscreen);
30
+ void SetProgressValue(IntPtr hwnd, ulong completed, ulong total);
31
+ void SetProgressState(IntPtr hwnd, int flags);
32
+ void RegisterTab(IntPtr hwndTab, IntPtr hwndMdi);
33
+ void UnregisterTab(IntPtr hwndTab);
34
+ void SetTabOrder(IntPtr hwndTab, IntPtr hwndInsertBefore);
35
+ void SetTabActive(IntPtr hwndTab, IntPtr hwndMdi, uint reserved);
36
+ void ThumbBarAddButtons(IntPtr hwnd, uint cButtons, IntPtr pButtons);
37
+ void ThumbBarUpdateButtons(IntPtr hwnd, uint cButtons, IntPtr pButtons);
38
+ void ThumbBarSetImageList(IntPtr hwnd, IntPtr himl);
39
+ void SetOverlayIcon(IntPtr hwnd, IntPtr hIcon, [MarshalAs(UnmanagedType.LPWStr)] string description);
40
+ void SetThumbnailTooltip(IntPtr hwnd, [MarshalAs(UnmanagedType.LPWStr)] string tip);
41
+ }
42
+
43
+ public static class Api {
44
+ static ITaskbarList3 list;
45
+
46
+ static ITaskbarList3 List() {
47
+ if (list == null) {
48
+ object coclass = new TaskbarListCoclass();
49
+ list = (ITaskbarList3)coclass;
50
+ list.HrInit();
51
+ }
52
+ return list;
53
+ }
54
+
55
+ public static void Apply(long hwndValue, int count, string tip) {
56
+ IntPtr hwnd = new IntPtr(hwndValue);
57
+ if (count <= 0) {
58
+ List().SetOverlayIcon(hwnd, IntPtr.Zero, "");
59
+ return;
60
+ }
61
+ IntPtr hicon = Render(count);
62
+ try { List().SetOverlayIcon(hwnd, hicon, tip == null ? "" : tip); }
63
+ finally { DestroyIcon(hicon); }
64
+ }
65
+
66
+ // 32x32 ARGB: red disc, white rim, bold white numeral -- the shape Windows
67
+ // itself uses for unread counts on a taskbar button.
68
+ public static IntPtr Render(int count) {
69
+ int s = 32;
70
+ Bitmap bmp = new Bitmap(s, s, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
71
+ using (Graphics g = Graphics.FromImage(bmp)) {
72
+ g.SmoothingMode = SmoothingMode.AntiAlias;
73
+ g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAliasGridFit;
74
+ g.Clear(Color.Transparent);
75
+ float pad = s * 0.04f;
76
+ using (SolidBrush fill = new SolidBrush(Color.FromArgb(255, 209, 52, 56)))
77
+ using (Pen edge = new Pen(Color.FromArgb(255, 255, 255, 255), s * 0.06f)) {
78
+ g.FillEllipse(fill, pad, pad, s - 2 * pad, s - 2 * pad);
79
+ g.DrawEllipse(edge, pad, pad, s - 2 * pad, s - 2 * pad);
80
+ }
81
+ string text = count > 99 ? "99+" : count.ToString();
82
+ float em = s * (text.Length > 2 ? 0.40f : text.Length > 1 ? 0.52f : 0.68f);
83
+ using (Font f = new Font("Segoe UI", em, FontStyle.Bold, GraphicsUnit.Pixel)) {
84
+ StringFormat sf = new StringFormat();
85
+ sf.Alignment = StringAlignment.Center;
86
+ sf.LineAlignment = StringAlignment.Center;
87
+ g.DrawString(text, f, Brushes.White, new RectangleF(0, s * 0.02f, s, s), sf);
88
+ }
89
+ }
90
+ return bmp.GetHicon();
91
+ }
92
+
93
+ [DllImport("user32.dll")]
94
+ public static extern bool DestroyIcon(IntPtr hIcon);
95
+ [DllImport("user32.dll")]
96
+ public static extern IntPtr GetForegroundWindow();
97
+ [DllImport("user32.dll")]
98
+ public static extern bool IsWindow(IntPtr hWnd);
99
+ [DllImport("user32.dll")]
100
+ public static extern bool IsIconic(IntPtr hWnd);
101
+ [DllImport("user32.dll")]
102
+ public static extern bool IsWindowVisible(IntPtr hWnd);
103
+ }
104
+ }
105
+ '@
106
+
107
+ function Write-Line([string]$text) {
108
+ [Console]::Out.WriteLine($text)
109
+ [Console]::Out.Flush()
110
+ }
111
+
112
+ # The DSH window is owned by the Electron main process -- our parent process.
113
+ # Fall back to any process carrying a DSH main window if that lookup fails.
114
+ function Find-Window {
115
+ if ($TargetPid -gt 0) {
116
+ $p = Get-Process -Id $TargetPid -ErrorAction SilentlyContinue
117
+ if ($p -and $p.MainWindowHandle -ne [IntPtr]::Zero) { return $p.MainWindowHandle }
118
+ }
119
+ foreach ($n in @('DeepSeek Harness', 'DSH')) {
120
+ $c = Get-Process -Name $n -ErrorAction SilentlyContinue |
121
+ Where-Object { $_.MainWindowHandle -ne [IntPtr]::Zero } |
122
+ Sort-Object StartTime | Select-Object -First 1
123
+ if ($c) { return $c.MainWindowHandle }
124
+ }
125
+ return [IntPtr]::Zero
126
+ }
127
+
128
+ function Play-Ding {
129
+ if ($SoundEnabled -ne 1) { return }
130
+ if (-not (Test-Path -LiteralPath $Sound)) { return }
131
+ try {
132
+ $player = New-Object System.Media.SoundPlayer -ArgumentList $Sound
133
+ $player.Play()
134
+ } catch { }
135
+ }
136
+
137
+ function Show-Badge([int]$n) {
138
+ if ($BadgeEnabled -ne 1 -or $n -le 0) { return }
139
+ if ($target -eq [IntPtr]::Zero) { return }
140
+ $tip = if ($n -eq 1) { '1 个会话已完成' } else { "$n 个会话已完成" }
141
+ try { [DshBadge.Api]::Apply($target.ToInt64(), $n, $tip) } catch { }
142
+ }
143
+
144
+ $target = Find-Window
145
+ $count = 0
146
+ $ticks = 0
147
+ $wasFocused = ($target -ne [IntPtr]::Zero) -and ([DshBadge.Api]::GetForegroundWindow() -eq $target)
148
+ $focusedTicks = 0
149
+ $reader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), (New-Object System.Text.UTF8Encoding($false)))
150
+ Write-Line ("ready hwnd=" + $target.ToInt64() + " pid=" + $TargetPid)
151
+
152
+ $task = $reader.ReadLineAsync()
153
+ while ($true) {
154
+ $got = $task.AsyncWaitHandle.WaitOne(500)
155
+ if ($got) {
156
+ $line = $task.Result
157
+ if ($null -eq $line) { break }
158
+ $task = $reader.ReadLineAsync()
159
+ $line = $line.Trim()
160
+ if ($line.Length -eq 0) { continue }
161
+ $parts = $line.Split(' ', [StringSplitOptions]::RemoveEmptyEntries)
162
+ $cmd = $parts[0].ToLowerInvariant()
163
+ if ($cmd -eq 'quit') { break }
164
+
165
+ if ($target -eq [IntPtr]::Zero -or -not [DshBadge.Api]::IsWindow($target)) { $target = Find-Window }
166
+
167
+ switch ($cmd) {
168
+ 'notify' {
169
+ # A turn finished. Always counted: DSH is one window shared by several
170
+ # chats, so "the window has focus" says nothing about whether the user is
171
+ # watching *this* chat. Clearing happens on regaining focus instead.
172
+ $count += 1
173
+ Show-Badge $count
174
+ Play-Ding
175
+ Write-Line ("notify count=" + $count)
176
+ }
177
+ 'set' {
178
+ $n = 1
179
+ if ($parts.Length -gt 1) { [int]::TryParse($parts[1], [ref]$n) | Out-Null }
180
+ if ($n -le 0) { $n = 1 }
181
+ $count = $n
182
+ Show-Badge $count
183
+ Write-Line ("set " + $count)
184
+ }
185
+ 'clear' {
186
+ $count = 0
187
+ if ($target -ne [IntPtr]::Zero) { try { [DshBadge.Api]::Apply($target.ToInt64(), 0, "") } catch { } }
188
+ Write-Line "clear"
189
+ }
190
+ 'sound' {
191
+ Play-Ding
192
+ Write-Line "sound"
193
+ }
194
+ 'ping' { Write-Line "pong" }
195
+ default { Write-Line ("unknown " + $cmd) }
196
+ }
197
+ continue
198
+ }
199
+
200
+ $ticks += 1
201
+ if ($target -eq [IntPtr]::Zero -or -not [DshBadge.Api]::IsWindow($target)) {
202
+ $old = $target
203
+ $target = Find-Window
204
+ if ($target -ne [IntPtr]::Zero) {
205
+ Write-Line ("rebind hwnd=" + $target.ToInt64())
206
+ Show-Badge $count
207
+ }
208
+ continue
209
+ }
210
+
211
+ if ($count -gt 0) {
212
+ # Clear on a background -> foreground transition (the usual "I came back"
213
+ # case), or let a badge shown while already focused fade after ~10s so it
214
+ # cannot stick to a window the user never left.
215
+ $focusedNow = ($ClearOnFocus -eq 1) -and ([DshBadge.Api]::GetForegroundWindow() -eq $target)
216
+ if ($focusedNow -and -not $wasFocused) {
217
+ $count = 0
218
+ $focusedTicks = 0
219
+ try { [DshBadge.Api]::Apply($target.ToInt64(), 0, "") } catch { }
220
+ Write-Line "autoclear"
221
+ } elseif ($focusedNow -and $focusedTicks -ge 20) {
222
+ $count = 0
223
+ $focusedTicks = 0
224
+ try { [DshBadge.Api]::Apply($target.ToInt64(), 0, "") } catch { }
225
+ Write-Line "autofade"
226
+ } else {
227
+ if ($focusedNow) { $focusedTicks += 1 } else { $focusedTicks = 0 }
228
+ if ($ticks % 12 -eq 0) {
229
+ # Heal after Explorer restarts or a hide/show cycle drops the overlay.
230
+ Show-Badge $count
231
+ }
232
+ }
233
+ $wasFocused = $focusedNow
234
+ } else {
235
+ $wasFocused = ($ClearOnFocus -eq 1) -and ([DshBadge.Api]::GetForegroundWindow() -eq $target)
236
+ $focusedTicks = 0
237
+ }
238
+ }
239
+
240
+ if ($count -gt 0 -and $target -ne [IntPtr]::Zero) {
241
+ try { [DshBadge.Api]::Apply($target.ToInt64(), 0, "") } catch { }
242
+ }
243
+ Write-Line "bye"
package/lib/index.js ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * dsh-turn-ding — host half.
3
+ *
4
+ * Watches `session/event` for completed turns and drives a long-lived PowerShell
5
+ * helper that paints a numeric overlay badge on the DSH taskbar button through
6
+ * `ITaskbarList3.SetOverlayIcon`, optionally playing a sound.
7
+ *
8
+ * The overlay icon is the only channel Windows gives a non-Electron process for
9
+ * a dock badge: the desktop main process exposes no badge IPC to the renderer,
10
+ * and the host runs as a separate Node child process, so Win32 from the host is
11
+ * the right seam.
12
+ */
13
+ import { spawn } from 'node:child_process'
14
+ import { existsSync } from 'node:fs'
15
+ import { fileURLToPath } from 'node:url'
16
+ import { dirname, join } from 'node:path'
17
+
18
+ export const name = 'dsh-turn-ding'
19
+
20
+ const here = dirname(fileURLToPath(import.meta.url))
21
+
22
+ const DEFAULTS = {
23
+ sound: true,
24
+ soundFile: 'C:\\Windows\\Media\\Windows Notify System Generic.wav',
25
+ badge: true,
26
+ ignoreSubagents: true,
27
+ }
28
+
29
+ const POWERSHELL = 'powershell.exe'
30
+
31
+ class BadgeServer {
32
+ constructor(options, logger) {
33
+ this.options = options
34
+ this.logger = logger
35
+ this.child = null
36
+ this.count = 0
37
+ this.pending = []
38
+ this.ready = false
39
+ this.broken = false
40
+ }
41
+
42
+ /** Launch the helper; failures degrade to "no badge" rather than throwing. */
43
+ start() {
44
+ const script = join(here, 'badge-server.ps1')
45
+ if (!existsSync(script)) {
46
+ this.broken = true
47
+ this.logger.warn('dsh-turn-ding: badge-server.ps1 is missing, badge disabled')
48
+ return
49
+ }
50
+
51
+ let child
52
+ try {
53
+ child = spawn(
54
+ POWERSHELL,
55
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script,
56
+ '-TargetPid', String(process.ppid || 0),
57
+ '-Sound', this.options.soundFile,
58
+ '-SoundEnabled', this.options.sound ? '1' : '0',
59
+ '-BadgeEnabled', this.options.badge ? '1' : '0'],
60
+ { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true },
61
+ )
62
+ } catch (error) {
63
+ this.broken = true
64
+ this.logger.warn(`dsh-turn-ding: could not start the badge helper (${String(error)})`)
65
+ return
66
+ }
67
+
68
+ this.child = child
69
+ child.stdout.setEncoding('utf8')
70
+ child.stdout.on('data', (chunk) => {
71
+ for (const raw of String(chunk).split(/\r?\n/)) {
72
+ const line = raw.trim()
73
+ if (!line) continue
74
+ if (line.startsWith('ready')) {
75
+ this.ready = true
76
+ for (const command of this.pending.splice(0)) this.write(command)
77
+ continue
78
+ }
79
+ // The helper owns the authoritative counter: it reports the number it
80
+ // just painted, and tells us when it clears the badge itself.
81
+ const counted = /^notify count=(\d+)$/.exec(line)
82
+ if (counted) {
83
+ this.count = Number(counted[1])
84
+ continue
85
+ }
86
+ if (line === 'autoclear' || line === 'autofade') this.count = 0
87
+ }
88
+ })
89
+ child.stderr.setEncoding('utf8')
90
+ child.stderr.on('data', (chunk) => {
91
+ const text = String(chunk).trim()
92
+ if (text) this.logger.warn(`dsh-turn-ding: badge helper: ${text}`)
93
+ })
94
+ child.on('error', (error) => {
95
+ this.broken = true
96
+ this.logger.warn(`dsh-turn-ding: badge helper failed (${String(error)})`)
97
+ })
98
+ child.on('exit', (code) => {
99
+ this.child = null
100
+ this.ready = false
101
+ if (code !== 0 && code !== null) this.logger.warn(`dsh-turn-ding: badge helper exited with ${code}`)
102
+ })
103
+ }
104
+
105
+ write(command) {
106
+ if (!this.child || this.child.killed) return
107
+ if (!this.ready) {
108
+ this.pending.push(command)
109
+ return
110
+ }
111
+ try {
112
+ this.child.stdin.write(`${command}\n`)
113
+ } catch {
114
+ // Helper died mid-write; the next start() call recovers.
115
+ }
116
+ }
117
+
118
+ /**
119
+ * A turn finished. `notify` lets the helper decide whether the moment is
120
+ * badge-worthy: it only increments while the DSH window is in the background,
121
+ * so a turn that ends while the user is watching never leaves a stale number.
122
+ */
123
+ bump() {
124
+ if (this.broken) return
125
+ if (!this.child) this.start()
126
+ this.write('notify')
127
+ }
128
+
129
+ clear() {
130
+ this.count = 0
131
+ this.write('clear')
132
+ }
133
+
134
+ dispose() {
135
+ this.count = 0
136
+ if (!this.child) return
137
+ try {
138
+ this.child.stdin.write('quit\n')
139
+ this.child.stdin.end()
140
+ } catch { /* already gone */ }
141
+ const child = this.child
142
+ this.child = null
143
+ setTimeout(() => {
144
+ if (!child.killed) {
145
+ try { child.kill() } catch { /* already gone */ }
146
+ }
147
+ }, 1500).unref?.()
148
+ }
149
+ }
150
+
151
+ export function apply(ctx, config) {
152
+ const options = { ...DEFAULTS, ...(config && typeof config === 'object' ? config : {}) }
153
+ const logger = ctx.logger ?? console
154
+ const server = new BadgeServer(options, logger)
155
+
156
+ ctx.effect(() => {
157
+ server.start()
158
+ return () => server.dispose()
159
+ })
160
+
161
+ ctx.on('session/event', (session, event) => {
162
+ if (!event || event.type !== 'turn/end') return
163
+ const reason = event.data?.reason
164
+ if (!reason || reason.kind !== 'completed') return
165
+ if (options.ignoreSubagents && (session?.header?.origin === 'subagent' || (session?.header?.delegationDepth ?? 0) > 0)) return
166
+ server.bump()
167
+ })
168
+ }
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "dsh-turn-ding",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Windows taskbar unread badge and completion chime for DeepSeek Harness turns",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./cordis.patch.yml": "./cordis.patch.yml",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "lib",
14
+ "cordis.patch.yml",
15
+ "index.json",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "keywords": [
20
+ "dsh",
21
+ "dsh-plugin",
22
+ "badge",
23
+ "notification",
24
+ "windows",
25
+ "taskbar"
26
+ ],
27
+ "author": "Arron",
28
+ "license": "MIT",
29
+ "os": [
30
+ "win32"
31
+ ],
32
+ "engines": {
33
+ "node": ">=20",
34
+ "dsh": ">=0.2.1-alpha.2 <0.4.0"
35
+ },
36
+ "dsh": {
37
+ "bundle": {
38
+ "patch": "./cordis.patch.yml"
39
+ },
40
+ "manifestVersion": 1
41
+ }
42
+ }