agileflow 3.4.2 → 3.4.3

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 (37) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +2 -2
  3. package/lib/drivers/claude-driver.ts +1 -1
  4. package/lib/lazy-require.js +1 -1
  5. package/package.json +1 -1
  6. package/scripts/agent-loop.js +290 -230
  7. package/scripts/check-sessions.js +116 -0
  8. package/scripts/lib/quality-gates.js +35 -8
  9. package/scripts/lib/signal-detectors.js +0 -13
  10. package/scripts/lib/team-events.js +1 -1
  11. package/scripts/lib/tmux-audit-monitor.js +2 -1
  12. package/src/core/commands/ads/audit.md +19 -3
  13. package/src/core/commands/code/accessibility.md +22 -6
  14. package/src/core/commands/code/api.md +22 -6
  15. package/src/core/commands/code/architecture.md +22 -6
  16. package/src/core/commands/code/completeness.md +22 -6
  17. package/src/core/commands/code/legal.md +22 -6
  18. package/src/core/commands/code/logic.md +22 -6
  19. package/src/core/commands/code/performance.md +22 -6
  20. package/src/core/commands/code/security.md +22 -6
  21. package/src/core/commands/code/test.md +22 -6
  22. package/src/core/commands/ideate/features.md +5 -4
  23. package/src/core/commands/ideate/new.md +8 -7
  24. package/src/core/commands/seo/audit.md +21 -5
  25. package/lib/claude-cli-bridge.js +0 -215
  26. package/lib/dashboard-automations.js +0 -130
  27. package/lib/dashboard-git.js +0 -254
  28. package/lib/dashboard-inbox.js +0 -64
  29. package/lib/dashboard-protocol.js +0 -605
  30. package/lib/dashboard-server.js +0 -1296
  31. package/lib/dashboard-session.js +0 -136
  32. package/lib/dashboard-status.js +0 -72
  33. package/lib/dashboard-terminal.js +0 -354
  34. package/lib/dashboard-websocket.js +0 -88
  35. package/scripts/dashboard-serve.js +0 -336
  36. package/src/core/commands/serve.md +0 -127
  37. package/tools/cli/commands/serve.js +0 -492
