@goodandready/dsh-plugin-notify 0.3.3 → 0.3.4

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,7 +4,18 @@ 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.3.3
7
+ ## [0.3.4] - 2026-09-24
8
+
9
+ ### Fixed
10
+ - **Memory Leak in Turn Tracking (#32)**: Added TTL-based cleanup (30m max age) for stale `turnStarts` entries with automatic pruning on turn events.
11
+ - **SSE Connection Drops on Reverse Proxies (#33)**: Added periodic 25s keepalive ping comments (`: ping\n\n`) on `/dsh-plugin-notify/events` with unreferenced timer and automatic dead socket removal.
12
+ - **Reverse Iteration in Session Summarization (#34)**: Optimized `summarizeTurn` from O(N) forward iteration to reverse iteration with early break once the turn assistant message is found.
13
+ - **String Content Handling in textOf (#35)**: Fixed `textOf(content)` to properly return string content when passed a primitive string or strings within content arrays.
14
+
15
+ ### Documented
16
+ - **Architectural Exception for Client Bundle (#36)**: Formally documented the conscious >600-line monolithic bundle exception for `lib/client.js` in `docs/design/DESIGN.md` in accordance with DSH `ModuleLoader` architecture.
17
+
18
+ ## [0.3.3] - 2026-09-24
8
19
 
9
20
  ### Fixed
10
21
  - The settings card no longer waits for the removed `settingsScope` service. It uses `configForms` on current DeepSeek Harness (#37).
package/lib/index.js CHANGED
@@ -52,9 +52,24 @@ function isExcludedSession(sessionId, prefixes) {
52
52
 
53
53
  const DEFAULT_EVENTS = ['task_done', 'error', 'approval_requested'];
54
54
  /** Per-session last `turn/start` epoch ms, for turn-duration reporting. */
55
- const turnStarts = new Map();
55
+ export const turnStarts = new Map();
56
56
  const warnedLegacyUrls = new Set();
57
57
 
58
+ /**
59
+ * Purge turnStarts entries older than maxAgeMs (default 2 hours) to prevent
60
+ * memory leaks from abandoned or cancelled sessions.
61
+ */
62
+ export function cleanupTurnStarts(maxAgeMs = 7200000, now = Date.now()) {
63
+ let deleted = 0;
64
+ for (const [sid, start] of turnStarts.entries()) {
65
+ if (now - start > maxAgeMs) {
66
+ turnStarts.delete(sid);
67
+ deleted++;
68
+ }
69
+ }
70
+ return deleted;
71
+ }
72
+
58
73
  /** Resolve a config value to a webhook URL: credential ref (preferred), env fallback, or legacy raw URL. */
59
74
  export async function resolveWebhookValue(ctx, refOrUrl) {
60
75
  if (!refOrUrl || typeof refOrUrl !== 'string') return '';
@@ -87,7 +102,21 @@ export async function resolveWebhooks(ctx, webhooks = {}) {
87
102
  return out;
88
103
  }
89
104
 
90
- const sseClients = new Set();
105
+ export const sseClients = new Set();
106
+
107
+ export function sendSseHeartbeat() {
108
+ if (sseClients.size === 0) return 0;
109
+ let sent = 0;
110
+ for (const res of sseClients) {
111
+ try {
112
+ res.write(': ping\n\n');
113
+ sent++;
114
+ } catch {
115
+ sseClients.delete(res);
116
+ }
117
+ }
118
+ return sent;
119
+ }
91
120
 
92
121
  function isLoopback(address) {
93
122
  if (!address) return false;
@@ -179,10 +208,17 @@ function mountWebServer(targetCtx) {
179
208
  path: '/dsh-plugin-notify/events',
180
209
  handler: (req, res) => handleSseConnection(req, res),
181
210
  };
211
+ const startHeartbeat = () => {
212
+ const timer = setInterval(() => sendSseHeartbeat(), 25000);
213
+ timer?.unref?.();
214
+ return () => clearInterval(timer);
215
+ };
182
216
  if (typeof targetCtx.effect === 'function') {
183
217
  targetCtx.effect(() => targetCtx.webServer.register(routeDef), 'dsh-plugin-notify: sse events');
218
+ targetCtx.effect(startHeartbeat, 'dsh-plugin-notify: sse heartbeat');
184
219
  } else {
185
220
  targetCtx.webServer.register(routeDef);
221
+ startHeartbeat();
186
222
  }
187
223
  }
188
224
 
@@ -247,6 +283,9 @@ export function apply(ctx, config = {}) {
247
283
  const excludeSessionPrefixes = cfg.excludeSessionPrefixes ?? [];
248
284
 
249
285
  if (event.type === 'turn/start') {
286
+ if (turnStarts.size > 100) {
287
+ cleanupTurnStarts();
288
+ }
250
289
  turnStarts.set(String(session.id), Date.now());
251
290
  return;
252
291
  }
@@ -388,10 +427,14 @@ function reasonLabel(reason) {
388
427
  }
389
428
  }
390
429
 
391
- function textOf(content) {
430
+ export function textOf(content) {
431
+ if (typeof content === 'string') return content;
432
+ if (!Array.isArray(content)) return '';
392
433
  let out = '';
393
434
  for (const block of content) {
394
- if (typeof block === 'object' && block !== null && block.type === 'text') {
435
+ if (typeof block === 'string') {
436
+ out += block;
437
+ } else if (typeof block === 'object' && block !== null && block.type === 'text') {
395
438
  const text = block.text;
396
439
  if (typeof text === 'string')
397
440
  out += text;
@@ -400,27 +443,40 @@ function textOf(content) {
400
443
  return out;
401
444
  }
402
445
 
403
- function sessionTitle(session) {
404
- for (const event of session.events) {
405
- if (event.type === 'user/message') {
406
- const text = textOf(event.data.content).replace(/\s+/g, ' ').trim();
446
+ export function sessionTitle(session) {
447
+ const events = Array.isArray(session?.events) ? session.events : [];
448
+ for (const event of events) {
449
+ if (event && event.type === 'user/message') {
450
+ const text = textOf(event.data?.content).replace(/\s+/g, ' ').trim();
407
451
  if (text)
408
452
  return text.length > 60 ? `${text.slice(0, 60)}…` : text;
409
453
  }
410
454
  }
411
- return String(session.id);
455
+ return String(session?.id ?? 'session');
412
456
  }
413
457
 
414
- function summarizeTurn(session, turn) {
458
+ export function summarizeTurn(session, turn) {
459
+ const events = Array.isArray(session?.events) ? session.events : [];
415
460
  let toolCalls = 0;
416
461
  let lastText = '';
417
- for (const event of session.events) {
418
- if (event.type === 'tool/call' && event.data.turn === turn)
462
+ // Reverse iteration: events for current turn are at the end of the events array
463
+ for (let i = events.length - 1; i >= 0; i--) {
464
+ const event = events[i];
465
+ if (!event || !event.data) continue;
466
+ if (event.data.turn !== turn) {
467
+ if (typeof event.data.turn === 'number' && event.data.turn < turn) {
468
+ break;
469
+ }
470
+ continue;
471
+ }
472
+ if (event.type === 'tool/call') {
419
473
  toolCalls += 1;
420
- if (event.type === 'assistant/message' && event.data.turn === turn) {
421
- const text = textOf(event.data.message.content);
422
- if (text)
474
+ }
475
+ if (event.type === 'assistant/message' && !lastText) {
476
+ const text = textOf(event.data.message?.content);
477
+ if (text) {
423
478
  lastText = text;
479
+ }
424
480
  }
425
481
  }
426
482
  const parts = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-plugin-notify",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "DSH plugin: audio chimes, cross-session toasts, desktop push, and IM webhooks for turn completion, errors, and approvals.",
5
5
  "license": "MIT",
6
6
  "type": "module",