aicodeman 0.5.2 → 0.5.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.
Files changed (144) hide show
  1. package/dist/ai-checker-base.d.ts.map +1 -1
  2. package/dist/ai-checker-base.js +3 -2
  3. package/dist/ai-checker-base.js.map +1 -1
  4. package/dist/bash-tool-parser.d.ts +6 -0
  5. package/dist/bash-tool-parser.d.ts.map +1 -1
  6. package/dist/bash-tool-parser.js +87 -101
  7. package/dist/bash-tool-parser.js.map +1 -1
  8. package/dist/file-stream-manager.d.ts.map +1 -1
  9. package/dist/file-stream-manager.js +2 -1
  10. package/dist/file-stream-manager.js.map +1 -1
  11. package/dist/orchestrator-loop.d.ts +2 -0
  12. package/dist/orchestrator-loop.d.ts.map +1 -1
  13. package/dist/orchestrator-loop.js +27 -22
  14. package/dist/orchestrator-loop.js.map +1 -1
  15. package/dist/orchestrator-verifier.d.ts +1 -1
  16. package/dist/orchestrator-verifier.d.ts.map +1 -1
  17. package/dist/orchestrator-verifier.js +3 -2
  18. package/dist/orchestrator-verifier.js.map +1 -1
  19. package/dist/plan-orchestrator.d.ts +4 -1
  20. package/dist/plan-orchestrator.d.ts.map +1 -1
  21. package/dist/plan-orchestrator.js +66 -88
  22. package/dist/plan-orchestrator.js.map +1 -1
  23. package/dist/ralph-status-parser.d.ts +2 -0
  24. package/dist/ralph-status-parser.d.ts.map +1 -1
  25. package/dist/ralph-status-parser.js +98 -102
  26. package/dist/ralph-status-parser.js.map +1 -1
  27. package/dist/ralph-tracker.d.ts +9 -0
  28. package/dist/ralph-tracker.d.ts.map +1 -1
  29. package/dist/ralph-tracker.js +52 -60
  30. package/dist/ralph-tracker.js.map +1 -1
  31. package/dist/respawn-controller.d.ts +18 -1
  32. package/dist/respawn-controller.d.ts.map +1 -1
  33. package/dist/respawn-controller.js +215 -181
  34. package/dist/respawn-controller.js.map +1 -1
  35. package/dist/session-auto-ops.d.ts.map +1 -1
  36. package/dist/session-auto-ops.js +57 -55
  37. package/dist/session-auto-ops.js.map +1 -1
  38. package/dist/session.d.ts +5 -0
  39. package/dist/session.d.ts.map +1 -1
  40. package/dist/session.js +182 -218
  41. package/dist/session.js.map +1 -1
  42. package/dist/state-store.d.ts +6 -0
  43. package/dist/state-store.d.ts.map +1 -1
  44. package/dist/state-store.js +67 -79
  45. package/dist/state-store.js.map +1 -1
  46. package/dist/subagent-watcher.d.ts +24 -0
  47. package/dist/subagent-watcher.d.ts.map +1 -1
  48. package/dist/subagent-watcher.js +215 -220
  49. package/dist/subagent-watcher.js.map +1 -1
  50. package/dist/tmux-manager.d.ts +17 -0
  51. package/dist/tmux-manager.d.ts.map +1 -1
  52. package/dist/tmux-manager.js +57 -66
  53. package/dist/tmux-manager.js.map +1 -1
  54. package/dist/tunnel-manager.d.ts.map +1 -1
  55. package/dist/tunnel-manager.js +2 -1
  56. package/dist/tunnel-manager.js.map +1 -1
  57. package/dist/web/public/api-client.3adebdc2.js.gz +0 -0
  58. package/dist/web/public/app.16290ae3.js +26 -0
  59. package/dist/web/public/app.16290ae3.js.br +0 -0
  60. package/dist/web/public/app.16290ae3.js.gz +0 -0
  61. package/dist/web/public/constants.64161167.js.gz +0 -0
  62. package/dist/web/public/index.html +7 -7
  63. package/dist/web/public/index.html.br +0 -0
  64. package/dist/web/public/index.html.gz +0 -0
  65. package/dist/web/public/input-cjk.88082175.js.gz +0 -0
  66. package/dist/web/public/keyboard-accessory.9fb81db6.js.gz +0 -0
  67. package/dist/web/public/mobile-handlers.1e2a8ef8.js.gz +0 -0
  68. package/dist/web/public/mobile.0b213796.css.gz +0 -0
  69. package/dist/web/public/notification-manager.2d5ea8ec.js.gz +0 -0
  70. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  71. package/dist/web/public/{panels-ui.8204db1e.js → panels-ui.2d5b9703.js} +1 -1
  72. package/dist/web/public/panels-ui.2d5b9703.js.br +0 -0
  73. package/dist/web/public/panels-ui.2d5b9703.js.gz +0 -0
  74. package/dist/web/public/{ralph-panel.a2733fd5.js → ralph-panel.61076370.js} +1 -1
  75. package/dist/web/public/ralph-panel.61076370.js.br +0 -0
  76. package/dist/web/public/ralph-panel.61076370.js.gz +0 -0
  77. package/dist/web/public/ralph-wizard.f31ab90e.js.gz +0 -0
  78. package/dist/web/public/{respawn-ui.372c6ea7.js → respawn-ui.60be6ef5.js} +1 -1
  79. package/dist/web/public/respawn-ui.60be6ef5.js.br +0 -0
  80. package/dist/web/public/respawn-ui.60be6ef5.js.gz +0 -0
  81. package/dist/web/public/{session-ui.72f2f538.js → session-ui.554092ae.js} +1 -1
  82. package/dist/web/public/session-ui.554092ae.js.br +0 -0
  83. package/dist/web/public/session-ui.554092ae.js.gz +0 -0
  84. package/dist/web/public/{settings-ui.bd3eaadb.js → settings-ui.c58b0b9b.js} +7 -7
  85. package/dist/web/public/settings-ui.c58b0b9b.js.br +0 -0
  86. package/dist/web/public/settings-ui.c58b0b9b.js.gz +0 -0
  87. package/dist/web/public/styles.111ff326.css.gz +0 -0
  88. package/dist/web/public/subagent-windows.a366a4ad.js.gz +0 -0
  89. package/dist/web/public/sw.js.gz +0 -0
  90. package/dist/web/public/terminal-ui.474f79df.js.gz +0 -0
  91. package/dist/web/public/upload.html.gz +0 -0
  92. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  93. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  94. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  95. package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
  96. package/dist/web/public/vendor/xterm.css.gz +0 -0
  97. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  98. package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
  99. package/dist/web/respawn-event-wiring.d.ts +51 -0
  100. package/dist/web/respawn-event-wiring.d.ts.map +1 -0
  101. package/dist/web/respawn-event-wiring.js +280 -0
  102. package/dist/web/respawn-event-wiring.js.map +1 -0
  103. package/dist/web/route-helpers.d.ts +23 -0
  104. package/dist/web/route-helpers.d.ts.map +1 -1
  105. package/dist/web/route-helpers.js +53 -0
  106. package/dist/web/route-helpers.js.map +1 -1
  107. package/dist/web/routes/case-routes.d.ts.map +1 -1
  108. package/dist/web/routes/case-routes.js +2 -11
  109. package/dist/web/routes/case-routes.js.map +1 -1
  110. package/dist/web/routes/file-routes.d.ts.map +1 -1
  111. package/dist/web/routes/file-routes.js +8 -24
  112. package/dist/web/routes/file-routes.js.map +1 -1
  113. package/dist/web/routes/orchestrator-routes.d.ts.map +1 -1
  114. package/dist/web/routes/orchestrator-routes.js +23 -30
  115. package/dist/web/routes/orchestrator-routes.js.map +1 -1
  116. package/dist/web/routes/system-routes.d.ts.map +1 -1
  117. package/dist/web/routes/system-routes.js +17 -71
  118. package/dist/web/routes/system-routes.js.map +1 -1
  119. package/dist/web/server.d.ts +4 -51
  120. package/dist/web/server.d.ts.map +1 -1
  121. package/dist/web/server.js +98 -941
  122. package/dist/web/server.js.map +1 -1
  123. package/dist/web/session-listener-wiring.d.ts +89 -0
  124. package/dist/web/session-listener-wiring.d.ts.map +1 -0
  125. package/dist/web/session-listener-wiring.js +290 -0
  126. package/dist/web/session-listener-wiring.js.map +1 -0
  127. package/dist/web/sse-stream-manager.d.ts +91 -0
  128. package/dist/web/sse-stream-manager.d.ts.map +1 -0
  129. package/dist/web/sse-stream-manager.js +426 -0
  130. package/dist/web/sse-stream-manager.js.map +1 -0
  131. package/package.json +1 -1
  132. package/dist/web/public/app.e09fd4a6.js +0 -26
  133. package/dist/web/public/app.e09fd4a6.js.br +0 -0
  134. package/dist/web/public/app.e09fd4a6.js.gz +0 -0
  135. package/dist/web/public/panels-ui.8204db1e.js.br +0 -0
  136. package/dist/web/public/panels-ui.8204db1e.js.gz +0 -0
  137. package/dist/web/public/ralph-panel.a2733fd5.js.br +0 -0
  138. package/dist/web/public/ralph-panel.a2733fd5.js.gz +0 -0
  139. package/dist/web/public/respawn-ui.372c6ea7.js.br +0 -0
  140. package/dist/web/public/respawn-ui.372c6ea7.js.gz +0 -0
  141. package/dist/web/public/session-ui.72f2f538.js.br +0 -0
  142. package/dist/web/public/session-ui.72f2f538.js.gz +0 -0
  143. package/dist/web/public/settings-ui.bd3eaadb.js.br +0 -0
  144. package/dist/web/public/settings-ui.bd3eaadb.js.gz +0 -0
