@goodandready/dsh-plugin-notify 0.4.4 → 0.4.6

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 CHANGED
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.4.6] - 2026-10-22
8
+
9
+ ### Security
10
+ - **Web Push Endpoint Allowlist & SSRF Protection** (#64): Enforce strict validation of subscription endpoints against canonical Web Push gateways (Google FCM, Mozilla Autopush, Apple Web Push, Microsoft WNS) with optional `webPush.allowedHosts`; reject loopback, private IPv4/IPv6, link-local, userinfo, and non-standard ports.
11
+ - **Push Endpoint Masking in Logs** (#64): Mask subscription endpoint tokens in server logs (`maskEndpoint`) and delivery error messages to prevent token exfiltration.
12
+ - **Subscription Capacity Bound** (#64): Constrain total stored subscriptions (max 100) to prevent state file exhaustion DoS.
13
+ - **Dead & Disallowed Endpoint Pruning** (#64): Prune any invalid or unapproved endpoints during `deliverWebPush` without dispatching requests.
14
+
15
+ ## [0.4.5] - 2026-10-19
16
+
17
+ ### Fixed
18
+ - **Detached Logger Binding** (#62, GitHub #3): Guard logger method invocations with `safeLog(ctx, message)` binding receiver `this` (`fn.call(logger, message)`) to prevent `TypeError: this is not a function` in Cordis 0.2 `LoggerService`.
19
+ - **Object Settings Persistence** (#62, GitHub #3): Decompose non-leaf object configuration fields (`webhooks`, `dnd`, `webPush`) into individual leaf paths (`['webhooks', key]`) and save atomically via `scope.mutate(ops)` with fallback to `scope.set`, passing `dsh-settings` volatile path validation.
20
+ - **Session Events & Title Cascade** (#62, GitHub #3): Add `eventsOf(session)` supporting append-only `session.log`, `session.eventsSnapshot`, and `session.snapshotEvents()`; add `cachedTitle(session)` reading projected title cache from disk (`storages/session_projcache/sessions/<id>.json`), preventing fallback to raw UUIDs.
21
+ - **Session Navigation Priority** (#62, GitHub #3): Target `uiWorkspace.openSession(sessionId)` as the primary navigation service in `navigateToSession` with graceful fallbacks.
22
+
7
23
  ## [0.4.4] - 2026-10-15
8
24
 
9
25
  ### Fixed
package/README.md CHANGED
@@ -151,6 +151,7 @@ Put each webhook URL into **Settings → Credentials**. In the plugin card, type
151
151
  | `webPush.onlyWhenAway` | boolean | `true` | Skip web push while a browser client is connected to the SSE stream. |
152
152
  | `webPush.subject` | string | `mailto:notify@localhost` | VAPID subject (mailto:/https: contact) advertised to push services. |
153
153
  | `webPush.stateFile` | string | empty | State file for VAPID keys and subscriptions (default `$DSH_HOME/plugin-notify-state.json`). |
154
+ | `webPush.allowedHosts` | string[] | `[]` | Optional list of extra allowed push endpoint hostnames or wildcards (default allowlist covers Google FCM, Mozilla, Apple, Microsoft). |
154
155
  | `webhooks.*` | string | empty | Credential **name** whose value is the webhook URL. Empty disables the channel. |
155
156
  | `events` | string[] | `task_done`, `error`, `approval_requested` | Event whitelist. Empty restores the default three. |
156
157
  | `local` | boolean | `true` | macOS `osascript` popup; ignored on other platforms. |
package/README.ru.md CHANGED
@@ -159,6 +159,7 @@ dsh plugin --profile web add @goodandready/dsh-plugin-notify
159
159
  | `webPush.onlyWhenAway` | boolean | `true` | Пропускать web push, пока подключён браузерный клиент. |
160
160
  | `webPush.subject` | string | `mailto:notify@localhost` | VAPID-контакт (mailto:/https:), передаваемый push-сервисам. |
161
161
  | `webPush.stateFile` | string | пусто | Файл состояния для VAPID-ключей и подписок (по умолчанию `$DSH_HOME/plugin-notify-state.json`). |
162
+ | `webPush.allowedHosts` | string[] | `[]` | Дополнительные доверенные хосты/маски push-эндпоинтов (по умолчанию строго разрешены Google FCM, Mozilla, Apple, Microsoft). |
162
163
 
163
164
  Сырой `http(s)://` в `webhooks.*` всё ещё отправляется с предупреждением. Перенесите URL в Credentials.
164
165
 
package/README.zh.md CHANGED
@@ -158,6 +158,7 @@ dsh plugin --profile web add @goodandready/dsh-plugin-notify
158
158
  | `webPush.onlyWhenAway` | boolean | `true` | 有浏览器客户端连接时跳过网页推送。 |
159
159
  | `webPush.subject` | string | `mailto:notify@localhost` | 向推送服务公示的 VAPID 联系方式(mailto:/https:)。 |
160
160
  | `webPush.stateFile` | string | 空 | VAPID 密钥与订阅的状态文件(默认 `$DSH_HOME/plugin-notify-state.json`)。 |
161
+ | `webPush.allowedHosts` | string[] | `[]` | 额外允许的推送端点域名或通配符列表(默认严格仅允许 Google FCM、Mozilla、Apple、Microsoft)。 |
161
162
 
162
163
  `webhooks.*` 中残留的原始 `http(s)://` 仍会发送,但会给出弃用警告。请迁移到凭据。
163
164
 
package/lib/client.js CHANGED
@@ -260,15 +260,33 @@ window.__ModuleLoader__.load({
260
260
  function navigateToSession(ctx, sessionId) {
261
261
  if (!sessionId) return
262
262
  try {
263
- if (ctx && ctx.sessions && typeof ctx.sessions.activate === 'function') {
264
- ctx.sessions.activate(sessionId)
263
+ const ws = (ctx && typeof ctx.get === 'function' ? ctx.get('uiWorkspace') : null) || ctx?.uiWorkspace
264
+ if (ws && typeof ws.openSession === 'function') {
265
+ ws.openSession(sessionId)
265
266
  return
266
267
  }
267
268
  } catch (_err) {
268
- // best effort: session navigation fallback
269
+ // best effort: uiWorkspace navigation fallback
270
+ }
271
+ try {
272
+ const s = (ctx && typeof ctx.get === 'function' ? ctx.get('sessions') : null) || ctx?.sessions
273
+ if (s && typeof s.activate === 'function') {
274
+ s.activate(sessionId)
275
+ return
276
+ }
277
+ if (s && typeof s.open === 'function') {
278
+ s.open(sessionId)
279
+ return
280
+ }
281
+ } catch (_err) {
282
+ // best effort: session service fallback
269
283
  }
270
284
  if (typeof window !== 'undefined' && window.location) {
271
- window.location.hash = `session-${sessionId}`
285
+ try {
286
+ window.location.hash = `session-${sessionId}`
287
+ } catch (_err) {
288
+ // best effort: location hash update fallback
289
+ }
272
290
  }
273
291
  }
274
292
 
@@ -371,11 +389,9 @@ window.__ModuleLoader__.load({
371
389
  const timer = setInterval(() => {
372
390
  tries++
373
391
  try {
374
- if (ctx && ctx.sessions && typeof ctx.sessions.activate === 'function') {
375
- ctx.sessions.activate(sessionId)
376
- clearInterval(timer)
377
- return
378
- }
392
+ navigateToSession(ctx, sessionId)
393
+ clearInterval(timer)
394
+ return
379
395
  } catch (_err) {
380
396
  // retry until the budget is exhausted
381
397
  }
@@ -742,9 +758,33 @@ window.__ModuleLoader__.load({
742
758
  includeDuration: !!draft.includeDuration,
743
759
  excludeSessionPrefixes: splitList(draft.excludePrefixes),
744
760
  }
761
+ const ops = []
745
762
  for (const [k, v] of Object.entries(payload)) {
746
- try { await scope.set(k, v) }
747
- catch (e) { broken.push(k + ': ' + (e && e.message || String(e))) }
763
+ if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
764
+ for (const [subKey, subVal] of Object.entries(v)) {
765
+ ops.push({ op: 'set', path: [k, subKey], value: subVal })
766
+ }
767
+ } else {
768
+ ops.push({ op: 'set', path: [k], value: v })
769
+ }
770
+ }
771
+
772
+ if (scope && typeof scope.mutate === 'function') {
773
+ try {
774
+ const ok = await scope.mutate(ops)
775
+ if (ok === false) broken.push('mutate refused by host')
776
+ } catch (e) {
777
+ broken.push(e && e.message || String(e))
778
+ }
779
+ } else if (scope && typeof scope.set === 'function') {
780
+ for (const op of ops) {
781
+ try {
782
+ if (op.path.length === 1) await scope.set(op.path[0], op.value)
783
+ else await scope.set(op.path.join('.'), op.value)
784
+ } catch (e) {
785
+ broken.push(op.path.join('.') + ': ' + (e && e.message || String(e)))
786
+ }
787
+ }
748
788
  }
749
789
  setSaving(false)
750
790
  if (broken.length) { setErr(t('settings.saveFailed') + broken.join('; ')); return }
package/lib/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import crypto from 'node:crypto';
3
+ import net from 'node:net';
3
4
  import fs from 'node:fs';
4
5
  import fsp from 'node:fs/promises';
5
6
  import os from 'node:os';
@@ -62,6 +63,8 @@ export const Config = Schema.object({
62
63
  .description('VAPID subject (mailto: or https: contact URL) advertised to push services'),
63
64
  stateFile: Schema.string().default('').volatile()
64
65
  .description('State file for VAPID keys and push subscriptions (default: $DSH_HOME/plugin-notify-state.json)'),
66
+ allowedHosts: Schema.array(Schema.string()).default([]).volatile()
67
+ .description('Optional list of additional custom push endpoint hostnames or wildcards (e.g. push.mycorp.internal)'),
65
68
  }).description('Web Push (PWA) channel configuration'),
66
69
  });
67
70
 
@@ -398,25 +401,118 @@ export async function getVapidKeys(ctx, webPushConfig = {}) {
398
401
  return keys;
399
402
  }
400
403
 
401
- function isValidSubscription(sub) {
404
+ export const DEFAULT_ALLOWED_PUSH_HOST_PATTERNS = [
405
+ // Google FCM / Android
406
+ /^fcm\.googleapis\.com$/i,
407
+ /^[a-z0-9-]+\.fcm\.googleapis\.com$/i,
408
+ /^android\.googleapis\.com$/i,
409
+ // Mozilla Firefox
410
+ /^updates\.push\.services\.mozilla\.com$/i,
411
+ /^(?:[a-z0-9-]+\.)*push\.services\.mozilla\.com$/i,
412
+ /^(?:[a-z0-9-]+\.)*autopush\.services\.mozilla\.com$/i,
413
+ // Apple Safari (macOS / iOS)
414
+ /^web\.push\.apple\.com$/i,
415
+ /^(?:[a-z0-9-]+\.)*push\.apple\.com$/i,
416
+ // Microsoft Windows / Edge WNS
417
+ /^(?:[a-z0-9-]+\.)*notify\.windows\.com$/i,
418
+ /^(?:[a-z0-9-]+\.)*wns\.windows\.com$/i,
419
+ ];
420
+
421
+ let testAllowedPushHosts = null;
422
+ /** Test seam: temporarily allow extra test push hosts or wildcards (e.g. push.example). */
423
+ export function setAllowedPushHosts(hosts) {
424
+ testAllowedPushHosts = Array.isArray(hosts) ? hosts : (hosts ? [hosts] : null);
425
+ }
426
+ export function resetAllowedPushHosts() {
427
+ testAllowedPushHosts = null;
428
+ }
429
+
430
+ export function maskEndpoint(endpoint) {
431
+ if (typeof endpoint !== 'string') return '';
432
+ try {
433
+ const u = new URL(endpoint);
434
+ const parts = u.pathname.split('/').filter(Boolean);
435
+ const prefix = parts.slice(0, 2).join('/');
436
+ return `${u.protocol}//${u.host}/${prefix}${parts.length > 2 ? '/[masked]' : ''}`;
437
+ } catch {
438
+ return '[invalid-endpoint]';
439
+ }
440
+ }
441
+
442
+ export function isAllowedPushEndpoint(endpoint, customAllowedHosts = []) {
443
+ if (typeof endpoint !== 'string' || endpoint.length === 0 || endpoint.length > 2048) return false;
444
+ let url;
445
+ try {
446
+ url = new URL(endpoint);
447
+ } catch {
448
+ return false;
449
+ }
450
+ if (url.protocol !== 'https:') return false;
451
+ if (url.username || url.password) return false;
452
+ if (url.port && url.port !== '443') return false;
453
+
454
+ const host = url.hostname.toLowerCase().trim();
455
+ if (!host || host.length > 253) return false;
456
+ if (/[\s\x00-\x1f]/.test(host)) return false;
457
+
458
+ // Reject IP addresses (IPv4, IPv6, numeric, hex, octal, bracketed)
459
+ if (net.isIP(host) !== 0) return false;
460
+ if (/^(\d+|0x[0-9a-f]+)(\.(\d+|0x[0-9a-f]+)){0,3}$/i.test(host)) return false;
461
+ if (/^\[?[0-9a-f:]+\]?$/i.test(host)) return false;
462
+
463
+ const combinedCustom = [
464
+ ...(Array.isArray(customAllowedHosts) ? customAllowedHosts : []),
465
+ ...(Array.isArray(testAllowedPushHosts) ? testAllowedPushHosts : []),
466
+ ];
467
+
468
+ // Reject local / private TLDs unless explicitly permitted in custom allowed hosts
469
+ const blockedSuffixes = ['.localhost', '.local', '.internal', '.lan', '.home.arpa', '.invalid', '.test', '.example'];
470
+ if (host === 'localhost' || blockedSuffixes.some((s) => host.endsWith(s))) {
471
+ const explicitlyAllowed = combinedCustom.some((c) => typeof c === 'string' && c.toLowerCase().trim() === host);
472
+ if (!explicitlyAllowed) return false;
473
+ }
474
+
475
+ if (DEFAULT_ALLOWED_PUSH_HOST_PATTERNS.some((re) => re.test(host))) return true;
476
+
477
+ for (const pattern of combinedCustom) {
478
+ if (!pattern || typeof pattern !== 'string') continue;
479
+ const p = pattern.toLowerCase().trim();
480
+ if (p.startsWith('*.')) {
481
+ const root = p.slice(2);
482
+ if (host === root || host.endsWith('.' + root)) return true;
483
+ } else if (host === p) {
484
+ return true;
485
+ }
486
+ }
487
+
488
+ return false;
489
+ }
490
+
491
+ export function isValidSubscription(sub, customAllowedHosts = []) {
402
492
  return Boolean(
403
493
  sub
404
494
  && typeof sub.endpoint === 'string'
405
- && /^https:\/\//i.test(sub.endpoint)
495
+ && isAllowedPushEndpoint(sub.endpoint, customAllowedHosts)
406
496
  && sub.keys
407
497
  && typeof sub.keys.p256dh === 'string' && sub.keys.p256dh.length > 0
408
498
  && typeof sub.keys.auth === 'string' && sub.keys.auth.length > 0,
409
499
  );
410
500
  }
411
501
 
412
- export async function addSubscription(statePath, sub) {
413
- if (!isValidSubscription(sub))
414
- throw new Error('invalid push subscription (need https endpoint and keys.p256dh/auth)');
502
+ export async function addSubscription(statePath, sub, allowedHosts = [], maxSubscriptions = 100) {
503
+ if (!isValidSubscription(sub, allowedHosts))
504
+ throw new Error('invalid push subscription (need https endpoint from allowed push service and keys.p256dh/auth)');
415
505
  const state = await loadState(statePath);
416
506
  const clean = { endpoint: sub.endpoint, keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth }, addedAt: Date.now() };
417
507
  const existing = state.subscriptions.findIndex((s) => s.endpoint === clean.endpoint);
418
- if (existing >= 0) state.subscriptions[existing] = clean;
419
- else state.subscriptions.push(clean);
508
+ if (existing >= 0) {
509
+ state.subscriptions[existing] = clean;
510
+ } else {
511
+ if (typeof maxSubscriptions === 'number' && maxSubscriptions > 0 && state.subscriptions.length >= maxSubscriptions) {
512
+ throw new Error(`push subscriptions limit reached (max ${maxSubscriptions})`);
513
+ }
514
+ state.subscriptions.push(clean);
515
+ }
420
516
  await saveState(statePath, state);
421
517
  return state.subscriptions.length;
422
518
  }
@@ -490,6 +586,11 @@ export function deliverWebPush(ctx, n, config = {}) {
490
586
  const payload = JSON.stringify(webPushPayload(n, config));
491
587
  let sent = 0;
492
588
  for (const sub of subscriptions) {
589
+ if (!isValidSubscription(sub, wp.allowedHosts)) {
590
+ await removeSubscription(statePath, sub.endpoint);
591
+ ctx?.logger?.warn?.(`[plugin-notify] pruned disallowed push subscription (${maskEndpoint(sub.endpoint)})`);
592
+ continue;
593
+ }
493
594
  try {
494
595
  await webpush.sendNotification(sub, payload, {
495
596
  vapidDetails: {
@@ -505,7 +606,8 @@ export function deliverWebPush(ctx, n, config = {}) {
505
606
  await removeSubscription(statePath, sub.endpoint);
506
607
  ctx?.logger?.info?.(`[plugin-notify] pruned dead push subscription (${error.statusCode})`);
507
608
  } else {
508
- ctx?.logger?.warn?.(`[plugin-notify] web push send failed: ${String(error)}`);
609
+ const maskedErr = String(error?.message || error).replace(/https:\/\/[^\s"'`]+/g, (m) => maskEndpoint(m));
610
+ ctx?.logger?.warn?.(`[plugin-notify] web push send failed: ${maskedErr}`);
509
611
  }
510
612
  }
511
613
  }
@@ -612,7 +714,7 @@ export async function handlePushSubscribe(req, res, targetCtx, getConfig) {
612
714
  }
613
715
  const statePath = resolveStatePath(cfg.webPush || {});
614
716
  try {
615
- const count = await addSubscription(statePath, sub);
717
+ const count = await addSubscription(statePath, sub, cfg.webPush?.allowedHosts);
616
718
  targetCtx?.logger?.info?.(`[plugin-notify] push subscribed (${count} total)`);
617
719
  writeJson(res, 200, { ok: true, count });
618
720
  } catch (error) {
@@ -741,6 +843,18 @@ function mountWebServer(targetCtx, hostCtx, getConfig) {
741
843
  return cleanup;
742
844
  }
743
845
 
846
+ export function safeLog(ctx, message) {
847
+ try {
848
+ const logger = ctx?.logger;
849
+ const fn = logger?.debug ?? logger?.info;
850
+ if (typeof fn === 'function') {
851
+ fn.call(logger, message);
852
+ }
853
+ } catch {
854
+ // best effort: logger call must never throw or disrupt execution
855
+ }
856
+ }
857
+
744
858
  export function apply(ctx, config = {}) {
745
859
  let getConfig = () => unwrapConfig(config ?? {});
746
860
 
@@ -827,7 +941,7 @@ export function apply(ctx, config = {}) {
827
941
  const cfg = unwrapConfig(getConfig());
828
942
  const dnd = cfg.dnd;
829
943
  if (inDnd(dnd)) {
830
- (ctx?.logger?.debug ?? ctx?.logger?.info)?.(`[plugin-notify] ${n.kind} · ${n.title} · session ${n.sessionId} · DND (${dnd?.start}-${dnd?.end}), logged only`);
944
+ safeLog(ctx, `[plugin-notify] ${n.kind} · ${n.title} · session ${n.sessionId} · DND (${dnd?.start}-${dnd?.end}), logged only`);
831
945
  return;
832
946
  }
833
947
  const timeoutMs = cfg.timeoutMs ?? 5000;
@@ -863,7 +977,7 @@ export function apply(ctx, config = {}) {
863
977
  ctx?.logger?.warn?.(`[plugin-notify] web push dispatch failed: ${String(error)}`);
864
978
  });
865
979
  } else {
866
- (ctx?.logger?.debug ?? ctx?.logger?.info)?.(`[plugin-notify] ${n.kind} · session ${n.sessionId} · web push skipped (clients connected)`);
980
+ safeLog(ctx, `[plugin-notify] ${n.kind} · session ${n.sessionId} · web push skipped (clients connected)`);
867
981
  }
868
982
  }
869
983
  };
@@ -926,7 +1040,7 @@ function send(ctx, n, webhooks, timeoutMs, local, includeSession, includeDuratio
926
1040
  const text = renderText(n, includeSession, includeDuration);
927
1041
  const signal = AbortSignal.timeout(timeoutMs);
928
1042
  const channels = Object.keys(webhooks);
929
- (ctx?.logger?.debug ?? ctx?.logger?.info)?.(`[plugin-notify] ${n.kind} · ${n.title} · session ${n.sessionId} · channels ${channels.join(',') || 'none'} · local ${local}`);
1043
+ safeLog(ctx, `[plugin-notify] ${n.kind} · ${n.title} · session ${n.sessionId} · channels ${channels.join(',') || 'none'} · local ${local}`);
930
1044
  const post = (channel, url, body) => {
931
1045
  fetch(url, {
932
1046
  method: 'POST',
@@ -1034,20 +1148,90 @@ export function textOf(content) {
1034
1148
  return out;
1035
1149
  }
1036
1150
 
1151
+ export function eventsOf(session) {
1152
+ const pick = (v) => Array.isArray(v) ? v : (v && Array.isArray(v.entries) ? v.entries : null);
1153
+ try {
1154
+ for (const key of ['events', 'log', 'eventsSnapshot', 'history']) {
1155
+ const raw = session?.[key];
1156
+ const direct = pick(raw);
1157
+ if (direct) return direct;
1158
+ if (typeof raw === 'function') {
1159
+ const got = pick(raw.call(session));
1160
+ if (got) return got;
1161
+ }
1162
+ }
1163
+ if (typeof session?.snapshotEvents === 'function') {
1164
+ const got = pick(session.snapshotEvents());
1165
+ if (got) return got;
1166
+ }
1167
+ } catch (_err) {
1168
+ // best effort: session events resolution fallback
1169
+ }
1170
+ return [];
1171
+ }
1172
+
1173
+ export function cachedTitle(session) {
1174
+ const id = String(session?.id ?? '');
1175
+ if (!id) return '';
1176
+ try {
1177
+ const home = process.env.DSH_HOME || path.join(process.env.USERPROFILE || process.env.HOME || '', '.dsh');
1178
+ const projPath = path.join(home, 'storages', 'session_projcache', 'sessions', `${id}.json`);
1179
+ if (fs.existsSync(projPath)) {
1180
+ const content = fs.readFileSync(projPath, 'utf8');
1181
+ const doc = JSON.parse(content);
1182
+ const raw = doc?.record?.rows?.title ?? doc?.rows?.title;
1183
+ const val = typeof raw === 'string' ? raw : (raw && typeof raw.val === 'string' ? raw.val : '');
1184
+ if (val && typeof val === 'string') return val.trim();
1185
+ }
1186
+ } catch (_err) {
1187
+ // best effort: session projcache file read fallback
1188
+ }
1189
+ return '';
1190
+ }
1191
+
1037
1192
  export function sessionTitle(session) {
1038
- const events = Array.isArray(session?.events) ? session.events : [];
1193
+ if (typeof session?.title === 'string' && session.title.trim()) {
1194
+ return session.title.trim();
1195
+ }
1196
+ const headerTitle = session?.header?.title;
1197
+ if (typeof headerTitle === 'string' && headerTitle.trim()) {
1198
+ return headerTitle.trim();
1199
+ }
1200
+ if (headerTitle && typeof headerTitle.val === 'string' && headerTitle.val.trim()) {
1201
+ return headerTitle.val.trim();
1202
+ }
1203
+ const rowsTitle = session?.rows?.title;
1204
+ if (typeof rowsTitle === 'string' && rowsTitle.trim()) {
1205
+ return rowsTitle.trim();
1206
+ }
1207
+ if (rowsTitle && typeof rowsTitle.val === 'string' && rowsTitle.val.trim()) {
1208
+ return rowsTitle.val.trim();
1209
+ }
1210
+
1211
+ const events = eventsOf(session);
1212
+ for (const event of events) {
1213
+ if (event && event.type === 'session/title' && event.data?.title) {
1214
+ const t = String(event.data.title).replace(/\s+/g, ' ').trim();
1215
+ if (t) return t.length > 60 ? `${t.slice(0, 60)}…` : t;
1216
+ }
1217
+ }
1039
1218
  for (const event of events) {
1040
1219
  if (event && event.type === 'user/message') {
1041
1220
  const text = textOf(event.data?.content).replace(/\s+/g, ' ').trim();
1042
- if (text)
1043
- return text.length > 60 ? `${text.slice(0, 60)}…` : text;
1221
+ if (text) return text.length > 60 ? `${text.slice(0, 60)}…` : text;
1044
1222
  }
1045
1223
  }
1224
+
1225
+ const cached = cachedTitle(session);
1226
+ if (cached) {
1227
+ return cached.length > 60 ? `${cached.slice(0, 60)}…` : cached;
1228
+ }
1229
+
1046
1230
  return String(session?.id ?? 'session');
1047
1231
  }
1048
1232
 
1049
1233
  export function summarizeTurn(session, turn) {
1050
- const events = Array.isArray(session?.events) ? session.events : [];
1234
+ const events = eventsOf(session);
1051
1235
  let toolCalls = 0;
1052
1236
  let lastText = '';
1053
1237
  // Reverse iteration: events for current turn are at the end of the events array
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-plugin-notify",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
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",
@@ -40,14 +40,14 @@
40
40
  },
41
41
  "peerDependencies": {
42
42
  "@deepseek-ai/cordis": "^4.0.1",
43
- "@deepseek-ai/dsh-session": "^0.1.7-rc.2 || ^0.2.0-rc.1",
44
- "@deepseek-ai/dsh-credentials": "^0.1.7-rc.2 || ^0.2.0-rc.1",
43
+ "@deepseek-ai/dsh-session": "^0.1.7-rc.2 || ^0.2.0-rc.1 || ^0.2.0-rc.2",
44
+ "@deepseek-ai/dsh-credentials": "^0.1.7-rc.2 || ^0.2.0-rc.1 || ^0.2.0-rc.2",
45
45
  "@deepseek-ai/schemastery": "^3.18.4"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@deepseek-ai/cordis": "^4.0.1",
49
- "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
50
- "@deepseek-ai/dsh-credentials": "^0.1.0-rc.8",
49
+ "@deepseek-ai/dsh-session": "^0.1.7-rc.2",
50
+ "@deepseek-ai/dsh-credentials": "^0.1.7-rc.2",
51
51
  "@deepseek-ai/schemastery": "^3.18.4",
52
52
  "web-push": "^3.6.7"
53
53
  },