agileflow 3.0.2 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +58 -86
  3. package/lib/dashboard-automations.js +130 -0
  4. package/lib/dashboard-git.js +254 -0
  5. package/lib/dashboard-inbox.js +64 -0
  6. package/lib/dashboard-protocol.js +1 -0
  7. package/lib/dashboard-server.js +114 -924
  8. package/lib/dashboard-session.js +136 -0
  9. package/lib/dashboard-status.js +72 -0
  10. package/lib/dashboard-terminal.js +354 -0
  11. package/lib/dashboard-websocket.js +88 -0
  12. package/lib/drivers/codex-driver.ts +4 -4
  13. package/lib/feedback.js +9 -2
  14. package/lib/lazy-require.js +59 -0
  15. package/lib/logger.js +106 -0
  16. package/package.json +4 -2
  17. package/scripts/agileflow-configure.js +14 -2
  18. package/scripts/agileflow-welcome.js +450 -459
  19. package/scripts/claude-tmux.sh +113 -5
  20. package/scripts/context-loader.js +4 -9
  21. package/scripts/lib/command-prereqs.js +280 -0
  22. package/scripts/lib/configure-detect.js +92 -2
  23. package/scripts/lib/configure-features.js +411 -1
  24. package/scripts/lib/context-formatter.js +468 -233
  25. package/scripts/lib/context-loader.js +27 -15
  26. package/scripts/lib/damage-control-utils.js +8 -1
  27. package/scripts/lib/feature-catalog.js +321 -0
  28. package/scripts/lib/portable-tasks-cli.js +274 -0
  29. package/scripts/lib/portable-tasks.js +479 -0
  30. package/scripts/lib/signal-detectors.js +1 -1
  31. package/scripts/lib/team-events.js +86 -1
  32. package/scripts/obtain-context.js +28 -4
  33. package/scripts/smart-detect.js +17 -0
  34. package/scripts/strip-ai-attribution.js +63 -0
  35. package/scripts/team-manager.js +90 -0
  36. package/scripts/welcome-deferred.js +437 -0
  37. package/src/core/agents/legal-analyzer-a11y.md +110 -0
  38. package/src/core/agents/legal-analyzer-ai.md +117 -0
  39. package/src/core/agents/legal-analyzer-consumer.md +108 -0
  40. package/src/core/agents/legal-analyzer-content.md +113 -0
  41. package/src/core/agents/legal-analyzer-international.md +115 -0
  42. package/src/core/agents/legal-analyzer-licensing.md +115 -0
  43. package/src/core/agents/legal-analyzer-privacy.md +108 -0
  44. package/src/core/agents/legal-analyzer-security.md +112 -0
  45. package/src/core/agents/legal-analyzer-terms.md +111 -0
  46. package/src/core/agents/legal-consensus.md +242 -0
  47. package/src/core/agents/perf-analyzer-assets.md +174 -0
  48. package/src/core/agents/perf-analyzer-bundle.md +165 -0
  49. package/src/core/agents/perf-analyzer-caching.md +160 -0
  50. package/src/core/agents/perf-analyzer-compute.md +165 -0
  51. package/src/core/agents/perf-analyzer-memory.md +182 -0
  52. package/src/core/agents/perf-analyzer-network.md +157 -0
  53. package/src/core/agents/perf-analyzer-queries.md +155 -0
  54. package/src/core/agents/perf-analyzer-rendering.md +156 -0
  55. package/src/core/agents/perf-consensus.md +280 -0
  56. package/src/core/agents/security-analyzer-api.md +199 -0
  57. package/src/core/agents/security-analyzer-auth.md +160 -0
  58. package/src/core/agents/security-analyzer-authz.md +168 -0
  59. package/src/core/agents/security-analyzer-deps.md +147 -0
  60. package/src/core/agents/security-analyzer-infra.md +176 -0
  61. package/src/core/agents/security-analyzer-injection.md +148 -0
  62. package/src/core/agents/security-analyzer-input.md +191 -0
  63. package/src/core/agents/security-analyzer-secrets.md +175 -0
  64. package/src/core/agents/security-consensus.md +276 -0
  65. package/src/core/agents/team-lead.md +50 -13
  66. package/src/core/agents/test-analyzer-assertions.md +181 -0
  67. package/src/core/agents/test-analyzer-coverage.md +183 -0
  68. package/src/core/agents/test-analyzer-fragility.md +185 -0
  69. package/src/core/agents/test-analyzer-integration.md +155 -0
  70. package/src/core/agents/test-analyzer-maintenance.md +173 -0
  71. package/src/core/agents/test-analyzer-mocking.md +178 -0
  72. package/src/core/agents/test-analyzer-patterns.md +189 -0
  73. package/src/core/agents/test-analyzer-structure.md +177 -0
  74. package/src/core/agents/test-consensus.md +294 -0
  75. package/src/core/commands/audit/legal.md +446 -0
  76. package/src/core/commands/{logic/audit.md → audit/logic.md} +12 -12
  77. package/src/core/commands/audit/performance.md +443 -0
  78. package/src/core/commands/audit/security.md +443 -0
  79. package/src/core/commands/audit/test.md +442 -0
  80. package/src/core/commands/babysit.md +505 -463
  81. package/src/core/commands/configure.md +18 -33
  82. package/src/core/commands/research/ask.md +42 -9
  83. package/src/core/commands/research/import.md +14 -8
  84. package/src/core/commands/research/list.md +17 -16
  85. package/src/core/commands/research/synthesize.md +8 -8
  86. package/src/core/commands/research/view.md +28 -4
  87. package/src/core/commands/team/start.md +36 -7
  88. package/src/core/commands/team/stop.md +5 -2
  89. package/src/core/commands/whats-new.md +2 -2
  90. package/src/core/experts/devops/expertise.yaml +13 -2
  91. package/src/core/experts/documentation/expertise.yaml +26 -4
  92. package/src/core/profiles/COMPARISON.md +170 -0
  93. package/src/core/profiles/README.md +178 -0
  94. package/src/core/profiles/claude-code.yaml +111 -0
  95. package/src/core/profiles/codex.yaml +103 -0
  96. package/src/core/profiles/cursor.yaml +134 -0
  97. package/src/core/profiles/examples.js +250 -0
  98. package/src/core/profiles/loader.js +235 -0
  99. package/src/core/profiles/windsurf.yaml +159 -0
  100. package/src/core/teams/logic-audit.json +6 -0
  101. package/src/core/teams/perf-audit.json +71 -0
  102. package/src/core/teams/security-audit.json +71 -0
  103. package/src/core/teams/test-audit.json +71 -0
  104. package/src/core/templates/command-prerequisites.yaml +169 -0
  105. package/src/core/templates/damage-control-patterns.yaml +9 -0
  106. package/tools/cli/installers/ide/_base-ide.js +33 -3
  107. package/tools/cli/installers/ide/claude-code.js +2 -67
  108. package/tools/cli/installers/ide/codex.js +9 -9
  109. package/tools/cli/installers/ide/cursor.js +165 -4
  110. package/tools/cli/installers/ide/windsurf.js +237 -6
  111. package/tools/cli/lib/content-transformer.js +234 -9
  112. package/tools/cli/lib/docs-setup.js +1 -1
  113. package/tools/cli/lib/ide-generator.js +357 -0
  114. package/tools/cli/lib/ide-registry.js +2 -2
  115. package/scripts/tmux-task-name.sh +0 -75
  116. package/scripts/tmux-task-watcher.sh +0 -177
