@goodandready/dsh-plugin-notify 0.3.8 → 0.4.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/lib/index.js CHANGED
@@ -1,4 +1,9 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import fsp from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
2
7
  import Schema from '@deepseek-ai/schemastery';
3
8
  import { credentialRef } from '@deepseek-ai/dsh-credentials';
4
9
 
@@ -11,17 +16,13 @@ export const inject = ['sessions', 'credentials', 'settings', 'webServer', 'conn
11
16
 
12
17
  const webhookRef = (label) => Schema.string()
13
18
  .role('credential-ref')
14
- .description(`${label}: DSH credential name whose value is the full webhook URL (not the URL itself). Empty disables the channel.`);
15
-
16
- // Unwrap Volatile boxes before any caller reads a value. A volatile field holds a box
17
- // rather than its value, and the Loader mutates those boxes in place.
18
- export function plainConfig(value) {
19
- if (value === null || typeof value !== 'object') return value
20
- if (Array.isArray(value)) return value.map(plainConfig)
21
- if (typeof value.get === 'function') return plainConfig(value.get())
22
- return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plainConfig(v)]))
23
- }
19
+ .volatile()
20
+ .description(`${label}: DSH credential name whose value is the full webhook URL (not the URL itself). Empty disables the channel.`)
24
21
 
22
+ // Everything the settings card writes is marked volatile so dsh 0.2 accepts
23
+ // live edits through the settings controller (0.1 ignores the flag). Only
24
+ // leaves may be volatile: cordis 0.2 rejects volatile fields under a
25
+ // volatile enclosing object.
25
26
  export const Config = Schema.object({
26
27
  webhooks: Schema.object({
27
28
  feishu: webhookRef('Feishu custom bot'),
@@ -31,23 +32,35 @@ export const Config = Schema.object({
31
32
  discord: webhookRef('Discord webhook'),
32
33
  custom: webhookRef('Custom generic webhook (POST JSON)'),
33
34
  }).description('Per-channel credential refs for webhook URLs; leave empty to disable'),
34
- events: Schema.array(Schema.string()).volatile()
35
+ events: Schema.array(Schema.string())
36
+ .volatile()
35
37
  .description('Events that trigger notifications: task_done / error / approval_requested'),
36
- local: Schema.boolean().volatile().default(true).description('Also emit a local system notification (macOS osascript)'),
37
- enableSound: Schema.boolean().volatile().default(false).description('Play synthesized audio chime on completion, error, or approval'),
38
- enableToasts: Schema.boolean().volatile().default(false).description('Show in-app on-screen toast notifications across sessions'),
39
- enableDesktopNotifications: Schema.boolean().volatile().default(false).description('Show native desktop/OS push notifications (Windows, macOS, Linux)'),
40
- notifyBackgroundOnly: Schema.boolean().volatile().default(false).description('Notify only if the event occurred in a background/inactive session'),
41
- timeoutMs: Schema.number().volatile().default(5000).description('Per-webhook request timeout (ms)'),
38
+ local: Schema.boolean().default(true).volatile().description('Also emit a local system notification (macOS osascript)'),
39
+ enableSound: Schema.boolean().default(false).volatile().description('Play synthesized audio chime on completion, error, or approval'),
40
+ enableToasts: Schema.boolean().default(false).volatile().description('Show in-app on-screen toast notifications across sessions'),
41
+ enableDesktopNotifications: Schema.boolean().default(false).volatile().description('Show native desktop/OS push notifications (Windows, macOS, Linux)'),
42
+ notifyBackgroundOnly: Schema.boolean().default(false).volatile().description('Notify only if the event occurred in a background/inactive session'),
43
+ timeoutMs: Schema.number().default(5000).volatile().description('Per-webhook request timeout (ms)'),
42
44
  dnd: Schema.object({
43
- start: Schema.string().default('').description('Do-not-disturb start (HH:MM, empty disables)'),
44
- end: Schema.string().default('').description('Do-not-disturb end (HH:MM, cross-midnight ok)'),
45
+ start: Schema.string().default('').volatile().description('Do-not-disturb start (HH:MM, empty disables)'),
46
+ end: Schema.string().default('').volatile().description('Do-not-disturb end (HH:MM, cross-midnight ok)'),
45
47
  }).description('DND window: events are logged but no local/webhook emission'),
46
- includeSession: Schema.boolean().volatile().default(true).description('Include the session line in notification text'),
47
- includeDuration: Schema.boolean().volatile().default(true).description('Include the duration line in notification text'),
48
- excludeSessionPrefixes: Schema.array(Schema.string()).volatile()
48
+ includeSession: Schema.boolean().default(true).volatile().description('Include the session line in notification text'),
49
+ includeDuration: Schema.boolean().default(true).volatile().description('Include the duration line in notification text'),
50
+ excludeSessionPrefixes: Schema.array(Schema.string())
49
51
  .default([])
52
+ .volatile()
50
53
  .description('Skip notifications when session id starts with any prefix (e.g. msgw- for messenger-gateway)'),
54
+ webPush: Schema.object({
55
+ enabled: Schema.boolean().default(false).volatile()
56
+ .description('Web Push (PWA) channel: send service-worker push notifications that reach devices with every dsh tab closed'),
57
+ onlyWhenAway: Schema.boolean().default(true).volatile()
58
+ .description('Skip web push while a browser client is connected to the SSE stream'),
59
+ subject: Schema.string().default('mailto:notify@localhost').volatile()
60
+ .description('VAPID subject (mailto: or https: contact URL) advertised to push services'),
61
+ stateFile: Schema.string().default('').volatile()
62
+ .description('State file for VAPID keys and push subscriptions (default: $DSH_HOME/plugin-notify-state.json)'),
63
+ }).description('Web Push (PWA) channel configuration'),
51
64
  });
52
65
 
53
66
  function isExcludedSession(sessionId, prefixes) {
@@ -59,6 +72,17 @@ function isExcludedSession(sessionId, prefixes) {
59
72
  return false;
60
73
  }
61
74
 
75
+ // Unwrap Volatile boxes before any caller reads a value. A volatile field holds a box
76
+ // rather than its value, and the Loader mutates those boxes in place.
77
+ export function plainConfig(value) {
78
+ if (value === null || typeof value !== 'object') return value
79
+ if (Array.isArray(value)) return value.map(plainConfig)
80
+ if (typeof value.get === 'function') return plainConfig(value.get())
81
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plainConfig(v)]))
82
+ }
83
+
84
+ export const unwrapConfig = plainConfig
85
+
62
86
  const DEFAULT_EVENTS = ['task_done', 'error', 'approval_requested'];
