@vanadium-23/dsh-ping 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,426 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ //#region lib/types/decide.js
5
+ /**
6
+ * The decision core: whether to notify, and what the notification says.
7
+ *
8
+ * Everything here is a pure function so the interesting behaviour — the
9
+ * "don't be annoying" rules and the text assembly — is testable without a
10
+ * live harness, a desktop, or a PowerShell.
11
+ * @module dsh-ping/decide
12
+ */
13
+ /**
14
+ * Decide whether one fact should reach the user.
15
+ *
16
+ * Order matters: a disabled kind is a configuration statement, a subagent is a
17
+ * structural statement, and both outrank the rate limits, so the reported
18
+ * reason always names the real cause.
19
+ * @param input - the fact plus the limits and bookkeeping it is judged against.
20
+ * @returns the verdict and a short reason.
21
+ */
22
+ function shouldNotify(input) {
23
+ const { fact, limits } = input;
24
+ if (!limits.enabled) return {
25
+ notify: false,
26
+ reason: "plugin-disabled"
27
+ };
28
+ if (!limits.kinds[fact.kind]) return {
29
+ notify: false,
30
+ reason: `kind-disabled:${fact.kind}`
31
+ };
32
+ if (limits.rootsOnly && !input.isRoot) return {
33
+ notify: false,
34
+ reason: "subagent"
35
+ };
36
+ if (limits.cooldownMs > 0 && input.lastNotifiedAt !== void 0 && input.now - input.lastNotifiedAt < limits.cooldownMs) return {
37
+ notify: false,
38
+ reason: "cooldown"
39
+ };
40
+ if (fact.kind === "done") {
41
+ if ((fact.durationMs ?? 0) < limits.minTurnDurationMs) return {
42
+ notify: false,
43
+ reason: "too-short"
44
+ };
45
+ }
46
+ return {
47
+ notify: true,
48
+ reason: "ok"
49
+ };
50
+ }
51
+ /**
52
+ * Collapse a value into one printable line.
53
+ *
54
+ * Newlines are removed rather than kept: the Windows toast path carries text
55
+ * through an environment variable and a here-string, and a single line cannot
56
+ * terminate either of them.
57
+ * @param value - raw text.
58
+ * @param maxChars - maximum kept characters (0 keeps everything).
59
+ * @returns the flattened, truncated text.
60
+ */
61
+ function flatten(value, maxChars = 0) {
62
+ const cleaned = value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
63
+ if (maxChars <= 0 || cleaned.length <= maxChars) return cleaned;
64
+ return `${cleaned.slice(0, Math.max(1, maxChars - 1)).trimEnd()}…`;
65
+ }
66
+ /**
67
+ * Last path segment, used as a short workspace label.
68
+ * @param cwd - an absolute working directory.
69
+ * @returns the final segment, or an empty string.
70
+ */
71
+ function workspaceOf(cwd) {
72
+ if (cwd === void 0) return "";
73
+ const trimmed = cwd.replace(/[\\/]+$/, "");
74
+ if (trimmed === "") return "";
75
+ const parts = trimmed.split(/[\\/]/);
76
+ return parts[parts.length - 1] ?? "";
77
+ }
78
+ /**
79
+ * Human duration, Chinese units to match the default headings.
80
+ * @param ms - duration in milliseconds.
81
+ * @returns a compact label such as `2 分 13 秒`.
82
+ */
83
+ function formatDuration(ms) {
84
+ if (ms === void 0 || !Number.isFinite(ms) || ms < 0) return "";
85
+ const totalSeconds = Math.round(ms / 1e3);
86
+ if (totalSeconds < 60) return `${String(totalSeconds)} 秒`;
87
+ const minutes = Math.floor(totalSeconds / 60);
88
+ const seconds = totalSeconds % 60;
89
+ if (minutes < 60) return seconds === 0 ? `${String(minutes)} 分` : `${String(minutes)} 分 ${String(seconds)} 秒`;
90
+ const hours = Math.floor(minutes / 60);
91
+ const rest = minutes % 60;
92
+ return rest === 0 ? `${String(hours)} 小时` : `${String(hours)} 小时 ${String(rest)} 分`;
93
+ }
94
+ /**
95
+ * Render the notice for one fact.
96
+ *
97
+ * Shape: the heading states what happened, the first line carries the payload
98
+ * (or the session identity when there is none), and the last line always says
99
+ * which workspace and how long — that is what makes several concurrent
100
+ * sessions distinguishable at a glance.
101
+ * @param fact - the fact to render.
102
+ * @param titles - per-kind headings.
103
+ * @param maxBodyChars - maximum characters of the payload line.
104
+ * @returns the notice.
105
+ */
106
+ function buildNotice(fact, titles, maxBodyChars) {
107
+ const title = titles[fact.kind];
108
+ const workspace = workspaceOf(fact.cwd);
109
+ const subject = flatten(fact.sessionTitle ?? "", 80) || workspace || "DSH 会话";
110
+ const detail = fact.detail === void 0 ? "" : flatten(fact.detail, maxBodyChars);
111
+ const meta = [workspace === subject ? "" : workspace, formatDuration(fact.durationMs)].filter((part) => part !== "").join(" · ");
112
+ const lines = [];
113
+ if (detail !== "") {
114
+ lines.push(detail);
115
+ const attribution = [subject, meta].filter((part) => part !== "").join(" · ");
116
+ if (attribution !== "") lines.push(attribution);
117
+ } else {
118
+ lines.push(subject);
119
+ if (meta !== "" && meta !== subject) lines.push(meta);
120
+ }
121
+ return {
122
+ title,
123
+ lines: lines.slice(0, 2)
124
+ };
125
+ }
126
+ /**
127
+ * Escape text for inclusion in the toast XML.
128
+ * @param value - raw text.
129
+ * @returns XML-safe text.
130
+ */
131
+ function escapeXml(value) {
132
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
133
+ }
134
+ //#endregion
135
+ //#region lib/types/defaults.js
136
+ /**
137
+ * Configuration shape and defaults.
138
+ *
139
+ * Kept free of every runtime import — including the schema library — so the
140
+ * standalone smoke test can reuse the defaults without pulling a
141
+ * `@deepseek-ai/*` module into a CLI that has to run outside the harness.
142
+ * @module dsh-ping/defaults
143
+ */
144
+ /** The defaults, restated by the schema in `index.ts` so the loader applies them too. */
145
+ const DEFAULTS = {
146
+ enabled: true,
147
+ notifyOn: {
148
+ done: true,
149
+ error: true,
150
+ approval: true,
151
+ question: true
152
+ },
153
+ rootsOnly: true,
154
+ cooldownMs: 3e4,
155
+ minTurnDurationMs: 2e4,
156
+ channels: {
157
+ toast: true,
158
+ console: true,
159
+ webhook: false
160
+ },
161
+ webhookUrl: "",
162
+ webhookTimeoutMs: 5e3,
163
+ url: "",
164
+ maxBodyChars: 180,
165
+ toastAppId: "",
166
+ toastSoundDone: "ms-winsoundevent:Notification.Default",
167
+ toastSoundAttention: "ms-winsoundevent:Notification.Reminder",
168
+ powershellPath: "",
169
+ titles: {
170
+ done: "DSH · 任务完成",
171
+ error: "DSH · 出错了",
172
+ approval: "DSH · 等你批准",
173
+ question: "DSH · 等你回答"
174
+ },
175
+ debug: false
176
+ };
177
+ /**
178
+ * Merge a partially applied configuration over the defaults.
179
+ *
180
+ * The loader normally hands over a fully defaulted object, but a deployment
181
+ * that instantiates the plugin directly should not have to.
182
+ * @param config - raw configuration.
183
+ * @returns a complete configuration.
184
+ */
185
+ function resolveConfig(config) {
186
+ const source = config ?? {};
187
+ return {
188
+ ...DEFAULTS,
189
+ ...source,
190
+ notifyOn: {
191
+ ...DEFAULTS.notifyOn,
192
+ ...source.notifyOn
193
+ },
194
+ channels: {
195
+ ...DEFAULTS.channels,
196
+ ...source.channels
197
+ },
198
+ titles: {
199
+ ...DEFAULTS.titles,
200
+ ...source.titles
201
+ }
202
+ };
203
+ }
204
+ //#endregion
205
+ //#region lib/types/channels.js
206
+ /**
207
+ * Delivery channels: Windows toast, console line, optional webhook.
208
+ *
209
+ * All three are best-effort by contract — a notification that cannot be shown
210
+ * must never affect the harness, so every failure is logged and swallowed.
211
+ * @module dsh-ping/channels
212
+ */
213
+ /**
214
+ * The toast script, sent as one `-EncodedCommand` payload.
215
+ *
216
+ * It is a constant. No session text is ever concatenated into PowerShell
217
+ * source, so no amount of text in a session title, an error message, or a tool
218
+ * name can reach the PowerShell parser: the text arrives as an XML document in
219
+ * an environment variable and is handed straight to the WinRT XML parser.
220
+ */
221
+ const TOAST_SCRIPT = `$ErrorActionPreference = 'Stop'
222
+ [void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime]
223
+ [void][Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType=WindowsRuntime]
224
+ $xml = New-Object Windows.Data.Xml.Dom.XmlDocument
225
+ $xml.LoadXml([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($env:DSH_PING_XML)))
226
+ $toast = New-Object Windows.UI.Notifications.ToastNotification $xml
227
+ $appId = $env:DSH_PING_APPID
228
+ if ([string]::IsNullOrEmpty($appId)) { $appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe' }
229
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast)
230
+ `;
231
+ /**
232
+ * Render the toast XML payload.
233
+ * @param notice - the heading and body lines.
234
+ * @param delivery - presentation options.
235
+ * @returns a complete `<toast>` document.
236
+ */
237
+ function buildToastXml(notice, delivery) {
238
+ const attributes = [delivery.url === void 0 || delivery.url === "" ? "" : `activationType="protocol" launch="${escapeXml(delivery.url)}"`, delivery.long === true ? "duration=\"long\"" : ""].filter((part) => part !== "").join(" ");
239
+ const texts = [notice.title, ...notice.lines].map((line) => ` <text>${escapeXml(line)}</text>`).join("\n");
240
+ const audio = delivery.sound === void 0 || delivery.sound === "" ? " <audio silent=\"true\"/>" : ` <audio src="${escapeXml(delivery.sound)}" loop="false" silent="false"/>`;
241
+ return [
242
+ `<toast ${attributes}>`.replace(" >", ">"),
243
+ " <visual>",
244
+ " <binding template=\"ToastGeneric\">",
245
+ texts,
246
+ " </binding>",
247
+ " </visual>",
248
+ audio,
249
+ "</toast>"
250
+ ].join("\n");
251
+ }
252
+ /**
253
+ * Encode a script for `powershell.exe -EncodedCommand`.
254
+ * @param script - PowerShell source.
255
+ * @returns base64 of the UTF-16LE script.
256
+ */
257
+ function encodeCommand(script) {
258
+ return Buffer.from(script, "utf16le").toString("base64");
259
+ }
260
+ /**
261
+ * Resolve the Windows PowerShell 5.1 executable.
262
+ *
263
+ * WinRT type projection is a 5.1 feature; `pwsh` is not a substitute, so the
264
+ * in-box path is used rather than a PATH lookup that could find PowerShell 7.
265
+ * @param configured - an explicit path from configuration.
266
+ * @returns the executable path, or `undefined` when unavailable.
267
+ */
268
+ function resolvePowershell(configured) {
269
+ if (configured !== void 0 && configured !== "" && existsSync(configured)) return configured;
270
+ if (process.platform !== "win32") return void 0;
271
+ const path = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
272
+ return existsSync(path) ? path : void 0;
273
+ }
274
+ /** Environment names Windows PowerShell needs to start cleanly. */
275
+ const KEEP_ENV = [
276
+ "SystemRoot",
277
+ "windir",
278
+ "SystemDrive",
279
+ "PATH",
280
+ "Path",
281
+ "PATHEXT",
282
+ "COMSPEC",
283
+ "TEMP",
284
+ "TMP",
285
+ "USERPROFILE",
286
+ "USERNAME",
287
+ "COMPUTERNAME",
288
+ "NUMBER_OF_PROCESSORS",
289
+ "PSModulePath",
290
+ "LANG",
291
+ "LC_ALL"
292
+ ];
293
+ /**
294
+ * Build a minimal child environment.
295
+ *
296
+ * A notification process has no business inheriting API keys, so the
297
+ * environment is rebuilt from an allowlist instead of copied.
298
+ * @returns the environment for the toast helper.
299
+ */
300
+ function minimalEnv() {
301
+ const env = {};
302
+ for (const name of KEEP_ENV) {
303
+ const value = process.env[name];
304
+ if (value !== void 0) env[name] = value;
305
+ }
306
+ return env;
307
+ }
308
+ /**
309
+ * Show a Windows toast. Fire-and-forget by design.
310
+ * @param notice - the heading and body lines.
311
+ * @param delivery - presentation options.
312
+ * @param log - diagnostic sink.
313
+ * @returns true when the helper was started.
314
+ */
315
+ function sendToast(notice, delivery, log) {
316
+ const powershell = resolvePowershell(delivery.powershellPath);
317
+ if (powershell === void 0) {
318
+ log("toast channel unavailable: Windows PowerShell 5.1 not found");
319
+ return false;
320
+ }
321
+ const xml = buildToastXml(notice, delivery);
322
+ try {
323
+ const child = spawn(powershell, [
324
+ "-NoProfile",
325
+ "-NonInteractive",
326
+ "-STA",
327
+ "-WindowStyle",
328
+ "Hidden",
329
+ "-EncodedCommand",
330
+ encodeCommand(TOAST_SCRIPT)
331
+ ], {
332
+ windowsHide: true,
333
+ detached: false,
334
+ stdio: "ignore",
335
+ env: {
336
+ ...minimalEnv(),
337
+ DSH_PING_XML: Buffer.from(xml, "utf16le").toString("base64"),
338
+ DSH_PING_APPID: delivery.appId
339
+ }
340
+ });
341
+ child.on("error", (error) => {
342
+ log(`toast helper failed: ${error.message}`);
343
+ });
344
+ child.unref();
345
+ return true;
346
+ } catch (error) {
347
+ log(`toast helper could not start: ${error instanceof Error ? error.message : String(error)}`);
348
+ return false;
349
+ }
350
+ }
351
+ /**
352
+ * Show a Windows toast and wait for the helper to finish.
353
+ *
354
+ * The plugin never uses this — a live notification must not block a session
355
+ * event — but a diagnostic run has to see the helper's exit code to tell
356
+ * "shown" apart from "silently refused".
357
+ * @param notice - the heading and body lines.
358
+ * @param delivery - presentation options.
359
+ * @returns the helper's exit code and captured error output.
360
+ */
361
+ function sendToastSync(notice, delivery) {
362
+ const powershell = resolvePowershell(delivery.powershellPath);
363
+ if (powershell === void 0) return {
364
+ started: false,
365
+ code: null,
366
+ output: "Windows PowerShell 5.1 not found"
367
+ };
368
+ const xml = buildToastXml(notice, delivery);
369
+ const result = spawnSync(powershell, [
370
+ "-NoProfile",
371
+ "-NonInteractive",
372
+ "-STA",
373
+ "-WindowStyle",
374
+ "Hidden",
375
+ "-EncodedCommand",
376
+ encodeCommand(TOAST_SCRIPT)
377
+ ], {
378
+ windowsHide: true,
379
+ encoding: "utf8",
380
+ timeout: 2e4,
381
+ env: {
382
+ ...minimalEnv(),
383
+ DSH_PING_XML: Buffer.from(xml, "utf16le").toString("base64"),
384
+ DSH_PING_APPID: delivery.appId
385
+ }
386
+ });
387
+ const output = `${result.stderr ?? ""}${result.stdout ?? ""}`.trim();
388
+ return {
389
+ started: true,
390
+ code: result.status,
391
+ output
392
+ };
393
+ }
394
+ /**
395
+ * Write the notice to stderr, where the launching terminal shows it.
396
+ * @param notice - the heading and body lines.
397
+ * @param log - diagnostic sink.
398
+ */
399
+ function sendConsole(notice, log) {
400
+ log(`${notice.title}${notice.lines.length === 0 ? "" : ` — ${notice.lines.join(" · ")}`}`);
401
+ }
402
+ /**
403
+ * POST the notice to a webhook.
404
+ * @param payload - the JSON body.
405
+ * @param url - destination URL.
406
+ * @param timeoutMs - request budget.
407
+ * @param log - diagnostic sink.
408
+ * @returns a promise that settles when the attempt finishes.
409
+ */
410
+ async function sendWebhook(payload, url, timeoutMs, log) {
411
+ if (url === "") return;
412
+ try {
413
+ const response = await fetch(url, {
414
+ method: "POST",
415
+ headers: { "content-type": "application/json" },
416
+ body: JSON.stringify(payload),
417
+ signal: AbortSignal.timeout(timeoutMs)
418
+ });
419
+ await response.body?.cancel();
420
+ if (!response.ok) log(`webhook answered ${String(response.status)}`);
421
+ } catch (error) {
422
+ log(`webhook failed: ${error instanceof Error ? error.message : String(error)}`);
423
+ }
424
+ }
425
+ //#endregion
426
+ export { DEFAULTS as a, shouldNotify as c, sendWebhook as i, sendToast as n, resolveConfig as o, sendToastSync as r, buildNotice as s, sendConsole as t };