@@ -2,15 +2,14 @@
2
2
  /**
3
3
  * dashboard-server.js - WebSocket Server for AgileFlow Dashboard
4
4
  *
5
- * Provides real-time bidirectional communication between the CLI and
6
- * the AgileFlow Dashboard web application.
7
- *
8
- * Features:
9
- * - WebSocket server with session management
10
- * - Message streaming (text, tool calls, etc.)
11
- * - Git status updates
12
- * - Task tracking integration
13
- * - Multiple client support
5
+ * Coordinator module that delegates to focused domain modules:
6
+ * - dashboard-websocket.js - WebSocket frame encode/decode
7
+ * - dashboard-session.js - Session lifecycle and rate limiting
8
+ * - dashboard-terminal.js - Terminal management (PTY/fallback)
9
+ * - dashboard-git.js - Git operations (status, diff, actions)
10
+ * - dashboard-automations.js - Automation scheduling
11
+ * - dashboard-status.js - Project status and team metrics
12
+ * - dashboard-inbox.js - Inbox management
14
13
  *
15
14
  * Usage:
16
15
  * const { createDashboardServer, startDashboardServer } = require('./dashboard-server');
@@ -23,6 +22,28 @@
23
22
 
24
23
  const { EventEmitter } = require('events');
25
24
 
25
+ // Import extracted modules
26
+ const { encodeWebSocketFrame, decodeWebSocketFrame } = require('./dashboard-websocket');
27
+ const {
28
+ DashboardSession,
29
+ SESSION_TIMEOUT_MS,
30
+ SESSION_CLEANUP_INTERVAL_MS,
31
+ RATE_LIMIT_TOKENS,
32
+ } = require('./dashboard-session');
33
+ const {
34
+ TerminalInstance,
35
+ TerminalManager,
36
+ SENSITIVE_ENV_PATTERNS,
37
+ } = require('./dashboard-terminal');
38
+ const { getGitStatus, getFileDiff, parseDiffStats, handleGitAction } = require('./dashboard-git');
39
+ const {
40
+ calculateNextRun,
41
+ createInboxItem,
42
+ enrichAutomationList,
43
+ } = require('./dashboard-automations');
44
+ const { buildStatusSummary, readTeamMetrics } = require('./dashboard-status');
45
+ const { getSortedInboxItems, handleInboxAction } = require('./dashboard-inbox');
46
+
26
47
  // Lazy-loaded dependencies - deferred until first use
27
48
  let _http, _crypto, _protocol, _paths, _validatePaths, _childProcess;
28
49
 
@@ -75,434 +96,9 @@ function getAutomationRunner(rootDir) {
75
96
  const DEFAULT_PORT = 8765;
76
97
  const DEFAULT_HOST = '127.0.0.1'; // Localhost only for security
77
98
 
78
- // Session lifecycle
79
- const SESSION_TIMEOUT_MS = 4 * 60 * 60 * 1000; // 4 hours
80
- const SESSION_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
81
-
82
- // Rate limiting (token bucket)
83
- const RATE_LIMIT_TOKENS = 100; // max messages per second
84
- const RATE_LIMIT_REFILL_MS = 1000; // refill interval
85
-
86
- // Sensitive env var patterns to strip from terminal spawn
87
- const SENSITIVE_ENV_PATTERNS = /SECRET|TOKEN|PASSWORD|CREDENTIAL|API_KEY|PRIVATE_KEY|AUTH/i;
88
-
89
99
  // WebSocket magic GUID for handshake
90
100
  const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
91
101
 
92
- /**
93
- * Session state for a connected dashboard
94
- */
95
- class DashboardSession {
96
- constructor(id, ws, projectRoot) {
97
- this.id = id;
98
- this.ws = ws;
99
- this.projectRoot = projectRoot;
100
- this.messages = [];
101
- this.state = 'connected';
102
- this.lastActivity = new Date();
103
- this.createdAt = new Date();
104
- this.metadata = {};
105
-
106
- // Token bucket rate limiter
107
- this._rateTokens = RATE_LIMIT_TOKENS;
108
- this._rateLastRefill = Date.now();
109
- }
110
-
111
- /**
112
- * Check if session has expired
113
- * @returns {boolean}
114
- */
115
- isExpired() {
116
- return Date.now() - this.lastActivity.getTime() > SESSION_TIMEOUT_MS;
117
- }
118
-
119
- /**
120
- * Rate-limit incoming messages (token bucket)
121
- * @returns {boolean} true if allowed, false if rate-limited
122
- */
123
- checkRateLimit() {
124
- const now = Date.now();
125
- const elapsed = now - this._rateLastRefill;
126
-
127
- // Refill tokens based on elapsed time
128
- if (elapsed >= RATE_LIMIT_REFILL_MS) {
129
- this._rateTokens = RATE_LIMIT_TOKENS;
130
- this._rateLastRefill = now;
131
- }
132
-
133
- if (this._rateTokens <= 0) {
134
- return false;
135
- }
136
-
137
- this._rateTokens--;
138
- return true;
139
- }
140
-
141
- /**
142
- * Send a message to the dashboard
143
- * @param {Object} message - Message object
144
- */
145
- send(message) {
146
- if (this.ws && this.ws.writable) {
147
- try {
148
- const frame = encodeWebSocketFrame(getProtocol().serializeMessage(message));
149
- this.ws.write(frame);
150
- this.lastActivity = new Date();
151
- } catch (error) {
152
- console.error(`[Session ${this.id}] Send error:`, error.message);
153
- }
154
- }
155
- }
156
-
157
- /**
158
- * Add a message to conversation history
159
- * @param {string} role - 'user' or 'assistant'
160
- * @param {string} content - Message content
161
- */
162
- addMessage(role, content) {
163
- this.messages.push({
164
- role,
165
- content,
166
- timestamp: new Date().toISOString(),
167
- });
168
- this.lastActivity = new Date();
169
- }
170
-
171
- /**
172
- * Get conversation history
173
- * @returns {Array}
174
- */
175
- getHistory() {
176
- return this.messages;
177
- }
178
-
179
- /**
180
- * Update session state
181
- * @param {string} state - New state (connected, thinking, idle, error)
182
- */
183
- setState(state) {
184
- this.state = state;
185
- this.send(
186
- getProtocol().createSessionState(this.id, state, {
187
- messageCount: this.messages.length,
188
- lastActivity: this.lastActivity.toISOString(),
189
- })
190
- );
191
- }
192
- }
193
-
194
- /**
195
- * Terminal instance for integrated terminal
196
- */
197
- class TerminalInstance {
198
- constructor(id, session, options = {}) {
199
- this.id = id;
200
- this.session = session;
201
- this.cwd = options.cwd || session.projectRoot;
202
- this.shell = options.shell || this.getDefaultShell();
203
- this.cols = options.cols || 80;
204
- this.rows = options.rows || 24;
205
- this.pty = null;
206
- this.closed = false;
207
- }
208
-
209
- /**
210
- * Get the default shell for the current OS
211
- */
212
- getDefaultShell() {
213
- if (process.platform === 'win32') {
214
- return process.env.COMSPEC || 'cmd.exe';
215
- }
216
- return process.env.SHELL || '/bin/bash';
217
- }
218
-
219
- /**
220
- * Start the terminal process
221
- */
222
- /**
223
- * Get a filtered copy of environment variables with secrets removed
224
- */
225
- _getFilteredEnv() {
226
- const filtered = {};
227
- for (const [key, value] of Object.entries(process.env)) {
228
- if (!SENSITIVE_ENV_PATTERNS.test(key)) {
229
- filtered[key] = value;
230
- }
231
- }
232
- return filtered;
233
- }
234
-
235
- start() {
236
- try {
237
- // Try to use node-pty for proper PTY support
238
- const pty = require('node-pty');
239
- const filteredEnv = this._getFilteredEnv();
240
-
241
- this.pty = pty.spawn(this.shell, [], {
242
- name: 'xterm-256color',
243
- cols: this.cols,
244
- rows: this.rows,
245
- cwd: this.cwd,
246
- env: {
247
- ...filteredEnv,
248
- TERM: 'xterm-256color',
249
- COLORTERM: 'truecolor',
250
- },
251
- });
252
-
253
- this.pty.onData(data => {
254
- if (!this.closed) {
255
- this.session.send(getProtocol().createTerminalOutput(this.id, data));
256
- }
257
- });
258
-
259
- this.pty.onExit(({ exitCode }) => {
260
- this.closed = true;
261
- this.session.send(getProtocol().createTerminalExit(this.id, exitCode));
262
- });
263
-
264
- return true;
265
- } catch (error) {
266
- // Fallback to basic spawn if node-pty is not available
267
- console.warn('[Terminal] node-pty not available, using basic spawn:', error.message);
268
- return this.startBasicShell();
269
- }
270
- }
271
-
272
- /**
273
- * Fallback shell using basic spawn (no PTY)
274
- * Note: This provides limited functionality without node-pty
275
- */
276
- startBasicShell() {
277
- try {
278
- const filteredEnv = this._getFilteredEnv();
279
-
280
- // Use bash with interactive flag for better compatibility
281
- this.pty = getChildProcess().spawn(this.shell, ['-i'], {
282
- cwd: this.cwd,
283
- env: {
284
- ...filteredEnv,
285
- TERM: 'dumb',
286
- PS1: '\\w $ ', // Simple prompt
287
- },
288
- shell: false,
289
- stdio: ['pipe', 'pipe', 'pipe'],
290
- });
291
-
292
- // Track input buffer for local echo (since no PTY)
293
- this.inputBuffer = '';
294
-
295
- this.pty.stdout.on('data', data => {
296
- if (!this.closed) {
297
- this.session.send(getProtocol().createTerminalOutput(this.id, data.toString()));
298
- }
299
- });
300
-
301
- this.pty.stderr.on('data', data => {
302
- if (!this.closed) {
303
- this.session.send(getProtocol().createTerminalOutput(this.id, data.toString()));
304
- }
305
- });
306
-
307
- this.pty.on('close', exitCode => {
308
- this.closed = true;
309
- this.session.send(getProtocol().createTerminalExit(this.id, exitCode));
310
- });
311
-
312
- this.pty.on('error', error => {
313
- console.error('[Terminal] Shell error:', error.message);
314
- if (!this.closed) {
315
- this.session.send(
316
- getProtocol().createTerminalOutput(this.id, `\r\nError: ${error.message}\r\n`)
317
- );
318
- }
319
- });
320
-
321
- // Send welcome message
322
- setTimeout(() => {
323
- if (!this.closed) {
324
- const welcomeMsg = `\x1b[32mAgileFlow Terminal\x1b[0m (basic mode - node-pty not available)\r\n`;
325
- const cwdMsg = `Working directory: ${this.cwd}\r\n\r\n`;
326
- this.session.send(getProtocol().createTerminalOutput(this.id, welcomeMsg + cwdMsg));
327
- }
328
- }, 100);
329
-
330
- return true;
331
- } catch (error) {
332
- console.error('[Terminal] Failed to start basic shell:', error.message);
333
- return false;
334
- }
335
- }
336
-
337
- /**
338
- * Write data to the terminal
339
- * @param {string} data - Data to write (user input)
340
- */
341
- write(data) {
342
- if (this.pty && !this.closed) {
343
- if (this.pty.write) {
344
- // node-pty style - has built-in echo
345
- this.pty.write(data);
346
- } else if (this.pty.stdin) {
347
- // basic spawn style - need manual echo
348
- // Echo the input back to the terminal (since no PTY)
349
- let echoData = data;
350
-
351
- // Handle special characters
352
- if (data === '\r' || data === '\n') {
353
- echoData = '\r\n';
354
- } else if (data === '\x7f' || data === '\b') {
355
- // Backspace - move cursor back and clear
356
- echoData = '\b \b';
357
- } else if (data === '\x03') {
358
- // Ctrl+C
359
- echoData = '^C\r\n';
360
- }
361
-
362
- // Echo to terminal
363
- this.session.send(getProtocol().createTerminalOutput(this.id, echoData));
364
-
365
- // Send to shell stdin
366
- this.pty.stdin.write(data);
367
- }
368
- }
369
- }
370
-
371
- /**
372
- * Resize the terminal
373
- * @param {number} cols - New column count
374
- * @param {number} rows - New row count
375
- */
376
- resize(cols, rows) {
377
- this.cols = cols;
378
- this.rows = rows;
379
- if (this.pty && this.pty.resize && !this.closed) {
380
- this.pty.resize(cols, rows);
381
- }
382
- }
383
-
384
- /**
385
- * Close the terminal
386
- */
387
- close() {
388
- this.closed = true;
389
- if (this.pty) {
390
- if (this.pty.kill) {
391
- this.pty.kill();
392
- } else if (this.pty.destroy) {
393
- this.pty.destroy();
394
- }
395
- }
396
- }
397
- }
398
-
399
- /**
400
- * Terminal manager for handling multiple terminals per session
401
- */
402
- class TerminalManager {
403
- constructor() {
404
- this.terminals = new Map();
405
- }
406
-
407
- /**
408
- * Create a new terminal for a session
409
- * @param {DashboardSession} session - The session
410
- * @param {Object} options - Terminal options
411
- * @returns {string} - Terminal ID
412
- */
413
- createTerminal(session, options = {}) {
414
- const terminalId = options.id || getCrypto().randomBytes(8).toString('hex');
415
- const terminal = new TerminalInstance(terminalId, session, {
416
- cwd: options.cwd || session.projectRoot,
417
- cols: options.cols,
418
- rows: options.rows,
419
- });
420
-
421
- if (terminal.start()) {
422
- this.terminals.set(terminalId, terminal);
423
- console.log(`[Terminal ${terminalId}] Created for session ${session.id}`);
424
- return terminalId;
425
- }
426
-
427
- return null;
428
- }
429
-
430
- /**
431
- * Get a terminal by ID
432
- * @param {string} terminalId - Terminal ID
433
- * @returns {TerminalInstance | undefined}
434
- */
435
- getTerminal(terminalId) {
436
- return this.terminals.get(terminalId);
437
- }
438
-
439
- /**
440
- * Write to a terminal
441
- * @param {string} terminalId - Terminal ID
442
- * @param {string} data - Data to write
443
- */
444
- writeToTerminal(terminalId, data) {
445
- const terminal = this.terminals.get(terminalId);
446
- if (terminal) {
447
- terminal.write(data);
448
- }
449
- }
450
-
451
- /**
452
- * Resize a terminal
453
- * @param {string} terminalId - Terminal ID
454
- * @param {number} cols - New columns
455
- * @param {number} rows - New rows
456
- */
457
- resizeTerminal(terminalId, cols, rows) {
458
- const terminal = this.terminals.get(terminalId);
459
- if (terminal) {
460
- terminal.resize(cols, rows);
461
- }
462
- }
463
-
464
- /**
465
- * Close a terminal
466
- * @param {string} terminalId - Terminal ID
467
- */
468
- closeTerminal(terminalId) {
469
- const terminal = this.terminals.get(terminalId);
470
- if (terminal) {
471
- terminal.close();
472
- this.terminals.delete(terminalId);
473
- console.log(`[Terminal ${terminalId}] Closed`);
474
- }
475
- }
476
-
477
- /**
478
- * Close all terminals for a session
479
- * @param {string} sessionId - Session ID
480
- */
481
- closeSessionTerminals(sessionId) {
482
- for (const [terminalId, terminal] of this.terminals) {
483
- if (terminal.session.id === sessionId) {
484
- terminal.close();
485
- this.terminals.delete(terminalId);
486
- }
487
- }
488
- }
489
-
490
- /**
491
- * Get all terminals for a session
492
- * @param {string} sessionId - Session ID
493
- * @returns {Array<string>} - Terminal IDs
494
- */
495
- getSessionTerminals(sessionId) {
496
- const terminalIds = [];
497
- for (const [terminalId, terminal] of this.terminals) {
498
- if (terminal.session.id === sessionId) {
499
- terminalIds.push(terminalId);
500
- }
501
- }
502
- return terminalIds;
503
- }
504
- }
505
-
506
102
  /**
507
103
  * Dashboard WebSocket Server
508
104
  */
