pi-app-server 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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/dist/command-classification.d.ts +59 -0
  4. package/dist/command-classification.d.ts.map +1 -0
  5. package/dist/command-classification.js +78 -0
  6. package/dist/command-classification.js.map +7 -0
  7. package/dist/command-execution-engine.d.ts +118 -0
  8. package/dist/command-execution-engine.d.ts.map +1 -0
  9. package/dist/command-execution-engine.js +259 -0
  10. package/dist/command-execution-engine.js.map +7 -0
  11. package/dist/command-replay-store.d.ts +241 -0
  12. package/dist/command-replay-store.d.ts.map +1 -0
  13. package/dist/command-replay-store.js +306 -0
  14. package/dist/command-replay-store.js.map +7 -0
  15. package/dist/command-router.d.ts +25 -0
  16. package/dist/command-router.d.ts.map +1 -0
  17. package/dist/command-router.js +353 -0
  18. package/dist/command-router.js.map +7 -0
  19. package/dist/extension-ui.d.ts +139 -0
  20. package/dist/extension-ui.d.ts.map +1 -0
  21. package/dist/extension-ui.js +189 -0
  22. package/dist/extension-ui.js.map +7 -0
  23. package/dist/resource-governor.d.ts +254 -0
  24. package/dist/resource-governor.d.ts.map +1 -0
  25. package/dist/resource-governor.js +603 -0
  26. package/dist/resource-governor.js.map +7 -0
  27. package/dist/server-command-handlers.d.ts +120 -0
  28. package/dist/server-command-handlers.d.ts.map +1 -0
  29. package/dist/server-command-handlers.js +234 -0
  30. package/dist/server-command-handlers.js.map +7 -0
  31. package/dist/server-ui-context.d.ts +22 -0
  32. package/dist/server-ui-context.d.ts.map +1 -0
  33. package/dist/server-ui-context.js +221 -0
  34. package/dist/server-ui-context.js.map +7 -0
  35. package/dist/server.d.ts +82 -0
  36. package/dist/server.d.ts.map +1 -0
  37. package/dist/server.js +561 -0
  38. package/dist/server.js.map +7 -0
  39. package/dist/session-lock-manager.d.ts +100 -0
  40. package/dist/session-lock-manager.d.ts.map +1 -0
  41. package/dist/session-lock-manager.js +199 -0
  42. package/dist/session-lock-manager.js.map +7 -0
  43. package/dist/session-manager.d.ts +196 -0
  44. package/dist/session-manager.d.ts.map +1 -0
  45. package/dist/session-manager.js +1010 -0
  46. package/dist/session-manager.js.map +7 -0
  47. package/dist/session-store.d.ts +190 -0
  48. package/dist/session-store.d.ts.map +1 -0
  49. package/dist/session-store.js +446 -0
  50. package/dist/session-store.js.map +7 -0
  51. package/dist/session-version-store.d.ts +83 -0
  52. package/dist/session-version-store.d.ts.map +1 -0
  53. package/dist/session-version-store.js +117 -0
  54. package/dist/session-version-store.js.map +7 -0
  55. package/dist/type-guards.d.ts +59 -0
  56. package/dist/type-guards.d.ts.map +1 -0
  57. package/dist/type-guards.js +40 -0
  58. package/dist/type-guards.js.map +7 -0
  59. package/dist/types.d.ts +621 -0
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +23 -0
  62. package/dist/types.js.map +7 -0
  63. package/dist/validation.d.ts +22 -0
  64. package/dist/validation.d.ts.map +1 -0
  65. package/dist/validation.js +323 -0
  66. package/dist/validation.js.map +7 -0
  67. package/package.json +135 -0