@@ -1,1296 +0,0 @@
1
- /* global URL */
2
- /**
3
- * dashboard-server.js - WebSocket Server for AgileFlow Dashboard
4
- *
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
13
- *
14
- * Usage:
15
- * const { createDashboardServer, startDashboardServer } = require('./dashboard-server');
16
- *
17
- * const server = createDashboardServer({ port: 8765 });
18
- * await startDashboardServer(server);
19
- */
20
-
21
- 'use strict';
22
-
23
- const { EventEmitter } = require('events');
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
-
47
- // Lazy-loaded dependencies - deferred until first use
48
- let _http, _crypto, _protocol, _paths, _validatePaths, _childProcess;
49
-
50
- function getHttp() {
51
- if (!_http) _http = require('http');
52
- return _http;
53
- }
54
- function getCrypto() {
55
- if (!_crypto) _crypto = require('crypto');
56
- return _crypto;
57
- }
58
- function getProtocol() {
59
- if (!_protocol) _protocol = require('./dashboard-protocol');
60
- return _protocol;
61
- }
62
- function getPaths() {
63
- if (!_paths) _paths = require('./paths');
64
- return _paths;
65
- }
66
- function getValidatePaths() {
67
- if (!_validatePaths) _validatePaths = require('./validate-paths');
68
- return _validatePaths;
69
- }
70
- function getChildProcess() {
71
- if (!_childProcess) _childProcess = require('child_process');
72
- return _childProcess;
73
- }
74
-
75
- // Lazy-load automation modules to avoid circular dependencies
76
- let AutomationRegistry = null;
77
- let AutomationRunner = null;
78
-
79
- function getAutomationRegistry(rootDir) {
80
- if (!AutomationRegistry) {
81
- const mod = require('../scripts/lib/automation-registry');
82
- AutomationRegistry = mod.getAutomationRegistry;
83
- }
84
- return AutomationRegistry({ rootDir });
85
- }
86
-
87
- function getAutomationRunner(rootDir) {
88
- if (!AutomationRunner) {
89
- const mod = require('../scripts/lib/automation-runner');
90
- AutomationRunner = mod.getAutomationRunner;
91
- }
92
- return AutomationRunner({ rootDir });
93
- }
94
-
95
- // Default configuration
96
- const DEFAULT_PORT = 8765;
97
- const DEFAULT_HOST = '127.0.0.1'; // Localhost only for security
98
-
99
- // WebSocket magic GUID for handshake
100
- const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
101
-
102
- /**
103
- * Dashboard WebSocket Server
104
- */
105
- class DashboardServer extends EventEmitter {
106
- constructor(options = {}) {
107
- super();
108
-
109
- this.port = options.port || DEFAULT_PORT;
110
- this.host = options.host || DEFAULT_HOST;
111
- this.projectRoot = options.projectRoot || getPaths().getProjectRoot();
112
-
113
- // Auth is on by default - auto-generate key if not provided
114
- // Set requireAuth: false explicitly to disable
115
- this.requireAuth = options.requireAuth !== false;
116
- this.apiKey =
117
- options.apiKey || (this.requireAuth ? getCrypto().randomBytes(32).toString('hex') : null);
118
-
119
- // Session management
120
- this.sessions = new Map();
121
-
122
- // Terminal management
123
- this.terminalManager = new TerminalManager();
124
-
125
- // Automation management
126
- this._automationRegistry = null;
127
- this._automationRunner = null;
128
- this._runningAutomations = new Map(); // automationId -> { startTime, session }
129
-
130
- // Inbox management
131
- this._inbox = new Map(); // itemId -> InboxItem
132
-
133
- // Session cleanup interval
134
- this._cleanupInterval = null;
135
-
136
- // HTTP server for WebSocket upgrade
137
- this.httpServer = null;
138
-
139
- // Validate project
140
- if (!getPaths().isAgileflowProject(this.projectRoot)) {
141
- throw new Error(`Not an AgileFlow project: ${this.projectRoot}`);
142
- }
143
-
144
- // Initialize automation registry lazily
145
- this._initAutomations();
146
-
147
- // Listen for team metrics saves to broadcast to clients
148
- this._initTeamMetricsListener();
149
- }
150
-
151
- /**
152
- * Initialize automation registry and runner
153
- */
154
- _initAutomations() {
155
- try {
156
- this._automationRegistry = getAutomationRegistry(this.projectRoot);
157
- this._automationRunner = getAutomationRunner(this.projectRoot);
158
-
159
- // Listen to runner events
160
- this._automationRunner.on('started', ({ automationId }) => {
161
- this._runningAutomations.set(automationId, { startTime: Date.now() });
162
- this.broadcast(getProtocol().createAutomationStatus(automationId, 'running'));
163
- });
164
-
165
- this._automationRunner.on('completed', ({ automationId, result }) => {
166
- this._runningAutomations.delete(automationId);
167
- this.broadcast(getProtocol().createAutomationStatus(automationId, 'completed', result));
168
-
169
- // Add result to inbox if it has output or changes
170
- if (result.output || result.changes) {
171
- this._addToInbox(automationId, result);
172
- }
173
- });
174
-
175
- this._automationRunner.on('failed', ({ automationId, result }) => {
176
- this._runningAutomations.delete(automationId);
177
- this.broadcast(
178
- getProtocol().createAutomationStatus(automationId, 'error', { error: result.error })
179
- );
180
-
181
- // Add failure to inbox
182
- this._addToInbox(automationId, result);
183
- });
184
- } catch (error) {
185
- console.error('[DashboardServer] Failed to init automations:', error.message);
186
- }
187
- }
188
-
189
- /**
190
- * Add an automation result to the inbox
191
- */
192
- _addToInbox(automationId, result) {
193
- const automation = this._automationRegistry?.get(automationId);
194
- const item = createInboxItem(automationId, result, automation?.name);
195
- this._inbox.set(item.id, item);
196
- this.broadcast(getProtocol().createInboxItem(item));
197
- }
198
-
199
- /**
200
- * Start the WebSocket server
201
- * @returns {Promise<{ url: string, wsUrl: string }>}
202
- */
203
- start() {
204
- return new Promise((resolve, reject) => {
205
- const securityHeaders = {
206
- 'Content-Type': 'application/json',
207
- 'X-Content-Type-Options': 'nosniff',
208
- 'X-Frame-Options': 'DENY',
209
- 'Cache-Control': 'no-store',
210
- };
211
-
212
- this.httpServer = getHttp().createServer((req, res) => {
213
- // Simple health check endpoint
214
- if (req.url === '/health') {
215
- res.writeHead(200, securityHeaders);
216
- res.end(
217
- JSON.stringify({
218
- status: 'ok',
219
- sessions: this.sessions.size,
220
- project: require('path').basename(this.projectRoot),
221
- })
222
- );
223
- return;
224
- }
225
-
226
- // Info endpoint
227
- if (req.url === '/') {
228
- res.writeHead(200, securityHeaders);
229
- res.end(
230
- JSON.stringify({
231
- name: 'AgileFlow Dashboard Server',
232
- version: '1.0.0',
233
- ws: `ws://${this.host === '127.0.0.1' ? 'localhost' : this.host}:${this.port}`,
234
- sessions: this.sessions.size,
235
- })
236
- );
237
- return;
238
- }
239
-
240
- res.writeHead(404);
241
- res.end();
242
- });
243
-
244
- // Handle WebSocket upgrade
245
- this.httpServer.on('upgrade', (req, socket, head) => {
246
- this.handleUpgrade(req, socket, head);
247
- });
248
-
249
- this.httpServer.on('error', err => {
250
- if (err.code === 'EADDRINUSE') {
251
- reject(new Error(`Port ${this.port} is already in use`));
252
- } else {
253
- reject(err);
254
- }
255
- });
256
-
257
- this.httpServer.listen(this.port, this.host, () => {
258
- const url = `http://${this.host === '0.0.0.0' ? 'localhost' : this.host}:${this.port}`;
259
- const wsUrl = `ws://${this.host === '0.0.0.0' ? 'localhost' : this.host}:${this.port}`;
260
-
261
- console.log(`\n[AgileFlow Dashboard Server]`);
262
- console.log(` WebSocket: ${wsUrl}`);
263
- console.log(` Health: ${url}/health`);
264
- console.log(` Project: ${this.projectRoot}`);
265
- console.log(` Auth: ${this.requireAuth ? 'Required' : 'Not required'}`);
266
- if (this.requireAuth && this.apiKey) {
267
- console.log(` API Key: ${this.apiKey.slice(0, 8)}...`);
268
- }
269
- console.log('');
270
-
271
- // Start session cleanup interval
272
- this._cleanupInterval = setInterval(() => {
273
- this._cleanupExpiredSessions();
274
- }, SESSION_CLEANUP_INTERVAL_MS);
275
- this._cleanupInterval.unref();
276
-
277
- resolve({ url, wsUrl, apiKey: this.apiKey });
278
- });
279
- });
280
- }
281
-
282
- /**
283
- * Handle WebSocket upgrade request
284
- */
285
- handleUpgrade(req, socket, head) {
286
- // Validate WebSocket upgrade headers
287
- if (req.headers.upgrade?.toLowerCase() !== 'websocket') {
288
- socket.destroy();
289
- return;
290
- }
291
-
292
- // Check API key if required
293
- if (this.requireAuth && this.apiKey) {
294
- const authHeader = req.headers['x-api-key'] || req.headers.authorization;
295
- const providedKey = authHeader?.replace('Bearer ', '') || '';
296
-
297
- // Use timing-safe comparison to prevent timing attacks
298
- const keyBuffer = Buffer.from(this.apiKey, 'utf8');
299
- const providedBuffer = Buffer.from(providedKey, 'utf8');
300
- if (
301
- keyBuffer.length !== providedBuffer.length ||
302
- !getCrypto().timingSafeEqual(keyBuffer, providedBuffer)
303
- ) {
304
- socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
305
- socket.destroy();
306
- return;
307
- }
308
- }
309
-
310
- // Check WebSocket origin against localhost allowlist
311
- const origin = req.headers.origin;
312
- if (origin) {
313
- const LOCALHOST_ORIGINS = [
314
- 'http://localhost',
315
- 'https://localhost',
316
- 'http://127.0.0.1',
317
- 'https://127.0.0.1',
318
- 'http://[::1]',
319
- 'https://[::1]',
320
- ];
321
- const isLocalhost = LOCALHOST_ORIGINS.some(
322
- allowed => origin === allowed || origin.startsWith(allowed + ':')
323
- );
324
- if (!isLocalhost) {
325
- socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
326
- socket.destroy();
327
- return;
328
- }
329
- }
330
-
331
- // Complete WebSocket handshake
332
- const key = req.headers['sec-websocket-key'];
333
- const acceptKey = getCrypto()
334
- .createHash('sha1')
335
- .update(key + WS_GUID)
336
- .digest('base64');
337
-
338
- const responseHeaders = [
339
- 'HTTP/1.1 101 Switching Protocols',
340
- 'Upgrade: websocket',
341
- 'Connection: Upgrade',
342
- `Sec-WebSocket-Accept: ${acceptKey}`,
343
- '',
344
- '',
345
- ].join('\r\n');
346
-
347
- socket.write(responseHeaders);
348
-
349
- // Create session
350
- const sessionId = this.getSessionId(req);
351
- this.createSession(sessionId, socket);
352
- }
353
-
354
- /**
355
- * Get or generate session ID from request
356
- */
357
- getSessionId(req) {
358
- // Check for session ID in query string
359
- const url = new URL(req.url, `http://${req.headers.host}`);
360
- const sessionId = url.searchParams.get('session_id');
361
-
362
- if (sessionId && this.sessions.has(sessionId)) {
363
- return sessionId; // Resume existing session
364
- }
365
-
366
- // Generate new session ID
367
- return getCrypto().randomBytes(16).toString('hex');
368
- }
369
-
370
- /**
371
- * Create a new dashboard session
372
- */
373
- createSession(sessionId, socket) {
374
- // Check if resuming existing session
375
- let session = this.sessions.get(sessionId);
376
- const isResume = !!session;
377
-
378
- if (!session) {
379
- session = new DashboardSession(sessionId, socket, this.projectRoot);
380
- this.sessions.set(sessionId, session);
381
- } else {
382
- // Clean up old socket before replacing
383
- if (session.ws && session.ws !== socket) {
384
- session.ws.removeAllListeners();
385
- session.ws.destroy();
386
- }
387
- session.ws = socket;
388
- }
389
-
390
- console.log(`[Session ${sessionId}] ${isResume ? 'Resumed' : 'Connected'}`);
391
-
392
- // Send initial state
393
- session.send(
394
- getProtocol().createSessionState(sessionId, 'connected', {
395
- resumed: isResume,
396
- messageCount: session.messages.length,
397
- project: require('path').basename(this.projectRoot),
398
- })
399
- );
400
-
401
- // Send initial git status
402
- this.sendGitStatus(session);
403
-
404
- // Send project status (stories/epics)
405
- this.sendStatusUpdate(session);
406
-
407
- // Send team metrics
408
- this.sendTeamMetrics(session);
409
-
410
- // Send session list with sync info
411
- this.sendSessionList(session);
412
-
413
- // Send initial automation list and inbox
414
- this.sendAutomationList(session);
415
- this.sendInboxList(session);
416
-
417
- // Handle incoming messages
418
- let buffer = Buffer.alloc(0);
419
-
420
- socket.on('data', data => {
421
- buffer = Buffer.concat([buffer, data]);
422
-
423
- // Process complete WebSocket frames
424
- while (buffer.length >= 2) {
425
- const frame = decodeWebSocketFrame(buffer);
426
- if (!frame) break;
427
-
428
- buffer = buffer.slice(frame.totalLength);
429
-
430
- if (frame.opcode === 0x8) {
431
- // Close frame
432
- socket.end();
433
- return;
434
- }
435
-
436
- if (frame.opcode === 0x9) {
437
- // Ping - send pong
438
- socket.write(encodeWebSocketFrame('', 0x0a));
439
- continue;
440
- }
441
-
442
- if (frame.opcode === 0x1 || frame.opcode === 0x2) {
443
- // Text or binary frame
444
- this.handleMessage(session, frame.payload.toString());
445
- }
446
- }
447
- });
448
-
449
- socket.on('close', () => {
450
- console.log(`[Session ${sessionId}] Disconnected`);
451
- // Keep session for potential reconnect
452
- session.ws = null;
453
- session.state = 'disconnected';
454
- this.emit('session:disconnected', sessionId);
455
- });
456
-
457
- socket.on('error', err => {
458
- console.error(`[Session ${sessionId}] Socket error:`, err.message);
459
- });
460
-
461
- this.emit('session:connected', sessionId, session);
462
- }
463
-
464
- /**
465
- * Handle incoming message from dashboard
466
- */
467
- handleMessage(session, data) {
468
- // Rate limit incoming messages
469
- if (!session.checkRateLimit()) {
470
- session.send(
471
- getProtocol().createError('RATE_LIMITED', 'Too many messages, please slow down')
472
- );
473
- return;
474
- }
475
-
476
- const message = getProtocol().parseInboundMessage(data);
477
- if (!message) {
478
- session.send(getProtocol().createError('INVALID_MESSAGE', 'Failed to parse message'));
479
- return;
480
- }
481
-
482
- console.log(`[Session ${session.id}] Received: ${message.type}`);
483
-
484
- switch (message.type) {
485
- case getProtocol().InboundMessageType.MESSAGE:
486
- this.handleUserMessage(session, message);
487
- break;
488
-
489
- case getProtocol().InboundMessageType.CANCEL:
490
- this.handleCancel(session);
491
- break;
492
-
493
- case getProtocol().InboundMessageType.REFRESH:
494
- this.handleRefresh(session, message);
495
- break;
496
-
497
- case getProtocol().InboundMessageType.GIT_STAGE:
498
- case getProtocol().InboundMessageType.GIT_UNSTAGE:
499
- case getProtocol().InboundMessageType.GIT_REVERT:
500
- case getProtocol().InboundMessageType.GIT_COMMIT:
501
- this.handleGitAction(session, message);
502
- break;
503
-
504
- case getProtocol().InboundMessageType.GIT_DIFF_REQUEST:
505
- this.handleDiffRequest(session, message);
506
- break;
507
-
508
- case getProtocol().InboundMessageType.SESSION_CLOSE:
509
- this.closeSession(session.id);
510
- break;
511
-
512
- case getProtocol().InboundMessageType.TERMINAL_SPAWN:
513
- this.handleTerminalSpawn(session, message);
514
- break;
515
-
516
- case getProtocol().InboundMessageType.TERMINAL_INPUT:
517
- this.handleTerminalInput(session, message);
518
- break;
519
-
520
- case getProtocol().InboundMessageType.TERMINAL_RESIZE:
521
- this.handleTerminalResize(session, message);
522
- break;
523
-
524
- case getProtocol().InboundMessageType.TERMINAL_CLOSE:
525
- this.handleTerminalClose(session, message);
526
- break;
527
-
528
- case getProtocol().InboundMessageType.AUTOMATION_LIST_REQUEST:
529
- this.sendAutomationList(session);
530
- break;
531
-
532
- case getProtocol().InboundMessageType.AUTOMATION_RUN:
533
- this.handleAutomationRun(session, message);
534
- break;
535
-
536
- case getProtocol().InboundMessageType.AUTOMATION_STOP:
537
- this.handleAutomationStop(session, message);
538
- break;
539
-
540
- case getProtocol().InboundMessageType.INBOX_LIST_REQUEST:
541
- this.sendInboxList(session);
542
- break;
543
-
544
- case getProtocol().InboundMessageType.INBOX_ACTION:
545
- this.handleInboxAction(session, message);
546
- break;
547
-
548
- case getProtocol().InboundMessageType.OPEN_FILE:
549
- this.handleOpenFile(session, message);
550
- break;
551
-
552
- default:
553
- console.log(`[Session ${session.id}] Unhandled message type: ${message.type}`);
554
- this.emit('message', session, message);
555
- }
556
- }
557
-
558
- /**
559
- * Handle user message - forward to Claude
560
- */
561
- handleUserMessage(session, message) {
562
- const content = message.content?.trim();
563
- if (!content) {
564
- session.send(getProtocol().createError('EMPTY_MESSAGE', 'Message content is empty'));
565
- return;
566
- }
567
-
568
- // Add to conversation history
569
- session.addMessage('user', content);
570
-
571
- // Update state
572
- session.setState('thinking');
573
-
574
- // Emit for external handling (Claude API integration)
575
- this.emit('user:message', session, content);
576
- }
577
-
578
- /**
579
- * Handle cancel request
580
- */
581
- handleCancel(session) {
582
- session.setState('idle');
583
- session.send(getProtocol().createNotification('info', 'Cancelled', 'Operation cancelled'));
584
- this.emit('user:cancel', session);
585
- }
586
-
587
- /**
588
- * Handle refresh request
589
- */
590
- handleRefresh(session, message) {
591
- const what = message.what || 'all';
592
-
593
- switch (what) {
594
- case 'git':
595
- this.sendGitStatus(session);
596
- break;
597
- case 'tasks':
598
- this.emit('refresh:tasks', session);
599
- break;
600
- case 'status':
601
- this.sendStatusUpdate(session);
602
- this.emit('refresh:status', session);
603
- break;
604
- case 'sessions':
605
- this.sendSessionList(session);
606
- break;
607
- case 'automations':
608
- this.sendAutomationList(session);
609
- break;
610
- case 'inbox':
611
- this.sendInboxList(session);
612
- break;
613
- case 'team_metrics':
614
- this.sendTeamMetrics(session);
615
- break;
616
- default:
617
- this.sendGitStatus(session);
618
- this.sendStatusUpdate(session);
619
- this.sendTeamMetrics(session);
620
- this.sendSessionList(session);
621
- this.sendAutomationList(session);
622
- this.sendInboxList(session);
623
- this.emit('refresh:all', session);
624
- }
625
- }
626
-
627
- // ==========================================================================
628
- // Git Handlers (delegating to dashboard-git.js)
629
- // ==========================================================================
630
-
631
- /**
632
- * Handle git actions
633
- */
634
- handleGitAction(session, message) {
635
- const { type, files, message: commitMessage } = message;
636
-
637
- try {
638
- handleGitAction(type, this.projectRoot, { files, commitMessage }, getProtocol());
639
-
640
- // Send updated git status
641
- this.sendGitStatus(session);
642
- session.send(
643
- getProtocol().createNotification('success', 'Git', `${type.replace('git_', '')} completed`)
644
- );
645
- } catch (error) {
646
- console.error('[Git Error]', error.message);
647
- session.send(getProtocol().createError('GIT_ERROR', error.message || 'Git operation failed'));
648
- }
649
- }
650
-
651
- /**
652
- * Send git status to session
653
- */
654
- sendGitStatus(session) {
655
- try {
656
- const status = getGitStatus(this.projectRoot);
657
- session.send({
658
- type: getProtocol().OutboundMessageType.GIT_STATUS,
659
- ...status,
660
- timestamp: new Date().toISOString(),
661
- });
662
- } catch (error) {
663
- console.error('[Git Status Error]', error.message);
664
- }
665
- }
666
-
667
- /**
668
- * Handle diff request for a file
669
- */
670
- handleDiffRequest(session, message) {
671
- const { path: filePath, staged } = message;
672
-
673
- if (!filePath) {
674
- session.send(getProtocol().createError('INVALID_REQUEST', 'File path is required'));
675
- return;
676
- }
677
-
678
- try {
679
- const diff = getFileDiff(filePath, this.projectRoot, staged);
680
- const stats = parseDiffStats(diff);
681
-
682
- session.send(
683
- getProtocol().createGitDiff(filePath, diff, {
684
- additions: stats.additions,
685
- deletions: stats.deletions,
686
- staged: !!staged,
687
- })
688
- );
689
- } catch (error) {
690
- console.error('[Diff Error]', error.message);
691
- session.send(getProtocol().createError('DIFF_ERROR', 'Failed to get diff'));
692
- }
693
- }
694
-
695
- // ==========================================================================
696
- // Status/Metrics Handlers (delegating to dashboard-status.js)
697
- // ==========================================================================
698
-
699
- /**
700
- * Send project status update (stories/epics summary) to session
701
- */
702
- sendStatusUpdate(session) {
703
- try {
704
- const summary = buildStatusSummary(this.projectRoot);
705
- if (summary) {
706
- session.send(getProtocol().createStatusUpdate(summary));
707
- }
708
- } catch (error) {
709
- console.error('[Status Update Error]', error.message);
710
- }
711
- }
712
-
713
- /**
714
- * Initialize listener for team metrics events
715
- */
716
- _initTeamMetricsListener() {
717
- try {
718
- const { teamMetricsEmitter } = require('../scripts/lib/team-events');
719
- this._teamMetricsListener = () => {
720
- this.broadcastTeamMetrics();
721
- };
722
- teamMetricsEmitter.on('metrics_saved', this._teamMetricsListener);
723
- } catch (e) {
724
- // team-events not available - non-critical
725
- }
726
- }
727
-
728
- /**
729
- * Send team metrics to a single session
730
- */
731
- sendTeamMetrics(session) {
732
- const traces = readTeamMetrics(this.projectRoot);
733
- for (const [traceId, metrics] of Object.entries(traces)) {
734
- session.send(getProtocol().createTeamMetrics(traceId, metrics));
735
- }
736
- }
737
-
738
- /**
739
- * Broadcast team metrics to all connected clients
740
- */
741
- broadcastTeamMetrics() {
742
- const traces = readTeamMetrics(this.projectRoot);
743
- for (const [traceId, metrics] of Object.entries(traces)) {
744
- this.broadcast(getProtocol().createTeamMetrics(traceId, metrics));
745
- }
746
- }
747
-
748
- /**
749
- * Send session list with sync status to dashboard
750
- */
751
- sendSessionList(session) {
752
- const sessions = [];
753
-
754
- for (const [id, s] of this.sessions) {
755
- const entry = {
756
- id,
757
- name: s.metadata.name || id,
758
- type: s.metadata.type || 'local',
759
- status: s.state === 'connected' ? 'active' : s.state === 'disconnected' ? 'idle' : s.state,
760
- branch: null,
761
- messageCount: s.messages.length,
762
- lastActivity: s.lastActivity.toISOString(),
763
- syncStatus: 'offline',
764
- ahead: 0,
765
- behind: 0,
766
- };
767
-
768
- // Get branch and sync status via git
769
- try {
770
- const cwd = s.metadata.worktreePath || this.projectRoot;
771
- entry.branch = getChildProcess()
772
- .execFileSync('git', ['branch', '--show-current'], {
773
- cwd,
774
- encoding: 'utf8',
775
- stdio: ['pipe', 'pipe', 'pipe'],
776
- })
777
- .trim();
778
-
779
- // Get ahead/behind counts relative to upstream
780
- try {
781
- const counts = getChildProcess()
782
- .execFileSync('git', ['rev-list', '--left-right', '--count', 'HEAD...@{u}'], {
783
- cwd,
784
- encoding: 'utf8',
785
- stdio: ['pipe', 'pipe', 'pipe'],
786
- })
787
- .trim();
788
- const [ahead, behind] = counts.split(/\s+/).map(Number);
789
- entry.ahead = ahead || 0;
790
- entry.behind = behind || 0;
791
-
792
- if (ahead > 0 && behind > 0) {
793
- entry.syncStatus = 'diverged';
794
- } else if (ahead > 0) {
795
- entry.syncStatus = 'ahead';
796
- } else if (behind > 0) {
797
- entry.syncStatus = 'behind';
798
- } else {
799
- entry.syncStatus = 'synced';
800
- }
801
- } catch {
802
- // No upstream configured
803
- entry.syncStatus = 'synced';
804
- }
805
- } catch {
806
- entry.syncStatus = 'offline';
807
- }
808
-
809
- sessions.push(entry);
810
- }
811
-
812
- session.send(getProtocol().createSessionList(sessions));
813
- }
814
-
815
- /**
816
- * Handle open file in editor request
817
- */
818
- handleOpenFile(session, message) {
819
- const { path: filePath, line } = message;
820
-
821
- if (!filePath || typeof filePath !== 'string') {
822
- session.send(getProtocol().createError('INVALID_REQUEST', 'File path is required'));
823
- return;
824
- }
825
-
826
- // Validate the path stays within project root
827
- const pathResult = getValidatePaths().validatePath(filePath, this.projectRoot, {
828
- allowSymlinks: true,
829
- });
830
- if (!pathResult.ok) {
831
- session.send(getProtocol().createError('OPEN_FILE_ERROR', 'File path outside project'));
832
- return;
833
- }
834
-
835
- const fullPath = pathResult.resolvedPath;
836
-
837
- // Detect editor from environment
838
- const editor = process.env.VISUAL || process.env.EDITOR || 'code';
839
- const editorBase = require('path').basename(editor).toLowerCase();
840
-
841
- try {
842
- const lineNum = Number.isFinite(line) && line > 0 ? line : null;
843
-
844
- switch (editorBase) {
845
- case 'code':
846
- case 'cursor':
847
- case 'windsurf': {
848
- const gotoArg = lineNum ? `${fullPath}:${lineNum}` : fullPath;
849
- getChildProcess()
850
- .spawn(editor, ['--goto', gotoArg], { detached: true, stdio: 'ignore' })
851
- .unref();
852
- break;
853
- }
854
- case 'subl':
855
- case 'sublime_text': {
856
- const sublArg = lineNum ? `${fullPath}:${lineNum}` : fullPath;
857
- getChildProcess().spawn(editor, [sublArg], { detached: true, stdio: 'ignore' }).unref();
858
- break;
859
- }
860
- default: {
861
- // Generic: just open the file
862
- getChildProcess().spawn(editor, [fullPath], { detached: true, stdio: 'ignore' }).unref();
863
- break;
864
- }
865
- }
866
-
867
- session.send(
868
- getProtocol().createNotification(
869
- 'info',
870
- 'Editor',
871
- `Opened ${require('path').basename(fullPath)}`
872
- )
873
- );
874
- } catch (error) {
875
- console.error('[Open File Error]', error.message);
876
- session.send(
877
- getProtocol().createError('OPEN_FILE_ERROR', `Failed to open file: ${error.message}`)
878
- );
879
- }
880
- }
881
-
882
- // ==========================================================================
883
- // Terminal Handlers (delegating to dashboard-terminal.js)
884
- // ==========================================================================
885
-
886
- /**
887
- * Handle terminal spawn request
888
- */
889
- handleTerminalSpawn(session, message) {
890
- const { cols, rows, cwd } = message;
891
-
892
- // Validate cwd stays within project root
893
- let safeCwd = this.projectRoot;
894
- if (cwd) {
895
- const cwdResult = getValidatePaths().validatePath(cwd, this.projectRoot, {
896
- allowSymlinks: true,
897
- });
898
- if (!cwdResult.ok) {
899
- session.send(
900
- getProtocol().createError(
901
- 'TERMINAL_ERROR',
902
- 'Working directory must be within project root'
903
- )
904
- );
905
- return;
906
- }
907
- safeCwd = cwdResult.resolvedPath;
908
- }
909
-
910
- const terminalId = this.terminalManager.createTerminal(session, {
911
- cols: cols || 80,
912
- rows: rows || 24,
913
- cwd: safeCwd,
914
- });
915
-
916
- if (terminalId) {
917
- session.send({
918
- type: 'terminal_spawned',
919
- terminalId,
920
- timestamp: new Date().toISOString(),
921
- });
922
- } else {
923
- session.send(getProtocol().createError('TERMINAL_ERROR', 'Failed to spawn terminal'));
924
- }
925
- }
926
-
927
- /**
928
- * Handle terminal input
929
- */
930
- handleTerminalInput(session, message) {
931
- const { terminalId, data } = message;
932
-
933
- if (!terminalId || !data) {
934
- return;
935
- }
936
-
937
- this.terminalManager.writeToTerminal(terminalId, data);
938
- }
939
-
940
- /**
941
- * Handle terminal resize
942
- */
943
- handleTerminalResize(session, message) {
944
- const { terminalId, cols, rows } = message;
945
-
946
- if (!terminalId || !cols || !rows) {
947
- return;
948
- }
949
-
950
- this.terminalManager.resizeTerminal(terminalId, cols, rows);
951
- }
952
-
953
- /**
954
- * Handle terminal close
955
- */
956
- handleTerminalClose(session, message) {
957
- const { terminalId } = message;
958
-
959
- if (!terminalId) {
960
- return;
961
- }
962
-
963
- this.terminalManager.closeTerminal(terminalId);
964
- session.send(getProtocol().createNotification('info', 'Terminal', 'Terminal closed'));
965
- }
966
-
967
- // ==========================================================================
968
- // Automation Handlers (delegating to dashboard-automations.js)
969
- // ==========================================================================
970
-
971
- /**
972
- * Send automation list to session
973
- */
974
- sendAutomationList(session) {
975
- if (!this._automationRegistry) {
976
- session.send(getProtocol().createAutomationList([]));
977
- return;
978
- }
979
-
980
- try {
981
- const automations = this._automationRegistry.list() || [];
982
- const enriched = enrichAutomationList(
983
- automations,
984
- this._runningAutomations,
985
- this._automationRegistry
986
- );
987
- session.send(getProtocol().createAutomationList(enriched));
988
- } catch (error) {
989
- console.error('[Automations] List error:', error.message);
990
- session.send(getProtocol().createAutomationList([]));
991
- }
992
- }
993
-
994
- /**
995
- * Handle automation run request
996
- */
997
- async handleAutomationRun(session, message) {
998
- const { id: automationId } = message;
999
-
1000
- if (!automationId) {
1001
- session.send(getProtocol().createError('INVALID_REQUEST', 'Automation ID is required'));
1002
- return;
1003
- }
1004
-
1005
- if (!this._automationRunner) {
1006
- session.send(
1007
- getProtocol().createError('AUTOMATION_ERROR', 'Automation runner not initialized')
1008
- );
1009
- return;
1010
- }
1011
-
1012
- try {
1013
- // Check if already running
1014
- if (this._runningAutomations.has(automationId)) {
1015
- session.send(
1016
- getProtocol().createNotification(
1017
- 'warning',
1018
- 'Automation',
1019
- `${automationId} is already running`
1020
- )
1021
- );
1022
- return;
1023
- }
1024
-
1025
- // Mark as running BEFORE the async call to prevent duplicate execution
1026
- this._runningAutomations.set(automationId, { startTime: Date.now() });
1027
-
1028
- session.send(
1029
- getProtocol().createNotification('info', 'Automation', `Starting ${automationId}...`)
1030
- );
1031
-
1032
- // Run the automation (async)
1033
- const result = await this._automationRunner.run(automationId);
1034
-
1035
- // Send result notification
1036
- if (result.success) {
1037
- session.send(
1038
- getProtocol().createNotification(
1039
- 'success',
1040
- 'Automation',
1041
- `${automationId} completed successfully`
1042
- )
1043
- );
1044
- } else {
1045
- session.send(
1046
- getProtocol().createNotification(
1047
- 'error',
1048
- 'Automation',
1049
- `${automationId} failed: ${result.error}`
1050
- )
1051
- );
1052
- }
1053
-
1054
- // Send final status
1055
- session.send(
1056
- getProtocol().createAutomationStatus(
1057
- automationId,
1058
- result.success ? 'idle' : 'error',
1059
- result
1060
- )
1061
- );
1062
-
1063
- // Refresh the list
1064
- this.sendAutomationList(session);
1065
- } catch (error) {
1066
- console.error('[Automation Error]', error.message);
1067
- session.send(getProtocol().createError('AUTOMATION_ERROR', 'Automation execution failed'));
1068
- session.send(
1069
- getProtocol().createAutomationStatus(automationId, 'error', { error: 'Execution failed' })
1070
- );
1071
- }
1072
- }
1073
-
1074
- /**
1075
- * Handle automation stop request
1076
- */
1077
- handleAutomationStop(session, message) {
1078
- const { id: automationId } = message;
1079
-
1080
- if (!automationId) {
1081
- session.send(getProtocol().createError('INVALID_REQUEST', 'Automation ID is required'));
1082
- return;
1083
- }
1084
-
1085
- // Cancel via runner
1086
- if (this._automationRunner) {
1087
- this._automationRunner.cancelAll(); // TODO: Add single automation cancel
1088
- }
1089
-
1090
- this._runningAutomations.delete(automationId);
1091
- session.send(getProtocol().createAutomationStatus(automationId, 'idle'));
1092
- session.send(getProtocol().createNotification('info', 'Automation', `${automationId} stopped`));
1093
- }
1094
-
1095
- // ==========================================================================
1096
- // Inbox Handlers (delegating to dashboard-inbox.js)
1097
- // ==========================================================================
1098
-
1099
- /**
1100
- * Send inbox list to session
1101
- */
1102
- sendInboxList(session) {
1103
- const items = getSortedInboxItems(this._inbox);
1104
- session.send(getProtocol().createInboxList(items));
1105
- }
1106
-
1107
- /**
1108
- * Handle inbox action (accept, dismiss, mark read)
1109
- */
1110
- handleInboxAction(session, message) {
1111
- const { id: itemId, action } = message;
1112
-
1113
- if (!itemId) {
1114
- session.send(getProtocol().createError('INVALID_REQUEST', 'Item ID is required'));
1115
- return;
1116
- }
1117
-
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));
1123
- return;
1124
- }
1125
-
1126
- if (result.notification) {
1127
- session.send(
1128
- getProtocol().createNotification(
1129
- result.notification.level,
1130
- 'Inbox',
1131
- result.notification.message
1132
- )
1133
- );
1134
- }
1135
-
1136
- // Send updated inbox list
1137
- this.sendInboxList(session);
1138
- }
1139
-
1140
- // ==========================================================================
1141
- // Session Lifecycle
1142
- // ==========================================================================
1143
-
1144
- /**
1145
- * Cleanup expired sessions
1146
- */
1147
- _cleanupExpiredSessions() {
1148
- // Collect expired IDs first to avoid mutating Map during iteration
1149
- const expiredIds = [];
1150
- for (const [sessionId, session] of this.sessions) {
1151
- if (session.isExpired()) {
1152
- expiredIds.push(sessionId);
1153
- }
1154
- }
1155
- for (const sessionId of expiredIds) {
1156
- console.log(`[Session ${sessionId}] Expired (idle > ${SESSION_TIMEOUT_MS / 3600000}h)`);
1157
- this.closeSession(sessionId);
1158
- }
1159
- }
1160
-
1161
- /**
1162
- * Close a session
1163
- */
1164
- closeSession(sessionId) {
1165
- const session = this.sessions.get(sessionId);
1166
- if (session) {
1167
- // Close all terminals for this session
1168
- this.terminalManager.closeSessionTerminals(sessionId);
1169
-
1170
- if (session.ws) {
1171
- session.ws.end();
1172
- }
1173
- this.sessions.delete(sessionId);
1174
- console.log(`[Session ${sessionId}] Closed`);
1175
- this.emit('session:closed', sessionId);
1176
- }
1177
- }
1178
-
1179
- /**
1180
- * Get session by ID
1181
- */
1182
- getSession(sessionId) {
1183
- return this.sessions.get(sessionId);
1184
- }
1185
-
1186
- /**
1187
- * Broadcast message to all sessions
1188
- */
1189
- broadcast(message) {
1190
- for (const session of this.sessions.values()) {
1191
- if (session.ws) {
1192
- session.send(message);
1193
- }
1194
- }
1195
- }
1196
-
1197
- /**
1198
- * Stop the server
1199
- */
1200
- stop() {
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
-
1213
- // Clear cleanup interval
1214
- if (this._cleanupInterval) {
1215
- clearInterval(this._cleanupInterval);
1216
- this._cleanupInterval = null;
1217
- }
1218
-
1219
- // Close all sessions
1220
- for (const session of this.sessions.values()) {
1221
- if (session.ws) {
1222
- session.ws.end();
1223
- }
1224
- }
1225
- this.sessions.clear();
1226
-
1227
- // Close HTTP server
1228
- if (this.httpServer) {
1229
- this.httpServer.close(() => {
1230
- console.log('[AgileFlow Dashboard Server] Stopped');
1231
- resolve();
1232
- });
1233
- } else {
1234
- resolve();
1235
- }
1236
- });
1237
- }
1238
- }
1239
-
1240
- // ============================================================================
1241
- // Factory Functions
1242
- // ============================================================================
1243
-
1244
- /**
1245
- * Create a dashboard server instance
1246
- * @param {Object} [options={}] - Server options
1247
- * @param {number} [options.port=8765] - Port to listen on
1248
- * @param {string} [options.host='0.0.0.0'] - Host to bind to
1249
- * @param {string} [options.projectRoot] - Project root directory
1250
- * @param {string} [options.apiKey] - API key for authentication
1251
- * @param {boolean} [options.requireAuth=false] - Require API key
1252
- * @returns {DashboardServer}
1253
- */
1254
- function createDashboardServer(options = {}) {
1255
- return new DashboardServer(options);
1256
- }
1257
-
1258
- /**
1259
- * Start a dashboard server
1260
- * @param {DashboardServer} server - Server instance
1261
- * @returns {Promise<{ url: string, wsUrl: string }>}
1262
- */
1263
- async function startDashboardServer(server) {
1264
- return server.start();
1265
- }
1266
-
1267
- /**
1268
- * Stop a dashboard server
1269
- * @param {DashboardServer} server - Server instance
1270
- * @returns {Promise<void>}
1271
- */
1272
- async function stopDashboardServer(server) {
1273
- return server.stop();
1274
- }
1275
-
1276
- // ============================================================================
1277
- // Exports (backward-compatible - re-exports from extracted modules)
1278
- // ============================================================================
1279
-
1280
- module.exports = {
1281
- DashboardServer,
1282
- DashboardSession,
1283
- TerminalInstance,
1284
- TerminalManager,
1285
- createDashboardServer,
1286
- startDashboardServer,
1287
- stopDashboardServer,
1288
- DEFAULT_PORT,
1289
- DEFAULT_HOST,
1290
- SESSION_TIMEOUT_MS,
1291
- SESSION_CLEANUP_INTERVAL_MS,
1292
- RATE_LIMIT_TOKENS,
1293
- SENSITIVE_ENV_PATTERNS,
1294
- encodeWebSocketFrame,
1295
- decodeWebSocketFrame,
1296
- };