@@ -592,31 +188,11 @@ class DashboardServer extends EventEmitter {
592
188
 
593
189
  /**
594
190
  * Add an automation result to the inbox
595
- * @param {string} automationId - Automation ID
596
- * @param {Object} result - Run result
597
191
  */
598
192
  _addToInbox(automationId, result) {
599
193
  const automation = this._automationRegistry?.get(automationId);
600
- const itemId = `inbox_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
601
-
602
- const item = {
603
- id: itemId,
604
- automationId,
605
- title: automation?.name || automationId,
606
- summary: result.success
607
- ? result.output?.slice(0, 200) || 'Completed successfully'
608
- : result.error?.slice(0, 200) || 'Failed',
609
- timestamp: new Date().toISOString(),
610
- status: 'unread',
611
- result: {
612
- success: result.success,
613
- output: result.output,
614
- error: result.error,
615
- duration_ms: result.duration_ms,
616
- },
617
- };
618
-
619
- this._inbox.set(itemId, item);
194
+ const item = createInboxItem(automationId, result, automation?.name);
195
+ this._inbox.set(item.id, item);
620
196
  this.broadcast(getProtocol().createInboxItem(item));
621
197
  }
622
198
 
@@ -803,7 +379,11 @@ class DashboardServer extends EventEmitter {
803
379
  session = new DashboardSession(sessionId, socket, this.projectRoot);
804
380
  this.sessions.set(sessionId, session);
805
381
  } else {
806
- // Update socket for resumed session
382
+ // Clean up old socket before replacing
383
+ if (session.ws && session.ws !== socket) {
384
+ session.ws.removeAllListeners();
385
+ session.ws.destroy();
386
+ }
807
387
  session.ws = socket;
808
388
  }
809
389
 
@@ -1044,78 +624,18 @@ class DashboardServer extends EventEmitter {
1044
624
  }
1045
625
  }
1046
626
 
627
+ // ==========================================================================
628
+ // Git Handlers (delegating to dashboard-git.js)
629
+ // ==========================================================================
630
+
1047
631
  /**
1048
632
  * Handle git actions
1049
633
  */
1050
634
  handleGitAction(session, message) {
1051
635
  const { type, files, message: commitMessage } = message;
1052
636
 
1053
- // Validate file paths - reject path traversal attempts
1054
- if (files && files.length > 0) {
1055
- for (const f of files) {
1056
- if (typeof f !== 'string' || f.includes('\0')) {
1057
- session.send(getProtocol().createError('GIT_ERROR', 'Invalid file path'));
1058
- return;
1059
- }
1060
- const resolved = require('path').resolve(this.projectRoot, f);
1061
- if (!resolved.startsWith(this.projectRoot)) {
1062
- session.send(getProtocol().createError('GIT_ERROR', 'File path outside project'));
1063
- return;
1064
- }
1065
- }
1066
- }
1067
-
1068
- // Validate commit message
1069
- if (commitMessage !== undefined && commitMessage !== null) {
1070
- if (
1071
- typeof commitMessage !== 'string' ||
1072
- commitMessage.length > 10000 ||
1073
- commitMessage.includes('\0')
1074
- ) {
1075
- session.send(getProtocol().createError('GIT_ERROR', 'Invalid commit message'));
1076
- return;
1077
- }
1078
- }
1079
-
1080
- const fileArgs = files && files.length > 0 ? files : null;
1081
-
1082
637
  try {
1083
- switch (type) {
1084
- case getProtocol().InboundMessageType.GIT_STAGE:
1085
- if (fileArgs) {
1086
- getChildProcess().execFileSync('git', ['add', '--', ...fileArgs], {
1087
- cwd: this.projectRoot,
1088
- });
1089
- } else {
1090
- getChildProcess().execFileSync('git', ['add', '-A'], { cwd: this.projectRoot });
1091
- }
1092
- break;
1093
- case getProtocol().InboundMessageType.GIT_UNSTAGE:
1094
- if (fileArgs) {
1095
- getChildProcess().execFileSync('git', ['restore', '--staged', '--', ...fileArgs], {
1096
- cwd: this.projectRoot,
1097
- });
1098
- } else {
1099
- getChildProcess().execFileSync('git', ['restore', '--staged', '.'], {
1100
- cwd: this.projectRoot,
1101
- });
1102
- }
1103
- break;
1104
- case getProtocol().InboundMessageType.GIT_REVERT:
1105
- if (fileArgs) {
1106
- getChildProcess().execFileSync('git', ['checkout', '--', ...fileArgs], {
1107
- cwd: this.projectRoot,
1108
- });
1109
- }
1110
- break;
1111
- case getProtocol().InboundMessageType.GIT_COMMIT:
1112
- if (commitMessage) {
1113
- getChildProcess().execFileSync('git', ['commit', '-m', commitMessage], {
1114
- cwd: this.projectRoot,
1115
- });
1116
- }
1117
- break;
1118
- }
638
+ handleGitAction(type, this.projectRoot, { files, commitMessage }, getProtocol());
1119
639
 
1120
640
  // Send updated git status
1121
641
  this.sendGitStatus(session);
@@ -1124,7 +644,7 @@ class DashboardServer extends EventEmitter {
1124
644
  );
1125
645
  } catch (error) {
1126
646
  console.error('[Git Error]', error.message);
1127
- session.send(getProtocol().createError('GIT_ERROR', 'Git operation failed'));
647
+ session.send(getProtocol().createError('GIT_ERROR', error.message || 'Git operation failed'));
1128
648
  }
1129
649
  }
1130
650
 
@@ -1133,7 +653,7 @@ class DashboardServer extends EventEmitter {
1133
653
  */
1134
654
  sendGitStatus(session) {
1135
655
  try {
1136
- const status = this.getGitStatus();
656
+ const status = getGitStatus(this.projectRoot);
1137
657
  session.send({
1138
658
  type: getProtocol().OutboundMessageType.GIT_STATUS,
1139
659
  ...status,
@@ -1144,71 +664,6 @@ class DashboardServer extends EventEmitter {
1144
664
  }
1145
665
  }
1146
666
 
1147
- /**
1148
- * Get current git status
1149
- */
1150
- getGitStatus() {
1151
- try {
1152
- const branch = getChildProcess()
1153
- .execFileSync('git', ['branch', '--show-current'], {
1154
- cwd: this.projectRoot,
1155
- encoding: 'utf8',
1156
- stdio: ['pipe', 'pipe', 'pipe'],
1157
- })
1158
- .trim();
1159
-
1160
- const statusOutput = getChildProcess().execFileSync('git', ['status', '--porcelain'], {
1161
- cwd: this.projectRoot,
1162
- encoding: 'utf8',
1163
- stdio: ['pipe', 'pipe', 'pipe'],
1164
- });
1165
-
1166
- const staged = [];
1167
- const unstaged = [];
1168
-
1169
- for (const line of statusOutput.split('\n').filter(Boolean)) {
1170
- const indexStatus = line[0];
1171
- const workTreeStatus = line[1];
1172
- const file = line.slice(3);
1173
-
1174
- // Parse the status character to a descriptive status
1175
- const parseStatus = char => {
1176
- switch (char) {
1177
- case 'A':
1178
- return 'added';
1179
- case 'M':
1180
- return 'modified';
1181
- case 'D':
1182
- return 'deleted';
1183
- case 'R':
1184
- return 'renamed';
1185
- case 'C':
1186
- return 'copied';
1187
- case '?':
1188
- return 'untracked';
1189
- default:
1190
- return 'modified';
1191
- }
1192
- };
1193
-
1194
- if (indexStatus !== ' ' && indexStatus !== '?') {
1195
- staged.push({ path: file, file, status: parseStatus(indexStatus) });
1196
- }
1197
- if (workTreeStatus !== ' ') {
1198
- unstaged.push({
1199
- path: file,
1200
- file,
1201
- status: workTreeStatus === '?' ? 'untracked' : parseStatus(workTreeStatus),
1202
- });
1203
- }
1204
- }
1205
-
1206
- return { branch, staged, unstaged };
1207
- } catch {
1208
- return { branch: 'unknown', staged: [], unstaged: [] };
1209
- }
1210
- }
1211
-
1212
667
  /**
1213
668
  * Handle diff request for a file
1214
669
  */
@@ -1221,8 +676,8 @@ class DashboardServer extends EventEmitter {
1221
676
  }
1222
677
 
1223
678
  try {
1224
- const diff = this.getFileDiff(filePath, staged);
1225
- const stats = this.parseDiffStats(diff);
679
+ const diff = getFileDiff(filePath, this.projectRoot, staged);
680
+ const stats = parseDiffStats(diff);
1226
681
 
1227
682
  session.send(
1228
683
  getProtocol().createGitDiff(filePath, diff, {
@@ -1237,123 +692,19 @@ class DashboardServer extends EventEmitter {
1237
692
  }
1238
693
  }
1239
694
 
1240
- /**
1241
- * Get diff for a specific file
1242
- * @param {string} filePath - Path to the file
1243
- * @param {boolean} staged - Whether to get staged diff
1244
- * @returns {string} - The diff content
1245
- */
1246
- getFileDiff(filePath, staged = false) {
1247
- // Validate filePath stays within project root
1248
- const pathResult = getValidatePaths().validatePath(filePath, this.projectRoot, {
1249
- allowSymlinks: true,
1250
- });
1251
- if (!pathResult.ok) {
1252
- return '';
1253
- }
1254
-
1255
- try {
1256
- const diffArgs = staged ? ['diff', '--cached', '--', filePath] : ['diff', '--', filePath];
1257
-
1258
- const diff = getChildProcess().execFileSync('git', diffArgs, {
1259
- cwd: this.projectRoot,
1260
- encoding: 'utf8',
1261
- });
1262
-
1263
- // If no diff, file might be untracked - show entire file content as addition
1264
- if (!diff && !staged) {
1265
- const statusOutput = getChildProcess()
1266
- .execFileSync('git', ['status', '--porcelain', '--', filePath], {
1267
- cwd: this.projectRoot,
1268
- encoding: 'utf8',
1269
- })
1270
- .trim();
1271
-
1272
- // Check if file is untracked
1273
- if (statusOutput.startsWith('??')) {
1274
- try {
1275
- const content = require('fs').readFileSync(
1276
- require('path').join(this.projectRoot, filePath),
1277
- 'utf8'
1278
- );
1279
- // Format as a new file diff
1280
- const lines = content.split('\n');
1281
- return [
1282
- `diff --git a/${filePath} b/${filePath}`,
1283
- `new file mode 100644`,
1284
- `--- /dev/null`,
1285
- `+++ b/${filePath}`,
1286
- `@@ -0,0 +1,${lines.length} @@`,
1287
- ...lines.map(line => `+${line}`),
1288
- ].join('\n');
1289
- } catch {
1290
- return '';
1291
- }
1292
- }
1293
- }
1294
-
1295
- return diff;
1296
- } catch (error) {
1297
- console.error('[Diff Error]', error.message);
1298
- return '';
1299
- }
1300
- }
1301
-
1302
- /**
1303
- * Parse diff statistics from diff content
1304
- * @param {string} diff - The diff content
1305
- * @returns {{ additions: number, deletions: number }}
1306
- */
1307
- parseDiffStats(diff) {
1308
- let additions = 0;
1309
- let deletions = 0;
1310
-
1311
- for (const line of diff.split('\n')) {
1312
- if (line.startsWith('+') && !line.startsWith('+++')) {
1313
- additions++;
1314
- } else if (line.startsWith('-') && !line.startsWith('---')) {
1315
- deletions++;
1316
- }
1317
- }
1318
-
1319
- return { additions, deletions };
1320
- }
695
+ // ==========================================================================
696
+ // Status/Metrics Handlers (delegating to dashboard-status.js)
697
+ // ==========================================================================
1321
698
 
1322
699
  /**
1323
700
  * Send project status update (stories/epics summary) to session
1324
701
  */
1325
702
  sendStatusUpdate(session) {
1326
- const path = require('path');
1327
- const fs = require('fs');
1328
- const statusPath = path.join(this.projectRoot, 'docs', '09-agents', 'status.json');
1329
- if (!fs.existsSync(statusPath)) return;
1330
-
1331
703
  try {
1332
- const data = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
1333
- const stories = data.stories || {};
1334
- const epics = data.epics || {};
1335
-
1336
- const storyValues = Object.values(stories);
1337
- const summary = {
1338
- total: storyValues.length,
1339
- done: storyValues.filter(s => s.status === 'done' || s.status === 'completed').length,
1340
- inProgress: storyValues.filter(s => s.status === 'in-progress').length,
1341
- ready: storyValues.filter(s => s.status === 'ready').length,
1342
- blocked: storyValues.filter(s => s.status === 'blocked').length,
1343
- epics: Object.entries(epics).map(([id, e]) => ({
1344
- id,
1345
- title: e.title || id,
1346
- status: e.status || 'unknown',
1347
- storyCount: (e.stories || []).length,
1348
- doneCount: (e.stories || []).filter(
1349
- sid =>
1350
- stories[sid] &&
1351
- (stories[sid].status === 'done' || stories[sid].status === 'completed')
1352
- ).length,
1353
- })),
1354
- };
1355
-
1356
- session.send(getProtocol().createStatusUpdate(summary));
704
+ const summary = buildStatusSummary(this.projectRoot);
705
+ if (summary) {
706
+ session.send(getProtocol().createStatusUpdate(summary));
707
+ }
1357
708
  } catch (error) {
1358
709
  console.error('[Status Update Error]', error.message);
1359
710
  }
@@ -1365,9 +716,10 @@ class DashboardServer extends EventEmitter {
1365
716
  _initTeamMetricsListener() {
1366
717
  try {
1367
718
  const { teamMetricsEmitter } = require('../scripts/lib/team-events');
1368
- teamMetricsEmitter.on('metrics_saved', () => {
719
+ this._teamMetricsListener = () => {
1369
720
  this.broadcastTeamMetrics();
1370
- });
721
+ };
722
+ teamMetricsEmitter.on('metrics_saved', this._teamMetricsListener);
1371
723
  } catch (e) {
1372
724
  // team-events not available - non-critical
1373
725
  }
@@ -1377,20 +729,9 @@ class DashboardServer extends EventEmitter {
1377
729
  * Send team metrics to a single session
1378
730
  */
1379
731
  sendTeamMetrics(session) {
1380
- const path = require('path');
1381
- const fs = require('fs');
1382
- const sessionStatePath = path.join(this.projectRoot, 'docs', '09-agents', 'session-state.json');
1383
- if (!fs.existsSync(sessionStatePath)) return;
1384
-
1385
- try {
1386
- const state = JSON.parse(fs.readFileSync(sessionStatePath, 'utf8'));
1387
- const traces = (state.team_metrics && state.team_metrics.traces) || {};
1388
-
1389
- for (const [traceId, metrics] of Object.entries(traces)) {
1390
- session.send(getProtocol().createTeamMetrics(traceId, metrics));
1391
- }
1392
- } catch (error) {
1393
- // Non-critical
732
+ const traces = readTeamMetrics(this.projectRoot);
733
+ for (const [traceId, metrics] of Object.entries(traces)) {
734
+ session.send(getProtocol().createTeamMetrics(traceId, metrics));
1394
735
  }
1395
736
  }
1396
737
 
@@ -1398,20 +739,9 @@ class DashboardServer extends EventEmitter {
1398
739
  * Broadcast team metrics to all connected clients
1399
740
  */
1400
741
  broadcastTeamMetrics() {
1401
- const path = require('path');
1402
- const fs = require('fs');
1403
- const sessionStatePath = path.join(this.projectRoot, 'docs', '09-agents', 'session-state.json');
1404
- if (!fs.existsSync(sessionStatePath)) return;
1405
-
1406
- try {
1407
- const state = JSON.parse(fs.readFileSync(sessionStatePath, 'utf8'));
1408
- const traces = (state.team_metrics && state.team_metrics.traces) || {};
1409
-
1410
- for (const [traceId, metrics] of Object.entries(traces)) {
1411
- this.broadcast(getProtocol().createTeamMetrics(traceId, metrics));
1412
- }
1413
- } catch (error) {
1414
- // Non-critical
742
+ const traces = readTeamMetrics(this.projectRoot);
743
+ for (const [traceId, metrics] of Object.entries(traces)) {
744
+ this.broadcast(getProtocol().createTeamMetrics(traceId, metrics));
1415
745
  }
1416
746
  }
1417
747
 
@@ -1549,6 +879,10 @@ class DashboardServer extends EventEmitter {
1549
879
  }
1550
880
  }
1551
881
 
882
+ // ==========================================================================
883
+ // Terminal Handlers (delegating to dashboard-terminal.js)
884
+ // ==========================================================================
885
+
1552
886
  /**
1553
887
  * Handle terminal spawn request
1554
888
  */
@@ -1631,7 +965,7 @@ class DashboardServer extends EventEmitter {
1631
965
  }
1632
966
 
1633
967
  // ==========================================================================
1634
- // Automation Handlers
968
+ // Automation Handlers (delegating to dashboard-automations.js)
1635
969
  // ==========================================================================
1636
970
 
1637
971
  /**
@@ -1644,23 +978,12 @@ class DashboardServer extends EventEmitter {
1644
978
  }
1645
979
 
1646
980
  try {
1647
- const automations = this._automationRegistry.list();
1648
-
1649
- // Enrich with running status and next run time
1650
- const enriched = automations.map(automation => {
1651
- const isRunning = this._runningAutomations.has(automation.id);
1652
- const lastRun = this._automationRegistry.getRunHistory(automation.id, 1)[0];
1653
- const nextRun = this._calculateNextRun(automation);
1654
-
1655
- return {
1656
- ...automation,
1657
- status: isRunning ? 'running' : automation.enabled ? 'idle' : 'disabled',
1658
- lastRun: lastRun?.at,
1659
- lastRunSuccess: lastRun?.success,
1660
- nextRun,
1661
- };
1662
- });
1663
-
981
+ const automations = this._automationRegistry.list() || [];
982
+ const enriched = enrichAutomationList(
983
+ automations,
984
+ this._runningAutomations,
985
+ this._automationRegistry
986
+ );
1664
987
  session.send(getProtocol().createAutomationList(enriched));
1665
988
  } catch (error) {
1666
989
  console.error('[Automations] List error:', error.message);
@@ -1668,65 +991,6 @@ class DashboardServer extends EventEmitter {
1668
991
  }
1669
992
  }
1670
993
 
1671
- /**
1672
- * Calculate next run time for an automation
1673
- */
1674
- _calculateNextRun(automation) {
1675
- if (!automation.enabled || !automation.schedule) return null;
1676
-
1677
- const now = new Date();
1678
- const schedule = automation.schedule;
1679
-
1680
- switch (schedule.type) {
1681
- case 'on_session':
1682
- return 'Every session';
1683
- case 'daily': {
1684
- // Next day at midnight (or specified hour)
1685
- const nextDaily = new Date(now);
1686
- nextDaily.setDate(nextDaily.getDate() + 1);
1687
- nextDaily.setHours(schedule.hour || 0, 0, 0, 0);
1688
- return nextDaily.toISOString();
1689
- }
1690
- case 'weekly': {
1691
- // Next occurrence of the specified day
1692
- const targetDay =
1693
- typeof schedule.day === 'string'
1694
- ? [
1695
- 'sunday',
1696
- 'monday',
1697
- 'tuesday',
1698
- 'wednesday',
1699
- 'thursday',
1700
- 'friday',
1701
- 'saturday',
1702
- ].indexOf(schedule.day.toLowerCase())
1703
- : schedule.day || 0;
1704
- const nextWeekly = new Date(now);
1705
- const daysUntil = (targetDay - now.getDay() + 7) % 7 || 7;
1706
- nextWeekly.setDate(nextWeekly.getDate() + daysUntil);
1707
- nextWeekly.setHours(schedule.hour || 0, 0, 0, 0);
1708
- return nextWeekly.toISOString();
1709
- }
1710
- case 'monthly': {
1711
- // Next occurrence of the specified date
1712
- const nextMonthly = new Date(now);
1713
- const targetDate = schedule.date || 1;
1714
- if (now.getDate() >= targetDate) {
1715
- nextMonthly.setMonth(nextMonthly.getMonth() + 1);
1716
- }
1717
- nextMonthly.setDate(targetDate);
1718
- nextMonthly.setHours(schedule.hour || 0, 0, 0, 0);
1719
- return nextMonthly.toISOString();
1720
- }
1721
- case 'interval': {
1722
- const hours = schedule.hours || 24;
1723
- return `Every ${hours} hour${hours > 1 ? 's' : ''}`;
1724
- }
1725
- default:
1726
- return null;
1727
- }
1728
- }
1729
-
1730
994
  /**
1731
995
  * Handle automation run request
1732
996
  */
@@ -1758,6 +1022,9 @@ class DashboardServer extends EventEmitter {
1758
1022
  return;
1759
1023
  }
1760
1024
 
1025
+ // Mark as running BEFORE the async call to prevent duplicate execution
1026
+ this._runningAutomations.set(automationId, { startTime: Date.now() });
1027
+
1761
1028
  session.send(
1762
1029
  getProtocol().createNotification('info', 'Automation', `Starting ${automationId}...`)
1763
1030
  );
@@ -1826,17 +1093,14 @@ class DashboardServer extends EventEmitter {
1826
1093
  }
1827
1094
 
1828
1095
  // ==========================================================================
1829
- // Inbox Handlers
1096
+ // Inbox Handlers (delegating to dashboard-inbox.js)
1830
1097
  // ==========================================================================
1831
1098
 
1832
1099
  /**
1833
1100
  * Send inbox list to session
1834
1101
  */
1835
1102
  sendInboxList(session) {
1836
- const items = Array.from(this._inbox.values()).sort(
1837
- (a, b) => new Date(b.timestamp) - new Date(a.timestamp)
1838
- );
1839
-
1103
+ const items = getSortedInboxItems(this._inbox);
1840
1104
  session.send(getProtocol().createInboxList(items));
1841
1105
  }
1842
1106
 
@@ -1851,53 +1115,47 @@ class DashboardServer extends EventEmitter {
1851
1115
  return;
1852
1116
  }
1853
1117
 
1854
- const item = this._inbox.get(itemId);
1855
- if (!item) {
1856
- session.send(getProtocol().createError('NOT_FOUND', `Inbox item ${itemId} not found`));
1118
+ const result = handleInboxAction(this._inbox, itemId, action);
1119
+
1120
+ if (!result.success) {
1121
+ const errorCode = result.error.includes('not found') ? 'NOT_FOUND' : 'INVALID_ACTION';
1122
+ session.send(getProtocol().createError(errorCode, result.error));
1857
1123
  return;
1858
1124
  }
1859
1125
 
1860
- switch (action) {
1861
- case 'accept':
1862
- // Mark as accepted and remove
1863
- item.status = 'accepted';
1864
- session.send(
1865
- getProtocol().createNotification('success', 'Inbox', `Accepted: ${item.title}`)
1866
- );
1867
- this._inbox.delete(itemId);
1868
- break;
1869
-
1870
- case 'dismiss':
1871
- // Mark as dismissed and remove
1872
- item.status = 'dismissed';
1873
- session.send(getProtocol().createNotification('info', 'Inbox', `Dismissed: ${item.title}`));
1874
- this._inbox.delete(itemId);
1875
- break;
1876
-
1877
- case 'read':
1878
- // Mark as read
1879
- item.status = 'read';
1880
- break;
1881
-
1882
- default:
1883
- session.send(getProtocol().createError('INVALID_ACTION', `Unknown action: ${action}`));
1884
- return;
1126
+ if (result.notification) {
1127
+ session.send(
1128
+ getProtocol().createNotification(
1129
+ result.notification.level,
1130
+ 'Inbox',
1131
+ result.notification.message
1132
+ )
1133
+ );
1885
1134
  }
1886
1135
 
1887
1136
  // Send updated inbox list
1888
1137
  this.sendInboxList(session);
1889
1138
  }
1890
1139
 
1140
+ // ==========================================================================
1141
+ // Session Lifecycle
1142
+ // ==========================================================================
1143
+
1891
1144
  /**
1892
1145
  * Cleanup expired sessions
1893
1146
  */
1894
1147
  _cleanupExpiredSessions() {
1148
+ // Collect expired IDs first to avoid mutating Map during iteration
1149
+ const expiredIds = [];
1895
1150
  for (const [sessionId, session] of this.sessions) {
1896
1151
  if (session.isExpired()) {
1897
- console.log(`[Session ${sessionId}] Expired (idle > ${SESSION_TIMEOUT_MS / 3600000}h)`);
1898
- this.closeSession(sessionId);
1152
+ expiredIds.push(sessionId);
1899
1153
  }
1900
1154
  }
1155
+ for (const sessionId of expiredIds) {
1156
+ console.log(`[Session ${sessionId}] Expired (idle > ${SESSION_TIMEOUT_MS / 3600000}h)`);
1157
+ this.closeSession(sessionId);
1158
+ }
1901
1159
  }
1902
1160
 
1903
1161
  /**
@@ -1941,6 +1199,17 @@ class DashboardServer extends EventEmitter {
1941
1199
  */
1942
1200
  stop() {
1943
1201
  return new Promise(resolve => {
1202
+ // Remove team metrics listener to prevent leak
1203
+ if (this._teamMetricsListener) {
1204
+ try {
1205
+ const { teamMetricsEmitter } = require('../scripts/lib/team-events');
1206
+ teamMetricsEmitter.removeListener('metrics_saved', this._teamMetricsListener);
1207
+ } catch (e) {
1208
+ // Ignore if module not available
1209
+ }
1210
+ this._teamMetricsListener = null;
1211
+ }
1212
+
1944
1213
  // Clear cleanup interval
1945
1214
  if (this._cleanupInterval) {
1946
1215
  clearInterval(this._cleanupInterval);
@@ -1968,85 +1237,6 @@ class DashboardServer extends EventEmitter {
1968
1237
  }
1969
1238
  }
1970
1239
 
1971
- // ============================================================================
1972
- // WebSocket Frame Encoding/Decoding
1973
- // ============================================================================
1974
-
1975
- /**
1976
- * Encode a WebSocket frame
1977
- * @param {string|Buffer} data - Data to encode
1978
- * @param {number} [opcode=0x1] - Frame opcode (0x1 = text, 0x2 = binary)
1979
- * @returns {Buffer}
1980
- */
1981
- function encodeWebSocketFrame(data, opcode = 0x1) {
1982
- const payload = Buffer.isBuffer(data) ? data : Buffer.from(data);
1983
- const length = payload.length;
1984
-
1985
- let header;
1986
- if (length < 126) {
1987
- header = Buffer.alloc(2);
1988
- header[0] = 0x80 | opcode; // FIN + opcode
1989
- header[1] = length;
1990
- } else if (length < 65536) {
1991
- header = Buffer.alloc(4);
1992
- header[0] = 0x80 | opcode;
1993
- header[1] = 126;
1994
- header.writeUInt16BE(length, 2);
1995
- } else {
1996
- header = Buffer.alloc(10);
1997
- header[0] = 0x80 | opcode;
1998
- header[1] = 127;
1999
- header.writeBigUInt64BE(BigInt(length), 2);
2000
- }
2001
-
2002
- return Buffer.concat([header, payload]);
2003
- }
2004
-
2005
- /**
2006
- * Decode a WebSocket frame
2007
- * @param {Buffer} buffer - Buffer containing frame data
2008
- * @returns {{ opcode: number, payload: Buffer, totalLength: number } | null}
2009
- */
2010
- function decodeWebSocketFrame(buffer) {
2011
- if (buffer.length < 2) return null;
2012
-
2013
- const firstByte = buffer[0];
2014
- const secondByte = buffer[1];
2015
-
2016
- const opcode = firstByte & 0x0f;
2017
- const masked = (secondByte & 0x80) !== 0;
2018
- let payloadLength = secondByte & 0x7f;
2019
-
2020
- let headerLength = 2;
2021
- if (payloadLength === 126) {
2022
- if (buffer.length < 4) return null;
2023
- payloadLength = buffer.readUInt16BE(2);
2024
- headerLength = 4;
2025
- } else if (payloadLength === 127) {
2026
- if (buffer.length < 10) return null;
2027
- payloadLength = Number(buffer.readBigUInt64BE(2));
2028
- headerLength = 10;
2029
- }
2030
-
2031
- if (masked) headerLength += 4;
2032
-
2033
- const totalLength = headerLength + payloadLength;
2034
- if (buffer.length < totalLength) return null;
2035
-
2036
- let payload = buffer.slice(headerLength, totalLength);
2037
-
2038
- // Unmask if needed
2039
- if (masked) {
2040
- const mask = buffer.slice(headerLength - 4, headerLength);
2041
- payload = Buffer.alloc(payloadLength);
2042
- for (let i = 0; i < payloadLength; i++) {
2043
- payload[i] = buffer[headerLength + i] ^ mask[i % 4];
2044
- }
2045
- }
2046
-
2047
- return { opcode, payload, totalLength };
2048
- }
2049
-
2050
1240
  // ============================================================================
2051
1241
  // Factory Functions
2052
1242
  // ============================================================================
@@ -2084,7 +1274,7 @@ async function stopDashboardServer(server) {
2084
1274
  }
2085
1275
 
2086
1276
  // ============================================================================
2087
- // Exports
1277
+ // Exports (backward-compatible - re-exports from extracted modules)
2088
1278
  // ============================================================================
2089
1279
 
2090
1280
  module.exports = {