@@ -38,8 +38,7 @@ import fs from 'node:fs/promises';
38
38
  import { execSync } from 'node:child_process';
39
39
  import { homedir } from 'node:os';
40
40
  import { EventEmitter } from 'node:events';
41
- import { Session, } from '../session.js';
42
- import { RespawnController } from '../respawn-controller.js';
41
+ import { Session } from '../session.js';
43
42
  import { createMultiplexer } from '../mux-factory.js';
44
43
  import { getStore } from '../state-store.js';
45
44
  import { extractCompletionPhrase } from '../ralph-config.js';
@@ -56,22 +55,20 @@ import { OrchestratorLoop } from '../orchestrator-loop.js';
56
55
  import { getLifecycleLog } from '../session-lifecycle-log.js';
57
56
  import { PushSubscriptionStore } from '../push-store.js';
58
57
  import webpush from 'web-push';
58
+ import { SseStreamManager } from './sse-stream-manager.js';
59
+ import { createSessionListeners, attachSessionListeners, detachSessionListeners, } from './session-listener-wiring.js';
60
+ import { wireRespawnListeners, setupTimedRespawn, restoreRespawnController, saveRespawnConfig, } from './respawn-event-wiring.js';
59
61
  // Load version from package.json
60
62
  const require = createRequire(import.meta.url);
61
63
  const { version: APP_VERSION } = require('../../package.json');
62
64
  import { getErrorMessage, ApiErrorCode, createErrorResponse, DEFAULT_NICE_CONFIG, } from '../types.js';
63
- import { CleanupManager, KeyedDebouncer, StaleExpirationMap } from '../utils/index.js';
65
+ import { CleanupManager, KeyedDebouncer } from '../utils/index.js';
64
66
  import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
65
67
  import { SseEvent } from './sse-events.js';
66
68
  import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js';
67
69
  import { registerPushRoutes, registerTeamRoutes, registerMuxRoutes, registerFileRoutes, registerScheduledRoutes, registerHookEventRoutes, registerSystemRoutes, registerCaseRoutes, registerSessionRoutes, registerRespawnRoutes, registerRalphRoutes, registerPlanRoutes, registerOrchestratorRoutes, registerWsRoutes, } from './routes/index.js';
68
70
  const __dirname = dirname(fileURLToPath(import.meta.url));
69
- import { TERMINAL_BATCH_INTERVAL, TASK_UPDATE_BATCH_INTERVAL, STATE_UPDATE_DEBOUNCE_INTERVAL, SESSIONS_LIST_CACHE_TTL, SCHEDULED_CLEANUP_INTERVAL, SCHEDULED_RUN_MAX_AGE, SSE_HEARTBEAT_INTERVAL, SSE_PADDING_SIZE, SESSION_LIMIT_WAIT_MS, ITERATION_PAUSE_MS, BATCH_FLUSH_THRESHOLD, STATS_COLLECTION_INTERVAL_MS, INACTIVITY_TIMEOUT_MS, } from '../config/server-timing.js';
70
- // SSE padding for Cloudflare tunnel buffer flushing.
71
- // Cloudflare quick tunnels buffer small SSE responses, causing lag for real-time events.
72
- // Appending SSE comment padding (ignored by EventSource) forces the proxy to flush.
73
- // Pre-computed once at startup to avoid repeated string allocation.
74
- const SSE_PADDING = ':' + 'p'.repeat(SSE_PADDING_SIZE) + '\n';
71
+ import { SESSIONS_LIST_CACHE_TTL, SCHEDULED_CLEANUP_INTERVAL, SCHEDULED_RUN_MAX_AGE, SSE_HEARTBEAT_INTERVAL, SESSION_LIMIT_WAIT_MS, ITERATION_PAUSE_MS, STATS_COLLECTION_INTERVAL_MS, INACTIVITY_TIMEOUT_MS, } from '../config/server-timing.js';
75
72
  /**
76
73
  * Get or generate a self-signed TLS certificate for HTTPS.
77
74
  * Certs are stored in ~/.codeman/certs/ and reused across restarts.
@@ -109,41 +106,14 @@ export class WebServer extends EventEmitter {
109
106
  // Store session listener references for explicit cleanup (prevents memory leaks)
110
107
  sessionListenerRefs = new Map();
111
108
  scheduledRuns = new Map();
112
- /**
113
- * SSE clients mapped to their session subscription filter.
114
- * Value is a Set of session IDs the client wants events for,
115
- * or `null` meaning "receive all events" (backwards-compatible default).
116
- */
117
- sseClients = new Map();
118
- /** SSE clients connecting from non-localhost (i.e. through tunnel) */
119
- remoteSseClients = new Set();
120
- /** Clients with backpressure — skip writes until 'drain' fires */
121
- backpressuredClients = new Set();
109
+ sse;
122
110
  store = getStore();
123
111
  port;
124
112
  https;
125
113
  testMode;
126
114
  mux;
127
- // Terminal batching for performance
128
- terminalBatches = new Map();
129
- terminalBatchSizes = new Map(); // Running total avoids O(n) reduce per push
130
- terminalBatchTimers = new Map(); // Per-session timers (staggered flushes)
131
- // Adaptive batching: track rapid events to extend batch window (per-session)
132
- // StaleExpirationMap auto-cleans entries for sessions that stop generating output
133
- lastTerminalEventTime = new StaleExpirationMap({
134
- ttlMs: INACTIVITY_TIMEOUT_MS, // 5 minutes - auto-expire stale session timing data
135
- refreshOnGet: false, // Don't refresh on reads, only on explicit sets
136
- });
137
115
  // Centralized cleanup for standalone timers (intervals + resettable timeouts)
138
116
  cleanup = new CleanupManager();
139
- // SSE event batching
140
- taskUpdateBatches = new Map();
141
- taskUpdateBatchTimerId = null;
142
- // State update batching (reduce expensive toDetailedState() serialization)
143
- stateUpdatePending = new Set();
144
- stateUpdateTimerId = null;
145
- // Flag to prevent new timers during shutdown
146
- _isStopping = false;
147
117
  // Cached light state for SSE init (avoids rebuilding on every reconnect)
148
118
  cachedLightState = null;
149
119
  static LIGHT_STATE_CACHE_TTL_MS = 1000;
