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/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
- // Goal rounds (/goal auto-continuation) are quiet by default: only the final
11
- // round that completes or blocks the goal produces a toast.
12
- //
13
- // Zero runtime dependencies: notifications go through the toast sender
14
- // script (./notify.ps1) executed by Windows PowerShell 5.1, whose .NET
15
- // Framework runtime supports the WinRT toast API.
16
- import { spawn } from "node:child_process";
17
- import { appendFileSync, mkdirSync } from "node:fs";
18
- import { tmpdir } from "node:os";
19
- import { join } from "node:path";
20
- import { fileURLToPath } from "node:url";
21
-
22
- const NOTIFY_SCRIPT = fileURLToPath(new URL("./notify.ps1", import.meta.url));
23
- const VERSION = "0.6.0";
24
-
25
- const REASON_TEXT = {
26
- completed: "任务已完成",
27
- error: "任务出错",
28
- "max-tokens": "输出达到 token 上限",
29
- aborted: "任务已取消",
30
- interrupted: "任务中断",
31
- blocked: "任务未开始",
32
- };
33
-
34
- export const name = "dsh-notify-windows";
35
-
36
- function truncate(text, max) {
37
- if (typeof text !== "string") return "";
38
- return text.length > max ? text.slice(0, max) + "" : text;
39
- }
40
-
41
- /** First question text of an ask_user_question tool call, if parseable. */
42
- function extractAskQuestion(argumentsJson) {
43
- try {
44
- const parsed = JSON.parse(argumentsJson);
45
- const first = Array.isArray(parsed?.questions) ? parsed.questions[0] : undefined;
46
- return typeof first?.question === "string" ? first.question : undefined;
47
- } catch {
48
- return undefined;
49
- }
50
- }
51
-
52
- /**
53
- * This deployment invokes every tool through run_code programs, so a real
54
- * question never appears as a model-level ask_user_question tool call.
55
- * Detect the nested call inside a run_code program's code and pull out the
56
- * first question text.
57
- */
58
- function extractQuestionFromCode(argumentsJson) {
59
- try {
60
- const parsed = JSON.parse(argumentsJson);
61
- const code = typeof parsed?.code === "string" ? parsed.code : "";
62
- const callIndex = code.search(/tools\.ask_user_question\s*\(/);
63
- if (callIndex < 0) return undefined;
64
- const region = code.slice(callIndex, callIndex + 8000);
65
- const matches = [...region.matchAll(/question\s*:\s*["']([^"']{2,200})["']/g)];
66
- return matches.length > 0 ? matches[0][1] : undefined;
67
- } catch {
68
- return undefined;
69
- }
70
- }
71
-
72
- /** First non-empty text block of a message, trimmed. */
73
- function textOfMessage(message) {
74
- const blocks = message?.content;
75
- if (!Array.isArray(blocks)) return undefined;
76
- for (const block of blocks) {
77
- if (block?.type === "text" && typeof block.text === "string" && block.text.trim().length > 0) return block.text.trim();
78
- }
79
- return undefined;
80
- }
81
-
82
- /**
83
- * Scan the session log from the turn/end event back to its turn/start and
84
- * collect what the notification needs: the driving user message's source,
85
- * every goal/change operation in the window, and the last assistant reply
86
- * text (for the excerpt).
87
- */
88
- function turnWindow(session, turnEnd) {
89
- const events = session?.events ?? [];
90
- const turn = turnEnd?.data?.turn;
91
- let endIndex = events.length - 1;
92
- for (let i = events.length - 1; i >= 0; i -= 1) {
93
- if (events[i]?.seq === turnEnd?.seq) { endIndex = i; break; }
94
- }
95
- let userSource;
96
- const goalOps = [];
97
- let lastAssistantText;
98
- for (let i = endIndex; i >= 0; i -= 1) {
99
- const event = events[i];
100
- if (!event) continue;
101
- if (event.type === "turn/start" && event.data?.turn === turn) break;
102
- // Every user/message in the window overwrites: walking backwards, the
103
- // final value is the FIRST message of the turn — the driving input.
104
- if (event.type === "user/message" && event.data?.source) userSource = event.data.source;
105
- if (event.type === "goal/change" && event.data?.operation) goalOps.push(event.data.operation);
106
- if (event.type === "assistant/message" && event.data?.turn === turn && lastAssistantText === undefined) {
107
- const text = textOfMessage(event.data.message);
108
- if (text) lastAssistantText = text;
109
- }
110
- }
111
- return { userSource, goalOps, lastAssistantText };
112
- }
113
-
114
- export const apply = (ctx, config = {}) => {
115
- const cfg = {
116
- enabled: true,
117
- reasons: ["completed", "error", "max-tokens"],
118
- includeSubagents: false,
119
- notifyOnStart: false,
120
- notifyOnApproval: true,
121
- notifyOnAskUser: true,
122
- notifyOnGoalRounds: false,
123
- excerpt: true,
124
- excerptMaxChars: 80,
125
- appName: "DeepSeek Harness",
126
- aumid: "DeepSeekHarness.Notify",
127
- log: true,
128
- debug: false,
129
- ...config,
130
- };
131
- const reasons = new Set(cfg.reasons);
132
- const logPath = join(tmpdir(), "dsh-notify", "notify.log");
133
-
134
- const log = (entry) => {
135
- if (!cfg.log) return;
136
- try {
137
- mkdirSync(join(tmpdir(), "dsh-notify"), { recursive: true });
138
- appendFileSync(logPath, JSON.stringify({ time: Date.now(), ...entry }) + "\n");
139
- } catch {
140
- // diagnostics must never break the agent loop
141
- }
142
- };
143
-
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
- );
165
- child.on("error", (error) => {
166
- log({ event: "error", message: String(error?.message ?? error) });
167
- ctx.logger?.warn?.("dsh-notify: 启动通知进程失败", error);
168
- });
169
- child.on("exit", (code) => {
170
- if (code !== 0) {
171
- log({ event: "error", code });
172
- ctx.logger?.warn?.("dsh-notify: 通知进程退出码 " + code);
173
- }
174
- });
175
- };
176
-
177
- const titleFor = (session) => {
178
- try {
179
- return ctx.sessionTitle?.get?.(session)?.title;
180
- } catch {
181
- return undefined;
182
- }
183
- };
184
-
185
- /** True for subagent sessions when subagents are excluded. */
186
- const isFilteredSession = (session) => {
187
- if (cfg.includeSubagents) return false;
188
- return session?.header?.origin === "subagent" || session?.header?.delegationDepth !== undefined;
189
- };
190
-
191
- /** Last approval/policy event of the session, or undefined (defaults to ask). */
192
- const effectivePolicy = (session) => {
193
- const events = session?.events ?? [];
194
- for (let index = events.length - 1; index >= 0; index -= 1) {
195
- const event = events[index];
196
- if (event?.type === "approval/policy") return event.data?.policy;
197
- }
198
- return undefined;
199
- };
200
-
201
- ctx.on("session/event", (session, event) => {
202
- if (!cfg.enabled) return;
203
- if (cfg.debug) {
204
- log({ event: "debug", type: event.type, name: event.data?.name ?? null, sessionId: session?.id });
205
- }
206
- if (isFilteredSession(session)) return;
207
-
208
- if (event.type === "turn/end") {
209
- const reason = event.data?.reason;
210
- if (typeof reason?.kind !== "string" || !reasons.has(reason.kind)) return;
211
- const window = turnWindow(session, event);
212
-
213
- // /goal 自动推进回合默认静默:仅在目标完成/阻塞的最终回合提醒。
214
- if (!cfg.notifyOnGoalRounds) {
215
- const isGoalRound = window.userSource?.kind === "goal" && window.userSource.round > 0;
216
- const terminal = window.goalOps.some((op) => op === "complete" || op === "block");
217
- if (isGoalRound && !terminal) return;
218
- }
219
-
220
- const title = titleFor(session) || cfg.appName;
221
- let body = REASON_TEXT[reason.kind] ?? "任务已结束";
222
- if (Number.isInteger(event.data.turn)) body += "(第 " + event.data.turn + " 轮)";
223
- if (cfg.excerpt && window.lastAssistantText) {
224
- body += "\n" + truncate(window.lastAssistantText, cfg.excerptMaxChars);
225
- }
226
- sendToast(title, body);
227
- log({
228
- event: "notify",
229
- sessionId: session?.id,
230
- reason: reason.kind,
231
- turn: event.data?.turn,
232
- title,
233
- body,
234
- });
235
- return;
236
- }
237
-
238
- if (event.type === "approval/asked" && cfg.notifyOnApproval) {
239
- // Never notify when the session policy auto-rejects: nothing waits for
240
- // the user, so a toast would be noise.
241
- if (effectivePolicy(session) === "never") return;
242
- const data = event.data ?? {};
243
- const body = typeof data.toolName === "string"
244
- ? (typeof data.reason === "string" && data.reason.length > 0
245
- ? "工具 " + data.toolName + ":" + truncate(data.reason, 100)
246
- : "工具 " + data.toolName)
247
- : "有一项操作等待你的批准";
248
- sendToast(cfg.appName + " · 需要审批", body);
249
- log({ event: "approval", sessionId: session?.id, id: data.id, toolName: data.toolName, reason: data.reason });
250
- return;
251
- }
252
-
253
- if (event.type === "tool/call" && cfg.notifyOnAskUser) {
254
- const data = event.data ?? {};
255
- let question;
256
- if (data.name === "ask_user_question") {
257
- question = extractAskQuestion(data.arguments);
258
- } else if (data.name === "run_code" && typeof data.arguments === "string" && data.arguments.includes("ask_user_question")) {
259
- question = extractQuestionFromCode(data.arguments);
260
- }
261
- 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 });
264
- }
265
- return;
266
- }
267
- });
268
-
269
- log({ event: "start", version: VERSION, enabled: cfg.enabled, reasons: [...reasons], sessionId: null });
270
- if (cfg.enabled && cfg.notifyOnStart) {
271
- sendToast(cfg.appName, "任务完成提醒已激活(v" + VERSION + ")");
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
+ }