63
87
  /** Per-session last `turn/start` epoch ms, for turn-duration reporting. */
64
88
  export const turnStarts = new Map();
@@ -252,13 +276,416 @@ export function closeAllSseClients() {
252
276
  sseClients.clear();
253
277
  }
254
278
 
255
- function mountWebServer(targetCtx, hostCtx) {
279
+ // ---------------------------------------------------------------------------
280
+ // Web Push (PWA) channel: VAPID keys + subscription state, push routes, sender.
281
+ // Reaches devices with every dsh tab closed — the SSE/local channels cannot.
282
+ // ---------------------------------------------------------------------------
283
+
284
+ /** Service worker served at GET /dsh-plugin-notify/sw.js (no secrets inside). */
285
+ export const SW_SOURCE = [
286
+ 'self.addEventListener("push", (event) => {',
287
+ ' let data = {};',
288
+ ' try { data = event.data ? event.data.json() : {}; } catch (_) { data = {}; }',
289
+ ' const title = data.title || "DeepSeek Harness";',
290
+ ' event.waitUntil(self.registration.showNotification(title, {',
291
+ ' body: data.body || "",',
292
+ ' tag: data.tag || "dsh-notify",',
293
+ ' data: { sessionId: data.sessionId || "" },',
294
+ ' }));',
295
+ '});',
296
+ 'self.addEventListener("notificationclick", (event) => {',
297
+ ' event.notification.close();',
298
+ ' const sessionId = (event.notification.data && event.notification.data.sessionId) || "";',
299
+ ' const target = sessionId ? "./?dshNotifySession=" + encodeURIComponent(sessionId) : "./";',
300
+ ' event.waitUntil((async () => {',
301
+ ' const clientList = await self.clients.matchAll({ type: "window", includeUncontrolled: true });',
302
+ ' for (const client of clientList) {',
303
+ ' let pathname = "";',
304
+ ' try { pathname = new URL(client.url).pathname; } catch (_) { continue; }',
305
+ ' if (pathname.endsWith("/") || pathname.endsWith("/index.html")) {',
306
+ ' await client.focus();',
307
+ ' if (sessionId) client.postMessage({ type: "dsh-notify-jump", sessionId });',
308
+ ' return;',
309
+ ' }',
310
+ ' }',
311
+ ' await self.clients.openWindow(target);',
312
+ ' })());',
313
+ '});',
314
+ ].join('\n');
315
+
316
+ /** Env overrides so users can keep VAPID keys out of the state file entirely. */
317
+ export const VAPID_PUBLIC_ENV = 'DSH_NOTIFY_VAPID_PUBLIC_KEY';
318
+ export const VAPID_PRIVATE_ENV = 'DSH_NOTIFY_VAPID_PRIVATE_KEY';
319
+
320
+ /** P-256 VAPID keypair as base64url — raw 65-byte point / raw 32-byte scalar,
321
+ * the exact shapes web-push.generateVAPIDKeys() produces (applicationServerKey needs the raw point).
322
+ * ECDH, not DER slicing: the pkcs8 DER ends with the public key, so slice(-32) would grab point bytes. */
323
+ export function generateVapidKeys() {
324
+ const ecdh = crypto.createECDH('prime256v1');
325
+ ecdh.generateKeys();
326
+ return {
327
+ publicKey: ecdh.getPublicKey('base64url'),
328
+ privateKey: ecdh.getPrivateKey('base64url'),
329
+ };
330
+ }
331
+
332
+ export function resolveStatePath(webPushConfig = {}) {
333
+ if (webPushConfig && typeof webPushConfig.stateFile === 'string' && webPushConfig.stateFile.trim())
334
+ return webPushConfig.stateFile.trim();
335
+ const home = (process.env.DSH_HOME && process.env.DSH_HOME.trim())
336
+ || path.join(os.homedir(), '.dsh');
337
+ return path.join(home, 'plugin-notify-state.json');
338
+ }
339
+
340
+ export async function loadState(statePath) {
341
+ try {
342
+ const raw = await fsp.readFile(statePath, 'utf8');
343
+ const parsed = JSON.parse(raw);
344
+ return {
345
+ vapid: (parsed && typeof parsed.vapid === 'object' && parsed.vapid) || null,
346
+ subscriptions: Array.isArray(parsed?.subscriptions) ? parsed.subscriptions : [],
347
+ };
348
+ } catch {
349
+ return { vapid: null, subscriptions: [] };
350
+ }
351
+ }
352
+
353
+ /** Atomic-ish persist: temp file + rename, serialized per path to avoid RMW races. */
354
+ const writeChains = new Map();
355
+ export function saveState(statePath, state) {
356
+ const prev = writeChains.get(statePath) || Promise.resolve();
357
+ const next = prev.catch(() => {}).then(async () => {
358
+ await fsp.mkdir(path.dirname(statePath), { recursive: true });
359
+ const tmp = `${statePath}.${process.pid}.${Date.now()}.tmp`;
360
+ await fsp.writeFile(tmp, JSON.stringify(state, null, 2), 'utf8');
361
+ await fsp.rename(tmp, statePath);
362
+ });
363
+ writeChains.set(statePath, next);
364
+ return next;
365
+ }
366
+
367
+ /** Env keys win; otherwise state file; otherwise generate once and persist. */
368
+ export async function getVapidKeys(ctx, webPushConfig = {}) {
369
+ const envPub = process.env[VAPID_PUBLIC_ENV];
370
+ const envPriv = process.env[VAPID_PRIVATE_ENV];
371
+ if (envPub && envPriv) {
372
+ return { publicKey: envPub, privateKey: envPriv };
373
+ }
374
+ const statePath = resolveStatePath(webPushConfig);
375
+ const state = await loadState(statePath);
376
+ if (state.vapid && state.vapid.publicKey && state.vapid.privateKey)
377
+ return state.vapid;
378
+ const keys = generateVapidKeys();
379
+ state.vapid = keys;
380
+ await saveState(statePath, state);
381
+ ctx?.logger?.info?.(`[plugin-notify] generated VAPID keys → ${statePath}`);
382
+ return keys;
383
+ }
384
+
385
+ function isValidSubscription(sub) {
386
+ return Boolean(
387
+ sub
388
+ && typeof sub.endpoint === 'string'
389
+ && /^https:\/\//i.test(sub.endpoint)
390
+ && sub.keys
391
+ && typeof sub.keys.p256dh === 'string' && sub.keys.p256dh.length > 0
392
+ && typeof sub.keys.auth === 'string' && sub.keys.auth.length > 0,
393
+ );
394
+ }
395
+
396
+ export async function addSubscription(statePath, sub) {
397
+ if (!isValidSubscription(sub))
398
+ throw new Error('invalid push subscription (need https endpoint and keys.p256dh/auth)');
399
+ const state = await loadState(statePath);
400
+ const clean = { endpoint: sub.endpoint, keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth }, addedAt: Date.now() };
401
+ const existing = state.subscriptions.findIndex((s) => s.endpoint === clean.endpoint);
402
+ if (existing >= 0) state.subscriptions[existing] = clean;
403
+ else state.subscriptions.push(clean);
404
+ await saveState(statePath, state);
405
+ return state.subscriptions.length;
406
+ }
407
+
408
+ export async function removeSubscription(statePath, endpoint) {
409
+ const state = await loadState(statePath);
410
+ const before = state.subscriptions.length;
411
+ state.subscriptions = state.subscriptions.filter((s) => s.endpoint !== endpoint);
412
+ if (state.subscriptions.length !== before)
413
+ await saveState(statePath, state);
414
+ return before - state.subscriptions.length;
415
+ }
416
+
417
+ export async function listSubscriptions(statePath) {
418
+ const state = await loadState(statePath);
419
+ return state.subscriptions;
420
+ }
421
+
422
+ let webPushLibrary = null;
423
+ /** Test seam: inject a stub with async sendNotification(sub, payload, options). */
424
+ export function setWebPushLibrary(lib) {
425
+ webPushLibrary = lib;
426
+ }
427
+ async function getWebPush() {
428
+ if (!webPushLibrary) {
429
+ const mod = await import('web-push');
430
+ webPushLibrary = mod.default ?? mod;
431
+ }
432
+ return webPushLibrary;
433
+ }
434
+
435
+ /** Compact payload for the 4 KB push budget: pointer + label, never secret material. */
436
+ export function webPushPayload(n, config = {}) {
437
+ const badge = n.kind === 'task_done' ? '✅' : n.kind === 'error' ? '⚠️' : '⏸️';
438
+ const kindLabel = n.kind === 'task_done' ? 'Task done' : n.kind === 'error' ? 'Error' : 'Approval needed';
439
+ const title = `${badge} ${kindLabel}: ${n.title || n.sessionId || ''}`.trim();
440
+ const bodyLines = [];
441
+ if (n.summary) bodyLines.push(n.summary);
442
+ if (n.reason) bodyLines.push(`Reason: ${n.reason}`);
443
+ if ((config.includeDuration ?? true) && n.durationMs !== undefined)
444
+ bodyLines.push(`Duration: ${formatDuration(n.durationMs)}`);
445
+ if ((config.includeSession ?? true) && n.sessionId)
446
+ bodyLines.push(`Session: ${n.sessionId}`);
447
+ return {
448
+ kind: n.kind,
449
+ title,
450
+ body: bodyLines.join('\n'),
451
+ sessionId: n.sessionId || '',
452
+ tag: `dsh-notify-${n.sessionId || 'any'}`,
453
+ ts: Date.now(),
454
+ };
455
+ }
456
+
457
+ const inFlightDeliveries = new Set();
458
+ /** Test seam: resolves once every in-flight deliverWebPush has finished. */
459
+ export async function settleWebPushDeliveries() {
460
+ while (inFlightDeliveries.size > 0) {
461
+ await Promise.allSettled([...inFlightDeliveries]);
462
+ }
463
+ }
464
+
465
+ export function deliverWebPush(ctx, n, config = {}) {
466
+ let tracked;
467
+ tracked = (async () => {
468
+ const wp = config.webPush || {};
469
+ const statePath = resolveStatePath(wp);
470
+ const subscriptions = await listSubscriptions(statePath);
471
+ if (subscriptions.length === 0) return 0;
472
+ const keys = await getVapidKeys(ctx, wp);
473
+ const webpush = await getWebPush();
474
+ const payload = JSON.stringify(webPushPayload(n, config));
475
+ let sent = 0;
476
+ for (const sub of subscriptions) {
477
+ try {
478
+ await webpush.sendNotification(sub, payload, {
479
+ vapidDetails: {
480
+ subject: wp.subject || 'mailto:notify@localhost',
481
+ publicKey: keys.publicKey,
482
+ privateKey: keys.privateKey,
483
+ },
484
+ timeout: Math.max(config.timeoutMs ?? 5000, 1000),
485
+ });
486
+ sent++;
487
+ } catch (error) {
488
+ if (error?.statusCode === 404 || error?.statusCode === 410) {
489
+ await removeSubscription(statePath, sub.endpoint);
490
+ ctx?.logger?.info?.(`[plugin-notify] pruned dead push subscription (${error.statusCode})`);
491
+ } else {
492
+ ctx?.logger?.warn?.(`[plugin-notify] web push send failed: ${String(error)}`);
493
+ }
494
+ }
495
+ }
496
+ return sent;
497
+ })().finally(() => {
498
+ inFlightDeliveries.delete(tracked);
499
+ });
500
+ inFlightDeliveries.add(tracked);
501
+ return tracked;
502
+ }
503
+
504
+ function readJsonBody(req, limit = 64 * 1024) {
505
+ return new Promise((resolve, reject) => {
506
+ let size = 0;
507
+ const chunks = [];
508
+ req.on('data', (chunk) => {
509
+ size += chunk.length;
510
+ if (size > limit) {
511
+ reject(new Error('request body too large'));
512
+ req.destroy();
513
+ return;
514
+ }
515
+ chunks.push(chunk);
516
+ });
517
+ req.on('end', () => {
518
+ try {
519
+ resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {});
520
+ } catch {
521
+ reject(new Error('invalid JSON body'));
522
+ }
523
+ });
524
+ req.on('error', reject);
525
+ });
526
+ }
527
+
528
+ function writeJson(res, status, body, headers = {}) {
529
+ if (typeof res.writeHead === 'function') {
530
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...headers });
531
+ } else {
532
+ res.statusCode = status;
533
+ }
534
+ if (typeof res.end === 'function') res.end(JSON.stringify(body));
535
+ }
536
+
537
+ function writeText(res, status, text, headers = {}) {
538
+ if (typeof res.writeHead === 'function') {
539
+ res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8', ...headers });
540
+ } else {
541
+ res.statusCode = status;
542
+ }
543
+ if (typeof res.end === 'function') res.end(text);
544
+ }
545
+
546
+ /** Shared auth for push routes: same fence as the SSE stream. Returns true when accepted. */
547
+ export function authorizePushRequest(req, res, targetCtx) {
548
+ const conn = connectionOf(targetCtx);
549
+ if (conn && typeof conn.requestRejection === 'function') {
550
+ const rejection = conn.requestRejection(req);
551
+ if (rejection !== void 0) {
552
+ writeText(res, rejection, rejection === 401 ? 'Unauthorized' : 'Forbidden');
553
+ return false;
554
+ }
555
+ return true;
556
+ }
557
+ const remoteAddr = req.socket?.remoteAddress || req.connection?.remoteAddress;
558
+ if (!remoteAddr || !isLoopback(remoteAddr)) {
559
+ writeText(res, 403, 'Forbidden');
560
+ return false;
561
+ }
562
+ return true;
563
+ }
564
+
565
+ export function handleServiceWorker(req, res) {
566
+ if (req.method !== 'GET') {
567
+ writeText(res, 405, 'Method Not Allowed', { allow: 'GET' });
568
+ return;
569
+ }
570
+ // Unauthenticated on purpose: static, no secrets; the header lets the SW
571
+ // control the app root even though the script lives under /dsh-plugin-notify/.
572
+ if (typeof res.writeHead === 'function') {
573
+ res.writeHead(200, {
574
+ 'Content-Type': 'text/javascript; charset=utf-8',
575
+ 'Service-Worker-Allowed': '/',
576
+ 'Cache-Control': 'no-store',
577
+ });
578
+ } else {
579
+ res.statusCode = 200;
580
+ }
581
+ if (typeof res.end === 'function') res.end(SW_SOURCE);
582
+ }
583
+
584
+ export async function handlePushKey(req, res, targetCtx, getConfig) {
585
+ const getConfigU = () => unwrapConfig(getConfig());
586
+ if (req.method !== 'GET') return writeText(res, 405, 'Method Not Allowed', { allow: 'GET' });
587
+ if (!authorizePushRequest(req, res, targetCtx)) return;
588
+ const cfg = getConfigU();
589
+ if (!(cfg.webPush && cfg.webPush.enabled)) return writeText(res, 404, 'web push disabled');
590
+ const keys = await getVapidKeys(targetCtx, cfg.webPush || {});
591
+ writeJson(res, 200, { publicKey: keys.publicKey, subject: cfg.webPush.subject || 'mailto:notify@localhost' });
592
+ }
593
+
594
+ export async function handlePushSubscribe(req, res, targetCtx, getConfig) {
595
+ const getConfigU = () => unwrapConfig(getConfig());
596
+ if (req.method !== 'POST') return writeText(res, 405, 'Method Not Allowed', { allow: 'POST' });
597
+ if (!authorizePushRequest(req, res, targetCtx)) return;
598
+ const cfg = getConfigU();
599
+ if (!(cfg.webPush && cfg.webPush.enabled)) return writeText(res, 404, 'web push disabled');
600
+ let sub;
601
+ try {
602
+ sub = await readJsonBody(req);
603
+ } catch (error) {
604
+ return writeText(res, 400, String(error.message || error));
605
+ }
606
+ const statePath = resolveStatePath(cfg.webPush || {});
607
+ try {
608
+ const count = await addSubscription(statePath, sub);
609
+ targetCtx?.logger?.info?.(`[plugin-notify] push subscribed (${count} total)`);
610
+ writeJson(res, 200, { ok: true, count });
611
+ } catch (error) {
612
+ writeText(res, 400, String(error.message || error));
613
+ }
614
+ }
615
+
616
+ export async function handlePushUnsubscribe(req, res, targetCtx, getConfig) {
617
+ const getConfigU = () => unwrapConfig(getConfig());
618
+ if (req.method !== 'POST') return writeText(res, 405, 'Method Not Allowed', { allow: 'POST' });
619
+ if (!authorizePushRequest(req, res, targetCtx)) return;
620
+ const cfg = getConfigU();
621
+ if (!(cfg.webPush && cfg.webPush.enabled)) return writeText(res, 404, 'web push disabled');
622
+ let body;
623
+ try {
624
+ body = await readJsonBody(req);
625
+ } catch (error) {
626
+ return writeText(res, 400, String(error.message || error));
627
+ }
628
+ if (!body || typeof body.endpoint !== 'string') return writeText(res, 400, 'endpoint required');
629
+ const removed = await removeSubscription(resolveStatePath(cfg.webPush || {}), body.endpoint);
630
+ writeJson(res, 200, { ok: true, removed });
631
+ }
632
+
633
+ export async function handlePushTest(req, res, targetCtx, getConfig) {
634
+ const getConfigU = () => unwrapConfig(getConfig());
635
+ if (req.method !== 'POST') return writeText(res, 405, 'Method Not Allowed', { allow: 'POST' });
636
+ if (!authorizePushRequest(req, res, targetCtx)) return;
637
+ const cfg = getConfigU();
638
+ if (!(cfg.webPush && cfg.webPush.enabled)) return writeText(res, 404, 'web push disabled');
639
+ const sent = await deliverWebPush(targetCtx, {
640
+ kind: 'task_done',
641
+ title: 'Test notification',
642
+ sessionId: 'test',
643
+ summary: 'If you can read this, the dsh-plugin-notify web push channel works.',
644
+ }, cfg);
645
+ writeJson(res, 200, { ok: true, sent });
646
+ }
647
+
648
+ function mountWebServer(targetCtx, hostCtx, getConfig) {
256
649
  if (!targetCtx?.webServer?.register) return () => {};
257
- const routeDef = {
258
- kind: 'exact',
259
- path: '/dsh-plugin-notify/events',
260
- handler: (req, res) => handleSseConnection(req, res, targetCtx),
650
+ // Async route wrapper: an unexpected rejection becomes a 500 instead of an
651
+ // unhandled rejection; authorized handlers have already written their response.
652
+ const asyncRoute = (handler) => (req, res) => {
653
+ Promise.resolve(handler(req, res)).catch(() => {
654
+ try { writeText(res, 500, 'internal error'); } catch { /* ignore */ }
655
+ });
261
656
  };
657
+ const routeDefs = [
658
+ {
659
+ kind: 'exact',
660
+ path: '/dsh-plugin-notify/events',
661
+ handler: (req, res) => handleSseConnection(req, res, targetCtx),
662
+ },
663
+ {
664
+ kind: 'exact',
665
+ path: '/dsh-plugin-notify/sw.js',
666
+ handler: (req, res) => handleServiceWorker(req, res),
667
+ },
668
+ {
669
+ kind: 'exact',
670
+ path: '/dsh-plugin-notify/push/key',
671
+ handler: asyncRoute((req, res) => handlePushKey(req, res, targetCtx, getConfig)),
672
+ },
673
+ {
674
+ kind: 'exact',
675
+ path: '/dsh-plugin-notify/push/subscribe',
676
+ handler: asyncRoute((req, res) => handlePushSubscribe(req, res, targetCtx, getConfig)),
677
+ },
678
+ {
679
+ kind: 'exact',
680
+ path: '/dsh-plugin-notify/push/unsubscribe',
681
+ handler: asyncRoute((req, res) => handlePushUnsubscribe(req, res, targetCtx, getConfig)),
682
+ },
683
+ {
684
+ kind: 'exact',
685
+ path: '/dsh-plugin-notify/push/test',
686
+ handler: asyncRoute((req, res) => handlePushTest(req, res, targetCtx, getConfig)),
687
+ },
688
+ ];
262
689
 
263
690
  let timer = null;
264
691
  const startHeartbeat = () => {
@@ -281,8 +708,10 @@ function mountWebServer(targetCtx, hostCtx) {
281
708
  clearInterval(timer);
282
709
  timer = null;
283
710
  }
284
- if (typeof unregister === 'function') {
285
- try { unregister(); } catch { /* ignore */ }
711
+ if (Array.isArray(unregister)) {
712
+ for (const fn of unregister) {
713
+ try { fn(); } catch { /* ignore */ }
714
+ }
286
715
  unregister = null;
287
716
  }
288
717
  closeAllSseClients();
@@ -290,14 +719,14 @@ function mountWebServer(targetCtx, hostCtx) {
290
719
 
291
720
  if (typeof targetCtx.effect === 'function') {
292
721
  targetCtx.effect(() => {
293
- unregister = targetCtx.webServer.register(routeDef);
722
+ unregister = routeDefs.map((routeDef) => targetCtx.webServer.register(routeDef));
294
723
  return () => cleanup();
295
724
  }, 'dsh-plugin-notify: sse events');
296
725
  targetCtx.effect(() => {
297
726
  return startHeartbeat();
298
727
  }, 'dsh-plugin-notify: sse heartbeat');
299
728
  } else {
300
- unregister = targetCtx.webServer.register(routeDef);
729
+ unregister = routeDefs.map((routeDef) => targetCtx.webServer.register(routeDef));
301
730
  startHeartbeat();
302
731
  }
303
732
 
@@ -315,7 +744,7 @@ export function apply(ctx, config = {}) {
315
744
  if (target?.webServer?.register) {
316
745
  webServerMounted = true;
317
746
  const effectiveTarget = target?.connection ? target : (ctx?.connection ? ctx : target);
318
- unmountWebServer = mountWebServer(effectiveTarget, ctx);
747
+ unmountWebServer = mountWebServer(effectiveTarget, ctx, () => getConfig());
319
748
  }
320
749
  };
321
750
 
@@ -338,21 +767,32 @@ export function apply(ctx, config = {}) {
338
767
 
339
768
  if (typeof ctx.inject === 'function') {
340
769
  ctx.inject(['settings'], (sctx) => {
770
+ if (typeof sctx.settings?.register === 'function') {
771
+ const scope = sctx.settings.register(NS, Config, { base: config ?? {} });
772
+ getConfig = () => scope.get() ?? config ?? {};
773
+ return;
774
+ }
341
775
  const row = (() => { try { return sctx.settings?.describe?.().find((r) => r && r.ns === NS); } catch { return null; } })();
342
776
  const scope = {
343
777
  get: () => row?.value ?? config ?? {},
344
- replace: async (next) => { try { return await sctx.settings?.replace?.(NS, next, row?.revision); } catch {} },
778
+ replace: async (next) => {
779
+ try {
780
+ return await sctx.settings?.replace?.(NS, next, row?.revision);
781
+ } catch (err) {
782
+ ctx?.logger?.warn?.(`[plugin-notify] settings replace failed: ${String(err)}`);
783
+ }
784
+ },
345
785
  };
346
786
  getConfig = () => scope.get() ?? config ?? {};
347
787
  });
348
- ctx.inject(['webServer'], (wctx) => tryMountWebServer(wctx));
788
+ ctx.inject(['webServer'], (wctx) => tryMountWebServer(wctx))
349
789
  }
350
790
  if (ctx.webServer) {
351
791
  tryMountWebServer(ctx);
352
792
  }
353
793
 
354
794
  const dispatch = (n) => {
355
- const cfg = getConfig();
795
+ const cfg = unwrapConfig(getConfig());
356
796
  const dnd = cfg.dnd;
357
797
  if (inDnd(dnd)) {
358
798
  (ctx?.logger?.debug ?? ctx?.logger?.info)?.(`[plugin-notify] ${n.kind} · ${n.title} · session ${n.sessionId} · DND (${dnd?.start}-${dnd?.end}), logged only`);
@@ -381,6 +821,19 @@ export function apply(ctx, config = {}) {
381
821
  .catch((error) => {
382
822
  ctx?.logger?.warn?.(`[plugin-notify] webhook resolve/send failed: ${String(error)}`);
383
823
  });
824
+
825
+ // Web Push (PWA) channel: reaches devices with every dsh tab closed.
826
+ const wp = cfg.webPush;
827
+ if (wp && wp.enabled) {
828
+ const away = sseClients.size === 0;
829
+ if (away || wp.onlyWhenAway === false) {
830
+ deliverWebPush(ctx, n, cfg).catch((error) => {
831
+ ctx?.logger?.warn?.(`[plugin-notify] web push dispatch failed: ${String(error)}`);
832
+ });
833
+ } else {
834
+ (ctx?.logger?.debug ?? ctx?.logger?.info)?.(`[plugin-notify] ${n.kind} · session ${n.sessionId} · web push skipped (clients connected)`);
835
+ }
836
+ }
384
837
  };
385
838
 
386
839
  ctx.on('session/event', (session, event) => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-plugin-notify",
3
- "version": "0.3.8",
4
- "description": "DSH plugin: audio chimes, cross-session toasts, desktop push, and IM webhooks for turn completion, errors, and approvals.",
3
+ "version": "0.4.0",
4
+ "description": "DSH plugin: audio chimes, cross-session toasts, desktop push, IM webhooks, and web push (PWA) for turn completion, errors, and approvals.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./lib/index.js",
@@ -35,6 +35,9 @@
35
35
  "pretest": "node --check lib/index.js && node --check lib/client.js",
36
36
  "test": "node --test test/*.test.mjs"
37
37
  },
38
+ "dependencies": {
39
+ "web-push": "^3.6.7"
40
+ },
38
41
  "peerDependencies": {
39
42
  "@deepseek-ai/cordis": "^4.0.1",
40
43
  "@deepseek-ai/dsh-session": "^0.1.7-rc.2 || ^0.2.0-rc.1",
@@ -45,7 +48,8 @@
45
48
  "@deepseek-ai/cordis": "^4.0.1",
46
49
  "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
47
50
  "@deepseek-ai/dsh-credentials": "^0.1.0-rc.8",
48
- "@deepseek-ai/schemastery": "^3.18.4"
51
+ "@deepseek-ai/schemastery": "^3.18.4",
52
+ "web-push": "^3.6.7"
49
53
  },
50
54
  "repository": {
51
55
  "type": "git",
@@ -66,6 +70,8 @@
66
70
  "notifications",
67
71
  "webhooks",
68
72
  "desktop-push",
73
+ "web-push",
74
+ "pwa",
69
75
  "audio-chimes",
70
76
  "toasts"
71
77
  ]