dsh-notify-windows 0.6.0 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/LICENSE +21 -21
- package/README.en.md +131 -109
- package/README.md +129 -109
- package/lib/index.js +390 -273
- package/lib/launcher.ps1 +310 -0
- package/lib/notify.ps1 +99 -35
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,273 +1,390 @@
|
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
return
|
|
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
|
-
return
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
: "
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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, spawnSync } from "node:child_process";
|
|
22
|
+
import { appendFileSync, existsSync, 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.3";
|
|
29
|
+
|
|
30
|
+
// Convert a script path so powershell.exe can read it.
|
|
31
|
+
// - On WSL: powershell.exe is a Windows process and can't read Linux paths
|
|
32
|
+
// (e.g. /home/.../notify.ps1). wslpath -w rewrites it to a UNC path
|
|
33
|
+
// (\\wsl.localhost\...) that Windows can access.
|
|
34
|
+
// - On native Windows: wslpath does not exist, spawnSync fails with a
|
|
35
|
+
// non-zero status, and we fall back to the original path unchanged, so
|
|
36
|
+
// the native-Windows behaviour is preserved exactly.
|
|
37
|
+
const winPath = (p) => {
|
|
38
|
+
const r = spawnSync("wslpath", ["-w", p], { encoding: "utf8" });
|
|
39
|
+
return r.status === 0 && r.stdout ? r.stdout.trim() : p;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// NOTIFY_SCRIPT never changes, so the (synchronous) wslpath conversion is
|
|
43
|
+
// done once and cached instead of on every toast.
|
|
44
|
+
const NOTIFY_SCRIPT_WIN = winPath(NOTIFY_SCRIPT);
|
|
45
|
+
|
|
46
|
+
// Resolve powershell.exe without relying on PATH alone.
|
|
47
|
+
// - WSL: the standard Windows location is always readable via /mnt/c, even
|
|
48
|
+
// when DSH runs under systemd/cron/ssh with a minimal PATH that lacks the
|
|
49
|
+
// Windows directories (spawn("powershell.exe") would then fail with ENOENT
|
|
50
|
+
// and every notification would be lost).
|
|
51
|
+
// - Native Windows: /mnt/c does not exist, so we fall back to PATH lookup
|
|
52
|
+
// ("powershell.exe"), which is the standard behaviour.
|
|
53
|
+
const WSL_POWERSHELL = "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe";
|
|
54
|
+
const resolvePowerShell = () => (existsSync(WSL_POWERSHELL) ? WSL_POWERSHELL : "powershell.exe");
|
|
55
|
+
const POWERSHELL = resolvePowerShell();
|
|
56
|
+
|
|
57
|
+
const REASON_TEXT = {
|
|
58
|
+
completed: "任务已完成",
|
|
59
|
+
error: "任务出错",
|
|
60
|
+
"max-tokens": "输出达到 token 上限",
|
|
61
|
+
aborted: "任务已取消",
|
|
62
|
+
interrupted: "任务中断",
|
|
63
|
+
blocked: "任务未开始",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const name = "dsh-notify-windows";
|
|
67
|
+
|
|
68
|
+
function truncate(text, max) {
|
|
69
|
+
if (typeof text !== "string") return "";
|
|
70
|
+
return text.length > max ? text.slice(0, max) + "…" : text;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** First question text of an ask_user_question tool call, if parseable. */
|
|
74
|
+
function extractAskQuestion(argumentsJson) {
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(argumentsJson);
|
|
77
|
+
const first = Array.isArray(parsed?.questions) ? parsed.questions[0] : undefined;
|
|
78
|
+
return typeof first?.question === "string" ? first.question : undefined;
|
|
79
|
+
} catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* This deployment invokes every tool through run_code programs, so a real
|
|
86
|
+
* question never appears as a model-level ask_user_question tool call.
|
|
87
|
+
* Detect the nested call inside a run_code program's code and pull out the
|
|
88
|
+
* first question text.
|
|
89
|
+
*/
|
|
90
|
+
function extractQuestionFromCode(argumentsJson) {
|
|
91
|
+
try {
|
|
92
|
+
const parsed = JSON.parse(argumentsJson);
|
|
93
|
+
const code = typeof parsed?.code === "string" ? parsed.code : "";
|
|
94
|
+
const callIndex = code.search(/tools\.ask_user_question\s*\(/);
|
|
95
|
+
if (callIndex < 0) return undefined;
|
|
96
|
+
const region = code.slice(callIndex, callIndex + 8000);
|
|
97
|
+
const matches = [...region.matchAll(/question\s*:\s*["']([^"']{2,200})["']/g)];
|
|
98
|
+
return matches.length > 0 ? matches[0][1] : undefined;
|
|
99
|
+
} catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** First non-empty text block of a message, trimmed. */
|
|
105
|
+
function textOfMessage(message) {
|
|
106
|
+
const blocks = message?.content;
|
|
107
|
+
if (!Array.isArray(blocks)) return undefined;
|
|
108
|
+
for (const block of blocks) {
|
|
109
|
+
if (block?.type === "text" && typeof block.text === "string" && block.text.trim().length > 0) return block.text.trim();
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Scan the session log from the turn/end event back to its turn/start and
|
|
116
|
+
* collect what the notification needs: the driving user message's source,
|
|
117
|
+
* every goal/change operation in the window, and the last assistant reply
|
|
118
|
+
* text (for the excerpt).
|
|
119
|
+
*/
|
|
120
|
+
function turnWindow(session, turnEnd) {
|
|
121
|
+
const events = session?.events ?? [];
|
|
122
|
+
const turn = turnEnd?.data?.turn;
|
|
123
|
+
let endIndex = events.length - 1;
|
|
124
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
125
|
+
if (events[i]?.seq === turnEnd?.seq) { endIndex = i; break; }
|
|
126
|
+
}
|
|
127
|
+
let userSource;
|
|
128
|
+
const goalOps = [];
|
|
129
|
+
let lastAssistantText;
|
|
130
|
+
for (let i = endIndex; i >= 0; i -= 1) {
|
|
131
|
+
const event = events[i];
|
|
132
|
+
if (!event) continue;
|
|
133
|
+
if (event.type === "turn/start" && event.data?.turn === turn) break;
|
|
134
|
+
// Every user/message in the window overwrites: walking backwards, the
|
|
135
|
+
// final value is the FIRST message of the turn — the driving input.
|
|
136
|
+
if (event.type === "user/message" && event.data?.source) userSource = event.data.source;
|
|
137
|
+
if (event.type === "goal/change" && event.data?.operation) goalOps.push(event.data.operation);
|
|
138
|
+
if (event.type === "assistant/message" && event.data?.turn === turn && lastAssistantText === undefined) {
|
|
139
|
+
const text = textOfMessage(event.data.message);
|
|
140
|
+
if (text) lastAssistantText = text;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { userSource, goalOps, lastAssistantText };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const apply = (ctx, config = {}) => {
|
|
147
|
+
const cfg = {
|
|
148
|
+
enabled: true,
|
|
149
|
+
reasons: ["completed", "error", "max-tokens"],
|
|
150
|
+
includeSubagents: false,
|
|
151
|
+
notifyOnStart: false,
|
|
152
|
+
notifyOnApproval: true,
|
|
153
|
+
notifyOnAskUser: true,
|
|
154
|
+
notifyOnGoalRounds: false,
|
|
155
|
+
excerpt: true,
|
|
156
|
+
excerptMaxChars: 80,
|
|
157
|
+
appName: "DeepSeek Harness",
|
|
158
|
+
aumid: "DeepSeekHarness.Notify",
|
|
159
|
+
log: true,
|
|
160
|
+
debug: false,
|
|
161
|
+
// Click-to-open feature (v0.7.0):
|
|
162
|
+
openOnClick: true, // when false: never make toasts clickable (never pass -Url)
|
|
163
|
+
preferExisting: true, // true -> -LaunchProtocol 1 (launcher, prefer existing window); false -> 0 (plain open)
|
|
164
|
+
webUrl: "", // manual override of the web GUI base; empty = auto-discover
|
|
165
|
+
...config,
|
|
166
|
+
};
|
|
167
|
+
const reasons = new Set(cfg.reasons);
|
|
168
|
+
const logPath = join(tmpdir(), "dsh-notify", "notify.log");
|
|
169
|
+
|
|
170
|
+
const log = (entry) => {
|
|
171
|
+
if (!cfg.log) return;
|
|
172
|
+
try {
|
|
173
|
+
mkdirSync(join(tmpdir(), "dsh-notify"), { recursive: true });
|
|
174
|
+
appendFileSync(logPath, JSON.stringify({ time: Date.now(), ...entry }) + "\n");
|
|
175
|
+
} catch {
|
|
176
|
+
// diagnostics must never break the agent loop
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// Authoritative base URL discovery (verified against rc.8).
|
|
181
|
+
// Always loopback: ctx.webServer.host may be 0.0.0.0, which is not a valid
|
|
182
|
+
// browser target. Use the actual bound port (correct even when config port=0).
|
|
183
|
+
const webBaseUrl = () => {
|
|
184
|
+
if (cfg.webUrl) return cfg.webUrl.replace(/\/+$/, "");
|
|
185
|
+
try {
|
|
186
|
+
const port = ctx.get?.("webServer")?.port; // actual bound port
|
|
187
|
+
if (Number.isInteger(port) && port > 0) return "http://127.0.0.1:" + port;
|
|
188
|
+
} catch {
|
|
189
|
+
// fall through to documented default
|
|
190
|
+
}
|
|
191
|
+
return "http://127.0.0.1:3080"; // documented default (dsh-web-app patch.yml)
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// Per-session deep link. rc.8 ignores the ?session= query but it is harmless
|
|
195
|
+
// and future-proof for frontends that want to jump straight to a conversation.
|
|
196
|
+
const conversationUrl = (sessionId) => {
|
|
197
|
+
const base = webBaseUrl();
|
|
198
|
+
if (!sessionId) return base + "/";
|
|
199
|
+
return base + "/?session=" + encodeURIComponent(sessionId);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const sendToast = (title, body, opts = {}) => {
|
|
203
|
+
const args = [
|
|
204
|
+
"-NoProfile",
|
|
205
|
+
"-NonInteractive",
|
|
206
|
+
"-ExecutionPolicy",
|
|
207
|
+
"Bypass",
|
|
208
|
+
"-File",
|
|
209
|
+
NOTIFY_SCRIPT_WIN,
|
|
210
|
+
"-Title",
|
|
211
|
+
title,
|
|
212
|
+
"-Body",
|
|
213
|
+
body,
|
|
214
|
+
"-Aumid",
|
|
215
|
+
cfg.aumid,
|
|
216
|
+
"-AppName",
|
|
217
|
+
cfg.appName,
|
|
218
|
+
];
|
|
219
|
+
const sessionId = opts?.sessionId;
|
|
220
|
+
if (cfg.openOnClick && sessionId) {
|
|
221
|
+
const url = conversationUrl(sessionId);
|
|
222
|
+
args.push("-Url", url, "-LaunchProtocol", cfg.preferExisting ? 1 : 0);
|
|
223
|
+
}
|
|
224
|
+
// Capture output: PowerShell 5.1 exits 0 even when -File cannot load the
|
|
225
|
+
// script (e.g. a Linux path that was never converted), so the exit code
|
|
226
|
+
// alone cannot tell success from failure. notify.ps1 prints "toast shown"
|
|
227
|
+
// only after the WinRT Show() call returns, which is our success marker.
|
|
228
|
+
const child = spawn(POWERSHELL, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
229
|
+
let stdout = "";
|
|
230
|
+
let stderr = "";
|
|
231
|
+
child.stdout?.on("data", (chunk) => { stdout += chunk; });
|
|
232
|
+
child.stderr?.on("data", (chunk) => { stderr += chunk; });
|
|
233
|
+
child.on("error", (error) => {
|
|
234
|
+
log({ event: "error", message: String(error?.message ?? error) });
|
|
235
|
+
ctx.logger?.warn?.("dsh-notify: 启动通知进程失败", error);
|
|
236
|
+
});
|
|
237
|
+
child.on("exit", (code) => {
|
|
238
|
+
const shown = /toast shown/.test(stdout);
|
|
239
|
+
if (code !== 0 || !shown) {
|
|
240
|
+
log({
|
|
241
|
+
event: "error",
|
|
242
|
+
code,
|
|
243
|
+
shown,
|
|
244
|
+
stdout: stdout.slice(0, 500),
|
|
245
|
+
stderr: stderr.slice(0, 500),
|
|
246
|
+
});
|
|
247
|
+
ctx.logger?.warn?.("dsh-notify: 通知进程未成功显示 toast" + (code !== null ? "(退出码 " + code + ")" : ""));
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const titleFor = (session) => {
|
|
253
|
+
try {
|
|
254
|
+
return ctx.sessionTitle?.get?.(session)?.title;
|
|
255
|
+
} catch {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* True for subagent sessions when subagents are excluded.
|
|
262
|
+
*
|
|
263
|
+
* DSH stamps `delegationDepth` on the session header: main sessions carry 0
|
|
264
|
+
* (and, after a restart, the RESTORED header always includes it because the
|
|
265
|
+
* persistence layer writes `delegationDepth ?? 0`), while subagent sessions
|
|
266
|
+
* carry >= 1. Testing `!== undefined` therefore misclassifies every restored
|
|
267
|
+
* main session as a subagent and silently kills all notifications after a
|
|
268
|
+
* dsh web restart. Only depths >= 1 are real subagents.
|
|
269
|
+
*/
|
|
270
|
+
const isFilteredSession = (session) => {
|
|
271
|
+
if (cfg.includeSubagents) return false;
|
|
272
|
+
return session?.header?.origin === "subagent" || (session?.header?.delegationDepth ?? 0) > 0;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/** Last approval/policy event of the session, or undefined (defaults to ask). */
|
|
276
|
+
const effectivePolicy = (session) => {
|
|
277
|
+
const events = session?.events ?? [];
|
|
278
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
279
|
+
const event = events[index];
|
|
280
|
+
if (event?.type === "approval/policy") return event.data?.policy;
|
|
281
|
+
}
|
|
282
|
+
return undefined;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
ctx.on("session/event", (session, event) => {
|
|
286
|
+
if (!cfg.enabled) return;
|
|
287
|
+
if (cfg.debug) {
|
|
288
|
+
log({ event: "debug", type: event.type, name: event.data?.name ?? null, sessionId: session?.id });
|
|
289
|
+
}
|
|
290
|
+
if (isFilteredSession(session)) return;
|
|
291
|
+
|
|
292
|
+
if (event.type === "turn/end") {
|
|
293
|
+
const reason = event.data?.reason;
|
|
294
|
+
if (typeof reason?.kind !== "string" || !reasons.has(reason.kind)) return;
|
|
295
|
+
const window = turnWindow(session, event);
|
|
296
|
+
|
|
297
|
+
// /goal 自动推进回合默认静默:仅在目标完成/阻塞的最终回合提醒。
|
|
298
|
+
if (!cfg.notifyOnGoalRounds) {
|
|
299
|
+
const isGoalRound = window.userSource?.kind === "goal" && window.userSource.round > 0;
|
|
300
|
+
const terminal = window.goalOps.some((op) => op === "complete" || op === "block");
|
|
301
|
+
if (isGoalRound && !terminal) return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const title = titleFor(session) || cfg.appName;
|
|
305
|
+
let body = REASON_TEXT[reason.kind] ?? "任务已结束";
|
|
306
|
+
if (Number.isInteger(event.data.turn)) body += "(第 " + event.data.turn + " 轮)";
|
|
307
|
+
if (cfg.excerpt && window.lastAssistantText) {
|
|
308
|
+
body += "\n" + truncate(window.lastAssistantText, cfg.excerptMaxChars);
|
|
309
|
+
}
|
|
310
|
+
sendToast(title, body, { sessionId: session?.id });
|
|
311
|
+
const url = cfg.openOnClick && session?.id ? conversationUrl(session?.id) : undefined;
|
|
312
|
+
log({
|
|
313
|
+
event: "notify",
|
|
314
|
+
sessionId: session?.id,
|
|
315
|
+
reason: reason.kind,
|
|
316
|
+
turn: event.data?.turn,
|
|
317
|
+
title,
|
|
318
|
+
body,
|
|
319
|
+
...(url ? { url } : {}),
|
|
320
|
+
});
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (event.type === "approval/asked" && cfg.notifyOnApproval) {
|
|
325
|
+
// Never notify when the session policy auto-rejects: nothing waits for
|
|
326
|
+
// the user, so a toast would be noise.
|
|
327
|
+
if (effectivePolicy(session) === "never") return;
|
|
328
|
+
const data = event.data ?? {};
|
|
329
|
+
const body = typeof data.toolName === "string"
|
|
330
|
+
? (typeof data.reason === "string" && data.reason.length > 0
|
|
331
|
+
? "工具 " + data.toolName + ":" + truncate(data.reason, 100)
|
|
332
|
+
: "工具 " + data.toolName)
|
|
333
|
+
: "有一项操作等待你的批准";
|
|
334
|
+
sendToast(cfg.appName + " · 需要审批", body, { sessionId: session?.id });
|
|
335
|
+
const url = cfg.openOnClick && session?.id ? conversationUrl(session?.id) : undefined;
|
|
336
|
+
log({
|
|
337
|
+
event: "approval",
|
|
338
|
+
sessionId: session?.id,
|
|
339
|
+
id: data.id,
|
|
340
|
+
toolName: data.toolName,
|
|
341
|
+
reason: data.reason,
|
|
342
|
+
...(url ? { url } : {}),
|
|
343
|
+
});
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (event.type === "tool/call" && cfg.notifyOnAskUser) {
|
|
348
|
+
const data = event.data ?? {};
|
|
349
|
+
let question;
|
|
350
|
+
if (data.name === "ask_user_question") {
|
|
351
|
+
question = extractAskQuestion(data.arguments);
|
|
352
|
+
} else if (data.name === "run_code" && typeof data.arguments === "string" && data.arguments.includes("ask_user_question")) {
|
|
353
|
+
question = extractQuestionFromCode(data.arguments);
|
|
354
|
+
}
|
|
355
|
+
if (question !== undefined || data.name === "ask_user_question") {
|
|
356
|
+
sendToast(cfg.appName + " · 需要回答", question ? truncate(question, 100) : "Agent 正在等待你的确认", { sessionId: session?.id });
|
|
357
|
+
const url = cfg.openOnClick && session?.id ? conversationUrl(session?.id) : undefined;
|
|
358
|
+
log({
|
|
359
|
+
event: "ask-user",
|
|
360
|
+
sessionId: session?.id,
|
|
361
|
+
question: question ?? null,
|
|
362
|
+
...(url ? { url } : {}),
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// Startup self-check: if the notification channel is broken (powershell.exe
|
|
370
|
+
// unreachable or notify.ps1 missing), say so immediately instead of letting
|
|
371
|
+
// every toast fail silently later.
|
|
372
|
+
const channelOk = existsSync(NOTIFY_SCRIPT) && (POWERSHELL === "powershell.exe" || existsSync(POWERSHELL));
|
|
373
|
+
log({
|
|
374
|
+
event: "start",
|
|
375
|
+
version: VERSION,
|
|
376
|
+
enabled: cfg.enabled,
|
|
377
|
+
reasons: [...reasons],
|
|
378
|
+
powershell: POWERSHELL,
|
|
379
|
+
notifyScript: NOTIFY_SCRIPT_WIN,
|
|
380
|
+
channelOk,
|
|
381
|
+
sessionId: null,
|
|
382
|
+
});
|
|
383
|
+
if (cfg.enabled && !channelOk) {
|
|
384
|
+
ctx.logger?.warn?.("dsh-notify: 通知通道不可用(powershell.exe 或 notify.ps1 缺失),桌面通知将不会发送");
|
|
385
|
+
}
|
|
386
|
+
if (cfg.enabled && cfg.notifyOnStart) {
|
|
387
|
+
// Activation toast has no session: leave non-clickable (legacy behavior).
|
|
388
|
+
sendToast(cfg.appName, "任务完成提醒已激活(v" + VERSION + ")");
|
|
389
|
+
}
|
|
390
|
+
}
|