dsh-notify-windows 0.6.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 +21 -0
- package/README.en.md +22 -0
- package/README.md +19 -0
- package/lib/index.js +323 -273
- package/lib/launcher.ps1 +310 -0
- package/lib/notify.ps1 +99 -35
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
本项目遵循 [Semantic Versioning](https://semver.org/)。
|
|
4
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
|
+
|
|
5
26
|
## [0.6.0] - 2026-08-20
|
|
6
27
|
|
|
7
28
|
### 变更(dsh.bundle 安装 + 对齐 dsh 0.1.0-rc.8)
|
package/README.en.md
CHANGED
|
@@ -80,6 +80,9 @@ in `$DSH_HOME/profiles/web/cordis.patch.yml` (hot-applied by the running host
|
|
|
80
80
|
| `aumid` | `DeepSeekHarness.Notify` | toast AppUserModelId |
|
|
81
81
|
| `log` | `true` | write `%TEMP%\dsh-notify\notify.log` |
|
|
82
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`) |
|
|
83
86
|
|
|
84
87
|
## 🔔 Triggers
|
|
85
88
|
|
|
@@ -90,6 +93,25 @@ in `$DSH_HOME/profiles/web/cordis.patch.yml` (hot-applied by the running host
|
|
|
90
93
|
| Approval waiting | `approval/asked` | DeepSeek Harness · Needs approval / tool pwsh: … |
|
|
91
94
|
| Question waiting | `tool/call` (incl. run_code detection) | DeepSeek Harness · Needs your answer / Continue? |
|
|
92
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
|
+
|
|
93
115
|
## 🧪 Verify
|
|
94
116
|
|
|
95
117
|
```powershell
|
package/README.md
CHANGED
|
@@ -80,6 +80,9 @@ dsh plugin --profile web remove dsh-notify-windows
|
|
|
80
80
|
| `aumid` | `DeepSeekHarness.Notify` | 通知 AppUserModelId |
|
|
81
81
|
| `log` | `true` | 写日志到 `%TEMP%\dsh-notify\notify.log` |
|
|
82
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`) |
|
|
83
86
|
|
|
84
87
|
## 🔔 触发场景
|
|
85
88
|
|
|
@@ -90,6 +93,22 @@ dsh plugin --profile web remove dsh-notify-windows
|
|
|
90
93
|
| 等待审批 | `approval/asked` | 「DeepSeek Harness · 需要审批 / 工具 pwsh:…」 |
|
|
91
94
|
| 等待回答 | `tool/call`(含 run_code 检测) | 「DeepSeek Harness · 需要回答 / 是否继续?」 |
|
|
92
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
|
+
|
|
93
112
|
## 🧪 验证
|
|
94
113
|
|
|
95
114
|
```powershell
|
package/lib/index.js
CHANGED
|
@@ -1,273 +1,323 @@
|
|
|
1
|
-
// dsh-notify-windows — DeepSeek Harness plugin: send a Windows toast notification
|
|
2
|
-
// whenever the agent needs the user's attention:
|
|
3
|
-
// - turn/end — a task completed, failed, or was cut off (with an
|
|
4
|
-
// optional excerpt of the agent's final reply);
|
|
5
|
-
// - approval/asked — a permission approval is waiting (only when the
|
|
6
|
-
// session's effective approval policy is "ask");
|
|
7
|
-
// - tool/call — the agent called the ask_user_question tool (directly
|
|
8
|
-
// or inside a run_code program) and is waiting.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return undefined;
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
let
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (event
|
|
106
|
-
if (event.type === "
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
"
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
1
|
+
// dsh-notify-windows — DeepSeek Harness plugin: send a Windows toast notification
|
|
2
|
+
// whenever the agent needs the user's attention:
|
|
3
|
+
// - turn/end — a task completed, failed, or was cut off (with an
|
|
4
|
+
// optional excerpt of the agent's final reply);
|
|
5
|
+
// - approval/asked — a permission approval is waiting (only when the
|
|
6
|
+
// session's effective approval policy is "ask");
|
|
7
|
+
// - tool/call — the agent called the ask_user_question tool (directly
|
|
8
|
+
// or inside a run_code program) and is waiting.
|
|
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
|
+
//
|
|
15
|
+
// Goal rounds (/goal auto-continuation) are quiet by default: only the final
|
|
16
|
+
// round that completes or blocks the goal produces a toast.
|
|
17
|
+
//
|
|
18
|
+
// Zero runtime dependencies: notifications go through the toast sender
|
|
19
|
+
// script (./notify.ps1) executed by Windows PowerShell 5.1, whose .NET
|
|
20
|
+
// Framework runtime supports the WinRT toast API.
|
|
21
|
+
import { spawn } from "node:child_process";
|
|
22
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
23
|
+
import { tmpdir } from "node:os";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
const NOTIFY_SCRIPT = fileURLToPath(new URL("./notify.ps1", import.meta.url));
|
|
28
|
+
const VERSION = "0.7.0";
|
|
29
|
+
|
|
30
|
+
const REASON_TEXT = {
|
|
31
|
+
completed: "任务已完成",
|
|
32
|
+
error: "任务出错",
|
|
33
|
+
"max-tokens": "输出达到 token 上限",
|
|
34
|
+
aborted: "任务已取消",
|
|
35
|
+
interrupted: "任务中断",
|
|
36
|
+
blocked: "任务未开始",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const name = "dsh-notify-windows";
|
|
40
|
+
|
|
41
|
+
function truncate(text, max) {
|
|
42
|
+
if (typeof text !== "string") return "";
|
|
43
|
+
return text.length > max ? text.slice(0, max) + "…" : text;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** First question text of an ask_user_question tool call, if parseable. */
|
|
47
|
+
function extractAskQuestion(argumentsJson) {
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(argumentsJson);
|
|
50
|
+
const first = Array.isArray(parsed?.questions) ? parsed.questions[0] : undefined;
|
|
51
|
+
return typeof first?.question === "string" ? first.question : undefined;
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* This deployment invokes every tool through run_code programs, so a real
|
|
59
|
+
* question never appears as a model-level ask_user_question tool call.
|
|
60
|
+
* Detect the nested call inside a run_code program's code and pull out the
|
|
61
|
+
* first question text.
|
|
62
|
+
*/
|
|
63
|
+
function extractQuestionFromCode(argumentsJson) {
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(argumentsJson);
|
|
66
|
+
const code = typeof parsed?.code === "string" ? parsed.code : "";
|
|
67
|
+
const callIndex = code.search(/tools\.ask_user_question\s*\(/);
|
|
68
|
+
if (callIndex < 0) return undefined;
|
|
69
|
+
const region = code.slice(callIndex, callIndex + 8000);
|
|
70
|
+
const matches = [...region.matchAll(/question\s*:\s*["']([^"']{2,200})["']/g)];
|
|
71
|
+
return matches.length > 0 ? matches[0][1] : undefined;
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** First non-empty text block of a message, trimmed. */
|
|
78
|
+
function textOfMessage(message) {
|
|
79
|
+
const blocks = message?.content;
|
|
80
|
+
if (!Array.isArray(blocks)) return undefined;
|
|
81
|
+
for (const block of blocks) {
|
|
82
|
+
if (block?.type === "text" && typeof block.text === "string" && block.text.trim().length > 0) return block.text.trim();
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Scan the session log from the turn/end event back to its turn/start and
|
|
89
|
+
* collect what the notification needs: the driving user message's source,
|
|
90
|
+
* every goal/change operation in the window, and the last assistant reply
|
|
91
|
+
* text (for the excerpt).
|
|
92
|
+
*/
|
|
93
|
+
function turnWindow(session, turnEnd) {
|
|
94
|
+
const events = session?.events ?? [];
|
|
95
|
+
const turn = turnEnd?.data?.turn;
|
|
96
|
+
let endIndex = events.length - 1;
|
|
97
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
98
|
+
if (events[i]?.seq === turnEnd?.seq) { endIndex = i; break; }
|
|
99
|
+
}
|
|
100
|
+
let userSource;
|
|
101
|
+
const goalOps = [];
|
|
102
|
+
let lastAssistantText;
|
|
103
|
+
for (let i = endIndex; i >= 0; i -= 1) {
|
|
104
|
+
const event = events[i];
|
|
105
|
+
if (!event) continue;
|
|
106
|
+
if (event.type === "turn/start" && event.data?.turn === turn) break;
|
|
107
|
+
// Every user/message in the window overwrites: walking backwards, the
|
|
108
|
+
// final value is the FIRST message of the turn — the driving input.
|
|
109
|
+
if (event.type === "user/message" && event.data?.source) userSource = event.data.source;
|
|
110
|
+
if (event.type === "goal/change" && event.data?.operation) goalOps.push(event.data.operation);
|
|
111
|
+
if (event.type === "assistant/message" && event.data?.turn === turn && lastAssistantText === undefined) {
|
|
112
|
+
const text = textOfMessage(event.data.message);
|
|
113
|
+
if (text) lastAssistantText = text;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { userSource, goalOps, lastAssistantText };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const apply = (ctx, config = {}) => {
|
|
120
|
+
const cfg = {
|
|
121
|
+
enabled: true,
|
|
122
|
+
reasons: ["completed", "error", "max-tokens"],
|
|
123
|
+
includeSubagents: false,
|
|
124
|
+
notifyOnStart: false,
|
|
125
|
+
notifyOnApproval: true,
|
|
126
|
+
notifyOnAskUser: true,
|
|
127
|
+
notifyOnGoalRounds: false,
|
|
128
|
+
excerpt: true,
|
|
129
|
+
excerptMaxChars: 80,
|
|
130
|
+
appName: "DeepSeek Harness",
|
|
131
|
+
aumid: "DeepSeekHarness.Notify",
|
|
132
|
+
log: true,
|
|
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
|
|
138
|
+
...config,
|
|
139
|
+
};
|
|
140
|
+
const reasons = new Set(cfg.reasons);
|
|
141
|
+
const logPath = join(tmpdir(), "dsh-notify", "notify.log");
|
|
142
|
+
|
|
143
|
+
const log = (entry) => {
|
|
144
|
+
if (!cfg.log) return;
|
|
145
|
+
try {
|
|
146
|
+
mkdirSync(join(tmpdir(), "dsh-notify"), { recursive: true });
|
|
147
|
+
appendFileSync(logPath, JSON.stringify({ time: Date.now(), ...entry }) + "\n");
|
|
148
|
+
} catch {
|
|
149
|
+
// diagnostics must never break the agent loop
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
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 });
|
|
198
|
+
child.on("error", (error) => {
|
|
199
|
+
log({ event: "error", message: String(error?.message ?? error) });
|
|
200
|
+
ctx.logger?.warn?.("dsh-notify: 启动通知进程失败", error);
|
|
201
|
+
});
|
|
202
|
+
child.on("exit", (code) => {
|
|
203
|
+
if (code !== 0) {
|
|
204
|
+
log({ event: "error", code });
|
|
205
|
+
ctx.logger?.warn?.("dsh-notify: 通知进程退出码 " + code);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const titleFor = (session) => {
|
|
211
|
+
try {
|
|
212
|
+
return ctx.sessionTitle?.get?.(session)?.title;
|
|
213
|
+
} catch {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/** True for subagent sessions when subagents are excluded. */
|
|
219
|
+
const isFilteredSession = (session) => {
|
|
220
|
+
if (cfg.includeSubagents) return false;
|
|
221
|
+
return session?.header?.origin === "subagent" || session?.header?.delegationDepth !== undefined;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/** Last approval/policy event of the session, or undefined (defaults to ask). */
|
|
225
|
+
const effectivePolicy = (session) => {
|
|
226
|
+
const events = session?.events ?? [];
|
|
227
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
228
|
+
const event = events[index];
|
|
229
|
+
if (event?.type === "approval/policy") return event.data?.policy;
|
|
230
|
+
}
|
|
231
|
+
return undefined;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
ctx.on("session/event", (session, event) => {
|
|
235
|
+
if (!cfg.enabled) return;
|
|
236
|
+
if (cfg.debug) {
|
|
237
|
+
log({ event: "debug", type: event.type, name: event.data?.name ?? null, sessionId: session?.id });
|
|
238
|
+
}
|
|
239
|
+
if (isFilteredSession(session)) return;
|
|
240
|
+
|
|
241
|
+
if (event.type === "turn/end") {
|
|
242
|
+
const reason = event.data?.reason;
|
|
243
|
+
if (typeof reason?.kind !== "string" || !reasons.has(reason.kind)) return;
|
|
244
|
+
const window = turnWindow(session, event);
|
|
245
|
+
|
|
246
|
+
// /goal 自动推进回合默认静默:仅在目标完成/阻塞的最终回合提醒。
|
|
247
|
+
if (!cfg.notifyOnGoalRounds) {
|
|
248
|
+
const isGoalRound = window.userSource?.kind === "goal" && window.userSource.round > 0;
|
|
249
|
+
const terminal = window.goalOps.some((op) => op === "complete" || op === "block");
|
|
250
|
+
if (isGoalRound && !terminal) return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const title = titleFor(session) || cfg.appName;
|
|
254
|
+
let body = REASON_TEXT[reason.kind] ?? "任务已结束";
|
|
255
|
+
if (Number.isInteger(event.data.turn)) body += "(第 " + event.data.turn + " 轮)";
|
|
256
|
+
if (cfg.excerpt && window.lastAssistantText) {
|
|
257
|
+
body += "\n" + truncate(window.lastAssistantText, cfg.excerptMaxChars);
|
|
258
|
+
}
|
|
259
|
+
sendToast(title, body, { sessionId: session?.id });
|
|
260
|
+
const url = cfg.openOnClick && session?.id ? conversationUrl(session?.id) : undefined;
|
|
261
|
+
log({
|
|
262
|
+
event: "notify",
|
|
263
|
+
sessionId: session?.id,
|
|
264
|
+
reason: reason.kind,
|
|
265
|
+
turn: event.data?.turn,
|
|
266
|
+
title,
|
|
267
|
+
body,
|
|
268
|
+
...(url ? { url } : {}),
|
|
269
|
+
});
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (event.type === "approval/asked" && cfg.notifyOnApproval) {
|
|
274
|
+
// Never notify when the session policy auto-rejects: nothing waits for
|
|
275
|
+
// the user, so a toast would be noise.
|
|
276
|
+
if (effectivePolicy(session) === "never") return;
|
|
277
|
+
const data = event.data ?? {};
|
|
278
|
+
const body = typeof data.toolName === "string"
|
|
279
|
+
? (typeof data.reason === "string" && data.reason.length > 0
|
|
280
|
+
? "工具 " + data.toolName + ":" + truncate(data.reason, 100)
|
|
281
|
+
: "工具 " + data.toolName)
|
|
282
|
+
: "有一项操作等待你的批准";
|
|
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
|
+
});
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (event.type === "tool/call" && cfg.notifyOnAskUser) {
|
|
297
|
+
const data = event.data ?? {};
|
|
298
|
+
let question;
|
|
299
|
+
if (data.name === "ask_user_question") {
|
|
300
|
+
question = extractAskQuestion(data.arguments);
|
|
301
|
+
} else if (data.name === "run_code" && typeof data.arguments === "string" && data.arguments.includes("ask_user_question")) {
|
|
302
|
+
question = extractQuestionFromCode(data.arguments);
|
|
303
|
+
}
|
|
304
|
+
if (question !== undefined || data.name === "ask_user_question") {
|
|
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
|
+
});
|
|
313
|
+
}
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
log({ event: "start", version: VERSION, enabled: cfg.enabled, reasons: [...reasons], sessionId: null });
|
|
319
|
+
if (cfg.enabled && cfg.notifyOnStart) {
|
|
320
|
+
// Activation toast has no session: leave non-clickable (legacy behavior).
|
|
321
|
+
sendToast(cfg.appName, "任务完成提醒已激活(v" + VERSION + ")");
|
|
322
|
+
}
|
|
323
|
+
}
|
package/lib/launcher.ps1
ADDED
|
@@ -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
|
@@ -1,35 +1,99 @@
|
|
|
1
|
-
# dsh-notify toast sender (Windows PowerShell 5.1 required: WinRT projection).
|
|
2
|
-
# Parameters are bound by PowerShell from the process argument vector, so this
|
|
3
|
-
# file stays ASCII-only and needs no BOM.
|
|
4
|
-
param(
|
|
5
|
-
[Parameter(Mandatory = $true)][string]$Title,
|
|
6
|
-
[Parameter(Mandatory = $true)][string]$Body,
|
|
7
|
-
[string]$Aumid = "DeepSeekHarness.Notify",
|
|
8
|
-
[string]$AppName = "DeepSeek Harness"
|
|
9
|
-
|
|
10
|
-
$
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
try
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
$
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
$
|
|
34
|
-
$
|
|
35
|
-
|
|
1
|
+
# dsh-notify toast sender (Windows PowerShell 5.1 required: WinRT projection).
|
|
2
|
+
# Parameters are bound by PowerShell from the process argument vector, so this
|
|
3
|
+
# file stays ASCII-only and needs no BOM.
|
|
4
|
+
param(
|
|
5
|
+
[Parameter(Mandatory = $true)][string]$Title,
|
|
6
|
+
[Parameter(Mandatory = $true)][string]$Body,
|
|
7
|
+
[string]$Aumid = "DeepSeekHarness.Notify",
|
|
8
|
+
[string]$AppName = "DeepSeek Harness",
|
|
9
|
+
[string]$Url = "",
|
|
10
|
+
[int]$LaunchProtocol = 1 # 1 = route via dshnotify:// launcher (prefer existing); 0 = open targetUrl directly
|
|
11
|
+
)
|
|
12
|
+
$ErrorActionPreference = "Stop"
|
|
13
|
+
|
|
14
|
+
# Register the AppUserModelId once under HKCU so Win32 toasts are allowed.
|
|
15
|
+
# Best effort: if the write is denied we still try to show the toast below.
|
|
16
|
+
$key = "HKCU:\SOFTWARE\Classes\AppUserModelId\$Aumid"
|
|
17
|
+
try {
|
|
18
|
+
if (-not (Test-Path $key)) {
|
|
19
|
+
New-Item -Path $key -Force | Out-Null
|
|
20
|
+
New-ItemProperty -Path $key -Name DisplayName -Value $AppName -PropertyType String -Force | Out-Null
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
Write-Warning ("AUMID registration failed: " + $_.Exception.Message)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Project the WinRT toast API (supported natively by .NET Framework).
|
|
27
|
+
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
|
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
|
+
|
|
74
|
+
$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
|
|
75
|
+
$texts = $xml.GetElementsByTagName("text")
|
|
76
|
+
$texts.Item(0).AppendChild($xml.CreateTextNode($Title)) | Out-Null
|
|
77
|
+
$texts.Item(1).AppendChild($xml.CreateTextNode($Body)) | Out-Null
|
|
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
|
+
|
|
96
|
+
$toast = New-Object Windows.UI.Notifications.ToastNotification($xml)
|
|
97
|
+
$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($Aumid)
|
|
98
|
+
$notifier.Show($toast)
|
|
99
|
+
Write-Output "toast shown"
|