dsh-notify-windows 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,62 @@
1
+ # Changelog
2
+
3
+ 本项目遵循 [Semantic Versioning](https://semver.org/)。
4
+
5
+ ## [0.7.0] - 2026-08-20
6
+
7
+ ### 新增「点击通知跳转」功能
8
+
9
+ - 任务完成 / 审批 / 提问的 toast 变为**可点击**:点击后打开 DSH Web GUI。优先聚焦已打开的
10
+ DSH 浏览器标签或 Chrome Application 窗口;无已开窗口则在默认浏览器新开标签。
11
+ 目标 URL 携带 `?session=<id>`(rc.8 前端暂不消费,为未来深链预留)。
12
+ - **实现**:
13
+ - `lib/notify.ps1` 使用 `activationType="protocol"` + `launch`(协议 `dshnotify://` 或原始
14
+ URL),使 toast 点击可触发跳转;
15
+ - `lib/launcher.ps1` 处理聚焦 / 跳转:CDP 尽力 → Win32 聚焦 → `Start-Process` 兜底;
16
+ - `lib/index.js` 新增配置 `openOnClick` / `preferExisting` / `webUrl`,并自动发现 web
17
+ 基地址(`ctx.webServer.port`,默认 `http://127.0.0.1:3080`)。
18
+ - **配置表补三行**:`openOnClick`(bool, true)、`preferExisting`(bool, true)、
19
+ `webUrl`(string, '' 自动发现)。
20
+
21
+ ### 已知限制
22
+
23
+ - DSH 0.1.0-rc.8 前端暂无会话深链:点击后打开 GUI 首页 / 会话列表并聚焦已有窗口;真正的
24
+ 「一键直达某会话」需前端侧支持。
25
+
26
+ ## [0.6.0] - 2026-08-20
27
+
28
+ ### 变更(dsh.bundle 安装 + 对齐 dsh 0.1.0-rc.8)
29
+
30
+ - **改为 `dsh.bundle` 安装**:新增 `cordis.patch.yml`(包内自带 `insert:` 条目)并在
31
+ package.json 声明 `dsh.bundle.patch`。安装方式从「手动打 insert + 复制到
32
+ node_modules」改为 `dsh plugin --profile <name> add dsh-notify-windows`,
33
+ pnpm 安装后 `dsh` 自动把包加入 `dsh.profile.bundles` 层栈并挂载入口。
34
+ - **删除 manual-insert 部署**:移除 `scripts/install-profile.ps1`(旧的复制 lib 到
35
+ version-suffixed node_modules 目录并正则改写 profile `cordis.patch.yml` 的做法)。
36
+ - **rc.8 兼容**:
37
+ - 子代理过滤改用 rc.8 的 `session.header.delegationDepth`(旧字段 `depth` 已移除);
38
+ - 导出风格改为 rc.8 推荐的 `export const name` + `export const apply`(旧 default
39
+ export 会让 loader 丢弃 `name` 命名导出,fiber 名显示为 `dshNotify`)。
40
+ - **配置文件覆盖方式**:不再需要手动 `- insert:`;在 profile 的 `cordis.patch.yml`
41
+ 中按 id(`dsh-notify`)覆盖 `config` 即可,与 `dsh-yolo-mode` 等 bundle 插件一致。
42
+
43
+ ### 其它
44
+
45
+ - 冒烟测试 `scripts/smoke-test.mjs` 路径自发现(`DSH_HARNESS_DIR` 可覆盖),
46
+ 适配新的 `apply` 导出与 `delegationDepth` 字段。
47
+ - README 更新:安装 / 更新 / 卸载命令与配置覆盖示例。
48
+
49
+ ## [0.5.0] - 2026-08-13
50
+
51
+ ### 新增
52
+
53
+ - /goal 自动推进回合默认静默提醒,仅目标完成 / 阻塞的最终回合弹通知(可配置)。
54
+ - 任务完成通知自动附带 Agent 最后回复的摘要(可裁剪,`excerptMaxChars`)。
55
+
56
+ ## [0.4.0] - 2026-08-13
57
+
58
+ ### 新增
59
+
60
+ - 增加审批(`approval/asked`)与提问(`ask_user_question` / `run_code` 内联调用)
61
+ 提醒。
62
+ - Windows 桌面 Toast 通知,通过 PowerShell 5.1 WinRT API 发送,零运行时依赖。
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 SeverusZh
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.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SeverusZh
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.en.md CHANGED
@@ -1,104 +1,132 @@
1
- # dsh-notify-windows
2
-
3
- > A DeepSeek Harness (DSH) plugin that sends Windows toast notifications whenever the agent needs your attention.
4
-
5
- <p align="center">
6
- <img src="https://img.shields.io/npm/v/dsh-notify-windows" alt="npm">
7
- <img src="https://img.shields.io/github/license/SeverusZh/dsh-notify-windows" alt="license">
8
- <img src="https://img.shields.io/badge/platform-Windows%2010%2F11-blue" alt="platform">
9
- <img src="https://img.shields.io/github/stars/SeverusZh/dsh-notify-windows?style=social" alt="stars">
10
- </p>
11
-
12
- **Task finished** ✅ | **Approval pending** 🔐 | **Question pending** ❓ — never miss anything that needs you, even away from the screen.
13
-
14
- ## ✨ Features
15
-
16
- - **Task completion** — listens to `turn/end` session events; toasts on completed / errored / max-tokens turns, with the session title and reason;
17
- - **Approval pending** — listens to `approval/asked`; toasts the moment a permission decision is waiting, and skips sessions whose approval policy is `never` (nothing is waiting there);
18
- - **Question pending** — toasts when the agent calls `ask_user_question`; in this deployment every tool call goes through `run_code`, so the plugin also scans `run_code` source for `tools.ask_user_question(` calls and extracts the question text;
19
- - **Quiet by default** — subagent sessions are ignored unless `includeSubagents` is set;
20
- - **Zero dependencies** — toasts are sent through Windows PowerShell 5.1's WinRT toast API, with the AppUserModelId registered under HKCU on first use (no admin required);
21
- - **Diagnosable** — optional log (`%TEMP%\dsh-notify\notify.log`) and a debug event log.
22
-
23
- ## 🚀 Install
24
-
25
- ### Option A: npm (recommended)
26
-
27
- ```powershell
28
- dsh plugin --profile web add dsh-notify-windows
29
- ```
30
-
31
- Then add this entry to the profile's `cordis.patch.yml` (the running host hot-applies it — no restart needed):
32
-
33
- ```yaml
34
- - insert:
35
- - id: dsh-notify
36
- name: 'dsh-notify-windows'
37
- config:
38
- enabled: true
39
- reasons: [completed, error, max-tokens]
40
- includeSubagents: false
41
- notifyOnStart: true
42
- notifyOnApproval: true
43
- notifyOnAskUser: true
44
- appName: 'DeepSeek Harness'
45
- aumid: 'DeepSeekHarness.Notify'
46
- log: true
47
- debug: false
48
- ```
49
-
50
- ### Option B: from source
51
-
52
- ```powershell
53
- git clone git@github.com:SeverusZh/dsh-notify-windows.git
54
- cd dsh-notify-windows
55
- pwsh scripts\install-profile.ps1 -Profile web
56
- ```
57
-
58
- The script deploys the package into the profile's hoisted `node_modules` as `dsh-notify-windows-<version>` — the versioned directory lets a running host hot-load a redeploy without a restart — and writes the patch entry above idempotently.
59
-
60
- ## ⚙️ Configuration
61
-
62
- | Key | Default | Meaning |
63
- | --- | --- | --- |
64
- | `enabled` | `true` | master switch |
65
- | `reasons` | `[completed, error, max-tokens]` | turn-end reasons to notify (`aborted` / `interrupted` / `blocked` also available) |
66
- | `includeSubagents` | `false` | also notify subagent sessions |
67
- | `notifyOnStart` | `false` | toast when the plugin loads |
68
- | `notifyOnApproval` | `true` | toast on `approval/asked` (skipped under policy `never`) |
69
- | `notifyOnAskUser` | `true` | toast when the agent asks you a question |
70
- | `notifyOnGoalRounds` | `false` | stay quiet on /goal auto-continuation rounds (except the final complete/block round) |
71
- | `excerpt` | `true` | append an excerpt of the agent's final reply to the toast |
72
- | `excerptMaxChars` | `80` | max characters of the excerpt |
73
- | `appName` | `DeepSeek Harness` | toast source display name and fallback title |
74
- | `aumid` | `DeepSeekHarness.Notify` | toast AppUserModelId |
75
- | `log` | `true` | write `%TEMP%\dsh-notify\notify.log` |
76
- | `debug` | `false` | log every session event (diagnostics, heavy) |
77
-
78
- ## 🔔 Triggers
79
-
80
- | Scenario | Session event | Sample toast |
81
- | --- | --- | --- |
82
- | Task finished | `turn/end` | Task completed (turn N) |
83
- | Task errored / capped | `turn/end` | Task errored / Output hit token cap |
84
- | Approval waiting | `approval/asked` | DeepSeek Harness · Needs approval / tool pwsh: |
85
- | Question waiting | `tool/call` (incl. run_code detection) | DeepSeek Harness · Needs your answer / Continue? |
86
-
87
- ## 🧪 Verify
88
-
89
- ```powershell
90
- node scripts\smoke-test.mjs
91
- ```
92
-
93
- Runs the plugin on a bare cordis Context with synthetic events: three test toasts fire (completion, approval, question) and the log assertions verify the filtering (subagent, `never`-policy, unrelated calls).
94
-
95
- ## ❓ FAQ
96
-
97
- - **No toast?** Check Windows notification settings for this app and Focus Assist; the AUMID registers itself on first use.
98
- - **Do /goal rounds toast?** Not by default: auto-continuation rounds stay quiet and only the final round that completes (or blocks) the goal toasts. Set `notifyOnGoalRounds` to `true` to hear every round.
99
- - **Approval toasts missing?** Sessions whose approval policy is `never` auto-reject nothing waits, so the plugin stays quiet. Only `ask`-policy sessions notify.
100
- - **Hot-update after code changes?** Re-run the install script (the versioned directory name busts the module cache); for npm installs run `dsh plugin --profile web update dsh-notify-windows` and restart the host.
101
-
102
- ## 📄 License
103
-
1
+ # dsh-notify-windows
2
+
3
+ > A DeepSeek Harness (DSH) plugin that sends Windows toast notifications whenever the agent needs your attention.
4
+
5
+ <p align="center">
6
+ <img src="https://img.shields.io/npm/v/dsh-notify-windows" alt="npm">
7
+ <img src="https://img.shields.io/github/license/SeverusZh/dsh-notify-windows" alt="license">
8
+ <img src="https://img.shields.io/badge/platform-Windows%2010%2F11-blue" alt="platform">
9
+ <img src="https://img.shields.io/github/stars/SeverusZh/dsh-notify-windows?style=social" alt="stars">
10
+ </p>
11
+
12
+ **Task finished** ✅ | **Approval pending** 🔐 | **Question pending** ❓ — never miss anything that needs you, even away from the screen.
13
+
14
+ ## ✨ Features
15
+
16
+ - **Task completion** — listens to `turn/end` session events; toasts on completed / errored / max-tokens turns, with the session title and reason;
17
+ - **Approval pending** — listens to `approval/asked`; toasts the moment a permission decision is waiting, and skips sessions whose approval policy is `never` (nothing is waiting there);
18
+ - **Question pending** — toasts when the agent calls `ask_user_question`; in this deployment every tool call goes through `run_code`, so the plugin also scans `run_code` source for `tools.ask_user_question(` calls and extracts the question text;
19
+ - **Quiet by default** — subagent sessions are ignored unless `includeSubagents` is set;
20
+ - **Zero dependencies** — toasts are sent through Windows PowerShell 5.1's WinRT toast API, with the AppUserModelId registered under HKCU on first use (no admin required);
21
+ - **Diagnosable** — optional log (`%TEMP%\dsh-notify\notify.log`) and a debug event log.
22
+
23
+ ## 🚀 Install
24
+
25
+ The plugin installs through the **`dsh.bundle`** mechanism: the npm package ships its own
26
+ `cordis.patch.yml`, and after `dsh plugin add` it auto-mounts the `dsh-notify` entry — no
27
+ manual `- insert:` needed.
28
+
29
+ ```powershell
30
+ dsh plugin --profile web add dsh-notify-windows
31
+ ```
32
+
33
+ Restart DSH and refresh the browser:
34
+
35
+ ```powershell
36
+ dsh --profile web
37
+ ```
38
+
39
+ > Note: don't hand-add `- insert:` with `dsh-notify` anymore — DSH would fail to boot with
40
+ > `duplicate loader entry id: dsh-notify`. To tweak settings, override the entry by id in the
41
+ > profile's `cordis.patch.yml` (see below).
42
+
43
+ ### Update / Remove
44
+
45
+ ```powershell
46
+ dsh plugin --profile web update dsh-notify-windows
47
+ dsh plugin --profile web remove dsh-notify-windows
48
+ ```
49
+
50
+ Restart the DSH host after updating (a newly added bundle layer needs a boot).
51
+
52
+ ## ⚙️ Configuration
53
+
54
+ All `config` keys are optional and default in the plugin. To override, target the entry by id
55
+ in `$DSH_HOME/profiles/web/cordis.patch.yml` (hot-applied by the running host — no restart):
56
+
57
+ ```yaml
58
+ - id: dsh-notify
59
+ name: dsh-notify-windows
60
+ config:
61
+ enabled: true
62
+ reasons: [completed, error, max-tokens]
63
+ notifyOnStart: true
64
+ notifyOnApproval: true
65
+ log: true
66
+ ```
67
+
68
+ | Key | Default | Meaning |
69
+ | --- | --- | --- |
70
+ | `enabled` | `true` | master switch |
71
+ | `reasons` | `[completed, error, max-tokens]` | turn-end reasons to notify (`aborted` / `interrupted` / `blocked` also available) |
72
+ | `includeSubagents` | `false` | also notify subagent sessions |
73
+ | `notifyOnStart` | `false` | toast when the plugin loads |
74
+ | `notifyOnApproval` | `true` | toast on `approval/asked` (skipped under policy `never`) |
75
+ | `notifyOnAskUser` | `true` | toast when the agent asks you a question |
76
+ | `notifyOnGoalRounds` | `false` | stay quiet on /goal auto-continuation rounds (except the final complete/block round) |
77
+ | `excerpt` | `true` | append an excerpt of the agent's final reply to the toast |
78
+ | `excerptMaxChars` | `80` | max characters of the excerpt |
79
+ | `appName` | `DeepSeek Harness` | toast source display name and fallback title |
80
+ | `aumid` | `DeepSeekHarness.Notify` | toast AppUserModelId |
81
+ | `log` | `true` | write `%TEMP%\dsh-notify\notify.log` |
82
+ | `debug` | `false` | log every session event (diagnostics, heavy) |
83
+ | `openOnClick` | `true` | make completion / approval / question toasts clickable (false falls back to the old non-clickable toast) |
84
+ | `preferExisting` | `true` | when true, launcher prefers focusing an open DSH window; when false, just open the URL in the default browser |
85
+ | `webUrl` | `''` | override the auto-discovered base URL (e.g. `http://192.168.1.5:4000`); leave empty to auto-discover (defaults to `http://127.0.0.1:3080`) |
86
+
87
+ ## 🔔 Triggers
88
+
89
+ | Scenario | Session event | Sample toast |
90
+ | --- | --- | --- |
91
+ | Task finished | `turn/end` | Task completed (turn N) |
92
+ | Task errored / capped | `turn/end` | Task errored / Output hit token cap |
93
+ | Approval waiting | `approval/asked` | DeepSeek Harness · Needs approval / tool pwsh: |
94
+ | Question waiting | `tool/call` (incl. run_code detection) | DeepSeek Harness · Needs your answer / Continue? |
95
+
96
+ ## 🔗 Click to open
97
+
98
+ Toasts for task completion / approval pending / question pending are clickable they open the
99
+ DSH Web GUI. The plugin prefers to focus an already-open DSH browser tab or Chrome Application
100
+ window; if none is open, it opens a new tab in the default browser and loads the GUI. The target
101
+ URL carries a `?session=<id>` parameter (not yet consumed by the rc.8 frontend — reserved for
102
+ future deep links).
103
+
104
+ **Configuration:**
105
+
106
+ - `openOnClick` (default `true`; `false` disables clickability and falls back to the old non-clickable toast);
107
+ - `preferExisting` (default `true`; when true the launcher prefers reusing an existing window; when false it opens the URL directly in the default browser);
108
+ - `webUrl` (optional, overrides the auto-discovered base URL, e.g. `http://192.168.1.5:4000`; leave empty to auto-discover / default `http://127.0.0.1:3080`).
109
+
110
+ **Notes:** the first click auto-registers the `dshnotify://` protocol handler (HKCU, no admin
111
+ needed); on failure it silently degrades to "open in default browser".
112
+
113
+ > Note: DSH 0.1.0-rc.8 does not yet support deep-linking to a single session by URL; clicking opens the DSH interface (home / session list) and focuses the existing window, and you pick the target session from the list. Once the DSH frontend supports deep links, `?session=<id>` will jump straight to it.
114
+
115
+ ## 🧪 Verify
116
+
117
+ ```powershell
118
+ node scripts\smoke-test.mjs
119
+ ```
120
+
121
+ Runs the plugin on a bare cordis Context with synthetic events: three test toasts fire (completion, approval, question) and the log assertions verify the filtering (subagent, `never`-policy, unrelated calls).
122
+
123
+ ## ❓ FAQ
124
+
125
+ - **No toast?** Check Windows notification settings for this app and Focus Assist; the AUMID registers itself on first use.
126
+ - **Do /goal rounds toast?** Not by default: auto-continuation rounds stay quiet and only the final round that completes (or blocks) the goal toasts. Set `notifyOnGoalRounds` to `true` to hear every round.
127
+ - **Approval toasts missing?** Sessions whose approval policy is `never` auto-reject — nothing waits, so the plugin stays quiet. Only `ask`-policy sessions notify.
128
+ - **Hot-update after code changes?** Run `dsh plugin --profile web update dsh-notify-windows`, then restart the DSH host.
129
+
130
+ ## 📄 License
131
+
104
132
  [MIT](./LICENSE) © 2026 SeverusZh
package/README.md CHANGED
@@ -1,104 +1,129 @@
1
- # dsh-notify-windows
2
-
3
- > DeepSeek Harness(DSH)插件:当 Agent 需要你关注时,向 Windows 发送系统桌面通知。
4
-
5
- <p align="center">
6
- <img src="https://img.shields.io/npm/v/dsh-notify-windows" alt="npm">
7
- <img src="https://img.shields.io/github/license/SeverusZh/dsh-notify-windows" alt="license">
8
- <img src="https://img.shields.io/badge/platform-Windows%2010%2F11-blue" alt="platform">
9
- <img src="https://img.shields.io/github/stars/SeverusZh/dsh-notify-windows?style=social" alt="stars">
10
- </p>
11
-
12
- **任务完成** ✅ | **等待审批** 🔐 | **等待回答** ❓ —— 离开屏幕也不错过任何需要你处理的事。
13
-
14
- ## ✨ 功能
15
-
16
- - **任务完成提醒**:监听会话 `turn/end` 事件,任务完成 / 出错 / 输出超限时立即弹窗,标题自动使用会话标题,正文显示原因与轮次;
17
- - **权限审批提醒**:监听 `approval/asked` 事件,有操作等待你的审批时立即提醒;会话审批策略为 `never` 时自动跳过(此时没有东西在等你);
18
- - **提问确认提醒**:Agent 调用 `ask_user_question` 向你提问时提醒;本部署下所有工具都经 `run_code` 调用,插件会扫描 `run_code` 程序源码中的 `tools.ask_user_question(` 调用并提取问题文本;
19
- - **防打扰**:默认忽略子代理(subagent)会话,只提醒主会话;
20
- - **零依赖**:通知通过 Windows PowerShell 5.1 的 WinRT Toast API 发送,自动注册 HKCU 的 AppUserModelId(无需管理员权限);
21
- - **可诊断**:可选日志(`%TEMP%\dsh-notify\notify.log`)与 debug 事件日志。
22
-
23
- ## 🚀 安装
24
-
25
- ### 方式一:npm(推荐)
26
-
27
- ```powershell
28
- dsh plugin --profile web add dsh-notify-windows
29
- ```
30
-
31
- 然后在 profile 的 `cordis.patch.yml` 中追加条目(该文件被运行中的 DSH 热监视,改动立即生效,无需重启):
32
-
33
- ```yaml
34
- - insert:
35
- - id: dsh-notify
36
- name: 'dsh-notify-windows'
37
- config:
38
- enabled: true
39
- reasons: [completed, error, max-tokens]
40
- includeSubagents: false
41
- notifyOnStart: true
42
- notifyOnApproval: true
43
- notifyOnAskUser: true
44
- appName: 'DeepSeek Harness'
45
- aumid: 'DeepSeekHarness.Notify'
46
- log: true
47
- debug: false
48
- ```
49
-
50
- ### 方式二:源码安装
51
-
52
- ```powershell
53
- git clone git@github.com:SeverusZh/dsh-notify-windows.git
54
- cd dsh-notify-windows
55
- pwsh scripts\install-profile.ps1 -Profile web
56
- ```
57
-
58
- 脚本会把插件部署到 profile 的 hoisted `node_modules`(目录名 `dsh-notify-windows-<版本>`,重新部署时更换目录名,让运行中的宿主热加载新代码而无需重启),并幂等地写入上面的 patch 条目。
59
-
60
- ## ⚙️ 配置项
61
-
62
- | 配置 | 默认值 | 说明 |
63
- | --- | --- | --- |
64
- | `enabled` | `true` | 总开关 |
65
- | `reasons` | `[completed, error, max-tokens]` | 需要提醒的回合结束原因(可选 `aborted` / `interrupted` / `blocked`) |
66
- | `includeSubagents` | `false` | 是否也提醒子代理会话 |
67
- | `notifyOnStart` | `false` | 插件加载时发一条「已激活」通知 |
68
- | `notifyOnApproval` | `true` | 审批请求提醒(策略 `never` 时自动跳过) |
69
- | `notifyOnAskUser` | `true` | Agent 提问等待回答时提醒 |
70
- | `notifyOnGoalRounds` | `false` | /goal 自动推进回合不提醒(目标完成/阻塞的最终回合除外) |
71
- | `excerpt` | `true` | 通知正文附带 Agent 最后回复的摘要 |
72
- | `excerptMaxChars` | `80` | 摘要最大字符数 |
73
- | `appName` | `DeepSeek Harness` | 通知来源显示名与兜底标题 |
74
- | `aumid` | `DeepSeekHarness.Notify` | 通知 AppUserModelId |
75
- | `log` | `true` | 写日志到 `%TEMP%\dsh-notify\notify.log` |
76
- | `debug` | `false` | 把所有会话事件写入日志(排查用,量大) |
77
-
78
- ## 🔔 触发场景
79
-
80
- | 场景 | 会话事件 | 通知示例 |
81
- | --- | --- | --- |
82
- | 任务完成 | `turn/end` | 「任务已完成(第 N 轮)」 |
83
- | 任务出错 / 超限 | `turn/end` | 「任务出错」/「输出达到 token 上限」 |
84
- | 等待审批 | `approval/asked` | 「DeepSeek Harness · 需要审批 / 工具 pwsh:…」 |
85
- | 等待回答 | `tool/call`(含 run_code 检测) | 「DeepSeek Harness · 需要回答 / 是否继续?」 |
86
-
87
- ## 🧪 验证
88
-
89
- ```powershell
90
- node scripts\smoke-test.mjs
91
- ```
92
-
93
- 独立进程内用 cordis Context 模拟会话事件:应弹出任务完成 / 审批 / 提问三条测试通知,并断言日志记录与过滤行为(子代理、`never` 策略、无关调用均被过滤)。
94
-
95
- ## ❓ 常见问题
96
-
97
- - **收不到通知?** 检查 Windows「通知与操作」设置是否允许该应用显示通知,以及「专注助手」是否开启;首次发送会自动注册 AUMID。
98
- - **/goal 模式会提醒吗?** 默认不会:自动推进的中间回合保持静默,只有目标完成(或阻塞)的最终回合才提醒;如需每个回合都提醒,把 `notifyOnGoalRounds` 设为 `true`。
99
- - **为什么审批提醒有时不弹?** 会话审批策略为 `never` 时审批会被自动拒绝、不会等待,插件会跳过提醒;策略为 `ask` 时才提醒。
100
- - **更新插件代码后如何热更新?** 重新执行安装脚本(版本目录名变化即热加载);用 npm 安装时执行 `dsh plugin --profile web update dsh-notify-windows` 后重启宿主。
101
-
102
- ## 📄 许可证
103
-
1
+ # dsh-notify-windows
2
+
3
+ > DeepSeek Harness(DSH)插件:当 Agent 需要你关注时,向 Windows 发送系统桌面通知。
4
+
5
+ <p align="center">
6
+ <img src="https://img.shields.io/npm/v/dsh-notify-windows" alt="npm">
7
+ <img src="https://img.shields.io/github/license/SeverusZh/dsh-notify-windows" alt="license">
8
+ <img src="https://img.shields.io/badge/platform-Windows%2010%2F11-blue" alt="platform">
9
+ <img src="https://img.shields.io/github/stars/SeverusZh/dsh-notify-windows?style=social" alt="stars">
10
+ </p>
11
+
12
+ **任务完成** ✅ | **等待审批** 🔐 | **等待回答** ❓ —— 离开屏幕也不错过任何需要你处理的事。
13
+
14
+ ## ✨ 功能
15
+
16
+ - **任务完成提醒**:监听会话 `turn/end` 事件,任务完成 / 出错 / 输出超限时立即弹窗,标题自动使用会话标题,正文显示原因与轮次;
17
+ - **权限审批提醒**:监听 `approval/asked` 事件,有操作等待你的审批时立即提醒;会话审批策略为 `never` 时自动跳过(此时没有东西在等你);
18
+ - **提问确认提醒**:Agent 调用 `ask_user_question` 向你提问时提醒;本部署下所有工具都经 `run_code` 调用,插件会扫描 `run_code` 程序源码中的 `tools.ask_user_question(` 调用并提取问题文本;
19
+ - **防打扰**:默认忽略子代理(subagent)会话,只提醒主会话;
20
+ - **零依赖**:通知通过 Windows PowerShell 5.1 的 WinRT Toast API 发送,自动注册 HKCU 的 AppUserModelId(无需管理员权限);
21
+ - **可诊断**:可选日志(`%TEMP%\dsh-notify\notify.log`)与 debug 事件日志。
22
+
23
+ ## 🚀 安装
24
+
25
+ 项目通过 **`dsh.bundle`** 机制安装:npm 包自带的 `cordis.patch.yml` 会在
26
+ `dsh plugin add` 后自动挂载 `dsh-notify` 入口,**不需要**再手动 `- insert:`。
27
+
28
+ ```powershell
29
+ dsh plugin --profile web add dsh-notify-windows
30
+ ```
31
+
32
+ 重启 DSH 并刷新浏览器后生效:
33
+
34
+ ```powershell
35
+ dsh --profile web
36
+ ```
37
+
38
+ > 注意:不要再用 `- insert:` 手动添加 `dsh-notify`,否则启动会报
39
+ > `duplicate loader entry id: dsh-notify`。想调整配置,在 profile
40
+ > `cordis.patch.yml` 里按 id 覆盖即可(见下节)。
41
+
42
+ ### 更新 / 卸载
43
+
44
+ ```powershell
45
+ dsh plugin --profile web update dsh-notify-windows
46
+ dsh plugin --profile web remove dsh-notify-windows
47
+ ```
48
+
49
+ 更新后需重启 DSH 宿主(新增 bundle 层需要重新启动)。
50
+
51
+ ## ⚙️ 配置项
52
+
53
+ 插件行 `config` 全字段可选,未填按默认值。需要调整时,在
54
+ `$DSH_HOME/profiles/web/cordis.patch.yml` 里按 id 覆盖主条目即可
55
+ (该文件被运行中的 DSH 热监视,改动立即生效,无需重启):
56
+
57
+ ```yaml
58
+ - id: dsh-notify
59
+ name: dsh-notify-windows
60
+ config:
61
+ enabled: true
62
+ reasons: [completed, error, max-tokens]
63
+ notifyOnStart: true
64
+ notifyOnApproval: true
65
+ log: true
66
+ ```
67
+
68
+ | 配置 | 默认值 | 说明 |
69
+ | --- | --- | --- |
70
+ | `enabled` | `true` | 总开关 |
71
+ | `reasons` | `[completed, error, max-tokens]` | 需要提醒的回合结束原因(可选 `aborted` / `interrupted` / `blocked`) |
72
+ | `includeSubagents` | `false` | 是否也提醒子代理会话 |
73
+ | `notifyOnStart` | `false` | 插件加载时发一条「已激活」通知 |
74
+ | `notifyOnApproval` | `true` | 审批请求提醒(策略 `never` 时自动跳过) |
75
+ | `notifyOnAskUser` | `true` | Agent 提问等待回答时提醒 |
76
+ | `notifyOnGoalRounds` | `false` | /goal 自动推进回合不提醒(目标完成/阻塞的最终回合除外) |
77
+ | `excerpt` | `true` | 通知正文附带 Agent 最后回复的摘要 |
78
+ | `excerptMaxChars` | `80` | 摘要最大字符数 |
79
+ | `appName` | `DeepSeek Harness` | 通知来源显示名与兜底标题 |
80
+ | `aumid` | `DeepSeekHarness.Notify` | 通知 AppUserModelId |
81
+ | `log` | `true` | 写日志到 `%TEMP%\dsh-notify\notify.log` |
82
+ | `debug` | `false` | 把所有会话事件写入日志(排查用,量大) |
83
+ | `openOnClick` | `true` | 任务完成 / 审批 / 提问 toast 是否可点击(false 回退为旧的无点击 toast) |
84
+ | `preferExisting` | `true` | true launcher 优先聚焦已开 DSH 窗口;false 直接用默认浏览器打开 URL |
85
+ | `webUrl` | `''` | 覆盖自动发现的基地址(如 `http://192.168.1.5:4000`),留空自动发现(默认 `http://127.0.0.1:3080`) |
86
+
87
+ ## 🔔 触发场景
88
+
89
+ | 场景 | 会话事件 | 通知示例 |
90
+ | --- | --- | --- |
91
+ | 任务完成 | `turn/end` | 「任务已完成(第 N 轮)」 |
92
+ | 任务出错 / 超限 | `turn/end` | 「任务出错」/「输出达到 token 上限」 |
93
+ | 等待审批 | `approval/asked` | 「DeepSeek Harness · 需要审批 / 工具 pwsh:…」 |
94
+ | 等待回答 | `tool/call`(含 run_code 检测) | 「DeepSeek Harness · 需要回答 / 是否继续?」 |
95
+
96
+ ## 🔗 点击通知跳转
97
+
98
+ 任务完成 / 等待审批 / 等待回答的 toast 可点击 → 打开 DSH Web GUI。优先聚焦已打开的
99
+ DSH 浏览器标签或 Chrome Application 窗口;没有已开窗口时在默认浏览器新开标签并打开 GUI。
100
+ 目标 URL `?session=<id>` 参数(rc.8 前端暂不消费,为未来深链预留)。
101
+
102
+ **配置:**
103
+
104
+ - `openOnClick`(默认 `true`,`false` 则关闭可点击,回退为旧的无点击 toast);
105
+ - `preferExisting`(默认 `true`,`true` 走 launcher 优先复用已开窗口;`false` 直接用默认浏览器打开 URL);
106
+ - `webUrl`(可选,覆盖自动发现的基地址,如 `http://192.168.1.5:4000`,留空自动发现 / 默认 `http://127.0.0.1:3080`)。
107
+
108
+ **注意事项:** 首次点击会自动注册 `dshnotify://` 协议处理器(HKCU,无需管理员);故障会静默降级为「默认浏览器打开」。
109
+
110
+ > 说明:DSH 0.1.0-rc.8 前端暂不支持按 URL 直达单个会话;点击后打开的是 DSH 界面(首页/会话列表)并聚焦已有窗口,目标会话需在列表中选择。未来 DSH 前端支持深链后 `?session=<id>` 将直接定位。
111
+
112
+ ## 🧪 验证
113
+
114
+ ```powershell
115
+ node scripts\smoke-test.mjs
116
+ ```
117
+
118
+ 独立进程内用 cordis Context 模拟会话事件:应弹出任务完成 / 审批 / 提问三条测试通知,并断言日志记录与过滤行为(子代理、`never` 策略、无关调用均被过滤)。
119
+
120
+ ## ❓ 常见问题
121
+
122
+ - **收不到通知?** 检查 Windows「通知与操作」设置是否允许该应用显示通知,以及「专注助手」是否开启;首次发送会自动注册 AUMID。
123
+ - **/goal 模式会提醒吗?** 默认不会:自动推进的中间回合保持静默,只有目标完成(或阻塞)的最终回合才提醒;如需每个回合都提醒,把 `notifyOnGoalRounds` 设为 `true`。
124
+ - **为什么审批提醒有时不弹?** 会话审批策略为 `never` 时审批会被自动拒绝、不会等待,插件会跳过提醒;策略为 `ask` 时才提醒。
125
+ - **更新插件代码后如何生效?** 执行 `dsh plugin --profile web update dsh-notify-windows` 后重启 DSH 宿主。
126
+
127
+ ## 📄 许可证
128
+
104
129
  [MIT](./LICENSE) © 2026 SeverusZh
@@ -0,0 +1,14 @@
1
+ # dsh-notify-windows bundle patch.
2
+ #
3
+ # Declares the plugin's single loader entry. The `insert:` rows live in the
4
+ # package's own patch file, so `dsh plugin add dsh-notify-windows` auto-mounts
5
+ # the entry — no manual `- insert:` in the profile's cordis.patch.yml.
6
+ # The `name` (a bare package specifier) resolves to this package's
7
+ # `main`/`exports["."]` (lib/index.js) via the loader's two-anchor resolution
8
+ # (dsh install first, then the profile's node_modules).
9
+ #
10
+ # Install: dsh plugin --profile <name> add dsh-notify-windows
11
+ # Restart `dsh --profile <name>` after installing.
12
+ - insert:
13
+ - id: dsh-notify
14
+ name: dsh-notify-windows
package/lib/index.js CHANGED
@@ -7,6 +7,11 @@
7
7
  // - tool/call — the agent called the ask_user_question tool (directly
8
8
  // or inside a run_code program) and is waiting.
9
9
  //
10
+ // Click-to-open: when enabled (default) and a session is available, the toast
11
+ // becomes clickable and opens the DSH web GUI (preferring an already-open
12
+ // browser/App window) deep-linked to that conversation. Disable with
13
+ // openOnClick:false or omit the session to keep the legacy non-clickable toast.
14
+ //
10
15
  // Goal rounds (/goal auto-continuation) are quiet by default: only the final
11
16
  // round that completes or blocks the goal produces a toast.
12
17
  //
@@ -20,7 +25,7 @@ import { join } from "node:path";
20
25
  import { fileURLToPath } from "node:url";
21
26
 
22
27
  const NOTIFY_SCRIPT = fileURLToPath(new URL("./notify.ps1", import.meta.url));
23
- const VERSION = "0.5.0";
28
+ const VERSION = "0.7.0";
24
29
 
25
30
  const REASON_TEXT = {
26
31
  completed: "任务已完成",
@@ -111,7 +116,7 @@ function turnWindow(session, turnEnd) {
111
116
  return { userSource, goalOps, lastAssistantText };
112
117
  }
113
118
 
114
- export default function dshNotify(ctx, config = {}) {
119
+ export const apply = (ctx, config = {}) => {
115
120
  const cfg = {
116
121
  enabled: true,
117
122
  reasons: ["completed", "error", "max-tokens"],
@@ -126,6 +131,10 @@ export default function dshNotify(ctx, config = {}) {
126
131
  aumid: "DeepSeekHarness.Notify",
127
132
  log: true,
128
133
  debug: false,
134
+ // Click-to-open feature (v0.7.0):
135
+ openOnClick: true, // when false: never make toasts clickable (never pass -Url)
136
+ preferExisting: true, // true -> -LaunchProtocol 1 (launcher, prefer existing window); false -> 0 (plain open)
137
+ webUrl: "", // manual override of the web GUI base; empty = auto-discover
129
138
  ...config,
130
139
  };
131
140
  const reasons = new Set(cfg.reasons);
@@ -141,27 +150,51 @@ export default function dshNotify(ctx, config = {}) {
141
150
  }
142
151
  };
143
152
 
144
- const sendToast = (title, body) => {
145
- const child = spawn(
146
- "powershell.exe",
147
- [
148
- "-NoProfile",
149
- "-NonInteractive",
150
- "-ExecutionPolicy",
151
- "Bypass",
152
- "-File",
153
- NOTIFY_SCRIPT,
154
- "-Title",
155
- title,
156
- "-Body",
157
- body,
158
- "-Aumid",
159
- cfg.aumid,
160
- "-AppName",
161
- cfg.appName,
162
- ],
163
- { stdio: "ignore", windowsHide: true },
164
- );
153
+ // Authoritative base URL discovery (verified against rc.8).
154
+ // Always loopback: ctx.webServer.host may be 0.0.0.0, which is not a valid
155
+ // browser target. Use the actual bound port (correct even when config port=0).
156
+ const webBaseUrl = () => {
157
+ if (cfg.webUrl) return cfg.webUrl.replace(/\/+$/, "");
158
+ try {
159
+ const port = ctx.get?.("webServer")?.port; // actual bound port
160
+ if (Number.isInteger(port) && port > 0) return "http://127.0.0.1:" + port;
161
+ } catch {
162
+ // fall through to documented default
163
+ }
164
+ return "http://127.0.0.1:3080"; // documented default (dsh-web-app patch.yml)
165
+ };
166
+
167
+ // Per-session deep link. rc.8 ignores the ?session= query but it is harmless
168
+ // and future-proof for frontends that want to jump straight to a conversation.
169
+ const conversationUrl = (sessionId) => {
170
+ const base = webBaseUrl();
171
+ if (!sessionId) return base + "/";
172
+ return base + "/?session=" + encodeURIComponent(sessionId);
173
+ };
174
+
175
+ const sendToast = (title, body, opts = {}) => {
176
+ const args = [
177
+ "-NoProfile",
178
+ "-NonInteractive",
179
+ "-ExecutionPolicy",
180
+ "Bypass",
181
+ "-File",
182
+ NOTIFY_SCRIPT,
183
+ "-Title",
184
+ title,
185
+ "-Body",
186
+ body,
187
+ "-Aumid",
188
+ cfg.aumid,
189
+ "-AppName",
190
+ cfg.appName,
191
+ ];
192
+ const sessionId = opts?.sessionId;
193
+ if (cfg.openOnClick && sessionId) {
194
+ const url = conversationUrl(sessionId);
195
+ args.push("-Url", url, "-LaunchProtocol", cfg.preferExisting ? 1 : 0);
196
+ }
197
+ const child = spawn("powershell.exe", args, { stdio: "ignore", windowsHide: true });
165
198
  child.on("error", (error) => {
166
199
  log({ event: "error", message: String(error?.message ?? error) });
167
200
  ctx.logger?.warn?.("dsh-notify: 启动通知进程失败", error);
@@ -185,7 +218,7 @@ export default function dshNotify(ctx, config = {}) {
185
218
  /** True for subagent sessions when subagents are excluded. */
186
219
  const isFilteredSession = (session) => {
187
220
  if (cfg.includeSubagents) return false;
188
- return session?.header?.origin === "subagent" || session?.header?.depth !== undefined;
221
+ return session?.header?.origin === "subagent" || session?.header?.delegationDepth !== undefined;
189
222
  };
190
223
 
191
224
  /** Last approval/policy event of the session, or undefined (defaults to ask). */
@@ -223,7 +256,8 @@ export default function dshNotify(ctx, config = {}) {
223
256
  if (cfg.excerpt && window.lastAssistantText) {
224
257
  body += "\n" + truncate(window.lastAssistantText, cfg.excerptMaxChars);
225
258
  }
226
- sendToast(title, body);
259
+ sendToast(title, body, { sessionId: session?.id });
260
+ const url = cfg.openOnClick && session?.id ? conversationUrl(session?.id) : undefined;
227
261
  log({
228
262
  event: "notify",
229
263
  sessionId: session?.id,
@@ -231,6 +265,7 @@ export default function dshNotify(ctx, config = {}) {
231
265
  turn: event.data?.turn,
232
266
  title,
233
267
  body,
268
+ ...(url ? { url } : {}),
234
269
  });
235
270
  return;
236
271
  }
@@ -245,8 +280,16 @@ export default function dshNotify(ctx, config = {}) {
245
280
  ? "工具 " + data.toolName + ":" + truncate(data.reason, 100)
246
281
  : "工具 " + data.toolName)
247
282
  : "有一项操作等待你的批准";
248
- sendToast(cfg.appName + " · 需要审批", body);
249
- log({ event: "approval", sessionId: session?.id, id: data.id, toolName: data.toolName, reason: data.reason });
283
+ sendToast(cfg.appName + " · 需要审批", body, { sessionId: session?.id });
284
+ const url = cfg.openOnClick && session?.id ? conversationUrl(session?.id) : undefined;
285
+ log({
286
+ event: "approval",
287
+ sessionId: session?.id,
288
+ id: data.id,
289
+ toolName: data.toolName,
290
+ reason: data.reason,
291
+ ...(url ? { url } : {}),
292
+ });
250
293
  return;
251
294
  }
252
295
 
@@ -259,8 +302,14 @@ export default function dshNotify(ctx, config = {}) {
259
302
  question = extractQuestionFromCode(data.arguments);
260
303
  }
261
304
  if (question !== undefined || data.name === "ask_user_question") {
262
- sendToast(cfg.appName + " · 需要回答", question ? truncate(question, 100) : "Agent 正在等待你的确认");
263
- log({ event: "ask-user", sessionId: session?.id, question: question ?? null });
305
+ sendToast(cfg.appName + " · 需要回答", question ? truncate(question, 100) : "Agent 正在等待你的确认", { sessionId: session?.id });
306
+ const url = cfg.openOnClick && session?.id ? conversationUrl(session?.id) : undefined;
307
+ log({
308
+ event: "ask-user",
309
+ sessionId: session?.id,
310
+ question: question ?? null,
311
+ ...(url ? { url } : {}),
312
+ });
264
313
  }
265
314
  return;
266
315
  }
@@ -268,6 +317,7 @@ export default function dshNotify(ctx, config = {}) {
268
317
 
269
318
  log({ event: "start", version: VERSION, enabled: cfg.enabled, reasons: [...reasons], sessionId: null });
270
319
  if (cfg.enabled && cfg.notifyOnStart) {
320
+ // Activation toast has no session: leave non-clickable (legacy behavior).
271
321
  sendToast(cfg.appName, "任务完成提醒已激活(v" + VERSION + ")");
272
322
  }
273
323
  }
@@ -0,0 +1,310 @@
1
+ # dsh-notify launcher (Windows PowerShell 5.1).
2
+ # Registered as the "dshnotify" protocol handler. Invoked by the OS as:
3
+ # powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "launcher.ps1" "%1"
4
+ # It decodes the target URL from dshnotify://open?u=<encoded> and tries, in order:
5
+ # A) navigate an already-open DSH tab via Chrome DevTools Protocol (WebSocket);
6
+ # B) focus an already-open DSH window via Win32 P/Invoke;
7
+ # C) open the target URL in the default browser (Start-Process).
8
+ # Every failure degrades silently. The click must never do nothing.
9
+ param(
10
+ [Parameter(Mandatory = $true)][string]$ProtocolArgs
11
+ )
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Helpers
15
+ # ---------------------------------------------------------------------------
16
+ function Write-Trace {
17
+ param([string]$Message)
18
+ try {
19
+ $dir = Join-Path $env:TEMP "dsh-notify"
20
+ if (-not (Test-Path $dir)) { New-Item -Path $dir -ItemType Directory -Force | Out-Null }
21
+ $line = (Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff") + " " + $Message
22
+ Add-Content -Path (Join-Path $dir "launcher.log") -Value $line -Encoding UTF8
23
+ } catch {
24
+ # diagnostics must never break the flow
25
+ }
26
+ }
27
+
28
+ function Send-WebSocketJson {
29
+ # Synchronously sends a JSON string over a Chrome DevTools Protocol WebSocket
30
+ # and waits briefly for an ack frame. Returns $true on successful send.
31
+ param(
32
+ [string]$WsUrl,
33
+ [string]$Json,
34
+ [int]$TimeoutMs = 2000
35
+ )
36
+ try {
37
+ $ws = [System.Net.WebSockets.ClientWebSocket]::new()
38
+ $ct = [System.Threading.CancellationToken]::None
39
+ $connectTask = $ws.ConnectAsync($WsUrl, $ct)
40
+ $connectTask.Wait($TimeoutMs)
41
+ if ($ws.State -ne [System.Net.WebSockets.WebSocketState]::Open) {
42
+ try { $ws.Dispose() } catch {}
43
+ return $false
44
+ }
45
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes($Json)
46
+ $seg = New-Object System.ArraySegment[byte] (, $bytes)
47
+ $sendTask = $ws.SendAsync($seg, [System.Net.WebSockets.WebMessageType]::Text, $true, $ct)
48
+ $sendTask.Wait($TimeoutMs)
49
+ if (-not $sendTask.IsCompletedSuccessfully) {
50
+ try { $ws.Dispose() } catch {}
51
+ return $false
52
+ }
53
+ # Best-effort ack read so the frame is flushed before we close.
54
+ $buf = New-Object byte[] 4096
55
+ $recvSeg = New-Object System.ArraySegment[byte] (, $buf)
56
+ $recvTask = $ws.ReceiveAsync($recvSeg, $ct)
57
+ if ($recvTask.Wait($TimeoutMs)) {
58
+ $ackBytes = $recvSeg.Array[0..($recvTask.Result.Count - 1)]
59
+ Write-Trace ("[cdp] ack=" + [System.Convert]::ToBase64String($ackBytes))
60
+ }
61
+ try { $ws.CloseAsync([System.Net.WebSockets.WebSocketCloseStatus]::NormalClosure, "ok", $ct).Wait($TimeoutMs) } catch {}
62
+ try { $ws.Dispose() } catch {}
63
+ return $true
64
+ } catch {
65
+ Write-Trace ("[fail step] Send-WebSocketJson: " + $_.Exception.Message)
66
+ try { if ($ws -ne $null) { $ws.Dispose() } } catch {}
67
+ return $false
68
+ }
69
+ }
70
+
71
+ function Invoke-CdpNavigate {
72
+ # Tries CDP on the standard debug ports plus any port discovered from a
73
+ # running chrome.exe command line. Returns $true if it navigated a page.
74
+ param([string]$TargetUrl, [string]$HostPort)
75
+ $ports = @(9222, 9223, 9229)
76
+ try {
77
+ $procs = Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" -ErrorAction SilentlyContinue
78
+ foreach ($p in $procs) {
79
+ if ($p.CommandLine -match "--remote-debugging-port=(\d+)") {
80
+ $ports += [int]$Matches[1]
81
+ }
82
+ }
83
+ } catch {}
84
+ $ports = $ports | Sort-Object -Unique
85
+
86
+ foreach ($port in $ports) {
87
+ try {
88
+ $ver = $null
89
+ try {
90
+ $ver = Invoke-WebRequest -Uri "http://127.0.0.1:$port/json/version" -TimeoutSec 1 -UseBasicParsing
91
+ } catch {}
92
+ if ($ver -eq $null) { continue }
93
+ $list = Invoke-WebRequest -Uri "http://127.0.0.1:$port/json/list" -TimeoutSec 1 -UseBasicParsing
94
+ $targets = $list.Content | ConvertFrom-Json
95
+ $hit = $null
96
+ foreach ($t in $targets) {
97
+ if ($t.type -eq "page" -and $t.url -match [regex]::Escape($HostPort)) {
98
+ $hit = $t
99
+ break
100
+ }
101
+ }
102
+ if ($hit -eq $null) { continue }
103
+ $wsUrl = "ws://127.0.0.1:$port/devtools/page/$($hit.id)"
104
+ $json = '{"id":1,"method":"Page.navigate","params":{"url":"' + $TargetUrl.Replace('\', '\\').Replace('"', '\"') + '"}}'
105
+ Write-Trace ("[cdp] navigate target id=" + $hit.id + " port=" + $port)
106
+ if (Send-WebSocketJson -WsUrl $wsUrl -Json $json) {
107
+ return $true
108
+ }
109
+ } catch {
110
+ Write-Trace ("[fail step] Invoke-CdpNavigate port=$port : " + $_.Exception.Message)
111
+ }
112
+ }
113
+ return $false
114
+ }
115
+
116
+ # Win32 P/Invoke type compiled once for Step B.
117
+ $win32Source = @'
118
+ using System;
119
+ using System.Collections.Generic;
120
+ using System.Runtime.InteropServices;
121
+ using System.Text;
122
+
123
+ public class Win32WindowFinder {
124
+ public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
125
+ [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
126
+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
127
+ [DllImport("user32.dll")] public static extern int GetWindowThreadProcessId(IntPtr hWnd, out int lpdwProcessId);
128
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
129
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetClassName(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
130
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
131
+ [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
132
+ [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);
133
+
134
+ public static List<IntPtr> Find(string className, HashSet<int> pids, string titleCue, string[] matchTitles) {
135
+ var result = new List<IntPtr>();
136
+ EnumWindows(delegate(IntPtr hWnd, IntPtr lParam) {
137
+ try {
138
+ if (!IsWindowVisible(hWnd)) return true;
139
+ var cls = new StringBuilder(256);
140
+ GetClassName(hWnd, cls, cls.Capacity);
141
+ if (cls.ToString() != className) return true;
142
+ int pid;
143
+ GetWindowThreadProcessId(hWnd, out pid);
144
+ if (!pids.Contains(pid)) return true;
145
+ var title = new StringBuilder(512);
146
+ GetWindowText(hWnd, title, title.Capacity);
147
+ var t = title.ToString();
148
+ bool titleOk = false;
149
+ foreach (var mt in matchTitles) { if (t.Contains(mt)) { titleOk = true; break; } }
150
+ if (titleOk || t.Contains(titleCue)) result.Add(hWnd);
151
+ } catch {}
152
+ return true;
153
+ }, IntPtr.Zero);
154
+ return result;
155
+ }
156
+
157
+ public static string GetTitle(IntPtr hWnd) {
158
+ var sb = new StringBuilder(512);
159
+ GetWindowText(hWnd, sb, sb.Capacity);
160
+ return sb.ToString();
161
+ }
162
+ }
163
+ '@
164
+
165
+ function Invoke-Win32Focus {
166
+ # Enumerates top-level Chrome windows and focuses the best DSH candidate.
167
+ # Returns $true if a window was focused (navigation is not possible without
168
+ # CDP, so this is focus-only by design).
169
+ param([string]$HostPort, [string[]]$MatchTitles)
170
+ try {
171
+ if (-not ("Win32WindowFinder" -as [type])) {
172
+ Add-Type -TypeDefinition $win32Source -Language CSharp
173
+ }
174
+ $chromePids = New-Object System.Collections.Generic.HashSet[int]
175
+ $procs = Get-Process chrome -ErrorAction SilentlyContinue
176
+ foreach ($p in $procs) { [void]$chromePids.Add($p.Id) }
177
+
178
+ $wins = [Win32WindowFinder]::Find("Chrome_WidgetWin_1", $chromePids, $HostPort, $MatchTitles)
179
+ if ($wins -eq $null -or $wins.Count -eq 0) {
180
+ Write-Trace ("[win32] no candidate window for " + $HostPort)
181
+ return $false
182
+ }
183
+ # Prefer a window whose title contains the host:port cue.
184
+ $best = $null
185
+ foreach ($w in $wins) {
186
+ $title = [Win32WindowFinder]::GetTitle($w)
187
+ if ($title.Contains($HostPort)) { $best = $w; break }
188
+ }
189
+ if ($best -eq $null) { $best = $wins[0] }
190
+
191
+ if ([Win32WindowFinder]::IsIconic($best)) {
192
+ [void][Win32WindowFinder]::ShowWindow($best, 9) # SW_RESTORE
193
+ }
194
+ $ok = [Win32WindowFinder]::SetForegroundWindow($best)
195
+ if (-not $ok) {
196
+ [void][Win32WindowFinder]::ShowWindow($best, 5) # SW_SHOW
197
+ $ok = [Win32WindowFinder]::SetForegroundWindow($best)
198
+ }
199
+ Write-Trace ("[win32] focused hwnd=" + $best.ToString() + " ok=" + $ok)
200
+ return $true
201
+ } catch {
202
+ Write-Trace ("[fail step] Invoke-Win32Focus: " + $_.Exception.Message)
203
+ return $false
204
+ }
205
+ }
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # Main
209
+ # ---------------------------------------------------------------------------
210
+ function Main {
211
+ Write-Trace ("[start] args='" + $ProtocolArgs + "'")
212
+
213
+ # Space-separated args arrive as one token, but rejoin defensively.
214
+ $raw = $ProtocolArgs
215
+ if ($args -and $args.Count -gt 0) {
216
+ $raw = (@($ProtocolArgs) + $args) -join " "
217
+ }
218
+
219
+ # Strip the scheme prefix and parse the query.
220
+ $stripped = $raw
221
+ if ($stripped -match "^dshnotify://") {
222
+ $stripped = $stripped.Substring("dshnotify://".Length)
223
+ } elseif ($stripped -match "^dshnotify:") {
224
+ $stripped = $stripped.Substring("dshnotify:".Length)
225
+ }
226
+ $targetUrl = ""
227
+ if ($stripped -match "\?u=") {
228
+ $encoded = $stripped -split "\?u=" | Select-Object -Last 1
229
+ try { $targetUrl = [uri]::UnescapeDataString($encoded) } catch { $targetUrl = $encoded }
230
+ }
231
+ if ([string]::IsNullOrWhiteSpace($targetUrl)) {
232
+ Write-Trace ("[fail] no targetUrl parsed from '" + $raw + "'")
233
+ return $false
234
+ }
235
+
236
+ # Origin info from the decoded URL (best-effort).
237
+ $HostPort = ""
238
+ try {
239
+ $parsed = [Uri]$targetUrl
240
+ $HostPort = "$($parsed.Host):$($parsed.Port)"
241
+ } catch {
242
+ Write-Trace ("[warn] could not parse targetUrl as Uri: " + $targetUrl)
243
+ }
244
+ $MatchTitles = @("DeepSeek Harness")
245
+
246
+ $handled = $false
247
+
248
+ # Step A: CDP navigate (best-effort, silent on any failure).
249
+ try {
250
+ if ($HostPort -ne "") {
251
+ if (Invoke-CdpNavigate -TargetUrl $targetUrl -HostPort $HostPort) {
252
+ Write-Trace ("[ok] Step A CDP navigate succeeded for " + $HostPort)
253
+ $handled = $true
254
+ } else {
255
+ Write-Trace ("[step] Step A skipped/failed")
256
+ }
257
+ }
258
+ } catch {
259
+ Write-Trace ("[fail step] Step A: " + $_.Exception.Message)
260
+ }
261
+
262
+ # Step B: Win32 focus (best-effort, focus-only without CDP navigation).
263
+ if (-not $handled) {
264
+ try {
265
+ if (Invoke-Win32Focus -HostPort $HostPort -MatchTitles $MatchTitles) {
266
+ Write-Trace ("[ok] Step B focused existing DSH window (focus-only)")
267
+ $handled = $true
268
+ } else {
269
+ Write-Trace ("[step] Step B no candidate")
270
+ }
271
+ } catch {
272
+ Write-Trace ("[fail step] Step B: " + $_.Exception.Message)
273
+ }
274
+ }
275
+
276
+ # Step C: fallback open in default browser. Always runs if A and B failed.
277
+ if (-not $handled) {
278
+ try {
279
+ Write-Trace ("[fallback] Start-Process " + $targetUrl)
280
+ Start-Process $targetUrl
281
+ $handled = $true
282
+ } catch {
283
+ Write-Trace ("[fail step] Step C: " + $_.Exception.Message)
284
+ }
285
+ }
286
+
287
+ return $handled
288
+ }
289
+
290
+ $success = $false
291
+ try {
292
+ $success = Main
293
+ } catch {
294
+ Write-Trace ("[fatal] " + $_.Exception.Message)
295
+ # Last resort: try to open the URL directly.
296
+ try {
297
+ $m = $ProtocolArgs -match "u=([^&\s]+)"
298
+ if ($m) {
299
+ $u = [uri]::UnescapeDataString($Matches[1])
300
+ Start-Process $u
301
+ $success = $true
302
+ }
303
+ } catch {}
304
+ }
305
+
306
+ if ($success) {
307
+ exit 0
308
+ } else {
309
+ exit 1
310
+ }
package/lib/notify.ps1 CHANGED
@@ -5,7 +5,9 @@ param(
5
5
  [Parameter(Mandatory = $true)][string]$Title,
6
6
  [Parameter(Mandatory = $true)][string]$Body,
7
7
  [string]$Aumid = "DeepSeekHarness.Notify",
8
- [string]$AppName = "DeepSeek Harness"
8
+ [string]$AppName = "DeepSeek Harness",
9
+ [string]$Url = "",
10
+ [int]$LaunchProtocol = 1 # 1 = route via dshnotify:// launcher (prefer existing); 0 = open targetUrl directly
9
11
  )
10
12
  $ErrorActionPreference = "Stop"
11
13
 
@@ -24,11 +26,73 @@ try {
24
26
  # Project the WinRT toast API (supported natively by .NET Framework).
25
27
  [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
26
28
 
29
+ # Optional clickable activation: when $Url is set we make the toast open a URL
30
+ # on click. Best effort - any failure here degrades gracefully (either to a
31
+ # direct protocol activation or to a plain non-clickable toast) and never
32
+ # aborts the Show below.
33
+ $clickable = $false
34
+ $launchValue = ""
35
+ if ($Url -ne "") {
36
+ try {
37
+ if ($LaunchProtocol -eq 1) {
38
+ # Register the dshnotify:// protocol handler idempotently (best effort).
39
+ try {
40
+ $progId = "HKCU:\Software\Classes\dshnotify"
41
+ if (-not (Test-Path $progId)) {
42
+ New-Item -Path $progId -Force | Out-Null
43
+ }
44
+ # Default (unnamed) value names the protocol.
45
+ Set-Item -Path $progId -Value 'dshnotify' -Force
46
+ # "URL Protocol" marker value (presence marks the key as a protocol handler).
47
+ Set-ItemProperty -Path $progId -Name 'URL Protocol' -Value '' -Force
48
+ $cmdKey = "HKCU:\Software\Classes\dshnotify\shell\open\command"
49
+ if (-not (Test-Path $cmdKey)) {
50
+ New-Item -Path $cmdKey -Force | Out-Null
51
+ }
52
+ $launcherPath = Join-Path $PSScriptRoot "launcher.ps1"
53
+ $command = ('"powershell.exe" -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + $launcherPath + '" "%1"')
54
+ Set-Item -Path $cmdKey -Value $command -Force
55
+ } catch {
56
+ Write-Warning ("dshnotify protocol registration failed, falling back to direct URL: " + $_.Exception.Message)
57
+ # Fall back to direct-URL activation for this toast.
58
+ $LaunchProtocol = 0
59
+ }
60
+ }
61
+
62
+ if ($LaunchProtocol -eq 1) {
63
+ $launchValue = ("dshnotify://open?u=" + [uri]::EscapeDataString($Url))
64
+ } else {
65
+ $launchValue = $Url
66
+ }
67
+ $clickable = $true
68
+ } catch {
69
+ Write-Warning ("Toast activation setup failed, showing plain toast: " + $_.Exception.Message)
70
+ $clickable = $false
71
+ }
72
+ }
73
+
27
74
  $xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
28
75
  $texts = $xml.GetElementsByTagName("text")
29
76
  $texts.Item(0).AppendChild($xml.CreateTextNode($Title)) | Out-Null
30
77
  $texts.Item(1).AppendChild($xml.CreateTextNode($Body)) | Out-Null
31
78
 
79
+ if ($clickable) {
80
+ try {
81
+ $root = $xml.DocumentElement
82
+ if ($root.Name -eq "toast") {
83
+ $root.SetAttribute("activationType", "protocol") | Out-Null
84
+ $root.SetAttribute("duration", "long") | Out-Null
85
+ $root.SetAttribute("launch", $launchValue) | Out-Null
86
+ # Optional explicit audio cue.
87
+ $audio = $xml.CreateElement("audio")
88
+ $audio.SetAttribute("src", "ms-winsoundevent:Notification.Default") | Out-Null
89
+ $root.AppendChild($audio) | Out-Null
90
+ }
91
+ } catch {
92
+ Write-Warning ("Failed to attach activation attributes: " + $_.Exception.Message)
93
+ }
94
+ }
95
+
32
96
  $toast = New-Object Windows.UI.Notifications.ToastNotification($xml)
33
97
  $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($Aumid)
34
98
  $notifier.Show($toast)
package/package.json CHANGED
@@ -1,23 +1,37 @@
1
1
  {
2
2
  "name": "dsh-notify-windows",
3
- "version": "0.5.0",
4
- "description": "DeepSeek Harness 插件:任务完成时发送 Windows 系统通知",
3
+ "version": "0.7.0",
4
+ "description": "DeepSeek Harness (DSH) 插件:任务完成 / 等待审批 / 等待回答时发送 Windows 系统桌面通知",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/SeverusZh/dsh-notify-windows.git"
8
8
  },
9
+ "homepage": "https://github.com/SeverusZh/dsh-notify-windows",
10
+ "bugs": {
11
+ "url": "https://github.com/SeverusZh/dsh-notify-windows/issues"
12
+ },
9
13
  "type": "module",
10
14
  "main": "lib/index.js",
11
15
  "exports": {
12
16
  ".": "./lib/index.js"
13
17
  },
14
18
  "files": [
15
- "lib"
19
+ "lib",
20
+ "cordis.patch.yml",
21
+ "CHANGELOG.md"
16
22
  ],
17
23
  "license": "MIT",
18
24
  "engines": {
19
25
  "node": ">=20"
20
26
  },
27
+ "peerDependencies": {
28
+ "@deepseek-ai/cordis": "^4.0.1"
29
+ },
30
+ "dsh": {
31
+ "bundle": {
32
+ "patch": "./cordis.patch.yml"
33
+ }
34
+ },
21
35
  "keywords": [
22
36
  "deepseek-harness",
23
37
  "dsh",
@@ -26,4 +40,4 @@
26
40
  "toast",
27
41
  "windows"
28
42
  ]
29
- }
43
+ }