@@ -158,14 +128,10 @@ export class WebServer extends EventEmitter {
158
128
  // Active plan orchestrators (for cancellation via API)
159
129
  activePlanOrchestrators = new Map();
160
130
  persistDeb = new KeyedDebouncer(100);
161
- // Grace period before starting restored respawn controllers (2 minutes)
162
- static RESPAWN_RESTORE_GRACE_PERIOD_MS = 2 * 60 * 1000;
163
131
  // Stored listener handlers for cleanup
164
132
  subagentWatcherHandlers = null;
165
133
  imageWatcherHandlers = null;
166
134
  tunnelManager = new TunnelManager();
167
- /** Cached tunnel active state — updated on TunnelStarted/TunnelStopped to avoid getUrl() on every broadcast */
168
- _isTunnelActive = false;
169
135
  authSessions = null;
170
136
  authFailures = null;
171
137
  qrAuthFailures = null;
@@ -187,6 +153,12 @@ export class WebServer extends EventEmitter {
187
153
  this.app = Fastify({ logger: false });
188
154
  }
189
155
  this.mux = createMultiplexer();
156
+ this.sse = new SseStreamManager({
157
+ getSessionStateWithRespawn: (sessionId) => {
158
+ const session = this.sessions.get(sessionId);
159
+ return session ? this.getSessionStateWithRespawn(session) : null;
160
+ },
161
+ }, this.cleanup);
190
162
  // Set up mux event listeners
191
163
  this.mux.on('sessionCreated', (session) => {
192
164
  this.broadcast(SseEvent.MuxCreated, session);
@@ -213,11 +185,11 @@ export class WebServer extends EventEmitter {
213
185
  this.setupTeamWatcherListeners();
214
186
  // Set up tunnel manager listeners
215
187
  this.tunnelManager.on('started', (data) => {
216
- this._isTunnelActive = true;
188
+ this.sse.setTunnelActive(true);
217
189
  this.broadcast(SseEvent.TunnelStarted, data);
218
190
  });
219
191
  this.tunnelManager.on('stopped', () => {
220
- this._isTunnelActive = false;
192
+ this.sse.setTunnelActive(false);
221
193
  this.broadcast(SseEvent.TunnelStopped, {});
222
194
  });
223
195
  this.tunnelManager.on('error', (message) => {
@@ -376,7 +348,7 @@ export class WebServer extends EventEmitter {
376
348
  batchTerminalData: this.batchTerminalData.bind(this),
377
349
  broadcastSessionStateDebounced: this.broadcastSessionStateDebounced.bind(this),
378
350
  batchTaskUpdate: this.batchTaskUpdate.bind(this),
379
- getSseClientCount: () => this.remoteSseClients.size,
351
+ getSseClientCount: () => this.sse.remoteClientCount,
380
352
  // RespawnPort
381
353
  respawnControllers: this.respawnControllers,
382
354
  respawnTimers: this.respawnTimers,
@@ -472,7 +444,7 @@ export class WebServer extends EventEmitter {
472
444
  // SSE endpoint for real-time updates
473
445
  this.app.get('/api/events', (req, reply) => {
474
446
  // Enforce SSE client limit to prevent memory exhaustion from too many connections
475
- if (this.sseClients.size >= MAX_SSE_CLIENTS) {
447
+ if (this.sse.clientCount >= MAX_SSE_CLIENTS) {
476
448
  reply.code(503).send('Too many SSE connections');
477
449
  return;
478
450
  }
@@ -496,30 +468,19 @@ export class WebServer extends EventEmitter {
496
468
  Connection: 'keep-alive',
497
469
  'X-Accel-Buffering': 'no', // Disable nginx buffering
498
470
  });
499
- this.sseClients.set(reply, sessionFilter);
500
471
  // Track tunnel clients — cloudflared proxies locally so req.ip is always
501
472
  // 127.0.0.1; detect tunnel traffic via Cf-Connecting-Ip header instead.
502
- if (req.headers['cf-connecting-ip']) {
503
- this.remoteSseClients.add(reply);
504
- }
473
+ const isRemote = !!req.headers['cf-connecting-ip'];
474
+ this.sse.addClient(reply, sessionFilter, isRemote);
505
475
  // Send initial state
506
476
  // Use light state for SSE init to avoid sending 2MB+ terminal buffers
507
477
  // Buffers are fetched on-demand when switching tabs
508
- this.sendSSE(reply, SseEvent.Init, this.getLightState());
478
+ this.sse.sendSSE(reply, SseEvent.Init, this.getLightState());
509
479
  // Flush Cloudflare tunnel buffer with padding — ensures the init event
510
480
  // (and any immediately following events) are delivered without proxy delay.
511
- if (this._isTunnelActive) {
512
- try {
513
- reply.raw.write(SSE_PADDING);
514
- }
515
- catch {
516
- /* client gone */
517
- }
518
- }
481
+ this.sse.sendPadding(reply);
519
482
  req.raw.on('close', () => {
520
- this.sseClients.delete(reply);
521
- this.remoteSseClients.delete(reply);
522
- this.backpressuredClients.delete(reply);
483
+ this.sse.removeClient(reply);
523
484
  });
524
485
  });
525
486
  // Global error handler for structured errors thrown by findSessionOrFail
@@ -664,33 +625,8 @@ export class WebServer extends EventEmitter {
664
625
  }
665
626
  this.store.setSession(session.id, state);
666
627
  }
667
- // Helper to save respawn config to mux session for persistence
668
628
  saveRespawnConfig(sessionId, config, durationMinutes) {
669
- const persistedConfig = {
670
- enabled: config.enabled,
671
- idleTimeoutMs: config.idleTimeoutMs,
672
- updatePrompt: config.updatePrompt,
673
- interStepDelayMs: config.interStepDelayMs,
674
- sendClear: config.sendClear,
675
- sendInit: config.sendInit,
676
- kickstartPrompt: config.kickstartPrompt,
677
- autoAcceptPrompts: config.autoAcceptPrompts,
678
- autoAcceptDelayMs: config.autoAcceptDelayMs,
679
- completionConfirmMs: config.completionConfirmMs,
680
- noOutputTimeoutMs: config.noOutputTimeoutMs,
681
- aiIdleCheckEnabled: config.aiIdleCheckEnabled,
682
- aiIdleCheckModel: config.aiIdleCheckModel,
683
- aiIdleCheckMaxContext: config.aiIdleCheckMaxContext,
684
- aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs,
685
- aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs,
686
- aiPlanCheckEnabled: config.aiPlanCheckEnabled,
687
- aiPlanCheckModel: config.aiPlanCheckModel,
688
- aiPlanCheckMaxContext: config.aiPlanCheckMaxContext,
689
- aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs,
690
- aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs,
691
- durationMinutes,
692
- };
693
- this.mux.updateRespawnConfig(sessionId, persistedConfig);
629
+ saveRespawnConfig(sessionId, config, this.mux, durationMinutes);
694
630
  }
695
631
  // Clean up all resources associated with a session
696
632
  // Track sessions currently being cleaned up to prevent concurrent cleanup races
@@ -768,16 +704,7 @@ export class WebServer extends EventEmitter {
768
704
  // Clear pending persist-debounce timer (prevents stale closure holding session ref)
769
705
  this.persistDeb.cancelKey(sessionId);
770
706
  // Clear batches, per-session timers, and pending state updates
771
- this.terminalBatches.delete(sessionId);
772
- this.terminalBatchSizes.delete(sessionId);
773
- const batchTimer = this.terminalBatchTimers.get(sessionId);
774
- if (batchTimer) {
775
- clearTimeout(batchTimer);
776
- this.terminalBatchTimers.delete(sessionId);
777
- }
778
- this.taskUpdateBatches.delete(sessionId);
779
- this.stateUpdatePending.delete(sessionId);
780
- this.lastTerminalEventTime.delete(sessionId);
707
+ this.sse.cleanupSessionBatches(sessionId);
781
708
  // Reset Ralph tracker on the session before cleanup
782
709
  if (session) {
783
710
  session.ralphTracker.fullReset();
@@ -822,31 +749,7 @@ export class WebServer extends EventEmitter {
822
749
  // Explicitly remove stored listeners to break closure references (prevents memory leak)
823
750
  const listeners = this.sessionListenerRefs.get(sessionId);
824
751
  if (listeners) {
825
- session.off('terminal', listeners.terminal);
826
- session.off('clearTerminal', listeners.clearTerminal);
827
- session.off('needsRefresh', listeners.needsRefresh);
828
- session.off('message', listeners.message);
829
- session.off('error', listeners.error);
830
- session.off('completion', listeners.completion);
831
- session.off('exit', listeners.exit);
832
- session.off('working', listeners.working);
833
- session.off('idle', listeners.idle);
834
- session.off('taskCreated', listeners.taskCreated);
835
- session.off('taskUpdated', listeners.taskUpdated);
836
- session.off('taskCompleted', listeners.taskCompleted);
837
- session.off('taskFailed', listeners.taskFailed);
838
- session.off('autoClear', listeners.autoClear);
839
- session.off('autoCompact', listeners.autoCompact);
840
- session.off('cliInfoUpdated', listeners.cliInfoUpdated);
841
- session.off('ralphLoopUpdate', listeners.ralphLoopUpdate);
842
- session.off('ralphTodoUpdate', listeners.ralphTodoUpdate);
843
- session.off('ralphCompletionDetected', listeners.ralphCompletionDetected);
844
- session.off('ralphStatusBlockDetected', listeners.ralphStatusBlockDetected);
845
- session.off('ralphCircuitBreakerUpdate', listeners.ralphCircuitBreakerUpdate);
846
- session.off('ralphExitGateMet', listeners.ralphExitGateMet);
847
- session.off('bashToolStart', listeners.bashToolStart);
848
- session.off('bashToolEnd', listeners.bashToolEnd);
849
- session.off('bashToolsUpdate', listeners.bashToolsUpdate);
752
+ detachSessionListeners(session, listeners);
850
753
  this.sessionListenerRefs.delete(sessionId);
851
754
  }
852
755
  session.removeAllListeners();
@@ -877,525 +780,82 @@ export class WebServer extends EventEmitter {
877
780
  if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled) {
878
781
  imageWatcher.watchSession(session.id, session.workingDir);
879
782
  }
880
- // Store all listener references for explicit cleanup on session delete.
881
- // This prevents memory leaks from closure references keeping objects alive.
882
- const listeners = {
883
- // ─── Terminal Output ─────────────────────────────────────
884
- // These listeners handle raw PTY output streaming to SSE clients.
885
- /** Batches PTY output broadcasts `session:terminal` at 16-50ms intervals */
886
- terminal: (data) => {
887
- this.batchTerminalData(session.id, data);
888
- },
889
- /** Broadcasts `session:clearTerminal` — tells clients to wipe their xterm buffer (after mux attach) */
890
- clearTerminal: () => {
891
- this.broadcast(SseEvent.SessionClearTerminal, { id: session.id });
892
- },
893
- /** Broadcasts `session:needsRefresh` — tells clients to reload buffer (e.g., after OpenCode TUI stabilizes) */
894
- needsRefresh: () => {
895
- this.broadcast(SseEvent.SessionNeedsRefresh, { id: session.id });
896
- },
897
- // ─── Session Messages & Errors ──────────────────────────
898
- /** Broadcasts `session:message` — structured Claude JSON messages (assistant, tool_use, etc.) */
899
- message: (msg) => {
900
- this.broadcast(SseEvent.SessionMessage, { id: session.id, message: msg });
901
- },
902
- /** Broadcasts `session:error` + sends push notification */
903
- error: (error) => {
904
- this.broadcast(SseEvent.SessionError, { id: session.id, error });
905
- this.sendPushNotifications(SseEvent.SessionError, {
906
- sessionId: session.id,
907
- sessionName: session.name,
908
- error: String(error),
909
- });
910
- const tracker = this.runSummaryTrackers.get(session.id);
911
- if (tracker)
912
- tracker.recordError('Session error', String(error));
913
- },
914
- /** Broadcasts `session:completion` + `session:updated` — prompt finished, persists state */
915
- completion: (result, cost) => {
916
- this.broadcast(SseEvent.SessionCompletion, { id: session.id, result, cost });
917
- this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session));
918
- this.persistSessionState(session);
919
- const tracker = this.runSummaryTrackers.get(session.id);
920
- if (tracker)
921
- tracker.recordTokens(session.inputTokens, session.outputTokens);
922
- },
923
- // ─── Session Lifecycle ──────────────────────────────────
924
- /** Broadcasts `session:exit` + `session:updated` — PTY process exited; cleans up respawn, timers, listeners */
925
- exit: (code) => {
926
- getLifecycleLog().log({
927
- event: 'exit',
928
- sessionId: session.id,
929
- name: session.name,
930
- exitCode: code,
931
- });
932
- // Wrap in try/catch to ensure cleanup always happens
933
- try {
934
- this.broadcast(SseEvent.SessionExit, { id: session.id, code });
935
- this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session));
936
- this.persistSessionState(session);
937
- }
938
- catch (err) {
939
- console.error(`[Server] Error broadcasting session exit for ${session.id}:`, err);
940
- }
941
- // Always clean up respawn controller, even if broadcast failed
942
- try {
943
- const controller = this.respawnControllers.get(session.id);
944
- if (controller) {
945
- controller.stop();
946
- controller.removeAllListeners();
947
- this.respawnControllers.delete(session.id);
948
- }
949
- // Also clean up the respawn timer to prevent orphaned timers
950
- const timerInfo = this.respawnTimers.get(session.id);
951
- if (timerInfo) {
952
- clearTimeout(timerInfo.timer);
953
- this.respawnTimers.delete(session.id);
954
- }
955
- }
956
- catch (err) {
957
- console.error(`[Server] Error cleaning up respawn controller for ${session.id}:`, err);
958
- }
959
- // Clean up per-session resources that are stale after PTY exit.
960
- // These are only cleaned by cleanupSession() on explicit delete,
961
- // so without this they leak when a session exits without deletion.
962
- try {
963
- // Transcript watcher is tied to the specific PTY run
964
- this.stopTranscriptWatcher(session.id);
965
- // Finalize run summary tracker
966
- const summaryTracker = this.runSummaryTrackers.get(session.id);
967
- if (summaryTracker) {
968
- summaryTracker.recordSessionStopped();
969
- summaryTracker.stop();
970
- this.runSummaryTrackers.delete(session.id);
971
- }
972
- // Flush/clear terminal batching state (no more output coming)
973
- this.terminalBatches.delete(session.id);
974
- this.terminalBatchSizes.delete(session.id);
975
- const batchTimer = this.terminalBatchTimers.get(session.id);
976
- if (batchTimer) {
977
- clearTimeout(batchTimer);
978
- this.terminalBatchTimers.delete(session.id);
979
- }
980
- this.taskUpdateBatches.delete(session.id);
981
- this.stateUpdatePending.delete(session.id);
982
- this.lastTerminalEventTime.delete(session.id);
983
- // Clear pending persist-debounce timer
984
- this.persistDeb.cancelKey(session.id);
985
- // Close any active file streams
986
- fileStreamManager.closeSessionStreams(session.id);
987
- // Remove stored listener refs to break closure references (prevents memory leak).
988
- // Without this, the closures capture the Session object (including up to 2MB terminal buffer)
989
- // and keep it alive even after the PTY exits.
990
- const listenerRefs = this.sessionListenerRefs.get(session.id);
991
- if (listenerRefs) {
992
- session.off('terminal', listenerRefs.terminal);
993
- session.off('clearTerminal', listenerRefs.clearTerminal);
994
- session.off('needsRefresh', listenerRefs.needsRefresh);
995
- session.off('message', listenerRefs.message);
996
- session.off('error', listenerRefs.error);
997
- session.off('completion', listenerRefs.completion);
998
- session.off('exit', listenerRefs.exit);
999
- session.off('working', listenerRefs.working);
1000
- session.off('idle', listenerRefs.idle);
1001
- session.off('taskCreated', listenerRefs.taskCreated);
1002
- session.off('taskUpdated', listenerRefs.taskUpdated);
1003
- session.off('taskCompleted', listenerRefs.taskCompleted);
1004
- session.off('taskFailed', listenerRefs.taskFailed);
1005
- session.off('autoClear', listenerRefs.autoClear);
1006
- session.off('autoCompact', listenerRefs.autoCompact);
1007
- session.off('cliInfoUpdated', listenerRefs.cliInfoUpdated);
1008
- session.off('ralphLoopUpdate', listenerRefs.ralphLoopUpdate);
1009
- session.off('ralphTodoUpdate', listenerRefs.ralphTodoUpdate);
1010
- session.off('ralphCompletionDetected', listenerRefs.ralphCompletionDetected);
1011
- session.off('ralphStatusBlockDetected', listenerRefs.ralphStatusBlockDetected);
1012
- session.off('ralphCircuitBreakerUpdate', listenerRefs.ralphCircuitBreakerUpdate);
1013
- session.off('ralphExitGateMet', listenerRefs.ralphExitGateMet);
1014
- session.off('bashToolStart', listenerRefs.bashToolStart);
1015
- session.off('bashToolEnd', listenerRefs.bashToolEnd);
1016
- session.off('bashToolsUpdate', listenerRefs.bashToolsUpdate);
1017
- this.sessionListenerRefs.delete(session.id);
1018
- }
1019
- }
1020
- catch (err) {
1021
- console.error(`[Server] Error cleaning up session resources on exit for ${session.id}:`, err);
1022
- }
1023
- },
1024
- // ─── Activity State ─────────────────────────────────────
1025
- /** Broadcasts `session:working` — Claude started processing */
1026
- working: () => {
1027
- this.broadcast(SseEvent.SessionWorking, { id: session.id });
1028
- const tracker = this.runSummaryTrackers.get(session.id);
1029
- if (tracker) {
1030
- tracker.recordWorking();
1031
- tracker.recordTokens(session.inputTokens, session.outputTokens);
1032
- }
1033
- },
1034
- /** Broadcasts `session:idle` — Claude finished processing, waiting for input */
1035
- idle: () => {
1036
- this.broadcast(SseEvent.SessionIdle, { id: session.id });
1037
- this.broadcastSessionStateDebounced(session.id);
1038
- const tracker = this.runSummaryTrackers.get(session.id);
783
+ // Create and attach all listener handlers via dependency injection
784
+ const listeners = createSessionListeners(session, this.buildSessionListenerDeps());
785
+ this.sessionListenerRefs.set(session.id, listeners);
786
+ attachSessionListeners(session, listeners);
787
+ }
788
+ /** Build the deps object for session listener wiring. */
789
+ buildSessionListenerDeps() {
790
+ return {
791
+ broadcast: this.broadcast.bind(this),
792
+ batchTerminalData: this.batchTerminalData.bind(this),
793
+ batchTaskUpdate: this.batchTaskUpdate.bind(this),
794
+ broadcastSessionStateDebounced: this.broadcastSessionStateDebounced.bind(this),
795
+ sendPushNotifications: this.sendPushNotifications.bind(this),
796
+ persistSessionState: this.persistSessionState.bind(this),
797
+ getSessionStateWithRespawn: this.getSessionStateWithRespawn.bind(this),
798
+ getRunSummaryTracker: (id) => this.runSummaryTrackers.get(id),
799
+ stopTranscriptWatcher: this.stopTranscriptWatcher.bind(this),
800
+ cleanupSessionBatches: (id) => this.sse.cleanupSessionBatches(id),
801
+ cancelPersistDebounce: (id) => this.persistDeb.cancelKey(id),
802
+ removeRunSummaryTracker: (id) => {
803
+ const tracker = this.runSummaryTrackers.get(id);
1039
804
  if (tracker) {
1040
- tracker.recordIdle();
1041
- tracker.recordTokens(session.inputTokens, session.outputTokens);
805
+ tracker.recordSessionStopped();
806
+ tracker.stop();
807
+ this.runSummaryTrackers.delete(id);
1042
808
  }
1043
809
  },
1044
- // ─── Background Task Events ──────────────────────────────
1045
- // Debounced state updates to reduce serialization overhead.
1046
- /** Broadcasts `task:created` — new background task discovered */
1047
- taskCreated: (task) => {
1048
- this.broadcast(SseEvent.TaskCreated, { sessionId: session.id, task });
1049
- this.broadcastSessionStateDebounced(session.id);
1050
- },
1051
- /** Batched broadcast of `task:updated` — high-frequency progress updates */
1052
- taskUpdated: (task) => {
1053
- this.batchTaskUpdate(session.id, task);
1054
- },
1055
- /** Broadcasts `task:completed` — background task finished successfully */
1056
- taskCompleted: (task) => {
1057
- this.broadcast(SseEvent.TaskCompleted, { sessionId: session.id, task });
1058
- this.broadcastSessionStateDebounced(session.id);
1059
- },
1060
- /** Broadcasts `task:failed` — background task errored */
1061
- taskFailed: (task, error) => {
1062
- this.broadcast(SseEvent.TaskFailed, { sessionId: session.id, task, error });
1063
- this.broadcastSessionStateDebounced(session.id);
1064
- },
1065
- // ─── Auto-Operations ────────────────────────────────────
1066
- /** Broadcasts `session:autoClear` — context window auto-cleared at token threshold */
1067
- autoClear: (data) => {
1068
- this.broadcast(SseEvent.SessionAutoClear, { sessionId: session.id, ...data });
1069
- this.broadcastSessionStateDebounced(session.id);
1070
- const tracker = this.runSummaryTrackers.get(session.id);
1071
- if (tracker)
1072
- tracker.recordAutoClear(data.tokens, data.threshold);
1073
- },
1074
- /** Broadcasts `session:autoCompact` — context window auto-compacted at token threshold */
1075
- autoCompact: (data) => {
1076
- this.broadcast(SseEvent.SessionAutoCompact, { sessionId: session.id, ...data });
1077
- this.broadcastSessionStateDebounced(session.id);
1078
- const tracker = this.runSummaryTrackers.get(session.id);
1079
- if (tracker)
1080
- tracker.recordAutoCompact(data.tokens, data.threshold);
1081
- },
1082
- // ─── CLI Info ────────────────────────────────────────────
1083
- /** Broadcasts `session:cliInfo` — Claude Code version, model, account type parsed from terminal */
1084
- cliInfoUpdated: (data) => {
1085
- this.broadcast(SseEvent.SessionCliInfo, { sessionId: session.id, ...data });
1086
- this.broadcastSessionStateDebounced(session.id);
1087
- },
1088
- // ─── Ralph Tracking Events ──────────────────────────────
1089
- /** Broadcasts `session:ralphLoopUpdate` — Ralph tracker loop state changed (iteration, phase) */
1090
- ralphLoopUpdate: (state) => {
1091
- this.broadcast(SseEvent.SessionRalphLoopUpdate, { sessionId: session.id, state });
1092
- this.store.updateRalphState(session.id, { loop: state });
1093
- },
1094
- /** Broadcasts `session:ralphTodoUpdate` — todo items added, completed, or modified */
1095
- ralphTodoUpdate: (todos) => {
1096
- this.broadcast(SseEvent.SessionRalphTodoUpdate, { sessionId: session.id, todos });
1097
- this.store.updateRalphState(session.id, { todos });
1098
- },
1099
- /** Broadcasts `session:ralphCompletionDetected` + push notification — completion phrase matched */
1100
- ralphCompletionDetected: (phrase) => {
1101
- this.broadcast(SseEvent.SessionRalphCompletionDetected, { sessionId: session.id, phrase });
1102
- this.sendPushNotifications(SseEvent.SessionRalphCompletionDetected, {
1103
- sessionId: session.id,
1104
- sessionName: session.name,
1105
- phrase,
1106
- });
1107
- const tracker = this.runSummaryTrackers.get(session.id);
1108
- if (tracker)
1109
- tracker.recordRalphCompletion(phrase);
1110
- },
1111
- /** Broadcasts `session:ralphStatusUpdate` — RALPH_STATUS block parsed from output */
1112
- ralphStatusBlockDetected: (block) => {
1113
- this.broadcast(SseEvent.SessionRalphStatusUpdate, { sessionId: session.id, block });
1114
- const tracker = this.runSummaryTrackers.get(session.id);
1115
- if (tracker) {
1116
- tracker.addEvent(block.status === 'BLOCKED' ? 'warning' : 'idle_detected', block.status === 'BLOCKED' ? 'warning' : 'info', `Ralph Status: ${block.status}`, `Tasks: ${block.tasksCompletedThisLoop}, Files: ${block.filesModified}, Tests: ${block.testsStatus}`);
810
+ removeSessionListenerRefs: (id) => {
811
+ const refs = this.sessionListenerRefs.get(id);
812
+ const sess = this.sessions.get(id);
813
+ if (refs && sess) {
814
+ detachSessionListeners(sess, refs);
1117
815
  }
816
+ this.sessionListenerRefs.delete(id);
1118
817
  },
1119
- /** Broadcasts `session:circuitBreakerUpdate` — circuit breaker state changed (CLOSED/HALF_OPEN/OPEN) */
1120
- ralphCircuitBreakerUpdate: (status) => {
1121
- this.broadcast(SseEvent.SessionCircuitBreakerUpdate, { sessionId: session.id, status });
1122
- const tracker = this.runSummaryTrackers.get(session.id);
1123
- if (tracker && status.state === 'OPEN') {
1124
- tracker.addEvent('warning', 'warning', 'Circuit Breaker Opened', status.reason);
818
+ cleanupRespawnOnExit: (id) => {
819
+ const controller = this.respawnControllers.get(id);
820
+ if (controller) {
821
+ controller.stop();
822
+ controller.removeAllListeners();
823
+ this.respawnControllers.delete(id);
1125
824
  }
1126
- },
1127
- /** Broadcasts `session:exitGateMet` — all completion indicators met, ready to exit */
1128
- ralphExitGateMet: (data) => {
1129
- this.broadcast(SseEvent.SessionExitGateMet, { sessionId: session.id, ...data });
1130
- const tracker = this.runSummaryTrackers.get(session.id);
1131
- if (tracker) {
1132
- tracker.addEvent('ralph_completion', 'success', 'Exit Gate Met', `Indicators: ${data.completionIndicators}, EXIT_SIGNAL: ${data.exitSignal}`);
825
+ const timerInfo = this.respawnTimers.get(id);
826
+ if (timerInfo) {
827
+ clearTimeout(timerInfo.timer);
828
+ this.respawnTimers.delete(id);
1133
829
  }
1134
830
  },
1135
- // ─── Bash Tool Tracking ────────────────────────────────
1136
- // Used for clickable file paths in the UI.
1137
- /** Broadcasts `session:bashToolStart` — bash tool invocation started */
1138
- bashToolStart: (tool) => {
1139
- this.broadcast(SseEvent.SessionBashToolStart, { sessionId: session.id, tool });
1140
- },
1141
- /** Broadcasts `session:bashToolEnd` — bash tool invocation completed */
1142
- bashToolEnd: (tool) => {
1143
- this.broadcast(SseEvent.SessionBashToolEnd, { sessionId: session.id, tool });
1144
- },
1145
- /** Broadcasts `session:bashToolsUpdate` — full active bash tools list refreshed */
1146
- bashToolsUpdate: (tools) => {
1147
- this.broadcast(SseEvent.SessionBashToolsUpdate, { sessionId: session.id, tools });
1148
- },
831
+ getStore: () => this.store,
1149
832
  };
1150
- // Store listener refs for cleanup
1151
- this.sessionListenerRefs.set(session.id, listeners);
1152
- // Attach all listeners to the session
1153
- session.on('terminal', listeners.terminal);
1154
- session.on('clearTerminal', listeners.clearTerminal);
1155
- session.on('needsRefresh', listeners.needsRefresh);
1156
- session.on('message', listeners.message);
1157
- session.on('error', listeners.error);
1158
- session.on('completion', listeners.completion);
1159
- session.on('exit', listeners.exit);
1160
- session.on('working', listeners.working);
1161
- session.on('idle', listeners.idle);
1162
- session.on('taskCreated', listeners.taskCreated);
1163
- session.on('taskUpdated', listeners.taskUpdated);
1164
- session.on('taskCompleted', listeners.taskCompleted);
1165
- session.on('taskFailed', listeners.taskFailed);
1166
- session.on('autoClear', listeners.autoClear);
1167
- session.on('autoCompact', listeners.autoCompact);
1168
- session.on('cliInfoUpdated', listeners.cliInfoUpdated);
1169
- session.on('ralphLoopUpdate', listeners.ralphLoopUpdate);
1170
- session.on('ralphTodoUpdate', listeners.ralphTodoUpdate);
1171
- session.on('ralphCompletionDetected', listeners.ralphCompletionDetected);
1172
- session.on('ralphStatusBlockDetected', listeners.ralphStatusBlockDetected);
1173
- session.on('ralphCircuitBreakerUpdate', listeners.ralphCircuitBreakerUpdate);
1174
- session.on('ralphExitGateMet', listeners.ralphExitGateMet);
1175
- session.on('bashToolStart', listeners.bashToolStart);
1176
- session.on('bashToolEnd', listeners.bashToolEnd);
1177
- session.on('bashToolsUpdate', listeners.bashToolsUpdate);
1178
833
  }
1179
834
  setupRespawnListeners(sessionId, controller) {
1180
- // Wire team watcher for team-aware idle detection
1181
- controller.setTeamWatcher(this.teamWatcher);
1182
- // Helper to get tracker lazily (may not exist at setup time for restored sessions)
1183
- const getTracker = () => this.runSummaryTrackers.get(sessionId);
1184
- // ─── Respawn State Machine ──────────────────────────────
1185
- /** Broadcasts `respawn:stateChanged` — state machine transition (e.g., IDLE → DETECTING → RESPAWNING) */
1186
- controller.on('stateChanged', (state, prevState) => {
1187
- this.broadcast(SseEvent.RespawnStateChanged, { sessionId, state, prevState });
1188
- const tracker = getTracker();
1189
- if (tracker)
1190
- tracker.recordStateChange(state, `${prevState} → ${state}`);
1191
- });
1192
- // ─── Respawn Cycle Lifecycle ────────────────────────────
1193
- /** Broadcasts `respawn:cycleStarted` — new respawn cycle begins */
1194
- controller.on('respawnCycleStarted', (cycleNumber) => {
1195
- this.broadcast(SseEvent.RespawnCycleStarted, { sessionId, cycleNumber });
1196
- });
1197
- /** Broadcasts `respawn:cycleCompleted` — respawn cycle finished */
1198
- controller.on('respawnCycleCompleted', (cycleNumber) => {
1199
- this.broadcast(SseEvent.RespawnCycleCompleted, { sessionId, cycleNumber });
1200
- });
1201
- /** Broadcasts `respawn:blocked` + push notification — respawn blocked by error/circuit breaker */
1202
- controller.on('respawnBlocked', (data) => {
1203
- this.broadcast(SseEvent.RespawnBlocked, { sessionId, reason: data.reason, details: data.details });
1204
- const sessionForPush = this.sessions.get(sessionId);
1205
- this.sendPushNotifications(SseEvent.RespawnBlocked, {
1206
- sessionId,
1207
- sessionName: sessionForPush?.name ?? sessionId.slice(0, 8),
1208
- reason: data.reason,
1209
- });
1210
- const tracker = getTracker();
1211
- if (tracker)
1212
- tracker.recordWarning(`Respawn blocked: ${data.reason}`, data.details);
1213
- });
1214
- // ─── Respawn Step Progress ──────────────────────────────
1215
- /** Broadcasts `respawn:stepSent` — respawn step input sent (e.g., /clear, kickstart prompt) */
1216
- controller.on('stepSent', (step, input) => {
1217
- this.broadcast(SseEvent.RespawnStepSent, { sessionId, step, input });
1218
- });
1219
- /** Broadcasts `respawn:stepCompleted` — respawn step finished */
1220
- controller.on('stepCompleted', (step) => {
1221
- this.broadcast(SseEvent.RespawnStepCompleted, { sessionId, step });
1222
- });
1223
- /** Broadcasts `respawn:detectionUpdate` — idle/completion detection state changed */
1224
- controller.on('detectionUpdate', (detection) => {
1225
- this.broadcast(SseEvent.RespawnDetectionUpdate, { sessionId, detection });
1226
- });
1227
- /** Broadcasts `respawn:autoAcceptSent` — auto-accepted a permission prompt */
1228
- controller.on('autoAcceptSent', () => {
1229
- this.broadcast(SseEvent.RespawnAutoAcceptSent, { sessionId });
1230
- });
1231
- // ─── AI Checker Events ──────────────────────────────────
1232
- /** Broadcasts `respawn:aiCheckStarted` — AI idle checker invoked */
1233
- controller.on('aiCheckStarted', () => {
1234
- this.broadcast(SseEvent.RespawnAiCheckStarted, { sessionId });
1235
- });
1236
- /** Broadcasts `respawn:aiCheckCompleted` — AI idle check returned verdict (idle/working/stuck) */
1237
- controller.on('aiCheckCompleted', (result) => {
1238
- this.broadcast(SseEvent.RespawnAiCheckCompleted, {
1239
- sessionId,
1240
- verdict: result.verdict,
1241
- reasoning: result.reasoning,
1242
- durationMs: result.durationMs,
1243
- });
1244
- const tracker = getTracker();
1245
- if (tracker)
1246
- tracker.recordAiCheckResult(result.verdict);
1247
- });
1248
- /** Broadcasts `respawn:aiCheckFailed` — AI idle check errored */
1249
- controller.on('aiCheckFailed', (error) => {
1250
- this.broadcast(SseEvent.RespawnAiCheckFailed, { sessionId, error });
1251
- const tracker = getTracker();
1252
- if (tracker)
1253
- tracker.recordError('AI check failed', error);
1254
- });
1255
- /** Broadcasts `respawn:aiCheckCooldown` — AI check on cooldown after failure */
1256
- controller.on('aiCheckCooldown', (active, endsAt) => {
1257
- this.broadcast(SseEvent.RespawnAiCheckCooldown, { sessionId, active, endsAt });
1258
- });
1259
- // ─── Plan Checker Events ────────────────────────────────
1260
- /** Broadcasts `respawn:planCheckStarted` — AI plan completion checker invoked */
1261
- controller.on('planCheckStarted', () => {
1262
- this.broadcast(SseEvent.RespawnPlanCheckStarted, { sessionId });
1263
- });
1264
- /** Broadcasts `respawn:planCheckCompleted` — plan check returned verdict */
1265
- controller.on('planCheckCompleted', (result) => {
1266
- this.broadcast(SseEvent.RespawnPlanCheckCompleted, {
1267
- sessionId,
1268
- verdict: result.verdict,
1269
- reasoning: result.reasoning,
1270
- durationMs: result.durationMs,
1271
- });
1272
- });
1273
- /** Broadcasts `respawn:planCheckFailed` — plan check errored */
1274
- controller.on('planCheckFailed', (error) => {
1275
- this.broadcast(SseEvent.RespawnPlanCheckFailed, { sessionId, error });
1276
- });
1277
- // ─── Timer Events (UI countdown display) ────────────────
1278
- /** Broadcasts `respawn:timerStarted` — countdown timer started (idle, cooldown, etc.) */
1279
- controller.on('timerStarted', (timer) => {
1280
- this.broadcast(SseEvent.RespawnTimerStarted, { sessionId, timer });
1281
- });
1282
- /** Broadcasts `respawn:timerCancelled` — timer cancelled before expiry */
1283
- controller.on('timerCancelled', (timerName, reason) => {
1284
- this.broadcast(SseEvent.RespawnTimerCancelled, { sessionId, timerName, reason });
1285
- });
1286
- /** Broadcasts `respawn:timerCompleted` — timer expired */
1287
- controller.on('timerCompleted', (timerName) => {
1288
- this.broadcast(SseEvent.RespawnTimerCompleted, { sessionId, timerName });
1289
- });
1290
- // ─── Logging & Errors ───────────────────────────────────
1291
- /** Broadcasts `respawn:actionLog` — respawn action logged for audit/debugging */
1292
- controller.on('actionLog', (action) => {
1293
- this.broadcast(SseEvent.RespawnActionLog, { sessionId, action });
1294
- });
1295
- /** Broadcasts `respawn:log` — general respawn log message */
1296
- controller.on('log', (message) => {
1297
- this.broadcast(SseEvent.RespawnLog, { sessionId, message });
1298
- });
1299
- /** Broadcasts `respawn:error` — respawn controller error */
1300
- controller.on('error', (error) => {
1301
- this.broadcast(SseEvent.RespawnError, { sessionId, error: error.message });
1302
- const tracker = getTracker();
1303
- if (tracker)
1304
- tracker.recordError('Respawn error', error.message);
1305
- });
835
+ wireRespawnListeners(sessionId, controller, this.buildRespawnWiringDeps());
1306
836
  }
1307
837
  setupTimedRespawn(sessionId, durationMinutes) {
1308
- // Clear existing timer if any
1309
- const existing = this.respawnTimers.get(sessionId);
1310
- if (existing) {
1311
- clearTimeout(existing.timer);
1312
- }
1313
- const now = Date.now();
1314
- const endAt = now + durationMinutes * 60 * 1000;
1315
- const timer = setTimeout(() => {
1316
- // Stop respawn when time is up
1317
- const controller = this.respawnControllers.get(sessionId);
1318
- if (controller) {
1319
- controller.stop();
1320
- controller.removeAllListeners();
1321
- this.respawnControllers.delete(sessionId);
1322
- this.broadcast(SseEvent.RespawnStopped, { sessionId, reason: 'duration_expired' });
1323
- }
1324
- this.respawnTimers.delete(sessionId);
1325
- // Update persisted state (respawn no longer active)
1326
- const session = this.sessions.get(sessionId);
1327
- if (session) {
1328
- this.persistSessionState(session);
1329
- }
1330
- }, durationMinutes * 60 * 1000);
1331
- this.respawnTimers.set(sessionId, { timer, endAt, startedAt: now });
1332
- this.broadcast(SseEvent.RespawnTimerStarted, { sessionId, durationMinutes, endAt, startedAt: now });
838
+ setupTimedRespawn(sessionId, durationMinutes, this.buildRespawnWiringDeps());
1333
839
  }
1334
- /**
1335
- * Restore a RespawnController from persisted configuration.
1336
- * Creates the controller, sets up listeners, but does NOT start it.
1337
- *
1338
- * @param session - The session to attach the controller to
1339
- * @param config - The persisted respawn configuration
1340
- * @param source - Source of the config for logging (e.g., 'state.json' or 'mux-sessions.json')
1341
- */
1342
840
  restoreRespawnController(session, config, source) {
1343
- const controller = new RespawnController(session, {
1344
- idleTimeoutMs: config.idleTimeoutMs,
1345
- updatePrompt: config.updatePrompt,
1346
- interStepDelayMs: config.interStepDelayMs,
1347
- enabled: true,
1348
- sendClear: config.sendClear,
1349
- sendInit: config.sendInit,
1350
- kickstartPrompt: config.kickstartPrompt,
1351
- completionConfirmMs: config.completionConfirmMs,
1352
- noOutputTimeoutMs: config.noOutputTimeoutMs,
1353
- autoAcceptPrompts: config.autoAcceptPrompts,
1354
- autoAcceptDelayMs: config.autoAcceptDelayMs,
1355
- aiIdleCheckEnabled: config.aiIdleCheckEnabled,
1356
- aiIdleCheckModel: config.aiIdleCheckModel,
1357
- aiIdleCheckMaxContext: config.aiIdleCheckMaxContext,
1358
- aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs,
1359
- aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs,
1360
- aiPlanCheckEnabled: config.aiPlanCheckEnabled,
1361
- aiPlanCheckModel: config.aiPlanCheckModel,
1362
- aiPlanCheckMaxContext: config.aiPlanCheckMaxContext,
1363
- aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs,
1364
- aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs,
1365
- });
1366
- this.respawnControllers.set(session.id, controller);
1367
- this.setupRespawnListeners(session.id, controller);
1368
- // Calculate delay: wait until 2 minutes after server start before starting respawn
1369
- // This prevents false idle detection immediately after a server restart/rebuild
1370
- const timeSinceStart = Date.now() - this.serverStartTime;
1371
- const delayMs = Math.max(0, WebServer.RESPAWN_RESTORE_GRACE_PERIOD_MS - timeSinceStart);
1372
- if (delayMs > 0) {
1373
- console.log(`[Server] Restored respawn controller for session ${session.id} from ${source} (will start in ${Math.ceil(delayMs / 1000)}s)`);
1374
- const timer = setTimeout(() => {
1375
- this.pendingRespawnStarts.delete(session.id);
1376
- // Verify session still exists (may have been deleted during grace period)
1377
- if (!this.sessions.has(session.id)) {
1378
- console.log(`[Server] Skipping restored respawn start - session ${session.id} no longer exists`);
1379
- return;
1380
- }
1381
- // Double-check controller still exists and is stopped
1382
- const ctrl = this.respawnControllers.get(session.id);
1383
- if (ctrl && ctrl.state === 'stopped') {
1384
- ctrl.start();
1385
- this.broadcast(SseEvent.RespawnStarted, { sessionId: session.id });
1386
- console.log(`[Server] Restored respawn controller started for session ${session.id}`);
1387
- }
1388
- }, delayMs);
1389
- this.pendingRespawnStarts.set(session.id, timer);
1390
- }
1391
- else {
1392
- // Grace period has passed, start immediately
1393
- controller.start();
1394
- console.log(`[Server] Restored respawn controller for session ${session.id} from ${source} (started immediately)`);
1395
- }
1396
- if (config.durationMinutes && config.durationMinutes > 0) {
1397
- this.setupTimedRespawn(session.id, config.durationMinutes);
1398
- }
841
+ restoreRespawnController(session, config, source, this.buildRespawnWiringDeps());
842
+ }
843
+ buildRespawnWiringDeps() {
844
+ return {
845
+ broadcast: this.broadcast.bind(this),
846
+ sendPushNotifications: this.sendPushNotifications.bind(this),
847
+ persistSessionState: this.persistSessionState.bind(this),
848
+ getSession: (id) => this.sessions.get(id),
849
+ sessionExists: (id) => this.sessions.has(id),
850
+ getRunSummaryTracker: (id) => this.runSummaryTrackers.get(id),
851
+ getRespawnControllers: () => this.respawnControllers,
852
+ getRespawnTimers: () => this.respawnTimers,
853
+ getPendingRespawnStarts: () => this.pendingRespawnStarts,
854
+ teamWatcher: this.teamWatcher,
855
+ serverStartTime: this.serverStartTime,
856
+ respawnRestoreGracePeriodMs: 2 * 60 * 1000,
857
+ mux: this.mux,
858
+ };
1399
859
  }
1400
860
  // Helper to get custom CLAUDE.md template path from settings
1401
861
  async getDefaultClaudeMdPath() {
@@ -1485,7 +945,7 @@ export class WebServer extends EventEmitter {
1485
945
  const failedRun = this.scheduledRuns.get(id);
1486
946
  if (failedRun && failedRun.status === 'running') {
1487
947
  failedRun.status = 'stopped';
1488
- failedRun.logs.push(`[${new Date().toISOString()}] Error: ${err instanceof Error ? err.message : String(err)}`);
948
+ failedRun.logs.push(`[${new Date().toISOString()}] Error: ${getErrorMessage(err)}`);
1489
949
  this.broadcast(SseEvent.ScheduledStopped, { id, reason: 'error' });
1490
950
  }
1491
951
  });
@@ -1665,246 +1125,23 @@ export class WebServer extends EventEmitter {
1665
1125
  this.cachedLightState = { data: result, timestamp: now };
1666
1126
  return result;
1667
1127
  }
1668
- sendSSE(reply, event, data) {
1669
- try {
1670
- reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
1671
- }
1672
- catch {
1673
- this.sseClients.delete(reply);
1674
- this.remoteSseClients.delete(reply);
1675
- }
1676
- }
1677
- // Optimized: send pre-formatted SSE message to a client
1678
- // Returns false if client is backpressured or dead
1679
- sendSSEPreformatted(reply, message) {
1680
- // Skip backpressured clients to prevent unbounded memory growth.
1681
- // Terminal data dropped here is recovered via session:needsRefresh on drain.
1682
- if (this.backpressuredClients.has(reply))
1683
- return;
1684
- try {
1685
- const ok = reply.raw.write(message);
1686
- if (!ok) {
1687
- // Buffer is full — mark as backpressured, resume on drain
1688
- this.backpressuredClients.add(reply);
1689
- reply.raw.once('drain', () => {
1690
- this.backpressuredClients.delete(reply);
1691
- // Client may have missed terminal data during backpressure.
1692
- // Tell it to reload the active session's buffer to recover.
1693
- try {
1694
- const drainPadding = this._isTunnelActive ? SSE_PADDING : '';
1695
- reply.raw.write(`event: ${SseEvent.SessionNeedsRefresh}\ndata: {}\n\n${drainPadding}`);
1696
- }
1697
- catch {
1698
- /* client gone */
1699
- }
1700
- });
1701
- }
1702
- }
1703
- catch {
1704
- this.sseClients.delete(reply);
1705
- this.remoteSseClients.delete(reply);
1706
- this.backpressuredClients.delete(reply);
1707
- }
1708
- }
1128
+ // ========== SSE Delegates (SseStreamManager) ==========
1709
1129
  broadcast(event, data) {
1710
- // Skip serialization entirely when no clients are listening
1711
- if (this.sseClients.size === 0)
1712
- return;
1713
- // Invalidate caches only on structural changes (creation/deletion).
1714
- // SessionUpdated fires too frequently (working/idle transitions, completion)
1715
- // and makes the 1s TTL cache useless — the debounced session:updated follows
1716
- // within 500ms anyway, and these caches serve /api/sessions and SSE init
1717
- // which aren't polled rapidly.
1130
+ // Invalidate caches on structural changes (creation/deletion)
1718
1131
  if (event === SseEvent.SessionCreated || event === SseEvent.SessionDeleted) {
1719
1132
  this.cachedLightState = null;
1720
1133
  this.cachedSessionsList = null;
1721
1134
  }
1722
- // Performance optimization: serialize JSON once for all clients.
1723
- // Only append Cloudflare tunnel padding for latency-sensitive events —
1724
- // Recovery events need immediate proxy flush; low-frequency metadata events
1725
- // (session:created, ralph:*, respawn:*, etc.) don't need padding.
1726
- // Note: session:terminal has its own padding in flushSessionTerminalBatch().
1727
- const needsPadding = this._isTunnelActive && event === SseEvent.SessionNeedsRefresh;
1728
- const padding = needsPadding ? SSE_PADDING : '';
1729
- let message;
1730
- try {
1731
- message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` + padding;
1732
- }
1733
- catch (err) {
1734
- // Handle circular references or non-serializable values
1735
- console.error(`[Server] Failed to serialize SSE event "${event}":`, err);
1736
- return;
1737
- }
1738
- // Extract sessionId from event data for subscription filtering.
1739
- const eventSessionId = this.extractSessionId(event, data);
1740
- for (const [client, filter] of this.sseClients) {
1741
- // No filter (null) = receive everything. Otherwise, skip if event is
1742
- // session-scoped and the session isn't in the client's subscription set.
1743
- if (filter && eventSessionId && !filter.has(eventSessionId))
1744
- continue;
1745
- this.sendSSEPreformatted(client, message);
1746
- }
1747
- }
1748
- /**
1749
- * Extract the session ID from an event's data payload for subscription filtering.
1750
- * Returns the sessionId string if the event is session-scoped, or null for global events.
1751
- */
1752
- extractSessionId(event, data) {
1753
- if (data == null || typeof data !== 'object')
1754
- return null;
1755
- const record = data;
1756
- // Most session-scoped events use `sessionId`
1757
- if (typeof record.sessionId === 'string')
1758
- return record.sessionId;
1759
- // Session lifecycle events (session:*) use `id` from the session state object
1760
- if (typeof record.id === 'string' && event.startsWith('session:'))
1761
- return record.id;
1762
- // No session ID found — treat as global event (sent to all clients)
1763
- return null;
1135
+ this.sse.broadcast(event, data);
1764
1136
  }
1765
- // Batch terminal data for better performance (60fps)
1766
- // Uses per-session timers with adaptive intervals to prevent thundering herd:
1767
- // each session flushes independently rather than all sessions flushing in one burst.
1768
1137
  batchTerminalData(sessionId, data) {
1769
- // Skip if server is stopping
1770
- if (this._isStopping)
1771
- return;
1772
- let chunks = this.terminalBatches.get(sessionId);
1773
- if (!chunks) {
1774
- chunks = [];
1775
- this.terminalBatches.set(sessionId, chunks);
1776
- }
1777
- chunks.push(data);
1778
- const prevSize = this.terminalBatchSizes.get(sessionId) ?? 0;
1779
- const totalLength = prevSize + data.length;
1780
- this.terminalBatchSizes.set(sessionId, totalLength);
1781
- // Adaptive batching: detect rapid events and extend batch window (per-session)
1782
- const now = Date.now();
1783
- const lastEvent = this.lastTerminalEventTime.get(sessionId) ?? 0;
1784
- const eventGap = now - lastEvent;
1785
- this.lastTerminalEventTime.set(sessionId, now);
1786
- // Adjust batch interval based on event frequency (per-session)
1787
- // Rapid events (<10ms gap) = 50ms batch, moderate (<20ms) = 32ms, else 16ms
1788
- let sessionInterval;
1789
- if (eventGap > 0 && eventGap < 10) {
1790
- sessionInterval = 50;
1791
- }
1792
- else if (eventGap > 0 && eventGap < 20) {
1793
- sessionInterval = 32;
1794
- }
1795
- else {
1796
- sessionInterval = TERMINAL_BATCH_INTERVAL;
1797
- }
1798
- // Flush immediately if batch is large for responsiveness
1799
- if (totalLength > BATCH_FLUSH_THRESHOLD) {
1800
- const existingTimer = this.terminalBatchTimers.get(sessionId);
1801
- if (existingTimer) {
1802
- clearTimeout(existingTimer);
1803
- this.terminalBatchTimers.delete(sessionId);
1804
- }
1805
- this.flushSessionTerminalBatch(sessionId);
1806
- return;
1807
- }
1808
- // Start per-session batch timer if not already running
1809
- // Each session flushes independently — prevents one busy session from
1810
- // forcing all sessions to flush at its rate (thundering herd)
1811
- if (!this.terminalBatchTimers.has(sessionId)) {
1812
- this.terminalBatchTimers.set(sessionId, setTimeout(() => {
1813
- this.terminalBatchTimers.delete(sessionId);
1814
- this.flushSessionTerminalBatch(sessionId);
1815
- }, sessionInterval));
1816
- }
1817
- }
1818
- /** Flush a single session's batched terminal data */
1819
- flushSessionTerminalBatch(sessionId) {
1820
- if (this._isStopping) {
1821
- this.terminalBatches.delete(sessionId);
1822
- this.terminalBatchSizes.delete(sessionId);
1823
- return;
1824
- }
1825
- const chunks = this.terminalBatches.get(sessionId);
1826
- if (chunks && chunks.length > 0) {
1827
- // Join chunks only at flush time (avoids O(n^2) string concatenation in batchTerminalData)
1828
- const data = chunks.join('');
1829
- // xterm.js 6.0+ handles DEC 2026 synchronized output natively.
1830
- // Claude CLI (Ink) already emits its own DEC 2026 markers around redraws.
1831
- // Do NOT add an outer wrapper — DEC 2026 is not reference-counted, so
1832
- // the inner 2026l would prematurely exit sync mode, defeating the purpose.
1833
- // Fast path: build SSE message directly without JSON.stringify on wrapper object.
1834
- // Only the terminal data string needs escaping; sessionId is a UUID (safe to template).
1835
- const escapedData = JSON.stringify(data);
1836
- // Append tunnel padding for immediate Cloudflare proxy flush —
1837
- // terminal data is high-frequency and latency-sensitive.
1838
- const padding = this._isTunnelActive ? SSE_PADDING : '';
1839
- const message = `event: session:terminal\ndata: {"id":"${sessionId}","data":${escapedData}}\n\n` + padding;
1840
- for (const [client, filter] of this.sseClients) {
1841
- // Skip clients that have a session filter and aren't subscribed to this session
1842
- if (filter && !filter.has(sessionId))
1843
- continue;
1844
- this.sendSSEPreformatted(client, message);
1845
- }
1846
- }
1847
- this.terminalBatches.delete(sessionId);
1848
- this.terminalBatchSizes.delete(sessionId);
1138
+ this.sse.batchTerminalData(sessionId, data);
1849
1139
  }
1850
- // Batch task:updated events at 100ms - only send latest update per task
1851
- // Key is sessionId:taskId to avoid collisions when multiple tasks update concurrently
1852
1140
  batchTaskUpdate(sessionId, task) {
1853
- // Skip if server is stopping
1854
- if (this._isStopping)
1855
- return;
1856
- // Use composite key to avoid losing updates when multiple tasks update in same batch window
1857
- const key = `${sessionId}:${task.id}`;
1858
- this.taskUpdateBatches.set(key, { sessionId, task });
1859
- if (!this.taskUpdateBatchTimerId) {
1860
- this.taskUpdateBatchTimerId = this.cleanup.setTimeout(() => {
1861
- this.taskUpdateBatchTimerId = null;
1862
- this.flushTaskUpdateBatches();
1863
- }, TASK_UPDATE_BATCH_INTERVAL, { description: 'task update batch flush' });
1864
- }
1865
- }
1866
- flushTaskUpdateBatches() {
1867
- // Skip if server is stopping (timer may have been queued before stop() was called)
1868
- if (this._isStopping) {
1869
- this.taskUpdateBatches.clear();
1870
- return;
1871
- }
1872
- for (const [, { sessionId, task }] of this.taskUpdateBatches) {
1873
- this.broadcast(SseEvent.TaskUpdated, { sessionId, task });
1874
- }
1875
- this.taskUpdateBatches.clear();
1141
+ this.sse.batchTaskUpdate(sessionId, task);
1876
1142
  }
1877
- /**
1878
- * Debounce expensive session:updated broadcasts.
1879
- * Instead of calling toDetailedState() on every event, batch requests
1880
- * and only serialize once per STATE_UPDATE_DEBOUNCE_INTERVAL.
1881
- */
1882
1143
  broadcastSessionStateDebounced(sessionId) {
1883
- // Skip if server is stopping
1884
- if (this._isStopping)
1885
- return;
1886
- this.stateUpdatePending.add(sessionId);
1887
- if (!this.stateUpdateTimerId) {
1888
- this.stateUpdateTimerId = this.cleanup.setTimeout(() => {
1889
- this.stateUpdateTimerId = null;
1890
- this.flushStateUpdates();
1891
- }, STATE_UPDATE_DEBOUNCE_INTERVAL, { description: 'state update debounce flush' });
1892
- }
1893
- }
1894
- flushStateUpdates() {
1895
- // Skip if server is stopping (timer may have been queued before stop() was called)
1896
- if (this._isStopping) {
1897
- this.stateUpdatePending.clear();
1898
- return;
1899
- }
1900
- for (const sessionId of this.stateUpdatePending) {
1901
- const session = this.sessions.get(sessionId);
1902
- if (session) {
1903
- // Single expensive serialization per batch interval
1904
- this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session));
1905
- }
1906
- }
1907
- this.stateUpdatePending.clear();
1144
+ this.sse.broadcastSessionStateDebounced(sessionId);
1908
1145
  }
1909
1146
  // ========== Web Push ==========
1910
1147
  /** Map SSE event names to push notification payloads */
@@ -1982,42 +1219,8 @@ export class WebServer extends EventEmitter {
1982
1219
  });
1983
1220
  }
1984
1221
  }
1985
- /**
1986
- * Clean up dead SSE clients and send keep-alive comments.
1987
- * Keep-alive prevents proxy/load-balancer timeouts on idle connections.
1988
- * Dead client cleanup prevents memory leaks from abruptly terminated connections.
1989
- */
1990
1222
  cleanupDeadSSEClients() {
1991
- const deadClients = [];
1992
- for (const [client] of this.sseClients) {
1993
- try {
1994
- // Check if the underlying socket is still writable
1995
- const socket = client.raw.socket;
1996
- if (!socket || socket.destroyed || !socket.writable) {
1997
- deadClients.push(client);
1998
- }
1999
- else {
2000
- // Send SSE comment as keep-alive. Only add padding when tunnel is
2001
- // active — it flushes Cloudflare proxy buffers but wastes bandwidth
2002
- // for direct/Tailscale connections.
2003
- const ka = this._isTunnelActive ? ':keepalive\n' + SSE_PADDING : ':keepalive\n\n';
2004
- client.raw.write(ka);
2005
- }
2006
- }
2007
- catch {
2008
- // Error accessing socket means client is dead
2009
- deadClients.push(client);
2010
- }
2011
- }
2012
- // Remove dead clients
2013
- for (const client of deadClients) {
2014
- this.sseClients.delete(client);
2015
- this.remoteSseClients.delete(client);
2016
- this.backpressuredClients.delete(client);
2017
- }
2018
- if (deadClients.length > 0) {
2019
- console.log(`[Server] Cleaned up ${deadClients.length} dead SSE client(s)`);
2020
- }
1223
+ this.sse.cleanupDeadClients();
2021
1224
  }
2022
1225
  /**
2023
1226
  * Records token usage for long-running sessions periodically.
@@ -2330,32 +1533,11 @@ export class WebServer extends EventEmitter {
2330
1533
  async stop() {
2331
1534
  getLifecycleLog().log({ event: 'server_stopped', sessionId: '*' });
2332
1535
  // Set stopping flag to prevent new timer creation during shutdown
2333
- this._isStopping = true;
1536
+ this.sse.setStopping();
2334
1537
  // Dispose all managed timers (intervals + resettable timeouts)
2335
1538
  this.cleanup.dispose();
2336
- // Gracefully close all SSE connections before clearing
2337
- for (const [client] of this.sseClients) {
2338
- try {
2339
- // Send a final event to notify clients of shutdown
2340
- this.sendSSE(client, 'server:shutdown', { reason: 'Server stopping' });
2341
- client.raw.end();
2342
- }
2343
- catch {
2344
- // Client may already be disconnected
2345
- }
2346
- }
2347
- this.sseClients.clear();
2348
- this.remoteSseClients.clear();
2349
- this.backpressuredClients.clear();
2350
- // Clear per-session batch timers
2351
- for (const timer of this.terminalBatchTimers.values()) {
2352
- clearTimeout(timer);
2353
- }
2354
- this.terminalBatchTimers.clear();
2355
- this.terminalBatches.clear();
2356
- this.terminalBatchSizes.clear();
2357
- this.taskUpdateBatches.clear();
2358
- this.stateUpdatePending.clear();
1539
+ // Gracefully close all SSE connections and clear batching state
1540
+ this.sse.stop();
2359
1541
  this.lastRecordedTokens.clear();
2360
1542
  // Stop multiplexer and flush pending saves
2361
1543
  this.mux.destroy();
@@ -2398,31 +1580,7 @@ export class WebServer extends EventEmitter {
2398
1580
  // Remove listeners to avoid spurious events during teardown
2399
1581
  const listeners = this.sessionListenerRefs.get(sessionId);
2400
1582
  if (listeners) {
2401
- session.off('terminal', listeners.terminal);
2402
- session.off('clearTerminal', listeners.clearTerminal);
2403
- session.off('needsRefresh', listeners.needsRefresh);
2404
- session.off('message', listeners.message);
2405
- session.off('error', listeners.error);
2406
- session.off('completion', listeners.completion);
2407
- session.off('exit', listeners.exit);
2408
- session.off('working', listeners.working);
2409
- session.off('idle', listeners.idle);
2410
- session.off('taskCreated', listeners.taskCreated);
2411
- session.off('taskUpdated', listeners.taskUpdated);
2412
- session.off('taskCompleted', listeners.taskCompleted);
2413
- session.off('taskFailed', listeners.taskFailed);
2414
- session.off('autoClear', listeners.autoClear);
2415
- session.off('autoCompact', listeners.autoCompact);
2416
- session.off('cliInfoUpdated', listeners.cliInfoUpdated);
2417
- session.off('ralphLoopUpdate', listeners.ralphLoopUpdate);
2418
- session.off('ralphTodoUpdate', listeners.ralphTodoUpdate);
2419
- session.off('ralphCompletionDetected', listeners.ralphCompletionDetected);
2420
- session.off('ralphStatusBlockDetected', listeners.ralphStatusBlockDetected);
2421
- session.off('ralphCircuitBreakerUpdate', listeners.ralphCircuitBreakerUpdate);
2422
- session.off('ralphExitGateMet', listeners.ralphExitGateMet);
2423
- session.off('bashToolStart', listeners.bashToolStart);
2424
- session.off('bashToolEnd', listeners.bashToolEnd);
2425
- session.off('bashToolsUpdate', listeners.bashToolsUpdate);
1583
+ detachSessionListeners(session, listeners);
2426
1584
  this.sessionListenerRefs.delete(sessionId);
2427
1585
  }
2428
1586
  session.removeAllListeners();
@@ -2469,7 +1627,6 @@ export class WebServer extends EventEmitter {
2469
1627
  this.sessionListenerRefs.clear();
2470
1628
  this.scheduledRuns.clear();
2471
1629
  // Dispose StaleExpirationMaps (stops internal cleanup timers)
2472
- this.lastTerminalEventTime.dispose();
2473
1630
  if (this.authSessions) {
2474
1631
  this.authSessions.dispose();
2475
1632
  this.authSessions = null;