@@ -0,0 +1,603 @@
1
+ import { MetricNames } from "./metrics-types.js";
2
+ const DEFAULT_CONFIG = {
3
+ maxSessions: 100,
4
+ maxMessageSizeBytes: 10 * 1024 * 1024,
5
+ // 10MB
6
+ maxCommandsPerMinute: 100,
7
+ maxGlobalCommandsPerMinute: 1e3,
8
+ maxConnections: 1e3,
9
+ heartbeatIntervalMs: 3e4,
10
+ zombieTimeoutMs: 5 * 60 * 1e3,
11
+ // 5 minutes
12
+ maxExtensionUIResponsePerMinute: 60,
13
+ // 1 per second on average
14
+ maxSessionLifetimeMs: 24 * 60 * 60 * 1e3
15
+ // 24 hours
16
+ };
17
+ const TIMESTAMP_CLEANUP_THRESHOLD = 1e4;
18
+ const RATE_WINDOW_MS = 6e4;
19
+ const DEFAULT_TIMESTAMP_CLEANUP_INTERVAL_MS = 5 * 60 * 1e3;
20
+ const SESSION_ID_MAX_LENGTH = 256;
21
+ const CWD_MAX_LENGTH = 4096;
22
+ const SESSION_ID_PATTERN = /^[a-zA-Z0-9_.-]+$/;
23
+ const DANGEROUS_PATH_PATTERNS = [/\.\./, /^~/];
24
+ class ResourceGovernor {
25
+ constructor(config = DEFAULT_CONFIG, metrics) {
26
+ this.config = config;
27
+ this.metrics = metrics;
28
+ }
29
+ sessionCount = 0;
30
+ connectionCount = 0;
31
+ commandTimestamps = /* @__PURE__ */ new Map();
32
+ globalCommandTimestamps = [];
33
+ extensionUIResponseTimestamps = /* @__PURE__ */ new Map();
34
+ lastHeartbeat = /* @__PURE__ */ new Map();
35
+ sessionCreatedAt = /* @__PURE__ */ new Map();
36
+ totalCommandsExecuted = 0;
37
+ commandsRejected = {
38
+ sessionLimit: 0,
39
+ messageSize: 0,
40
+ rateLimit: 0,
41
+ globalRateLimit: 0,
42
+ connectionLimit: 0,
43
+ extensionUIResponseRateLimit: 0
44
+ };
45
+ zombieSessionsDetected = 0;
46
+ zombieSessionsCleaned = 0;
47
+ doubleUnregisterErrors = 0;
48
+ /** Generation counter for unique rate limit entry IDs */
49
+ generationCounter = 0;
50
+ /** Periodic cleanup timer for stale timestamps */
51
+ cleanupTimer = null;
52
+ /**
53
+ * Set the metrics emitter for monitoring.
54
+ * Can be called after construction to wire up metrics.
55
+ */
56
+ setMetrics(metrics) {
57
+ this.metrics = metrics;
58
+ }
59
+ /**
60
+ * Increment generation counter and emit metric.
61
+ * Used for unique rate limit entry IDs and overflow monitoring.
62
+ */
63
+ nextGeneration() {
64
+ const generation = ++this.generationCounter;
65
+ if (this.metrics && generation % 1e3 === 0) {
66
+ this.metrics.gauge(MetricNames.RATE_LIMIT_GENERATION_COUNTER, generation);
67
+ }
68
+ return generation;
69
+ }
70
+ // ==========================================================================
71
+ // CONFIG ACCESS
72
+ // ==========================================================================
73
+ getConfig() {
74
+ return this.config;
75
+ }
76
+ // ==========================================================================
77
+ // SESSION ID VALIDATION
78
+ // ==========================================================================
79
+ /**
80
+ * Validate a session ID.
81
+ * Returns null if valid, or an error message if invalid.
82
+ */
83
+ validateSessionId(sessionId) {
84
+ if (!sessionId || typeof sessionId !== "string") {
85
+ return "Session ID must be a non-empty string";
86
+ }
87
+ if (sessionId.length > SESSION_ID_MAX_LENGTH) {
88
+ return `Session ID too long (max ${SESSION_ID_MAX_LENGTH} characters)`;
89
+ }
90
+ if (!SESSION_ID_PATTERN.test(sessionId)) {
91
+ return "Session ID must contain only alphanumeric characters, underscores, dashes, and dots";
92
+ }
93
+ return null;
94
+ }
95
+ // ==========================================================================
96
+ // CWD VALIDATION
97
+ // ==========================================================================
98
+ /**
99
+ * Validate a working directory path.
100
+ * Returns null if valid, or an error message if invalid.
101
+ */
102
+ validateCwd(cwd) {
103
+ if (!cwd || typeof cwd !== "string") {
104
+ return "CWD must be a non-empty string";
105
+ }
106
+ if (cwd.length > CWD_MAX_LENGTH) {
107
+ return `CWD too long (max ${CWD_MAX_LENGTH} characters)`;
108
+ }
109
+ for (const pattern of DANGEROUS_PATH_PATTERNS) {
110
+ if (pattern.test(cwd)) {
111
+ return "CWD contains potentially dangerous path components";
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+ // ==========================================================================
117
+ // SESSION LIMITS
118
+ // ==========================================================================
119
+ /**
120
+ * Atomically check and reserve a session slot.
121
+ * Returns true if slot was reserved, false if limit reached.
122
+ */
123
+ tryReserveSessionSlot() {
124
+ if (this.sessionCount >= this.config.maxSessions) {
125
+ this.commandsRejected.sessionLimit++;
126
+ return false;
127
+ }
128
+ this.sessionCount++;
129
+ return true;
130
+ }
131
+ /**
132
+ * Release a reserved session slot (used if session creation fails after reservation).
133
+ * Tracks double-unregister as error metric instead of silently masking.
134
+ */
135
+ releaseSessionSlot() {
136
+ this.sessionCount--;
137
+ if (this.sessionCount < 0) {
138
+ this.doubleUnregisterErrors++;
139
+ this.sessionCount = 0;
140
+ console.error(
141
+ "[ResourceGovernor] ERROR: releaseSessionSlot called with no active slots (double-unregister)"
142
+ );
143
+ }
144
+ }
145
+ /**
146
+ * Check if a new session can be created.
147
+ * @deprecated Use tryReserveSessionSlot for atomic operation. Will be removed in v2.0.0.
148
+ */
149
+ canCreateSession() {
150
+ if (this.sessionCount >= this.config.maxSessions) {
151
+ this.commandsRejected.sessionLimit++;
152
+ return {
153
+ allowed: false,
154
+ reason: `Session limit reached (${this.config.maxSessions} sessions)`
155
+ };
156
+ }
157
+ return { allowed: true };
158
+ }
159
+ /**
160
+ * Register a new session.
161
+ * @deprecated Use tryReserveSessionSlot for atomic operation. Will be removed in v2.0.0.
162
+ */
163
+ registerSession(sessionId) {
164
+ this.sessionCount++;
165
+ this.recordHeartbeat(sessionId);
166
+ }
167
+ /**
168
+ * Unregister a session. Call AFTER session is deleted.
169
+ * Also cleans up rate limit timestamps for this session.
170
+ */
171
+ unregisterSession(sessionId) {
172
+ this.sessionCount--;
173
+ if (this.sessionCount < 0) {
174
+ this.doubleUnregisterErrors++;
175
+ this.sessionCount = 0;
176
+ console.error(
177
+ `[ResourceGovernor] ERROR: unregisterSession('${sessionId}') called with no active slots (double-unregister)`
178
+ );
179
+ }
180
+ this.lastHeartbeat.delete(sessionId);
181
+ this.sessionCreatedAt.delete(sessionId);
182
+ this.commandTimestamps.delete(sessionId);
183
+ }
184
+ /**
185
+ * Get current session count.
186
+ */
187
+ getSessionCount() {
188
+ return this.sessionCount;
189
+ }
190
+ // ==========================================================================
191
+ // CONNECTION LIMITS
192
+ // ==========================================================================
193
+ /**
194
+ * Check if a new connection can be accepted.
195
+ */
196
+ canAcceptConnection() {
197
+ if (this.connectionCount >= this.config.maxConnections) {
198
+ this.commandsRejected.connectionLimit++;
199
+ return {
200
+ allowed: false,
201
+ reason: `Connection limit reached (${this.config.maxConnections} connections)`
202
+ };
203
+ }
204
+ return { allowed: true };
205
+ }
206
+ /**
207
+ * Register a new connection.
208
+ */
209
+ registerConnection() {
210
+ this.connectionCount++;
211
+ }
212
+ /**
213
+ * Unregister a connection.
214
+ * Tracks double-unregister as error metric instead of silently masking.
215
+ */
216
+ unregisterConnection() {
217
+ this.connectionCount--;
218
+ if (this.connectionCount < 0) {
219
+ this.doubleUnregisterErrors++;
220
+ this.connectionCount = 0;
221
+ console.error(
222
+ "[ResourceGovernor] ERROR: unregisterConnection called with no active connections (double-unregister)"
223
+ );
224
+ }
225
+ }
226
+ /**
227
+ * Get current connection count.
228
+ */
229
+ getConnectionCount() {
230
+ return this.connectionCount;
231
+ }
232
+ // ==========================================================================
233
+ // MESSAGE SIZE LIMITS
234
+ // ==========================================================================
235
+ /**
236
+ * Check if a message of the given size can be accepted.
237
+ * Rejects negative sizes, NaN, and sizes exceeding the limit.
238
+ */
239
+ canAcceptMessage(sizeBytes) {
240
+ if (!Number.isFinite(sizeBytes) || sizeBytes < 0) {
241
+ this.commandsRejected.messageSize++;
242
+ return {
243
+ allowed: false,
244
+ reason: `Invalid message size: ${sizeBytes}`
245
+ };
246
+ }
247
+ if (sizeBytes > this.config.maxMessageSizeBytes) {
248
+ this.commandsRejected.messageSize++;
249
+ return {
250
+ allowed: false,
251
+ reason: `Message size ${sizeBytes} exceeds limit ${this.config.maxMessageSizeBytes}`
252
+ };
253
+ }
254
+ return { allowed: true };
255
+ }
256
+ // ==========================================================================
257
+ // RATE LIMITING
258
+ // ==========================================================================
259
+ /**
260
+ * Check if a command can be executed for the given session.
261
+ * Implements sliding window rate limiting (both per-session and global).
262
+ *
263
+ * @returns The generation marker for refund, or rejection result
264
+ */
265
+ canExecuteCommand(sessionId) {
266
+ const now = Date.now();
267
+ const windowStart = now - RATE_WINDOW_MS;
268
+ if (this.globalCommandTimestamps.length > TIMESTAMP_CLEANUP_THRESHOLD) {
269
+ this.globalCommandTimestamps = this.globalCommandTimestamps.filter(
270
+ (e) => e.timestamp > windowStart
271
+ );
272
+ } else {
273
+ this.globalCommandTimestamps = this.globalCommandTimestamps.filter(
274
+ (e) => e.timestamp > windowStart
275
+ );
276
+ }
277
+ if (this.globalCommandTimestamps.length >= this.config.maxGlobalCommandsPerMinute) {
278
+ this.commandsRejected.globalRateLimit++;
279
+ return {
280
+ allowed: false,
281
+ reason: `Global rate limit exceeded (${this.config.maxGlobalCommandsPerMinute} commands/minute)`
282
+ };
283
+ }
284
+ let entries = this.commandTimestamps.get(sessionId);
285
+ if (!entries) {
286
+ entries = [];
287
+ this.commandTimestamps.set(sessionId, entries);
288
+ } else {
289
+ entries = entries.filter((e) => e.timestamp > windowStart);
290
+ this.commandTimestamps.set(sessionId, entries);
291
+ }
292
+ if (entries.length >= this.config.maxCommandsPerMinute) {
293
+ this.commandsRejected.rateLimit++;
294
+ return {
295
+ allowed: false,
296
+ reason: `Rate limit exceeded (${this.config.maxCommandsPerMinute} commands/minute)`
297
+ };
298
+ }
299
+ const generation = this.nextGeneration();
300
+ const entry = { timestamp: now, generation };
301
+ entries.push(entry);
302
+ this.globalCommandTimestamps.push(entry);
303
+ this.totalCommandsExecuted++;
304
+ return { allowed: true, generation };
305
+ }
306
+ /**
307
+ * Refund a previously counted command (e.g. command failed before execution).
308
+ * Uses generation marker to ensure correct entry is removed.
309
+ */
310
+ refundCommand(sessionId, generation) {
311
+ const sessionEntries = this.commandTimestamps.get(sessionId);
312
+ if (!sessionEntries) {
313
+ return;
314
+ }
315
+ const sessionIdx = sessionEntries.findIndex((e) => e.generation === generation);
316
+ if (sessionIdx === -1) {
317
+ return;
318
+ }
319
+ sessionEntries.splice(sessionIdx, 1);
320
+ if (sessionEntries.length === 0) {
321
+ this.commandTimestamps.delete(sessionId);
322
+ }
323
+ const globalIdx = this.globalCommandTimestamps.findIndex((e) => e.generation === generation);
324
+ if (globalIdx !== -1) {
325
+ this.globalCommandTimestamps.splice(globalIdx, 1);
326
+ }
327
+ if (this.totalCommandsExecuted > 0) {
328
+ this.totalCommandsExecuted--;
329
+ }
330
+ }
331
+ /**
332
+ * Get current rate limit usage for observability.
333
+ */
334
+ getRateLimitUsage(sessionId) {
335
+ const now = Date.now();
336
+ const windowStart = now - RATE_WINDOW_MS;
337
+ const sessionCount = this.commandTimestamps.get(sessionId)?.filter((e) => e.timestamp > windowStart).length ?? 0;
338
+ const globalCount = this.globalCommandTimestamps.filter(
339
+ (e) => e.timestamp > windowStart
340
+ ).length;
341
+ return {
342
+ session: sessionCount,
343
+ global: globalCount
344
+ };
345
+ }
346
+ // ==========================================================================
347
+ // EXTENSION UI RESPONSE RATE LIMITING
348
+ // ==========================================================================
349
+ /**
350
+ * Check if an extension_ui_response command can be executed for the given session.
351
+ * This is a separate, more restrictive rate limit to prevent abuse of UI responses.
352
+ */
353
+ canExecuteExtensionUIResponse(sessionId) {
354
+ const now = Date.now();
355
+ const windowStart = now - RATE_WINDOW_MS;
356
+ let entries = this.extensionUIResponseTimestamps.get(sessionId);
357
+ if (!entries) {
358
+ entries = [];
359
+ this.extensionUIResponseTimestamps.set(sessionId, entries);
360
+ } else {
361
+ entries = entries.filter((e) => e.timestamp > windowStart);
362
+ this.extensionUIResponseTimestamps.set(sessionId, entries);
363
+ }
364
+ if (entries.length >= this.config.maxExtensionUIResponsePerMinute) {
365
+ this.commandsRejected.extensionUIResponseRateLimit++;
366
+ return {
367
+ allowed: false,
368
+ reason: `Extension UI response rate limit exceeded (${this.config.maxExtensionUIResponsePerMinute} responses/minute)`
369
+ };
370
+ }
371
+ const generation = this.nextGeneration();
372
+ entries.push({ timestamp: now, generation });
373
+ return { allowed: true };
374
+ }
375
+ // ==========================================================================
376
+ // HEARTBEAT / ZOMBIE DETECTION
377
+ // ==========================================================================
378
+ /**
379
+ * Record a heartbeat for a session.
380
+ * Also tracks session creation time for lifetime enforcement.
381
+ */
382
+ recordHeartbeat(sessionId) {
383
+ const now = Date.now();
384
+ this.lastHeartbeat.set(sessionId, now);
385
+ if (!this.sessionCreatedAt.has(sessionId)) {
386
+ this.sessionCreatedAt.set(sessionId, now);
387
+ }
388
+ }
389
+ /**
390
+ * Get list of zombie session IDs.
391
+ *
392
+ * @param recordDetection Whether to increment zombie detection metrics.
393
+ */
394
+ getZombieSessions(recordDetection = true) {
395
+ const now = Date.now();
396
+ const zombies = [];
397
+ for (const [sessionId, lastTime] of this.lastHeartbeat) {
398
+ if (now - lastTime > this.config.zombieTimeoutMs) {
399
+ zombies.push(sessionId);
400
+ }
401
+ }
402
+ if (recordDetection && zombies.length > 0) {
403
+ this.zombieSessionsDetected += zombies.length;
404
+ }
405
+ return zombies;
406
+ }
407
+ /**
408
+ * Clean up zombie sessions. Returns IDs of cleaned sessions.
409
+ * Call this periodically or when you want to force cleanup.
410
+ */
411
+ cleanupZombieSessions() {
412
+ const zombies = this.getZombieSessions();
413
+ for (const sessionId of zombies) {
414
+ this.lastHeartbeat.delete(sessionId);
415
+ this.commandTimestamps.delete(sessionId);
416
+ }
417
+ if (zombies.length > 0) {
418
+ this.zombieSessionsCleaned += zombies.length;
419
+ }
420
+ return zombies;
421
+ }
422
+ /**
423
+ * Get the last heartbeat time for a session.
424
+ */
425
+ getLastHeartbeat(sessionId) {
426
+ return this.lastHeartbeat.get(sessionId);
427
+ }
428
+ // ==========================================================================
429
+ // METRICS
430
+ // ==========================================================================
431
+ /**
432
+ * Get current metrics for observability.
433
+ */
434
+ getMetrics() {
435
+ const now = Date.now();
436
+ const windowStart = now - RATE_WINDOW_MS;
437
+ const globalCount = this.globalCommandTimestamps.filter(
438
+ (e) => e.timestamp > windowStart
439
+ ).length;
440
+ return {
441
+ sessionCount: this.sessionCount,
442
+ connectionCount: this.connectionCount,
443
+ totalCommandsExecuted: this.totalCommandsExecuted,
444
+ commandsRejected: { ...this.commandsRejected },
445
+ zombieSessionsDetected: this.zombieSessionsDetected,
446
+ zombieSessionsCleaned: this.zombieSessionsCleaned,
447
+ doubleUnregisterErrors: this.doubleUnregisterErrors,
448
+ rateLimitUsage: {
449
+ globalCount,
450
+ globalLimit: this.config.maxGlobalCommandsPerMinute
451
+ }
452
+ };
453
+ }
454
+ /**
455
+ * Check if the server is healthy.
456
+ */
457
+ isHealthy() {
458
+ const issues = [];
459
+ if (this.sessionCount >= this.config.maxSessions * 0.9) {
460
+ issues.push(`Session count at ${this.sessionCount}/${this.config.maxSessions} (90%+)`);
461
+ }
462
+ if (this.connectionCount >= this.config.maxConnections * 0.9) {
463
+ issues.push(
464
+ `Connection count at ${this.connectionCount}/${this.config.maxConnections} (90%+)`
465
+ );
466
+ }
467
+ const zombies = this.getZombieSessions(false);
468
+ if (zombies.length > 0) {
469
+ issues.push(`${zombies.length} zombie sessions detected`);
470
+ }
471
+ return {
472
+ healthy: issues.length === 0,
473
+ issues
474
+ };
475
+ }
476
+ /**
477
+ * Reset metrics (useful for testing).
478
+ */
479
+ resetMetrics() {
480
+ this.totalCommandsExecuted = 0;
481
+ this.commandsRejected = {
482
+ sessionLimit: 0,
483
+ messageSize: 0,
484
+ rateLimit: 0,
485
+ globalRateLimit: 0,
486
+ connectionLimit: 0,
487
+ extensionUIResponseRateLimit: 0
488
+ };
489
+ this.zombieSessionsDetected = 0;
490
+ this.zombieSessionsCleaned = 0;
491
+ }
492
+ // ==========================================================================
493
+ // CLEANUP
494
+ // ==========================================================================
495
+ /**
496
+ * Clean up stale rate limit data.
497
+ * Also cleans extensionUIResponseTimestamps.
498
+ */
499
+ cleanupStaleTimestamps() {
500
+ const now = Date.now();
501
+ const windowStart = now - RATE_WINDOW_MS;
502
+ this.globalCommandTimestamps = this.globalCommandTimestamps.filter(
503
+ (e) => e.timestamp > windowStart
504
+ );
505
+ for (const [sessionId, entries] of this.commandTimestamps) {
506
+ const filtered = entries.filter((e) => e.timestamp > windowStart);
507
+ if (filtered.length === 0) {
508
+ this.commandTimestamps.delete(sessionId);
509
+ } else {
510
+ this.commandTimestamps.set(sessionId, filtered);
511
+ }
512
+ }
513
+ for (const [sessionId, entries] of this.extensionUIResponseTimestamps) {
514
+ const filtered = entries.filter((e) => e.timestamp > windowStart);
515
+ if (filtered.length === 0) {
516
+ this.extensionUIResponseTimestamps.delete(sessionId);
517
+ } else {
518
+ this.extensionUIResponseTimestamps.set(sessionId, filtered);
519
+ }
520
+ }
521
+ }
522
+ /**
523
+ * Start periodic cleanup of stale timestamps.
524
+ * Call this during server startup to prevent memory leaks from inactive sessions.
525
+ * @param intervalMs Cleanup interval (default: 5 minutes)
526
+ */
527
+ startPeriodicCleanup(intervalMs = DEFAULT_TIMESTAMP_CLEANUP_INTERVAL_MS) {
528
+ if (this.cleanupTimer) {
529
+ return;
530
+ }
531
+ this.cleanupTimer = setInterval(() => {
532
+ this.cleanupStaleTimestamps();
533
+ }, intervalMs);
534
+ if (this.cleanupTimer.unref) {
535
+ this.cleanupTimer.unref();
536
+ }
537
+ }
538
+ /**
539
+ * Stop periodic cleanup.
540
+ */
541
+ stopPeriodicCleanup() {
542
+ if (this.cleanupTimer) {
543
+ clearInterval(this.cleanupTimer);
544
+ this.cleanupTimer = null;
545
+ }
546
+ }
547
+ /**
548
+ * Clean up stale data for deleted sessions.
549
+ */
550
+ cleanupStaleData(activeSessionIds) {
551
+ for (const sessionId of this.commandTimestamps.keys()) {
552
+ if (!activeSessionIds.has(sessionId)) {
553
+ this.commandTimestamps.delete(sessionId);
554
+ }
555
+ }
556
+ for (const sessionId of this.lastHeartbeat.keys()) {
557
+ if (!activeSessionIds.has(sessionId)) {
558
+ this.lastHeartbeat.delete(sessionId);
559
+ }
560
+ }
561
+ for (const sessionId of this.sessionCreatedAt.keys()) {
562
+ if (!activeSessionIds.has(sessionId)) {
563
+ this.sessionCreatedAt.delete(sessionId);
564
+ }
565
+ }
566
+ for (const sessionId of this.extensionUIResponseTimestamps.keys()) {
567
+ if (!activeSessionIds.has(sessionId)) {
568
+ this.extensionUIResponseTimestamps.delete(sessionId);
569
+ }
570
+ }
571
+ }
572
+ // ==========================================================================
573
+ // SESSION LIFETIME
574
+ // ==========================================================================
575
+ /**
576
+ * Get list of expired session IDs (exceeded maxSessionLifetimeMs).
577
+ * Returns empty array if maxSessionLifetimeMs is 0 (unlimited).
578
+ */
579
+ getExpiredSessions() {
580
+ if (this.config.maxSessionLifetimeMs === 0) {
581
+ return [];
582
+ }
583
+ const now = Date.now();
584
+ const expired = [];
585
+ for (const [sessionId, createdAt] of this.sessionCreatedAt) {
586
+ if (now - createdAt > this.config.maxSessionLifetimeMs) {
587
+ expired.push(sessionId);
588
+ }
589
+ }
590
+ return expired;
591
+ }
592
+ /**
593
+ * Get the creation time for a session.
594
+ */
595
+ getSessionCreatedAt(sessionId) {
596
+ return this.sessionCreatedAt.get(sessionId);
597
+ }
598
+ }
599
+ export {
600
+ DEFAULT_CONFIG,
601
+ ResourceGovernor
602
+ };
603
+ //# sourceMappingURL=resource-governor.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/resource-governor.ts"],
4
+ "sourcesContent": ["/**\n * ResourceGovernor - Enforces all resource limits for pi-server.\n *\n * A single nexus point for:\n * - Message size limits (prevents OOM)\n * - Session limits (prevents resource exhaustion)\n * - Rate limiting (prevents abuse)\n * - Heartbeat tracking (enables zombie detection)\n * - Connection limits (prevents DoS)\n *\n * Makes testing trivial (mock the governor).\n */\n\nimport type { MetricsEmitter } from \"./metrics-emitter.js\";\nimport { MetricNames } from \"./metrics-types.js\";\n\n// ============================================================================\n// CONFIG\n// ============================================================================\n\nexport interface ResourceGovernorConfig {\n /** Maximum concurrent sessions (default: 100) */\n maxSessions: number;\n /** Maximum message size in bytes (default: 10MB) */\n maxMessageSizeBytes: number;\n /** Maximum commands per minute per session (default: 100) */\n maxCommandsPerMinute: number;\n /** Maximum commands per minute globally across all sessions (default: 1000) */\n maxGlobalCommandsPerMinute: number;\n /** Maximum concurrent WebSocket connections (default: 1000) */\n maxConnections: number;\n /** Heartbeat interval in ms for zombie detection (default: 30000) */\n heartbeatIntervalMs: number;\n /** Time without heartbeat before session is considered zombie (default: 5 min) */\n zombieTimeoutMs: number;\n /** Maximum extension_ui_response commands per minute per session (default: 60) */\n maxExtensionUIResponsePerMinute: number;\n /** Maximum session lifetime in ms (0 = unlimited, default: 24 hours) */\n maxSessionLifetimeMs: number;\n}\n\nexport const DEFAULT_CONFIG: ResourceGovernorConfig = {\n maxSessions: 100,\n maxMessageSizeBytes: 10 * 1024 * 1024, // 10MB\n maxCommandsPerMinute: 100,\n maxGlobalCommandsPerMinute: 1000,\n maxConnections: 1000,\n heartbeatIntervalMs: 30000,\n zombieTimeoutMs: 5 * 60 * 1000, // 5 minutes\n maxExtensionUIResponsePerMinute: 60, // 1 per second on average\n maxSessionLifetimeMs: 24 * 60 * 60 * 1000, // 24 hours\n};\n\n// ============================================================================\n// METRICS\n// ============================================================================\n\nexport interface GovernorMetrics {\n sessionCount: number;\n connectionCount: number;\n totalCommandsExecuted: number;\n commandsRejected: {\n sessionLimit: number;\n messageSize: number;\n rateLimit: number;\n globalRateLimit: number;\n connectionLimit: number;\n extensionUIResponseRateLimit: number;\n };\n zombieSessionsDetected: number;\n zombieSessionsCleaned: number;\n /** Count of double-unregister errors (session or connection unregistered twice) */\n doubleUnregisterErrors: number;\n rateLimitUsage: {\n globalCount: number;\n globalLimit: number;\n };\n}\n\n// ============================================================================\n// RESULT TYPES\n// ============================================================================\n\nexport interface RejectionResult {\n allowed: false;\n reason: string;\n}\n\nexport interface AllowResult {\n allowed: true;\n}\n\nexport type GovernorResult = AllowResult | RejectionResult;\n\n// ============================================================================\n// CONSTANTS\n// ============================================================================\n\n/** Threshold for triggering automatic timestamp cleanup */\nconst TIMESTAMP_CLEANUP_THRESHOLD = 10000;\n\n/** Rate limit window in ms (1 minute) */\nconst RATE_WINDOW_MS = 60000;\n\n/** Default interval for periodic timestamp cleanup (5 minutes) */\nconst DEFAULT_TIMESTAMP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;\n\n/** Session ID max length */\nconst SESSION_ID_MAX_LENGTH = 256;\n\n/** CWD max length */\nconst CWD_MAX_LENGTH = 4096;\n\n/** Valid session ID pattern (alphanumeric, dash, underscore, dot) */\nconst SESSION_ID_PATTERN = /^[a-zA-Z0-9_.-]+$/;\n\n/** Dangerous path patterns */\nconst DANGEROUS_PATH_PATTERNS = [/\\.\\./, /^~/];\n\n/**\n * Rate limit timestamp with generation marker.\n * The generation ensures refundCommand removes the correct entry\n * even when multiple commands have the same timestamp.\n */\ninterface RateLimitEntry {\n timestamp: number;\n /** Unique generation marker for this entry */\n generation: number;\n}\n\n// ============================================================================\n// GOVERNOR CLASS\n// ============================================================================\n\n/**\n * ResourceGovernor enforces resource limits for the server.\n *\n * Memory management:\n * - Rate limit timestamps are cleaned up periodically via startPeriodicCleanup()\n * - Session-specific data is cleaned up when sessions are deleted\n * - Call cleanupStaleData() after deleting sessions\n */\nexport class ResourceGovernor {\n private sessionCount = 0;\n private connectionCount = 0;\n private commandTimestamps = new Map<string, RateLimitEntry[]>();\n private globalCommandTimestamps: RateLimitEntry[] = [];\n private extensionUIResponseTimestamps = new Map<string, RateLimitEntry[]>();\n private lastHeartbeat = new Map<string, number>();\n private sessionCreatedAt = new Map<string, number>();\n private totalCommandsExecuted = 0;\n private commandsRejected = {\n sessionLimit: 0,\n messageSize: 0,\n rateLimit: 0,\n globalRateLimit: 0,\n connectionLimit: 0,\n extensionUIResponseRateLimit: 0,\n };\n private zombieSessionsDetected = 0;\n private zombieSessionsCleaned = 0;\n private doubleUnregisterErrors = 0;\n\n /** Generation counter for unique rate limit entry IDs */\n private generationCounter = 0;\n\n /** Periodic cleanup timer for stale timestamps */\n private cleanupTimer: NodeJS.Timeout | null = null;\n\n constructor(\n private config: ResourceGovernorConfig = DEFAULT_CONFIG,\n private metrics?: MetricsEmitter\n ) {}\n\n /**\n * Set the metrics emitter for monitoring.\n * Can be called after construction to wire up metrics.\n */\n setMetrics(metrics: MetricsEmitter): void {\n this.metrics = metrics;\n }\n\n /**\n * Increment generation counter and emit metric.\n * Used for unique rate limit entry IDs and overflow monitoring.\n */\n private nextGeneration(): number {\n const generation = ++this.generationCounter;\n\n // Emit metric for threshold alerting (e.g., overflow at 1e15)\n if (this.metrics && generation % 1000 === 0) {\n this.metrics.gauge(MetricNames.RATE_LIMIT_GENERATION_COUNTER, generation);\n }\n\n return generation;\n }\n\n // ==========================================================================\n // CONFIG ACCESS\n // ==========================================================================\n\n getConfig(): Readonly<ResourceGovernorConfig> {\n return this.config;\n }\n\n // ==========================================================================\n // SESSION ID VALIDATION\n // ==========================================================================\n\n /**\n * Validate a session ID.\n * Returns null if valid, or an error message if invalid.\n */\n validateSessionId(sessionId: string): string | null {\n if (!sessionId || typeof sessionId !== \"string\") {\n return \"Session ID must be a non-empty string\";\n }\n if (sessionId.length > SESSION_ID_MAX_LENGTH) {\n return `Session ID too long (max ${SESSION_ID_MAX_LENGTH} characters)`;\n }\n if (!SESSION_ID_PATTERN.test(sessionId)) {\n return \"Session ID must contain only alphanumeric characters, underscores, dashes, and dots\";\n }\n return null;\n }\n\n // ==========================================================================\n // CWD VALIDATION\n // ==========================================================================\n\n /**\n * Validate a working directory path.\n * Returns null if valid, or an error message if invalid.\n */\n validateCwd(cwd: string): string | null {\n if (!cwd || typeof cwd !== \"string\") {\n return \"CWD must be a non-empty string\";\n }\n if (cwd.length > CWD_MAX_LENGTH) {\n return `CWD too long (max ${CWD_MAX_LENGTH} characters)`;\n }\n for (const pattern of DANGEROUS_PATH_PATTERNS) {\n if (pattern.test(cwd)) {\n return \"CWD contains potentially dangerous path components\";\n }\n }\n return null;\n }\n\n // ==========================================================================\n // SESSION LIMITS\n // ==========================================================================\n\n /**\n * Atomically check and reserve a session slot.\n * Returns true if slot was reserved, false if limit reached.\n */\n tryReserveSessionSlot(): boolean {\n if (this.sessionCount >= this.config.maxSessions) {\n this.commandsRejected.sessionLimit++;\n return false;\n }\n this.sessionCount++;\n return true;\n }\n\n /**\n * Release a reserved session slot (used if session creation fails after reservation).\n * Tracks double-unregister as error metric instead of silently masking.\n */\n releaseSessionSlot(): void {\n this.sessionCount--;\n if (this.sessionCount < 0) {\n this.doubleUnregisterErrors++;\n this.sessionCount = 0;\n console.error(\n \"[ResourceGovernor] ERROR: releaseSessionSlot called with no active slots (double-unregister)\"\n );\n }\n }\n\n /**\n * Check if a new session can be created.\n * @deprecated Use tryReserveSessionSlot for atomic operation. Will be removed in v2.0.0.\n */\n canCreateSession(): GovernorResult {\n if (this.sessionCount >= this.config.maxSessions) {\n this.commandsRejected.sessionLimit++;\n return {\n allowed: false,\n reason: `Session limit reached (${this.config.maxSessions} sessions)`,\n };\n }\n return { allowed: true };\n }\n\n /**\n * Register a new session.\n * @deprecated Use tryReserveSessionSlot for atomic operation. Will be removed in v2.0.0.\n */\n registerSession(sessionId: string): void {\n this.sessionCount++;\n this.recordHeartbeat(sessionId);\n }\n\n /**\n * Unregister a session. Call AFTER session is deleted.\n * Also cleans up rate limit timestamps for this session.\n */\n unregisterSession(sessionId: string): void {\n this.sessionCount--;\n if (this.sessionCount < 0) {\n this.doubleUnregisterErrors++;\n this.sessionCount = 0;\n console.error(\n `[ResourceGovernor] ERROR: unregisterSession('${sessionId}') called with no active slots (double-unregister)`\n );\n }\n this.lastHeartbeat.delete(sessionId);\n this.sessionCreatedAt.delete(sessionId);\n this.commandTimestamps.delete(sessionId);\n }\n\n /**\n * Get current session count.\n */\n getSessionCount(): number {\n return this.sessionCount;\n }\n\n // ==========================================================================\n // CONNECTION LIMITS\n // ==========================================================================\n\n /**\n * Check if a new connection can be accepted.\n */\n canAcceptConnection(): GovernorResult {\n if (this.connectionCount >= this.config.maxConnections) {\n this.commandsRejected.connectionLimit++;\n return {\n allowed: false,\n reason: `Connection limit reached (${this.config.maxConnections} connections)`,\n };\n }\n return { allowed: true };\n }\n\n /**\n * Register a new connection.\n */\n registerConnection(): void {\n this.connectionCount++;\n }\n\n /**\n * Unregister a connection.\n * Tracks double-unregister as error metric instead of silently masking.\n */\n unregisterConnection(): void {\n this.connectionCount--;\n if (this.connectionCount < 0) {\n this.doubleUnregisterErrors++;\n this.connectionCount = 0;\n console.error(\n \"[ResourceGovernor] ERROR: unregisterConnection called with no active connections (double-unregister)\"\n );\n }\n }\n\n /**\n * Get current connection count.\n */\n getConnectionCount(): number {\n return this.connectionCount;\n }\n\n // ==========================================================================\n // MESSAGE SIZE LIMITS\n // ==========================================================================\n\n /**\n * Check if a message of the given size can be accepted.\n * Rejects negative sizes, NaN, and sizes exceeding the limit.\n */\n canAcceptMessage(sizeBytes: number): GovernorResult {\n if (!Number.isFinite(sizeBytes) || sizeBytes < 0) {\n this.commandsRejected.messageSize++;\n return {\n allowed: false,\n reason: `Invalid message size: ${sizeBytes}`,\n };\n }\n\n if (sizeBytes > this.config.maxMessageSizeBytes) {\n this.commandsRejected.messageSize++;\n return {\n allowed: false,\n reason: `Message size ${sizeBytes} exceeds limit ${this.config.maxMessageSizeBytes}`,\n };\n }\n return { allowed: true };\n }\n\n // ==========================================================================\n // RATE LIMITING\n // ==========================================================================\n\n /**\n * Check if a command can be executed for the given session.\n * Implements sliding window rate limiting (both per-session and global).\n *\n * @returns The generation marker for refund, or rejection result\n */\n canExecuteCommand(sessionId: string): GovernorResult & { generation?: number } {\n const now = Date.now();\n const windowStart = now - RATE_WINDOW_MS;\n\n // Auto-cleanup if global timestamps exceed threshold\n if (this.globalCommandTimestamps.length > TIMESTAMP_CLEANUP_THRESHOLD) {\n this.globalCommandTimestamps = this.globalCommandTimestamps.filter(\n (e) => e.timestamp > windowStart\n );\n } else {\n // Normal filter\n this.globalCommandTimestamps = this.globalCommandTimestamps.filter(\n (e) => e.timestamp > windowStart\n );\n }\n\n // Check global rate limit first\n if (this.globalCommandTimestamps.length >= this.config.maxGlobalCommandsPerMinute) {\n this.commandsRejected.globalRateLimit++;\n return {\n allowed: false,\n reason: `Global rate limit exceeded (${this.config.maxGlobalCommandsPerMinute} commands/minute)`,\n };\n }\n\n // Check per-session rate limit\n let entries = this.commandTimestamps.get(sessionId);\n if (!entries) {\n entries = [];\n this.commandTimestamps.set(sessionId, entries);\n } else {\n entries = entries.filter((e) => e.timestamp > windowStart);\n this.commandTimestamps.set(sessionId, entries);\n }\n\n if (entries.length >= this.config.maxCommandsPerMinute) {\n this.commandsRejected.rateLimit++;\n return {\n allowed: false,\n reason: `Rate limit exceeded (${this.config.maxCommandsPerMinute} commands/minute)`,\n };\n }\n\n // Record this command with unique generation marker\n const generation = this.nextGeneration();\n const entry: RateLimitEntry = { timestamp: now, generation };\n entries.push(entry);\n this.globalCommandTimestamps.push(entry);\n this.totalCommandsExecuted++;\n\n return { allowed: true, generation };\n }\n\n /**\n * Refund a previously counted command (e.g. command failed before execution).\n * Uses generation marker to ensure correct entry is removed.\n */\n refundCommand(sessionId: string, generation: number): void {\n const sessionEntries = this.commandTimestamps.get(sessionId);\n if (!sessionEntries) {\n return;\n }\n\n // Find and remove the entry with matching generation\n const sessionIdx = sessionEntries.findIndex((e) => e.generation === generation);\n if (sessionIdx === -1) {\n return; // Already removed or never existed\n }\n\n sessionEntries.splice(sessionIdx, 1);\n if (sessionEntries.length === 0) {\n this.commandTimestamps.delete(sessionId);\n }\n\n // Remove from global timestamps using generation marker\n const globalIdx = this.globalCommandTimestamps.findIndex((e) => e.generation === generation);\n if (globalIdx !== -1) {\n this.globalCommandTimestamps.splice(globalIdx, 1);\n }\n\n if (this.totalCommandsExecuted > 0) {\n this.totalCommandsExecuted--;\n }\n }\n\n /**\n * Get current rate limit usage for observability.\n */\n getRateLimitUsage(sessionId: string): { session: number; global: number } {\n const now = Date.now();\n const windowStart = now - RATE_WINDOW_MS;\n\n const sessionCount =\n this.commandTimestamps.get(sessionId)?.filter((e) => e.timestamp > windowStart).length ?? 0;\n const globalCount = this.globalCommandTimestamps.filter(\n (e) => e.timestamp > windowStart\n ).length;\n\n return {\n session: sessionCount,\n global: globalCount,\n };\n }\n\n // ==========================================================================\n // EXTENSION UI RESPONSE RATE LIMITING\n // ==========================================================================\n\n /**\n * Check if an extension_ui_response command can be executed for the given session.\n * This is a separate, more restrictive rate limit to prevent abuse of UI responses.\n */\n canExecuteExtensionUIResponse(sessionId: string): GovernorResult {\n const now = Date.now();\n const windowStart = now - RATE_WINDOW_MS;\n\n let entries = this.extensionUIResponseTimestamps.get(sessionId);\n if (!entries) {\n entries = [];\n this.extensionUIResponseTimestamps.set(sessionId, entries);\n } else {\n entries = entries.filter((e) => e.timestamp > windowStart);\n this.extensionUIResponseTimestamps.set(sessionId, entries);\n }\n\n if (entries.length >= this.config.maxExtensionUIResponsePerMinute) {\n this.commandsRejected.extensionUIResponseRateLimit++;\n return {\n allowed: false,\n reason: `Extension UI response rate limit exceeded (${this.config.maxExtensionUIResponsePerMinute} responses/minute)`,\n };\n }\n\n // Record this response with generation marker\n const generation = this.nextGeneration();\n entries.push({ timestamp: now, generation });\n return { allowed: true };\n }\n\n // ==========================================================================\n // HEARTBEAT / ZOMBIE DETECTION\n // ==========================================================================\n\n /**\n * Record a heartbeat for a session.\n * Also tracks session creation time for lifetime enforcement.\n */\n recordHeartbeat(sessionId: string): void {\n const now = Date.now();\n this.lastHeartbeat.set(sessionId, now);\n // Track creation time if this is a new session\n if (!this.sessionCreatedAt.has(sessionId)) {\n this.sessionCreatedAt.set(sessionId, now);\n }\n }\n\n /**\n * Get list of zombie session IDs.\n *\n * @param recordDetection Whether to increment zombie detection metrics.\n */\n getZombieSessions(recordDetection = true): string[] {\n const now = Date.now();\n const zombies: string[] = [];\n\n for (const [sessionId, lastTime] of this.lastHeartbeat) {\n if (now - lastTime > this.config.zombieTimeoutMs) {\n zombies.push(sessionId);\n }\n }\n\n if (recordDetection && zombies.length > 0) {\n this.zombieSessionsDetected += zombies.length;\n }\n\n return zombies;\n }\n\n /**\n * Clean up zombie sessions. Returns IDs of cleaned sessions.\n * Call this periodically or when you want to force cleanup.\n */\n cleanupZombieSessions(): string[] {\n const zombies = this.getZombieSessions();\n for (const sessionId of zombies) {\n this.lastHeartbeat.delete(sessionId);\n this.commandTimestamps.delete(sessionId);\n }\n if (zombies.length > 0) {\n this.zombieSessionsCleaned += zombies.length;\n }\n return zombies;\n }\n\n /**\n * Get the last heartbeat time for a session.\n */\n getLastHeartbeat(sessionId: string): number | undefined {\n return this.lastHeartbeat.get(sessionId);\n }\n\n // ==========================================================================\n // METRICS\n // ==========================================================================\n\n /**\n * Get current metrics for observability.\n */\n getMetrics(): GovernorMetrics {\n const now = Date.now();\n const windowStart = now - RATE_WINDOW_MS;\n const globalCount = this.globalCommandTimestamps.filter(\n (e) => e.timestamp > windowStart\n ).length;\n\n return {\n sessionCount: this.sessionCount,\n connectionCount: this.connectionCount,\n totalCommandsExecuted: this.totalCommandsExecuted,\n commandsRejected: { ...this.commandsRejected },\n zombieSessionsDetected: this.zombieSessionsDetected,\n zombieSessionsCleaned: this.zombieSessionsCleaned,\n doubleUnregisterErrors: this.doubleUnregisterErrors,\n rateLimitUsage: {\n globalCount,\n globalLimit: this.config.maxGlobalCommandsPerMinute,\n },\n };\n }\n\n /**\n * Check if the server is healthy.\n */\n isHealthy(): { healthy: boolean; issues: string[] } {\n const issues: string[] = [];\n\n if (this.sessionCount >= this.config.maxSessions * 0.9) {\n issues.push(`Session count at ${this.sessionCount}/${this.config.maxSessions} (90%+)`);\n }\n if (this.connectionCount >= this.config.maxConnections * 0.9) {\n issues.push(\n `Connection count at ${this.connectionCount}/${this.config.maxConnections} (90%+)`\n );\n }\n\n const zombies = this.getZombieSessions(false);\n if (zombies.length > 0) {\n issues.push(`${zombies.length} zombie sessions detected`);\n }\n\n return {\n healthy: issues.length === 0,\n issues,\n };\n }\n\n /**\n * Reset metrics (useful for testing).\n */\n resetMetrics(): void {\n this.totalCommandsExecuted = 0;\n this.commandsRejected = {\n sessionLimit: 0,\n messageSize: 0,\n rateLimit: 0,\n globalRateLimit: 0,\n connectionLimit: 0,\n extensionUIResponseRateLimit: 0,\n };\n this.zombieSessionsDetected = 0;\n this.zombieSessionsCleaned = 0;\n }\n\n // ==========================================================================\n // CLEANUP\n // ==========================================================================\n\n /**\n * Clean up stale rate limit data.\n * Also cleans extensionUIResponseTimestamps.\n */\n cleanupStaleTimestamps(): void {\n const now = Date.now();\n const windowStart = now - RATE_WINDOW_MS;\n\n this.globalCommandTimestamps = this.globalCommandTimestamps.filter(\n (e) => e.timestamp > windowStart\n );\n\n for (const [sessionId, entries] of this.commandTimestamps) {\n const filtered = entries.filter((e) => e.timestamp > windowStart);\n if (filtered.length === 0) {\n this.commandTimestamps.delete(sessionId);\n } else {\n this.commandTimestamps.set(sessionId, filtered);\n }\n }\n\n // Also clean extension UI response timestamps\n for (const [sessionId, entries] of this.extensionUIResponseTimestamps) {\n const filtered = entries.filter((e) => e.timestamp > windowStart);\n if (filtered.length === 0) {\n this.extensionUIResponseTimestamps.delete(sessionId);\n } else {\n this.extensionUIResponseTimestamps.set(sessionId, filtered);\n }\n }\n }\n\n /**\n * Start periodic cleanup of stale timestamps.\n * Call this during server startup to prevent memory leaks from inactive sessions.\n * @param intervalMs Cleanup interval (default: 5 minutes)\n */\n startPeriodicCleanup(intervalMs = DEFAULT_TIMESTAMP_CLEANUP_INTERVAL_MS): void {\n if (this.cleanupTimer) {\n return; // Already running\n }\n\n this.cleanupTimer = setInterval(() => {\n this.cleanupStaleTimestamps();\n }, intervalMs);\n\n // Don't prevent process exit\n if (this.cleanupTimer.unref) {\n this.cleanupTimer.unref();\n }\n }\n\n /**\n * Stop periodic cleanup.\n */\n stopPeriodicCleanup(): void {\n if (this.cleanupTimer) {\n clearInterval(this.cleanupTimer);\n this.cleanupTimer = null;\n }\n }\n\n /**\n * Clean up stale data for deleted sessions.\n */\n cleanupStaleData(activeSessionIds: Set<string>): void {\n for (const sessionId of this.commandTimestamps.keys()) {\n if (!activeSessionIds.has(sessionId)) {\n this.commandTimestamps.delete(sessionId);\n }\n }\n\n for (const sessionId of this.lastHeartbeat.keys()) {\n if (!activeSessionIds.has(sessionId)) {\n this.lastHeartbeat.delete(sessionId);\n }\n }\n\n for (const sessionId of this.sessionCreatedAt.keys()) {\n if (!activeSessionIds.has(sessionId)) {\n this.sessionCreatedAt.delete(sessionId);\n }\n }\n\n for (const sessionId of this.extensionUIResponseTimestamps.keys()) {\n if (!activeSessionIds.has(sessionId)) {\n this.extensionUIResponseTimestamps.delete(sessionId);\n }\n }\n }\n\n // ==========================================================================\n // SESSION LIFETIME\n // ==========================================================================\n\n /**\n * Get list of expired session IDs (exceeded maxSessionLifetimeMs).\n * Returns empty array if maxSessionLifetimeMs is 0 (unlimited).\n */\n getExpiredSessions(): string[] {\n if (this.config.maxSessionLifetimeMs === 0) {\n return [];\n }\n\n const now = Date.now();\n const expired: string[] = [];\n\n for (const [sessionId, createdAt] of this.sessionCreatedAt) {\n if (now - createdAt > this.config.maxSessionLifetimeMs) {\n expired.push(sessionId);\n }\n }\n\n return expired;\n }\n\n /**\n * Get the creation time for a session.\n */\n getSessionCreatedAt(sessionId: string): number | undefined {\n return this.sessionCreatedAt.get(sessionId);\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }