aicodeman 0.3.0 → 0.3.1

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 (183) hide show
  1. package/README.md +27 -4
  2. package/dist/config/server-timing.d.ts +8 -2
  3. package/dist/config/server-timing.d.ts.map +1 -1
  4. package/dist/config/server-timing.js +8 -2
  5. package/dist/config/server-timing.js.map +1 -1
  6. package/dist/hooks-config.d.ts +21 -6
  7. package/dist/hooks-config.d.ts.map +1 -1
  8. package/dist/hooks-config.js +21 -6
  9. package/dist/hooks-config.js.map +1 -1
  10. package/dist/prompts/planner.d.ts +7 -8
  11. package/dist/prompts/planner.d.ts.map +1 -1
  12. package/dist/prompts/planner.js +7 -8
  13. package/dist/prompts/planner.js.map +1 -1
  14. package/dist/prompts/research-agent.d.ts +6 -4
  15. package/dist/prompts/research-agent.d.ts.map +1 -1
  16. package/dist/prompts/research-agent.js +6 -4
  17. package/dist/prompts/research-agent.js.map +1 -1
  18. package/dist/ralph-loop.d.ts +14 -4
  19. package/dist/ralph-loop.d.ts.map +1 -1
  20. package/dist/ralph-loop.js +14 -4
  21. package/dist/ralph-loop.js.map +1 -1
  22. package/dist/ralph-tracker.d.ts +28 -11
  23. package/dist/ralph-tracker.d.ts.map +1 -1
  24. package/dist/ralph-tracker.js +43 -13
  25. package/dist/ralph-tracker.js.map +1 -1
  26. package/dist/respawn-controller.d.ts +23 -14
  27. package/dist/respawn-controller.d.ts.map +1 -1
  28. package/dist/respawn-controller.js +23 -14
  29. package/dist/respawn-controller.js.map +1 -1
  30. package/dist/session-manager.d.ts +17 -5
  31. package/dist/session-manager.d.ts.map +1 -1
  32. package/dist/session-manager.js +17 -5
  33. package/dist/session-manager.js.map +1 -1
  34. package/dist/session.d.ts +21 -8
  35. package/dist/session.d.ts.map +1 -1
  36. package/dist/session.js +21 -8
  37. package/dist/session.js.map +1 -1
  38. package/dist/state-store.d.ts +17 -7
  39. package/dist/state-store.d.ts.map +1 -1
  40. package/dist/state-store.js +17 -7
  41. package/dist/state-store.js.map +1 -1
  42. package/dist/subagent-watcher.d.ts +23 -3
  43. package/dist/subagent-watcher.d.ts.map +1 -1
  44. package/dist/subagent-watcher.js +23 -3
  45. package/dist/subagent-watcher.js.map +1 -1
  46. package/dist/tunnel-manager.d.ts.map +1 -1
  47. package/dist/tunnel-manager.js +1 -2
  48. package/dist/tunnel-manager.js.map +1 -1
  49. package/dist/types/api.d.ts +16 -1
  50. package/dist/types/api.d.ts.map +1 -1
  51. package/dist/types/api.js +16 -1
  52. package/dist/types/api.js.map +1 -1
  53. package/dist/types/app-state.d.ts +18 -1
  54. package/dist/types/app-state.d.ts.map +1 -1
  55. package/dist/types/app-state.js +18 -1
  56. package/dist/types/app-state.js.map +1 -1
  57. package/dist/types/common.d.ts +10 -1
  58. package/dist/types/common.d.ts.map +1 -1
  59. package/dist/types/common.js +10 -1
  60. package/dist/types/common.js.map +1 -1
  61. package/dist/types/index.d.ts +50 -2
  62. package/dist/types/index.d.ts.map +1 -1
  63. package/dist/types/index.js +50 -2
  64. package/dist/types/index.js.map +1 -1
  65. package/dist/types/lifecycle.d.ts +12 -1
  66. package/dist/types/lifecycle.d.ts.map +1 -1
  67. package/dist/types/lifecycle.js +12 -1
  68. package/dist/types/lifecycle.js.map +1 -1
  69. package/dist/types/plan.d.ts +14 -1
  70. package/dist/types/plan.d.ts.map +1 -1
  71. package/dist/types/plan.js +14 -1
  72. package/dist/types/plan.js.map +1 -1
  73. package/dist/types/push.d.ts +14 -1
  74. package/dist/types/push.d.ts.map +1 -1
  75. package/dist/types/push.js +14 -1
  76. package/dist/types/push.js.map +1 -1
  77. package/dist/types/ralph.d.ts +22 -1
  78. package/dist/types/ralph.d.ts.map +1 -1
  79. package/dist/types/ralph.js +22 -1
  80. package/dist/types/ralph.js.map +1 -1
  81. package/dist/types/respawn.d.ts +22 -1
  82. package/dist/types/respawn.d.ts.map +1 -1
  83. package/dist/types/respawn.js +22 -1
  84. package/dist/types/respawn.js.map +1 -1
  85. package/dist/types/run-summary.d.ts +16 -1
  86. package/dist/types/run-summary.d.ts.map +1 -1
  87. package/dist/types/run-summary.js +16 -1
  88. package/dist/types/run-summary.js.map +1 -1
  89. package/dist/types/session.d.ts +23 -1
  90. package/dist/types/session.d.ts.map +1 -1
  91. package/dist/types/session.js +23 -1
  92. package/dist/types/session.js.map +1 -1
  93. package/dist/types/task.d.ts +15 -1
  94. package/dist/types/task.d.ts.map +1 -1
  95. package/dist/types/task.js +15 -1
  96. package/dist/types/task.js.map +1 -1
  97. package/dist/types/teams.d.ts +19 -1
  98. package/dist/types/teams.d.ts.map +1 -1
  99. package/dist/types/teams.js +19 -1
  100. package/dist/types/teams.js.map +1 -1
  101. package/dist/types/tools.d.ts +16 -1
  102. package/dist/types/tools.d.ts.map +1 -1
  103. package/dist/types/tools.js +16 -1
  104. package/dist/types/tools.js.map +1 -1
  105. package/dist/types.d.ts +11 -0
  106. package/dist/types.d.ts.map +1 -1
  107. package/dist/types.js +11 -0
  108. package/dist/types.js.map +1 -1
  109. package/dist/web/public/api-client.js +12 -0
  110. package/dist/web/public/api-client.js.br +0 -0
  111. package/dist/web/public/api-client.js.gz +0 -0
  112. package/dist/web/public/app.js +103 -103
  113. package/dist/web/public/app.js.br +0 -0
  114. package/dist/web/public/app.js.gz +0 -0
  115. package/dist/web/public/constants.js +133 -6
  116. package/dist/web/public/constants.js.br +0 -0
  117. package/dist/web/public/constants.js.gz +0 -0
  118. package/dist/web/public/index.html +14 -10
  119. package/dist/web/public/index.html.br +0 -0
  120. package/dist/web/public/index.html.gz +0 -0
  121. package/dist/web/public/keyboard-accessory.js +27 -4
  122. package/dist/web/public/keyboard-accessory.js.br +0 -0
  123. package/dist/web/public/keyboard-accessory.js.gz +0 -0
  124. package/dist/web/public/mobile-handlers.js +30 -6
  125. package/dist/web/public/mobile-handlers.js.br +0 -0
  126. package/dist/web/public/mobile-handlers.js.gz +0 -0
  127. package/dist/web/public/mobile.css.gz +0 -0
  128. package/dist/web/public/notification-manager.js +27 -0
  129. package/dist/web/public/notification-manager.js.br +0 -0
  130. package/dist/web/public/notification-manager.js.gz +0 -0
  131. package/dist/web/public/ralph-wizard.js +30 -6
  132. package/dist/web/public/ralph-wizard.js.br +0 -0
  133. package/dist/web/public/ralph-wizard.js.gz +0 -0
  134. package/dist/web/public/styles.css.gz +0 -0
  135. package/dist/web/public/subagent-windows.js +46 -12
  136. package/dist/web/public/subagent-windows.js.br +0 -0
  137. package/dist/web/public/subagent-windows.js.gz +0 -0
  138. package/dist/web/public/sw.js +15 -0
  139. package/dist/web/public/sw.js.br +0 -0
  140. package/dist/web/public/sw.js.gz +0 -0
  141. package/dist/web/public/upload.html.gz +0 -0
  142. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  143. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  144. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  145. package/dist/web/public/vendor/xterm-zerolag-input.js +4 -0
  146. package/dist/web/public/vendor/xterm-zerolag-input.js.br +0 -0
  147. package/dist/web/public/vendor/xterm-zerolag-input.js.gz +0 -0
  148. package/dist/web/public/vendor/xterm.css.gz +0 -0
  149. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  150. package/dist/web/public/voice-input.js +26 -2
  151. package/dist/web/public/voice-input.js.br +0 -0
  152. package/dist/web/public/voice-input.js.gz +0 -0
  153. package/dist/web/route-helpers.d.ts.map +1 -1
  154. package/dist/web/route-helpers.js +3 -2
  155. package/dist/web/route-helpers.js.map +1 -1
  156. package/dist/web/routes/case-routes.d.ts.map +1 -1
  157. package/dist/web/routes/case-routes.js +11 -4
  158. package/dist/web/routes/case-routes.js.map +1 -1
  159. package/dist/web/routes/plan-routes.d.ts.map +1 -1
  160. package/dist/web/routes/plan-routes.js +26 -22
  161. package/dist/web/routes/plan-routes.js.map +1 -1
  162. package/dist/web/routes/ralph-routes.d.ts.map +1 -1
  163. package/dist/web/routes/ralph-routes.js +16 -6
  164. package/dist/web/routes/ralph-routes.js.map +1 -1
  165. package/dist/web/routes/respawn-routes.d.ts.map +1 -1
  166. package/dist/web/routes/respawn-routes.js +25 -15
  167. package/dist/web/routes/respawn-routes.js.map +1 -1
  168. package/dist/web/routes/session-routes.d.ts.map +1 -1
  169. package/dist/web/routes/session-routes.js +40 -18
  170. package/dist/web/routes/session-routes.js.map +1 -1
  171. package/dist/web/routes/system-routes.d.ts.map +1 -1
  172. package/dist/web/routes/system-routes.js +26 -5
  173. package/dist/web/routes/system-routes.js.map +1 -1
  174. package/dist/web/server.d.ts +25 -6
  175. package/dist/web/server.d.ts.map +1 -1
  176. package/dist/web/server.js +237 -156
  177. package/dist/web/server.js.map +1 -1
  178. package/dist/web/sse-events.d.ts +361 -0
  179. package/dist/web/sse-events.d.ts.map +1 -0
  180. package/dist/web/sse-events.js +396 -0
  181. package/dist/web/sse-events.js.map +1 -0
  182. package/package.json +2 -1
  183. package/scripts/postinstall.js +58 -0
@@ -1,11 +1,28 @@
1
1
  /**
2
- * @fileoverview Codeman web server and REST API
2
+ * @fileoverview Codeman web server central hub coordinating all subsystems.
3
3
  *
4
- * Provides a Fastify-based web server with:
5
- * - REST API for session management, respawn control, and monitoring
6
- * - Server-Sent Events (SSE) for real-time updates at /api/events
7
- * - Static file serving for the web UI
8
- * - 60fps terminal streaming with batched updates
4
+ * Fastify-based web server providing:
5
+ * - ~111 REST API routes (delegated to `src/web/routes/` domain modules)
6
+ * - SSE streaming at `/api/events` with backpressure handling
7
+ * - Static file serving for the web UI (1-year cache in production)
8
+ * - 60fps terminal streaming via batched PTY output (16-50ms adaptive)
9
+ *
10
+ * Coordinates: SessionManager, RespawnController, SubagentWatcher, TeamWatcher,
11
+ * TranscriptWatcher, ImageWatcher, TunnelManager, PushSubscriptionStore,
12
+ * PlanOrchestrator, RunSummaryTracker, FileStreamManager.
13
+ *
14
+ * Key exports:
15
+ * - `WebServer` class — implements all port interfaces, extends EventEmitter
16
+ * - `startWebServer(options)` — factory function to create and start the server
17
+ *
18
+ * Implements port interfaces: `SessionPort`, `EventPort`, `ConfigPort`,
19
+ * `RespawnPort`, `MuxPort`, `FilePort`, `ScheduledPort`, `PushPort`, `TeamPort`
20
+ * (see `src/web/ports/` for definitions)
21
+ *
22
+ * @dependencies All major subsystems (session, respawn-controller, subagent-watcher,
23
+ * team-watcher, tunnel-manager, state-store, etc.)
24
+ * @consumedby src/index.ts (entry point), src/cli.ts
25
+ * @emits SSE events via broadcast() — see sse-events.ts for full registry
9
26
  *
10
27
  * @module web/server
11
28
  */
@@ -43,16 +60,22 @@ const { version: APP_VERSION } = require('../../package.json');
43
60
  import { getErrorMessage, ApiErrorCode, createErrorResponse, DEFAULT_NICE_CONFIG, } from '../types.js';
44
61
  import { CleanupManager, KeyedDebouncer, StaleExpirationMap } from '../utils/index.js';
45
62
  import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
63
+ import { SseEvent } from './sse-events.js';
46
64
  import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js';
47
65
  import { registerPushRoutes, registerTeamRoutes, registerMuxRoutes, registerFileRoutes, registerScheduledRoutes, registerHookEventRoutes, registerSystemRoutes, registerCaseRoutes, registerSessionRoutes, registerRespawnRoutes, registerRalphRoutes, registerPlanRoutes, } from './routes/index.js';
48
66
  const __dirname = dirname(fileURLToPath(import.meta.url));
49
- import { TERMINAL_BATCH_INTERVAL, TASK_UPDATE_BATCH_INTERVAL, STATE_UPDATE_DEBOUNCE_INTERVAL, SESSIONS_LIST_CACHE_TTL, SCHEDULED_CLEANUP_INTERVAL, SCHEDULED_RUN_MAX_AGE, SSE_HEALTH_CHECK_INTERVAL, SESSION_LIMIT_WAIT_MS, ITERATION_PAUSE_MS, BATCH_FLUSH_THRESHOLD, STATS_COLLECTION_INTERVAL_MS, } from '../config/server-timing.js';
67
+ 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, } from '../config/server-timing.js';
50
68
  // DEC mode 2026 - Synchronized Output
51
69
  // When terminal supports this, it buffers all output between start/end markers
52
70
  // and renders atomically, eliminating partial-frame flicker from Ink redraws.
53
71
  // Supported by: WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal
54
72
  const DEC_SYNC_START = '\x1b[?2026h'; // Begin synchronized update
55
73
  const DEC_SYNC_END = '\x1b[?2026l'; // End synchronized update (flush to screen)
74
+ // SSE padding for Cloudflare tunnel buffer flushing.
75
+ // Cloudflare quick tunnels buffer small SSE responses, causing lag for real-time events.
76
+ // Appending SSE comment padding (ignored by EventSource) forces the proxy to flush.
77
+ // Pre-computed once at startup to avoid repeated string allocation.
78
+ const SSE_PADDING = ':' + 'p'.repeat(SSE_PADDING_SIZE) + '\n';
56
79
  /**
57
80
  * Get or generate a self-signed TLS certificate for HTTPS.
58
81
  * Certs are stored in ~/.codeman/certs/ and reused across restarts.
@@ -138,6 +161,8 @@ export class WebServer extends EventEmitter {
138
161
  subagentWatcherHandlers = null;
139
162
  imageWatcherHandlers = null;
140
163
  tunnelManager = new TunnelManager();
164
+ /** Cached tunnel active state — updated on TunnelStarted/TunnelStopped to avoid getUrl() on every broadcast */
165
+ _isTunnelActive = false;
141
166
  authSessions = null;
142
167
  authFailures = null;
143
168
  qrAuthFailures = null;
@@ -160,10 +185,10 @@ export class WebServer extends EventEmitter {
160
185
  this.mux = createMultiplexer();
161
186
  // Set up mux event listeners
162
187
  this.mux.on('sessionCreated', (session) => {
163
- this.broadcast('mux:created', session);
188
+ this.broadcast(SseEvent.MuxCreated, session);
164
189
  });
165
190
  this.mux.on('sessionKilled', (data) => {
166
- this.broadcast('mux:killed', data);
191
+ this.broadcast(SseEvent.MuxKilled, data);
167
192
  });
168
193
  this.mux.on('sessionDied', (data) => {
169
194
  getLifecycleLog().log({
@@ -171,10 +196,10 @@ export class WebServer extends EventEmitter {
171
196
  sessionId: data.sessionId || 'unknown',
172
197
  extra: data,
173
198
  });
174
- this.broadcast('mux:died', data);
199
+ this.broadcast(SseEvent.MuxDied, data);
175
200
  });
176
201
  this.mux.on('statsUpdated', (sessions) => {
177
- this.broadcast('mux:statsUpdated', sessions);
202
+ this.broadcast(SseEvent.MuxStatsUpdated, sessions);
178
203
  });
179
204
  // Set up subagent watcher listeners
180
205
  this.setupSubagentWatcherListeners();
@@ -184,16 +209,18 @@ export class WebServer extends EventEmitter {
184
209
  this.setupTeamWatcherListeners();
185
210
  // Set up tunnel manager listeners
186
211
  this.tunnelManager.on('started', (data) => {
187
- this.broadcast('tunnel:started', data);
212
+ this._isTunnelActive = true;
213
+ this.broadcast(SseEvent.TunnelStarted, data);
188
214
  });
189
215
  this.tunnelManager.on('stopped', () => {
190
- this.broadcast('tunnel:stopped', {});
216
+ this._isTunnelActive = false;
217
+ this.broadcast(SseEvent.TunnelStopped, {});
191
218
  });
192
219
  this.tunnelManager.on('error', (message) => {
193
- this.broadcast('tunnel:error', { message });
220
+ this.broadcast(SseEvent.TunnelError, { message });
194
221
  });
195
222
  this.tunnelManager.on('progress', (data) => {
196
- this.broadcast('tunnel:progress', data);
223
+ this.broadcast(SseEvent.TunnelProgress, data);
197
224
  });
198
225
  // QR token rotation — broadcast inline SVG for instant desktop refresh
199
226
  this.tunnelManager.on('qrTokenRotated', async () => {
@@ -201,7 +228,7 @@ export class WebServer extends EventEmitter {
201
228
  if (url && process.env.CODEMAN_PASSWORD) {
202
229
  try {
203
230
  const svg = await this.tunnelManager.getQrSvg(url);
204
- this.broadcast('tunnel:qrRotated', { svg });
231
+ this.broadcast(SseEvent.TunnelQrRotated, { svg });
205
232
  }
206
233
  catch {
207
234
  // QR generation failed — skip this rotation
@@ -213,7 +240,7 @@ export class WebServer extends EventEmitter {
213
240
  if (url && process.env.CODEMAN_PASSWORD) {
214
241
  try {
215
242
  const svg = await this.tunnelManager.getQrSvg(url);
216
- this.broadcast('tunnel:qrRegenerated', { svg });
243
+ this.broadcast(SseEvent.TunnelQrRegenerated, { svg });
217
244
  }
218
245
  catch {
219
246
  // QR generation failed — skip
@@ -232,13 +259,13 @@ export class WebServer extends EventEmitter {
232
259
  setupSubagentWatcherListeners() {
233
260
  // Store handlers for cleanup on shutdown
234
261
  this.subagentWatcherHandlers = {
235
- discovered: (info) => this.broadcast('subagent:discovered', info),
236
- updated: (info) => this.broadcast('subagent:updated', info),
237
- toolCall: (data) => this.broadcast('subagent:tool_call', data),
238
- toolResult: (data) => this.broadcast('subagent:tool_result', data),
239
- progress: (data) => this.broadcast('subagent:progress', data),
240
- message: (data) => this.broadcast('subagent:message', data),
241
- completed: (info) => this.broadcast('subagent:completed', info),
262
+ discovered: (info) => this.broadcast(SseEvent.SubagentDiscovered, info),
263
+ updated: (info) => this.broadcast(SseEvent.SubagentUpdated, info),
264
+ toolCall: (data) => this.broadcast(SseEvent.SubagentToolCall, data),
265
+ toolResult: (data) => this.broadcast(SseEvent.SubagentToolResult, data),
266
+ progress: (data) => this.broadcast(SseEvent.SubagentProgress, data),
267
+ message: (data) => this.broadcast(SseEvent.SubagentMessage, data),
268
+ completed: (info) => this.broadcast(SseEvent.SubagentCompleted, info),
242
269
  error: (error, agentId) => {
243
270
  console.error(`[SubagentWatcher] Error${agentId ? ` for ${agentId}` : ''}:`, error.message);
244
271
  },
@@ -275,7 +302,7 @@ export class WebServer extends EventEmitter {
275
302
  setupImageWatcherListeners() {
276
303
  // Store handlers for cleanup on shutdown
277
304
  this.imageWatcherHandlers = {
278
- detected: (event) => this.broadcast('image:detected', event),
305
+ detected: (event) => this.broadcast(SseEvent.ImageDetected, event),
279
306
  error: (error, sessionId) => {
280
307
  console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
281
308
  },
@@ -299,10 +326,10 @@ export class WebServer extends EventEmitter {
299
326
  */
300
327
  setupTeamWatcherListeners() {
301
328
  this.teamWatcherHandlers = {
302
- teamCreated: (config) => this.broadcast('team:created', config),
303
- teamUpdated: (config) => this.broadcast('team:updated', config),
304
- teamRemoved: (config) => this.broadcast('team:removed', config),
305
- taskUpdated: (data) => this.broadcast('team:taskUpdated', data),
329
+ teamCreated: (config) => this.broadcast(SseEvent.TeamCreated, config),
330
+ teamUpdated: (config) => this.broadcast(SseEvent.TeamUpdated, config),
331
+ teamRemoved: (config) => this.broadcast(SseEvent.TeamRemoved, config),
332
+ taskUpdated: (data) => this.broadcast(SseEvent.TeamTaskUpdated, data),
306
333
  };
307
334
  this.teamWatcher.on('teamCreated', this.teamWatcherHandlers.teamCreated);
308
335
  this.teamWatcher.on('teamUpdated', this.teamWatcherHandlers.teamUpdated);
@@ -436,7 +463,17 @@ export class WebServer extends EventEmitter {
436
463
  // Send initial state
437
464
  // Use light state for SSE init to avoid sending 2MB+ terminal buffers
438
465
  // Buffers are fetched on-demand when switching tabs
439
- this.sendSSE(reply, 'init', this.getLightState());
466
+ this.sendSSE(reply, SseEvent.Init, this.getLightState());
467
+ // Flush Cloudflare tunnel buffer with padding — ensures the init event
468
+ // (and any immediately following events) are delivered without proxy delay.
469
+ if (this._isTunnelActive) {
470
+ try {
471
+ reply.raw.write(SSE_PADDING);
472
+ }
473
+ catch {
474
+ /* client gone */
475
+ }
476
+ }
440
477
  req.raw.on('close', () => {
441
478
  this.sseClients.delete(reply);
442
479
  this.backpressuredClients.delete(reply);
@@ -482,20 +519,20 @@ export class WebServer extends EventEmitter {
482
519
  if (controller) {
483
520
  controller.signalTranscriptComplete();
484
521
  }
485
- this.broadcast('transcript:complete', { sessionId, timestamp: Date.now() });
522
+ this.broadcast(SseEvent.TranscriptComplete, { sessionId, timestamp: Date.now() });
486
523
  });
487
524
  watcher.on('transcript:plan_mode', () => {
488
525
  const controller = this.respawnControllers.get(sessionId);
489
526
  if (controller) {
490
527
  controller.signalTranscriptPlanMode();
491
528
  }
492
- this.broadcast('transcript:plan_mode', { sessionId, timestamp: Date.now() });
529
+ this.broadcast(SseEvent.TranscriptPlanMode, { sessionId, timestamp: Date.now() });
493
530
  });
494
531
  watcher.on('transcript:tool_start', (toolName) => {
495
- this.broadcast('transcript:tool_start', { sessionId, toolName, timestamp: Date.now() });
532
+ this.broadcast(SseEvent.TranscriptToolStart, { sessionId, toolName, timestamp: Date.now() });
496
533
  });
497
534
  watcher.on('transcript:tool_end', (toolName, isError) => {
498
- this.broadcast('transcript:tool_end', {
535
+ this.broadcast(SseEvent.TranscriptToolEnd, {
499
536
  sessionId,
500
537
  toolName,
501
538
  isError,
@@ -635,7 +672,7 @@ export class WebServer extends EventEmitter {
635
672
  controller.removeAllListeners();
636
673
  this.respawnControllers.delete(sessionId);
637
674
  // Notify UI that respawn is stopped for this session
638
- this.broadcast('respawn:stopped', { sessionId, reason: 'session_cleanup' });
675
+ this.broadcast(SseEvent.RespawnStopped, { sessionId, reason: 'session_cleanup' });
639
676
  }
640
677
  // Clear respawn timer
641
678
  const timerInfo = this.respawnTimers.get(sessionId);
@@ -678,7 +715,7 @@ export class WebServer extends EventEmitter {
678
715
  // Clear Ralph state from store
679
716
  this.store.removeRalphState(sessionId);
680
717
  // Broadcast Ralph cleared to update UI
681
- this.broadcast('session:ralphLoopUpdate', {
718
+ this.broadcast(SseEvent.SessionRalphLoopUpdate, {
682
719
  sessionId,
683
720
  state: {
684
721
  enabled: false,
@@ -691,7 +728,7 @@ export class WebServer extends EventEmitter {
691
728
  elapsedHours: null,
692
729
  },
693
730
  });
694
- this.broadcast('session:ralphTodoUpdate', {
731
+ this.broadcast(SseEvent.SessionRalphTodoUpdate, {
695
732
  sessionId,
696
733
  todos: [],
697
734
  stats: { total: 0, pending: 0, inProgress: 0, completed: 0 },
@@ -755,7 +792,7 @@ export class WebServer extends EventEmitter {
755
792
  this.store.removeSession(sessionId);
756
793
  }
757
794
  }
758
- this.broadcast('session:deleted', { id: sessionId });
795
+ this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
759
796
  }
760
797
  async setupSessionListeners(session) {
761
798
  // Create run summary tracker for this session
@@ -770,45 +807,51 @@ export class WebServer extends EventEmitter {
770
807
  if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled) {
771
808
  imageWatcher.watchSession(session.id, session.workingDir);
772
809
  }
773
- // Store all listener references for explicit cleanup on session delete
774
- // This prevents memory leaks from closure references keeping objects alive
810
+ // Store all listener references for explicit cleanup on session delete.
811
+ // This prevents memory leaks from closure references keeping objects alive.
775
812
  const listeners = {
813
+ // ─── Terminal Output ─────────────────────────────────────
814
+ // These listeners handle raw PTY output streaming to SSE clients.
815
+ /** Batches PTY output → broadcasts `session:terminal` at 16-50ms intervals */
776
816
  terminal: (data) => {
777
- // Use batching for better performance at high throughput
778
817
  this.batchTerminalData(session.id, data);
779
818
  },
819
+ /** Broadcasts `session:clearTerminal` — tells clients to wipe their xterm buffer (after mux attach) */
780
820
  clearTerminal: () => {
781
- // Tell clients to clear their terminal (after mux attach)
782
- this.broadcast('session:clearTerminal', { id: session.id });
821
+ this.broadcast(SseEvent.SessionClearTerminal, { id: session.id });
783
822
  },
823
+ /** Broadcasts `session:needsRefresh` — tells clients to reload buffer (e.g., after OpenCode TUI stabilizes) */
784
824
  needsRefresh: () => {
785
- // Tell clients to reload the terminal buffer (e.g., after OpenCode TUI stabilizes)
786
- this.broadcast('session:needsRefresh', { id: session.id });
825
+ this.broadcast(SseEvent.SessionNeedsRefresh, { id: session.id });
787
826
  },
827
+ // ─── Session Messages & Errors ──────────────────────────
828
+ /** Broadcasts `session:message` — structured Claude JSON messages (assistant, tool_use, etc.) */
788
829
  message: (msg) => {
789
- this.broadcast('session:message', { id: session.id, message: msg });
830
+ this.broadcast(SseEvent.SessionMessage, { id: session.id, message: msg });
790
831
  },
832
+ /** Broadcasts `session:error` + sends push notification */
791
833
  error: (error) => {
792
- this.broadcast('session:error', { id: session.id, error });
793
- this.sendPushNotifications('session:error', {
834
+ this.broadcast(SseEvent.SessionError, { id: session.id, error });
835
+ this.sendPushNotifications(SseEvent.SessionError, {
794
836
  sessionId: session.id,
795
837
  sessionName: session.name,
796
838
  error: String(error),
797
839
  });
798
- // Track in run summary
799
840
  const tracker = this.runSummaryTrackers.get(session.id);
800
841
  if (tracker)
801
842
  tracker.recordError('Session error', String(error));
802
843
  },
844
+ /** Broadcasts `session:completion` + `session:updated` — prompt finished, persists state */
803
845
  completion: (result, cost) => {
804
- this.broadcast('session:completion', { id: session.id, result, cost });
805
- this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
846
+ this.broadcast(SseEvent.SessionCompletion, { id: session.id, result, cost });
847
+ this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session));
806
848
  this.persistSessionState(session);
807
- // Track tokens in run summary (completion event has updated token values)
808
849
  const tracker = this.runSummaryTrackers.get(session.id);
809
850
  if (tracker)
810
851
  tracker.recordTokens(session.inputTokens, session.outputTokens);
811
852
  },
853
+ // ─── Session Lifecycle ──────────────────────────────────
854
+ /** Broadcasts `session:exit` + `session:updated` — PTY process exited; cleans up respawn, timers, listeners */
812
855
  exit: (code) => {
813
856
  getLifecycleLog().log({
814
857
  event: 'exit',
@@ -818,8 +861,8 @@ export class WebServer extends EventEmitter {
818
861
  });
819
862
  // Wrap in try/catch to ensure cleanup always happens
820
863
  try {
821
- this.broadcast('session:exit', { id: session.id, code });
822
- this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
864
+ this.broadcast(SseEvent.SessionExit, { id: session.id, code });
865
+ this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session));
823
866
  this.persistSessionState(session);
824
867
  }
825
868
  catch (err) {
@@ -908,121 +951,130 @@ export class WebServer extends EventEmitter {
908
951
  console.error(`[Server] Error cleaning up session resources on exit for ${session.id}:`, err);
909
952
  }
910
953
  },
954
+ // ─── Activity State ─────────────────────────────────────
955
+ /** Broadcasts `session:working` — Claude started processing */
911
956
  working: () => {
912
- this.broadcast('session:working', { id: session.id });
913
- // Track in run summary
957
+ this.broadcast(SseEvent.SessionWorking, { id: session.id });
914
958
  const tracker = this.runSummaryTrackers.get(session.id);
915
959
  if (tracker) {
916
960
  tracker.recordWorking();
917
961
  tracker.recordTokens(session.inputTokens, session.outputTokens);
918
962
  }
919
963
  },
964
+ /** Broadcasts `session:idle` — Claude finished processing, waiting for input */
920
965
  idle: () => {
921
- this.broadcast('session:idle', { id: session.id });
922
- // Use debounced state update (idle can fire frequently)
966
+ this.broadcast(SseEvent.SessionIdle, { id: session.id });
923
967
  this.broadcastSessionStateDebounced(session.id);
924
- // Track in run summary
925
968
  const tracker = this.runSummaryTrackers.get(session.id);
926
969
  if (tracker) {
927
970
  tracker.recordIdle();
928
971
  tracker.recordTokens(session.inputTokens, session.outputTokens);
929
972
  }
930
973
  },
931
- // Background task events - use debounced state updates to reduce serialization overhead
974
+ // ─── Background Task Events ──────────────────────────────
975
+ // Debounced state updates to reduce serialization overhead.
976
+ /** Broadcasts `task:created` — new background task discovered */
932
977
  taskCreated: (task) => {
933
- this.broadcast('task:created', { sessionId: session.id, task });
978
+ this.broadcast(SseEvent.TaskCreated, { sessionId: session.id, task });
934
979
  this.broadcastSessionStateDebounced(session.id);
935
980
  },
981
+ /** Batched broadcast of `task:updated` — high-frequency progress updates */
936
982
  taskUpdated: (task) => {
937
- // Use batching for better performance at high update rates
938
983
  this.batchTaskUpdate(session.id, task);
939
984
  },
985
+ /** Broadcasts `task:completed` — background task finished successfully */
940
986
  taskCompleted: (task) => {
941
- this.broadcast('task:completed', { sessionId: session.id, task });
987
+ this.broadcast(SseEvent.TaskCompleted, { sessionId: session.id, task });
942
988
  this.broadcastSessionStateDebounced(session.id);
943
989
  },
990
+ /** Broadcasts `task:failed` — background task errored */
944
991
  taskFailed: (task, error) => {
945
- this.broadcast('task:failed', { sessionId: session.id, task, error });
992
+ this.broadcast(SseEvent.TaskFailed, { sessionId: session.id, task, error });
946
993
  this.broadcastSessionStateDebounced(session.id);
947
994
  },
995
+ // ─── Auto-Operations ────────────────────────────────────
996
+ /** Broadcasts `session:autoClear` — context window auto-cleared at token threshold */
948
997
  autoClear: (data) => {
949
- this.broadcast('session:autoClear', { sessionId: session.id, ...data });
998
+ this.broadcast(SseEvent.SessionAutoClear, { sessionId: session.id, ...data });
950
999
  this.broadcastSessionStateDebounced(session.id);
951
- // Track in run summary
952
1000
  const tracker = this.runSummaryTrackers.get(session.id);
953
1001
  if (tracker)
954
1002
  tracker.recordAutoClear(data.tokens, data.threshold);
955
1003
  },
1004
+ /** Broadcasts `session:autoCompact` — context window auto-compacted at token threshold */
956
1005
  autoCompact: (data) => {
957
- this.broadcast('session:autoCompact', { sessionId: session.id, ...data });
1006
+ this.broadcast(SseEvent.SessionAutoCompact, { sessionId: session.id, ...data });
958
1007
  this.broadcastSessionStateDebounced(session.id);
959
- // Track in run summary
960
1008
  const tracker = this.runSummaryTrackers.get(session.id);
961
1009
  if (tracker)
962
1010
  tracker.recordAutoCompact(data.tokens, data.threshold);
963
1011
  },
964
- // Claude Code CLI info parsed from terminal (version, model, account)
1012
+ // ─── CLI Info ────────────────────────────────────────────
1013
+ /** Broadcasts `session:cliInfo` — Claude Code version, model, account type parsed from terminal */
965
1014
  cliInfoUpdated: (data) => {
966
- this.broadcast('session:cliInfo', { sessionId: session.id, ...data });
1015
+ this.broadcast(SseEvent.SessionCliInfo, { sessionId: session.id, ...data });
967
1016
  this.broadcastSessionStateDebounced(session.id);
968
1017
  },
969
- // Ralph tracking events
1018
+ // ─── Ralph Tracking Events ──────────────────────────────
1019
+ /** Broadcasts `session:ralphLoopUpdate` — Ralph tracker loop state changed (iteration, phase) */
970
1020
  ralphLoopUpdate: (state) => {
971
- this.broadcast('session:ralphLoopUpdate', { sessionId: session.id, state });
972
- // Persist Ralph state
1021
+ this.broadcast(SseEvent.SessionRalphLoopUpdate, { sessionId: session.id, state });
973
1022
  this.store.updateRalphState(session.id, { loop: state });
974
1023
  },
1024
+ /** Broadcasts `session:ralphTodoUpdate` — todo items added, completed, or modified */
975
1025
  ralphTodoUpdate: (todos) => {
976
- this.broadcast('session:ralphTodoUpdate', { sessionId: session.id, todos });
977
- // Persist Ralph state
1026
+ this.broadcast(SseEvent.SessionRalphTodoUpdate, { sessionId: session.id, todos });
978
1027
  this.store.updateRalphState(session.id, { todos });
979
1028
  },
1029
+ /** Broadcasts `session:ralphCompletionDetected` + push notification — completion phrase matched */
980
1030
  ralphCompletionDetected: (phrase) => {
981
- this.broadcast('session:ralphCompletionDetected', { sessionId: session.id, phrase });
982
- this.sendPushNotifications('session:ralphCompletionDetected', {
1031
+ this.broadcast(SseEvent.SessionRalphCompletionDetected, { sessionId: session.id, phrase });
1032
+ this.sendPushNotifications(SseEvent.SessionRalphCompletionDetected, {
983
1033
  sessionId: session.id,
984
1034
  sessionName: session.name,
985
1035
  phrase,
986
1036
  });
987
- // Track in run summary
988
1037
  const tracker = this.runSummaryTrackers.get(session.id);
989
1038
  if (tracker)
990
1039
  tracker.recordRalphCompletion(phrase);
991
1040
  },
992
- // RALPH_STATUS block events
1041
+ /** Broadcasts `session:ralphStatusUpdate` — RALPH_STATUS block parsed from output */
993
1042
  ralphStatusBlockDetected: (block) => {
994
- this.broadcast('session:ralphStatusUpdate', { sessionId: session.id, block });
995
- // Track in run summary
1043
+ this.broadcast(SseEvent.SessionRalphStatusUpdate, { sessionId: session.id, block });
996
1044
  const tracker = this.runSummaryTrackers.get(session.id);
997
1045
  if (tracker) {
998
1046
  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}`);
999
1047
  }
1000
1048
  },
1049
+ /** Broadcasts `session:circuitBreakerUpdate` — circuit breaker state changed (CLOSED/HALF_OPEN/OPEN) */
1001
1050
  ralphCircuitBreakerUpdate: (status) => {
1002
- this.broadcast('session:circuitBreakerUpdate', { sessionId: session.id, status });
1003
- // Track state changes in run summary
1051
+ this.broadcast(SseEvent.SessionCircuitBreakerUpdate, { sessionId: session.id, status });
1004
1052
  const tracker = this.runSummaryTrackers.get(session.id);
1005
1053
  if (tracker && status.state === 'OPEN') {
1006
1054
  tracker.addEvent('warning', 'warning', 'Circuit Breaker Opened', status.reason);
1007
1055
  }
1008
1056
  },
1057
+ /** Broadcasts `session:exitGateMet` — all completion indicators met, ready to exit */
1009
1058
  ralphExitGateMet: (data) => {
1010
- this.broadcast('session:exitGateMet', { sessionId: session.id, ...data });
1011
- // Track in run summary
1059
+ this.broadcast(SseEvent.SessionExitGateMet, { sessionId: session.id, ...data });
1012
1060
  const tracker = this.runSummaryTrackers.get(session.id);
1013
1061
  if (tracker) {
1014
1062
  tracker.addEvent('ralph_completion', 'success', 'Exit Gate Met', `Indicators: ${data.completionIndicators}, EXIT_SIGNAL: ${data.exitSignal}`);
1015
1063
  }
1016
1064
  },
1017
- // Bash tool tracking events (for clickable file paths)
1065
+ // ─── Bash Tool Tracking ────────────────────────────────
1066
+ // Used for clickable file paths in the UI.
1067
+ /** Broadcasts `session:bashToolStart` — bash tool invocation started */
1018
1068
  bashToolStart: (tool) => {
1019
- this.broadcast('session:bashToolStart', { sessionId: session.id, tool });
1069
+ this.broadcast(SseEvent.SessionBashToolStart, { sessionId: session.id, tool });
1020
1070
  },
1071
+ /** Broadcasts `session:bashToolEnd` — bash tool invocation completed */
1021
1072
  bashToolEnd: (tool) => {
1022
- this.broadcast('session:bashToolEnd', { sessionId: session.id, tool });
1073
+ this.broadcast(SseEvent.SessionBashToolEnd, { sessionId: session.id, tool });
1023
1074
  },
1075
+ /** Broadcasts `session:bashToolsUpdate` — full active bash tools list refreshed */
1024
1076
  bashToolsUpdate: (tools) => {
1025
- this.broadcast('session:bashToolsUpdate', { sessionId: session.id, tools });
1077
+ this.broadcast(SseEvent.SessionBashToolsUpdate, { sessionId: session.id, tools });
1026
1078
  },
1027
1079
  };
1028
1080
  // Store listener refs for cleanup
@@ -1059,102 +1111,124 @@ export class WebServer extends EventEmitter {
1059
1111
  controller.setTeamWatcher(this.teamWatcher);
1060
1112
  // Helper to get tracker lazily (may not exist at setup time for restored sessions)
1061
1113
  const getTracker = () => this.runSummaryTrackers.get(sessionId);
1114
+ // ─── Respawn State Machine ──────────────────────────────
1115
+ /** Broadcasts `respawn:stateChanged` — state machine transition (e.g., IDLE → DETECTING → RESPAWNING) */
1062
1116
  controller.on('stateChanged', (state, prevState) => {
1063
- this.broadcast('respawn:stateChanged', { sessionId, state, prevState });
1064
- // Track in run summary (lazy lookup since tracker may be created after controller)
1117
+ this.broadcast(SseEvent.RespawnStateChanged, { sessionId, state, prevState });
1065
1118
  const tracker = getTracker();
1066
1119
  if (tracker)
1067
1120
  tracker.recordStateChange(state, `${prevState} → ${state}`);
1068
1121
  });
1122
+ // ─── Respawn Cycle Lifecycle ────────────────────────────
1123
+ /** Broadcasts `respawn:cycleStarted` — new respawn cycle begins */
1069
1124
  controller.on('respawnCycleStarted', (cycleNumber) => {
1070
- this.broadcast('respawn:cycleStarted', { sessionId, cycleNumber });
1125
+ this.broadcast(SseEvent.RespawnCycleStarted, { sessionId, cycleNumber });
1071
1126
  });
1127
+ /** Broadcasts `respawn:cycleCompleted` — respawn cycle finished */
1072
1128
  controller.on('respawnCycleCompleted', (cycleNumber) => {
1073
- this.broadcast('respawn:cycleCompleted', { sessionId, cycleNumber });
1129
+ this.broadcast(SseEvent.RespawnCycleCompleted, { sessionId, cycleNumber });
1074
1130
  });
1131
+ /** Broadcasts `respawn:blocked` + push notification — respawn blocked by error/circuit breaker */
1075
1132
  controller.on('respawnBlocked', (data) => {
1076
- this.broadcast('respawn:blocked', { sessionId, reason: data.reason, details: data.details });
1133
+ this.broadcast(SseEvent.RespawnBlocked, { sessionId, reason: data.reason, details: data.details });
1077
1134
  const sessionForPush = this.sessions.get(sessionId);
1078
- this.sendPushNotifications('respawn:blocked', {
1135
+ this.sendPushNotifications(SseEvent.RespawnBlocked, {
1079
1136
  sessionId,
1080
1137
  sessionName: sessionForPush?.name ?? sessionId.slice(0, 8),
1081
1138
  reason: data.reason,
1082
1139
  });
1083
- // Track in run summary (lazy lookup)
1084
1140
  const tracker = getTracker();
1085
1141
  if (tracker)
1086
1142
  tracker.recordWarning(`Respawn blocked: ${data.reason}`, data.details);
1087
1143
  });
1144
+ // ─── Respawn Step Progress ──────────────────────────────
1145
+ /** Broadcasts `respawn:stepSent` — respawn step input sent (e.g., /clear, kickstart prompt) */
1088
1146
  controller.on('stepSent', (step, input) => {
1089
- this.broadcast('respawn:stepSent', { sessionId, step, input });
1147
+ this.broadcast(SseEvent.RespawnStepSent, { sessionId, step, input });
1090
1148
  });
1149
+ /** Broadcasts `respawn:stepCompleted` — respawn step finished */
1091
1150
  controller.on('stepCompleted', (step) => {
1092
- this.broadcast('respawn:stepCompleted', { sessionId, step });
1151
+ this.broadcast(SseEvent.RespawnStepCompleted, { sessionId, step });
1093
1152
  });
1153
+ /** Broadcasts `respawn:detectionUpdate` — idle/completion detection state changed */
1094
1154
  controller.on('detectionUpdate', (detection) => {
1095
- this.broadcast('respawn:detectionUpdate', { sessionId, detection });
1155
+ this.broadcast(SseEvent.RespawnDetectionUpdate, { sessionId, detection });
1096
1156
  });
1157
+ /** Broadcasts `respawn:autoAcceptSent` — auto-accepted a permission prompt */
1097
1158
  controller.on('autoAcceptSent', () => {
1098
- this.broadcast('respawn:autoAcceptSent', { sessionId });
1159
+ this.broadcast(SseEvent.RespawnAutoAcceptSent, { sessionId });
1099
1160
  });
1161
+ // ─── AI Checker Events ──────────────────────────────────
1162
+ /** Broadcasts `respawn:aiCheckStarted` — AI idle checker invoked */
1100
1163
  controller.on('aiCheckStarted', () => {
1101
- this.broadcast('respawn:aiCheckStarted', { sessionId });
1164
+ this.broadcast(SseEvent.RespawnAiCheckStarted, { sessionId });
1102
1165
  });
1166
+ /** Broadcasts `respawn:aiCheckCompleted` — AI idle check returned verdict (idle/working/stuck) */
1103
1167
  controller.on('aiCheckCompleted', (result) => {
1104
- this.broadcast('respawn:aiCheckCompleted', {
1168
+ this.broadcast(SseEvent.RespawnAiCheckCompleted, {
1105
1169
  sessionId,
1106
1170
  verdict: result.verdict,
1107
1171
  reasoning: result.reasoning,
1108
1172
  durationMs: result.durationMs,
1109
1173
  });
1110
- // Track in run summary (lazy lookup)
1111
1174
  const tracker = getTracker();
1112
1175
  if (tracker)
1113
1176
  tracker.recordAiCheckResult(result.verdict);
1114
1177
  });
1178
+ /** Broadcasts `respawn:aiCheckFailed` — AI idle check errored */
1115
1179
  controller.on('aiCheckFailed', (error) => {
1116
- this.broadcast('respawn:aiCheckFailed', { sessionId, error });
1117
- // Track in run summary (lazy lookup)
1180
+ this.broadcast(SseEvent.RespawnAiCheckFailed, { sessionId, error });
1118
1181
  const tracker = getTracker();
1119
1182
  if (tracker)
1120
1183
  tracker.recordError('AI check failed', error);
1121
1184
  });
1185
+ /** Broadcasts `respawn:aiCheckCooldown` — AI check on cooldown after failure */
1122
1186
  controller.on('aiCheckCooldown', (active, endsAt) => {
1123
- this.broadcast('respawn:aiCheckCooldown', { sessionId, active, endsAt });
1187
+ this.broadcast(SseEvent.RespawnAiCheckCooldown, { sessionId, active, endsAt });
1124
1188
  });
1189
+ // ─── Plan Checker Events ────────────────────────────────
1190
+ /** Broadcasts `respawn:planCheckStarted` — AI plan completion checker invoked */
1125
1191
  controller.on('planCheckStarted', () => {
1126
- this.broadcast('respawn:planCheckStarted', { sessionId });
1192
+ this.broadcast(SseEvent.RespawnPlanCheckStarted, { sessionId });
1127
1193
  });
1194
+ /** Broadcasts `respawn:planCheckCompleted` — plan check returned verdict */
1128
1195
  controller.on('planCheckCompleted', (result) => {
1129
- this.broadcast('respawn:planCheckCompleted', {
1196
+ this.broadcast(SseEvent.RespawnPlanCheckCompleted, {
1130
1197
  sessionId,
1131
1198
  verdict: result.verdict,
1132
1199
  reasoning: result.reasoning,
1133
1200
  durationMs: result.durationMs,
1134
1201
  });
1135
1202
  });
1203
+ /** Broadcasts `respawn:planCheckFailed` — plan check errored */
1136
1204
  controller.on('planCheckFailed', (error) => {
1137
- this.broadcast('respawn:planCheckFailed', { sessionId, error });
1205
+ this.broadcast(SseEvent.RespawnPlanCheckFailed, { sessionId, error });
1138
1206
  });
1139
- // Timer tracking events for UI countdown display
1207
+ // ─── Timer Events (UI countdown display) ────────────────
1208
+ /** Broadcasts `respawn:timerStarted` — countdown timer started (idle, cooldown, etc.) */
1140
1209
  controller.on('timerStarted', (timer) => {
1141
- this.broadcast('respawn:timerStarted', { sessionId, timer });
1210
+ this.broadcast(SseEvent.RespawnTimerStarted, { sessionId, timer });
1142
1211
  });
1212
+ /** Broadcasts `respawn:timerCancelled` — timer cancelled before expiry */
1143
1213
  controller.on('timerCancelled', (timerName, reason) => {
1144
- this.broadcast('respawn:timerCancelled', { sessionId, timerName, reason });
1214
+ this.broadcast(SseEvent.RespawnTimerCancelled, { sessionId, timerName, reason });
1145
1215
  });
1216
+ /** Broadcasts `respawn:timerCompleted` — timer expired */
1146
1217
  controller.on('timerCompleted', (timerName) => {
1147
- this.broadcast('respawn:timerCompleted', { sessionId, timerName });
1218
+ this.broadcast(SseEvent.RespawnTimerCompleted, { sessionId, timerName });
1148
1219
  });
1220
+ // ─── Logging & Errors ───────────────────────────────────
1221
+ /** Broadcasts `respawn:actionLog` — respawn action logged for audit/debugging */
1149
1222
  controller.on('actionLog', (action) => {
1150
- this.broadcast('respawn:actionLog', { sessionId, action });
1223
+ this.broadcast(SseEvent.RespawnActionLog, { sessionId, action });
1151
1224
  });
1225
+ /** Broadcasts `respawn:log` — general respawn log message */
1152
1226
  controller.on('log', (message) => {
1153
- this.broadcast('respawn:log', { sessionId, message });
1227
+ this.broadcast(SseEvent.RespawnLog, { sessionId, message });
1154
1228
  });
1229
+ /** Broadcasts `respawn:error` — respawn controller error */
1155
1230
  controller.on('error', (error) => {
1156
- this.broadcast('respawn:error', { sessionId, error: error.message });
1157
- // Track in run summary (lazy lookup)
1231
+ this.broadcast(SseEvent.RespawnError, { sessionId, error: error.message });
1158
1232
  const tracker = getTracker();
1159
1233
  if (tracker)
1160
1234
  tracker.recordError('Respawn error', error.message);
@@ -1175,7 +1249,7 @@ export class WebServer extends EventEmitter {
1175
1249
  controller.stop();
1176
1250
  controller.removeAllListeners();
1177
1251
  this.respawnControllers.delete(sessionId);
1178
- this.broadcast('respawn:stopped', { sessionId, reason: 'duration_expired' });
1252
+ this.broadcast(SseEvent.RespawnStopped, { sessionId, reason: 'duration_expired' });
1179
1253
  }
1180
1254
  this.respawnTimers.delete(sessionId);
1181
1255
  // Update persisted state (respawn no longer active)
@@ -1185,7 +1259,7 @@ export class WebServer extends EventEmitter {
1185
1259
  }
1186
1260
  }, durationMinutes * 60 * 1000);
1187
1261
  this.respawnTimers.set(sessionId, { timer, endAt, startedAt: now });
1188
- this.broadcast('respawn:timerStarted', { sessionId, durationMinutes, endAt, startedAt: now });
1262
+ this.broadcast(SseEvent.RespawnTimerStarted, { sessionId, durationMinutes, endAt, startedAt: now });
1189
1263
  }
1190
1264
  /**
1191
1265
  * Restore a RespawnController from persisted configuration.
@@ -1238,7 +1312,7 @@ export class WebServer extends EventEmitter {
1238
1312
  const ctrl = this.respawnControllers.get(session.id);
1239
1313
  if (ctrl && ctrl.state === 'stopped') {
1240
1314
  ctrl.start();
1241
- this.broadcast('respawn:started', { sessionId: session.id });
1315
+ this.broadcast(SseEvent.RespawnStarted, { sessionId: session.id });
1242
1316
  console.log(`[Server] Restored respawn controller started for session ${session.id}`);
1243
1317
  }
1244
1318
  }, delayMs);
@@ -1334,7 +1408,7 @@ export class WebServer extends EventEmitter {
1334
1408
  logs: [`[${new Date().toISOString()}] Scheduled run started`],
1335
1409
  };
1336
1410
  this.scheduledRuns.set(id, run);
1337
- this.broadcast('scheduled:created', run);
1411
+ this.broadcast(SseEvent.ScheduledCreated, run);
1338
1412
  // Start the run loop (fire-and-forget with error handling)
1339
1413
  this.runScheduledLoop(id).catch((err) => {
1340
1414
  console.error(`[WebServer] Scheduled run ${id} failed:`, err);
@@ -1342,7 +1416,7 @@ export class WebServer extends EventEmitter {
1342
1416
  if (failedRun && failedRun.status === 'running') {
1343
1417
  failedRun.status = 'stopped';
1344
1418
  failedRun.logs.push(`[${new Date().toISOString()}] Error: ${err instanceof Error ? err.message : String(err)}`);
1345
- this.broadcast('scheduled:stopped', { id, reason: 'error' });
1419
+ this.broadcast(SseEvent.ScheduledStopped, { id, reason: 'error' });
1346
1420
  }
1347
1421
  });
1348
1422
  return run;
@@ -1353,7 +1427,7 @@ export class WebServer extends EventEmitter {
1353
1427
  return;
1354
1428
  const addLog = (msg) => {
1355
1429
  run.logs.push(`[${new Date().toISOString()}] ${msg}`);
1356
- this.broadcast('scheduled:log', { id: runId, log: run.logs[run.logs.length - 1] });
1430
+ this.broadcast(SseEvent.ScheduledLog, { id: runId, log: run.logs[run.logs.length - 1] });
1357
1431
  };
1358
1432
  while (Date.now() < run.endAt && run.status === 'running') {
1359
1433
  // Check session limit before creating new session
@@ -1372,7 +1446,7 @@ export class WebServer extends EventEmitter {
1372
1446
  await this.setupSessionListeners(session);
1373
1447
  run.sessionId = session.id;
1374
1448
  addLog(`Starting task iteration with session ${session.id.slice(0, 8)}`);
1375
- this.broadcast('scheduled:updated', run);
1449
+ this.broadcast(SseEvent.ScheduledUpdated, run);
1376
1450
  // Run the prompt
1377
1451
  const timeRemaining = Math.round((run.endAt - Date.now()) / 60000);
1378
1452
  const enhancedPrompt = `${run.prompt}\n\nNote: You have approximately ${timeRemaining} minutes remaining in this scheduled run. Work efficiently.`;
@@ -1380,7 +1454,7 @@ export class WebServer extends EventEmitter {
1380
1454
  run.completedTasks++;
1381
1455
  run.totalCost += result.cost;
1382
1456
  addLog(`Task completed. Cost: $${result.cost.toFixed(4)}. Total tasks: ${run.completedTasks}`);
1383
- this.broadcast('scheduled:updated', run);
1457
+ this.broadcast(SseEvent.ScheduledUpdated, run);
1384
1458
  // Clean up the session after iteration to prevent memory leaks
1385
1459
  await this.cleanupSession(session.id, true, 'scheduled_run');
1386
1460
  run.sessionId = null;
@@ -1389,7 +1463,7 @@ export class WebServer extends EventEmitter {
1389
1463
  }
1390
1464
  catch (err) {
1391
1465
  addLog(`Error: ${getErrorMessage(err)}`);
1392
- this.broadcast('scheduled:updated', run);
1466
+ this.broadcast(SseEvent.ScheduledUpdated, run);
1393
1467
  // Clean up the session on error too
1394
1468
  if (session) {
1395
1469
  try {
@@ -1408,7 +1482,7 @@ export class WebServer extends EventEmitter {
1408
1482
  run.status = 'completed';
1409
1483
  addLog(`Scheduled run completed. Total tasks: ${run.completedTasks}, Total cost: $${run.totalCost.toFixed(4)}`);
1410
1484
  }
1411
- this.broadcast('scheduled:completed', run);
1485
+ this.broadcast(SseEvent.ScheduledCompleted, run);
1412
1486
  }
1413
1487
  async stopScheduledRun(id) {
1414
1488
  const run = this.scheduledRuns.get(id);
@@ -1421,7 +1495,7 @@ export class WebServer extends EventEmitter {
1421
1495
  await this.cleanupSession(run.sessionId, true, 'scheduled_run_stopped');
1422
1496
  run.sessionId = null;
1423
1497
  }
1424
- this.broadcast('scheduled:stopped', run);
1498
+ this.broadcast(SseEvent.ScheduledStopped, run);
1425
1499
  }
1426
1500
  /**
1427
1501
  * Get session state with respawn controller info included.
@@ -1467,7 +1541,7 @@ export class WebServer extends EventEmitter {
1467
1541
  }
1468
1542
  for (const id of toDelete) {
1469
1543
  this.scheduledRuns.delete(id);
1470
- this.broadcast('scheduled:deleted', { id });
1544
+ this.broadcast(SseEvent.ScheduledDeleted, { id });
1471
1545
  }
1472
1546
  if (toDelete.length > 0) {
1473
1547
  console.log(`[Server] Cleaned up ${toDelete.length} old scheduled run(s)`);
@@ -1545,7 +1619,7 @@ export class WebServer extends EventEmitter {
1545
1619
  // Client may have missed terminal data during backpressure.
1546
1620
  // Tell it to reload the active session's buffer to recover.
1547
1621
  try {
1548
- reply.raw.write(`event: session:needsRefresh\ndata: {}\n\n`);
1622
+ reply.raw.write(`event: ${SseEvent.SessionNeedsRefresh}\ndata: {}\n\n`);
1549
1623
  }
1550
1624
  catch {
1551
1625
  /* client gone */
@@ -1559,21 +1633,25 @@ export class WebServer extends EventEmitter {
1559
1633
  }
1560
1634
  }
1561
1635
  broadcast(event, data) {
1562
- // Invalidate caches only on structurally significant events — ones that
1563
- // change session list content (creation, deletion, or full state refresh).
1564
- // High-frequency non-structural events (working/idle transitions, completion,
1565
- // error, respawn state changes) are NOT worth invalidating for because:
1566
- // 1. The debounced session:updated follows within 500ms with the new state
1567
- // 2. These caches serve /api/sessions and SSE initneither is polled rapidly
1568
- // 3. Invalidating on every working/idle transition makes the 1s TTL useless
1569
- if (event === 'session:created' || event === 'session:deleted' || event === 'session:updated') {
1636
+ // Skip serialization entirely when no clients are listening
1637
+ if (this.sseClients.size === 0)
1638
+ return;
1639
+ // Invalidate caches only on structural changes (creation/deletion).
1640
+ // SessionUpdated fires too frequently (working/idle transitions, completion)
1641
+ // and makes the 1s TTL cache uselessthe debounced session:updated follows
1642
+ // within 500ms anyway, and these caches serve /api/sessions and SSE init
1643
+ // which aren't polled rapidly.
1644
+ if (event === SseEvent.SessionCreated || event === SseEvent.SessionDeleted) {
1570
1645
  this.cachedLightState = null;
1571
1646
  this.cachedSessionsList = null;
1572
1647
  }
1573
- // Performance optimization: serialize JSON once for all clients
1648
+ // Performance optimization: serialize JSON once for all clients.
1649
+ // Only append Cloudflare tunnel padding when tunnel is actually active —
1650
+ // direct/Tailscale clients don't need 8KB padding on every event.
1651
+ const padding = this._isTunnelActive ? SSE_PADDING : '';
1574
1652
  let message;
1575
1653
  try {
1576
- message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
1654
+ message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` + padding;
1577
1655
  }
1578
1656
  catch (err) {
1579
1657
  // Handle circular references or non-serializable values
@@ -1687,7 +1765,7 @@ export class WebServer extends EventEmitter {
1687
1765
  return;
1688
1766
  }
1689
1767
  for (const [, { sessionId, task }] of this.taskUpdateBatches) {
1690
- this.broadcast('task:updated', { sessionId, task });
1768
+ this.broadcast(SseEvent.TaskUpdated, { sessionId, task });
1691
1769
  }
1692
1770
  this.taskUpdateBatches.clear();
1693
1771
  }
@@ -1718,7 +1796,7 @@ export class WebServer extends EventEmitter {
1718
1796
  const session = this.sessions.get(sessionId);
1719
1797
  if (session) {
1720
1798
  // Single expensive serialization per batch interval
1721
- this.broadcast('session:updated', this.getSessionStateWithRespawn(session));
1799
+ this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session));
1722
1800
  }
1723
1801
  }
1724
1802
  this.stateUpdatePending.clear();
@@ -1726,7 +1804,7 @@ export class WebServer extends EventEmitter {
1726
1804
  // ========== Web Push ==========
1727
1805
  /** Map SSE event names to push notification payloads */
1728
1806
  static PUSH_EVENT_MAP = {
1729
- 'hook:permission_prompt': {
1807
+ [SseEvent.HookPermissionPrompt]: {
1730
1808
  title: 'Permission Required',
1731
1809
  urgency: 'critical',
1732
1810
  actions: [
@@ -1734,12 +1812,12 @@ export class WebServer extends EventEmitter {
1734
1812
  { action: 'deny', title: 'Deny' },
1735
1813
  ],
1736
1814
  },
1737
- 'hook:elicitation_dialog': { title: 'Question Asked', urgency: 'critical' },
1738
- 'hook:idle_prompt': { title: 'Waiting for Input', urgency: 'warning' },
1739
- 'hook:stop': { title: 'Response Complete', urgency: 'info' },
1740
- 'session:error': { title: 'Session Error', urgency: 'critical' },
1741
- 'respawn:blocked': { title: 'Respawn Blocked', urgency: 'critical' },
1742
- 'session:ralphCompletionDetected': { title: 'Task Complete', urgency: 'warning' },
1815
+ [SseEvent.HookElicitationDialog]: { title: 'Question Asked', urgency: 'critical' },
1816
+ [SseEvent.HookIdlePrompt]: { title: 'Waiting for Input', urgency: 'warning' },
1817
+ [SseEvent.HookStop]: { title: 'Response Complete', urgency: 'info' },
1818
+ [SseEvent.SessionError]: { title: 'Session Error', urgency: 'critical' },
1819
+ [SseEvent.RespawnBlocked]: { title: 'Respawn Blocked', urgency: 'critical' },
1820
+ [SseEvent.SessionRalphCompletionDetected]: { title: 'Task Complete', urgency: 'warning' },
1743
1821
  };
1744
1822
  /**
1745
1823
  * Send push notifications for a given event to all subscribed devices.
@@ -1759,19 +1837,19 @@ export class WebServer extends EventEmitter {
1759
1837
  const sessionId = data.sessionId || '';
1760
1838
  // Build body text from event data
1761
1839
  let body = sessionName ? `[${sessionName}]` : '';
1762
- if (event === 'session:error' && data.error) {
1840
+ if (event === SseEvent.SessionError && data.error) {
1763
1841
  body += body ? ' ' : '';
1764
1842
  body += String(data.error).slice(0, 200);
1765
1843
  }
1766
- else if (event === 'respawn:blocked' && data.reason) {
1844
+ else if (event === SseEvent.RespawnBlocked && data.reason) {
1767
1845
  body += body ? ' ' : '';
1768
1846
  body += String(data.reason);
1769
1847
  }
1770
- else if (event === 'session:ralphCompletionDetected' && data.phrase) {
1848
+ else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) {
1771
1849
  body += body ? ' ' : '';
1772
1850
  body += String(data.phrase);
1773
1851
  }
1774
- else if (event === 'hook:permission_prompt' && data.tool_name) {
1852
+ else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
1775
1853
  body += body ? ' ' : '';
1776
1854
  body += `Tool: ${String(data.tool_name)}`;
1777
1855
  }
@@ -1814,8 +1892,11 @@ export class WebServer extends EventEmitter {
1814
1892
  deadClients.push(client);
1815
1893
  }
1816
1894
  else {
1817
- // Send SSE comment as keep-alive (comments start with ':')
1818
- client.raw.write(':keepalive\n\n');
1895
+ // Send SSE comment as keep-alive. Only add padding when tunnel is
1896
+ // active — it flushes Cloudflare proxy buffers but wastes bandwidth
1897
+ // for direct/Tailscale connections.
1898
+ const ka = this._isTunnelActive ? ':keepalive\n' + SSE_PADDING : ':keepalive\n\n';
1899
+ client.raw.write(ka);
1819
1900
  }
1820
1901
  }
1821
1902
  catch {
@@ -1881,7 +1962,7 @@ export class WebServer extends EventEmitter {
1881
1962
  // Start SSE client health check timer (prevents memory leaks from dead connections)
1882
1963
  this.cleanup.setInterval(() => {
1883
1964
  this.cleanupDeadSSEClients();
1884
- }, SSE_HEALTH_CHECK_INTERVAL, { description: 'SSE client health check' });
1965
+ }, SSE_HEARTBEAT_INTERVAL, { description: 'SSE heartbeat + dead client cleanup' });
1885
1966
  // Start token recording timer (every 5 minutes for long-running sessions)
1886
1967
  this.cleanup.setInterval(() => {
1887
1968
  this.recordPeriodicTokenUsage();