claude-team-dashboard 1.2.6

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.
package/server.js ADDED
@@ -0,0 +1,1996 @@
1
+ const express = require('express');
2
+ const http = require('http');
3
+ const WebSocket = require('ws');
4
+ const chokidar = require('chokidar');
5
+ const fs = require('fs').promises;
6
+ const path = require('path');
7
+ const cors = require('cors');
8
+ const os = require('os');
9
+ const helmet = require('helmet');
10
+ const rateLimit = require('express-rate-limit');
11
+ const compression = require('compression');
12
+ const crypto = require('crypto');
13
+ const { exec } = require('child_process');
14
+ const config = require('./config');
15
+
16
+ const IS_WINDOWS = process.platform === 'win32';
17
+
18
+ /**
19
+ * Restricts file permissions to the current user only (cross-platform).
20
+ * On Windows uses icacls; on Unix uses chmod 600.
21
+ * @param {string} filePath - Absolute path to the file to lock down
22
+ * @returns {Promise<void>}
23
+ */
24
+ async function lockFilePermissions(filePath) {
25
+ if (IS_WINDOWS) {
26
+ // Remove all inherited permissions, grant full control to current user only
27
+ const escaped = filePath.replace(/\//g, '\\');
28
+ await new Promise((resolve) => {
29
+ exec(`icacls "${escaped}" /inheritance:r /grant:r "%USERNAME%":F`, resolve);
30
+ });
31
+ } else {
32
+ await fs.chmod(filePath, 0o600);
33
+ }
34
+ }
35
+
36
+ const app = express();
37
+ const server = http.createServer(app);
38
+
39
+ // --- Password Authentication (always required) ---
40
+ // Password stored as scrypt hash in ~/.claude/dashboard.key
41
+ // Format: <hex-salt>:<hex-hash>
42
+ //
43
+ // Auth token lifecycle: the token is stored in the browser's sessionStorage.
44
+ // This means it is NOT protected by HttpOnly (sessionStorage is accessible to JS),
45
+ // but it is automatically cleared when the browser tab/window is closed. For a
46
+ // localhost-only developer tool this is an acceptable tradeoff: XSS risk is minimal
47
+ // on localhost and the short-lived session scope limits exposure.
48
+ const KEY_FILE = path.join(os.homedir(), '.claude', 'dashboard.key');
49
+ let authToken = crypto.randomBytes(32).toString('hex');
50
+
51
+ // OWASP-recommended scrypt parameters (minimum): N=16384, r=8, p=1
52
+ const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 };
53
+
54
+ /**
55
+ * Hashes a password using scrypt with a random 16-byte salt.
56
+ * @param {string} password - The plaintext password to hash
57
+ * @returns {Promise<string>} The stored format "hex-salt:hex-hash"
58
+ */
59
+ async function hashPassword(password) {
60
+ const salt = crypto.randomBytes(16);
61
+ const hash = await new Promise((resolve, reject) => {
62
+ crypto.scrypt(password, salt, 32, SCRYPT_PARAMS, (err, derived) => {
63
+ if (err) reject(err); else resolve(derived);
64
+ });
65
+ });
66
+ return `${salt.toString('hex')}:${hash.toString('hex')}`;
67
+ }
68
+
69
+ /**
70
+ * Verifies a plaintext password against a stored scrypt hash using timing-safe comparison.
71
+ * @param {string} password - The plaintext password to verify
72
+ * @param {string} stored - The stored "hex-salt:hex-hash" string
73
+ * @returns {Promise<boolean>} True if the password matches
74
+ */
75
+ async function verifyPassword(password, stored) {
76
+ const [saltHex, hashHex] = stored.split(':');
77
+ if (!saltHex || !hashHex) return false;
78
+ const salt = Buffer.from(saltHex, 'hex');
79
+ const expected = Buffer.from(hashHex, 'hex');
80
+ const derived = await new Promise((resolve, reject) => {
81
+ crypto.scrypt(password, salt, 32, SCRYPT_PARAMS, (err, d) => {
82
+ if (err) reject(err); else resolve(d);
83
+ });
84
+ });
85
+ if (derived.length !== expected.length) return false;
86
+ return crypto.timingSafeEqual(derived, expected);
87
+ }
88
+
89
+ async function loadStoredHash() {
90
+ try {
91
+ const data = await fs.readFile(KEY_FILE, 'utf8');
92
+ return data.trim() || null;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ // storedHash is null when no password has been set yet (first run)
99
+ let storedHash = null;
100
+ (async () => {
101
+ storedHash = await loadStoredHash();
102
+
103
+ // Security check: warn if the key file is world-readable, and auto-fix if possible
104
+ try {
105
+ const keyStats = await fs.stat(KEY_FILE);
106
+ if (keyStats.mode & 0o004) {
107
+ console.warn('WARNING: dashboard.key has loose permissions — fixing automatically...');
108
+ try {
109
+ await lockFilePermissions(KEY_FILE);
110
+ console.log('āœ“ dashboard.key permissions fixed.');
111
+ } catch {
112
+ const fix = IS_WINDOWS
113
+ ? `icacls "${KEY_FILE}" /inheritance:r /grant:r "%USERNAME%":F`
114
+ : `chmod 600 ${KEY_FILE}`;
115
+ console.warn(` Could not fix automatically. Run manually: ${fix}`);
116
+ }
117
+ }
118
+ } catch {
119
+ // Key file does not exist yet (first run) — nothing to check
120
+ }
121
+ })();
122
+
123
+ console.log('šŸ”’ Password authentication is ENABLED');
124
+
125
+ const wss = new WebSocket.Server({
126
+ server,
127
+ verifyClient: (info) => {
128
+ // Validate WebSocket origin
129
+ const origin = info.origin || info.req.headers.origin;
130
+ return !origin || config.CORS_ORIGINS.includes(origin);
131
+ }
132
+ });
133
+
134
+ // Security middleware
135
+ app.use(helmet(config.HELMET_CONFIG));
136
+
137
+ // Permissions-Policy header — restrict access to sensitive browser APIs
138
+ app.use((req, res, next) => {
139
+ res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()');
140
+ next();
141
+ });
142
+
143
+ // Rate limiting
144
+ const limiter = rateLimit({
145
+ windowMs: config.RATE_LIMIT.WINDOW_MS,
146
+ max: config.RATE_LIMIT.MAX_REQUESTS,
147
+ message: config.RATE_LIMIT.MESSAGE,
148
+ standardHeaders: true,
149
+ legacyHeaders: false
150
+ });
151
+
152
+ app.use('/api/', limiter);
153
+
154
+ // Stricter rate limiter for auth endpoints: max 5 requests per 15 minutes per IP
155
+ const authLimiter = rateLimit({
156
+ windowMs: 15 * 60 * 1000,
157
+ max: 5,
158
+ message: 'Too many authentication attempts, please try again later.',
159
+ standardHeaders: true,
160
+ legacyHeaders: false
161
+ });
162
+
163
+ // Restrict CORS to localhost only for security
164
+ app.use(cors({
165
+ origin: config.CORS_ORIGINS,
166
+ credentials: true
167
+ }));
168
+
169
+ // Gzip compression for all responses
170
+ app.use(compression({ level: 6, threshold: 1024 }));
171
+
172
+ // Request duration logging for API routes
173
+ app.use((req, res, next) => {
174
+ const start = Date.now();
175
+ res.on('finish', () => {
176
+ if (req.path.startsWith('/api/')) {
177
+ console.log(`API ${res.statusCode} ${Date.now()-start}ms`);
178
+ }
179
+ });
180
+ next();
181
+ });
182
+
183
+ // Explicit Content-Type for all API responses
184
+ app.use('/api/', (req, res, next) => {
185
+ res.set('Content-Type', 'application/json; charset=utf-8');
186
+ next();
187
+ });
188
+
189
+ app.use(express.json({ limit: '10kb' }));
190
+
191
+ // Content-Type validation — POST requests to /api/ must be application/json
192
+ app.use('/api/', (req, res, next) => {
193
+ if (req.method === 'POST') {
194
+ const ct = req.headers['content-type'] || '';
195
+ if (!ct.includes('application/json')) {
196
+ return res.status(415).json({ error: 'Unsupported Media Type: Content-Type must be application/json' });
197
+ }
198
+ }
199
+ next();
200
+ });
201
+
202
+ // --- Auth endpoints ---
203
+ // Returns { required: true, setup: true } when no password has been set yet
204
+ app.get('/api/auth/required', (req, res) => {
205
+ res.json({ required: true, setup: !storedHash });
206
+ });
207
+
208
+ // First-time setup — only works when no password is stored yet
209
+ app.post('/api/auth/setup', authLimiter, async (req, res) => {
210
+ if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) {
211
+ return res.status(400).json({ error: 'Invalid request body' });
212
+ }
213
+ if (storedHash) {
214
+ return res.status(403).json({ error: 'Password already set' });
215
+ }
216
+ const { password } = req.body;
217
+ if (!password || typeof password !== 'string' || password.length < 8) {
218
+ return res.status(400).json({ error: 'Password must be at least 8 characters' });
219
+ }
220
+ try {
221
+ storedHash = await hashPassword(password);
222
+ const claudeDir = path.join(os.homedir(), '.claude');
223
+ validatePath(KEY_FILE, claudeDir);
224
+ await fs.mkdir(path.dirname(KEY_FILE), { recursive: true });
225
+ await fs.writeFile(KEY_FILE, storedHash, { mode: 0o600 });
226
+ await lockFilePermissions(KEY_FILE);
227
+ authToken = crypto.randomBytes(32).toString('hex');
228
+ res.json({ token: authToken });
229
+ } catch (err) {
230
+ console.error('Auth setup error:', err.message);
231
+ res.status(500).json({ error: 'Internal server error' });
232
+ }
233
+ });
234
+
235
+ app.post('/api/auth/login', authLimiter, async (req, res) => {
236
+ if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) {
237
+ return res.status(400).json({ error: 'Invalid request body' });
238
+ }
239
+ if (!storedHash) {
240
+ return res.status(403).json({ error: 'No password set — complete setup first' });
241
+ }
242
+ const { password } = req.body;
243
+ if (!password || typeof password !== 'string') {
244
+ return res.status(400).json({ error: 'Password is required' });
245
+ }
246
+ try {
247
+ const valid = await verifyPassword(password, storedHash);
248
+ if (!valid) {
249
+ return res.status(401).json({ error: 'Invalid password' });
250
+ }
251
+ // Rotate token on every successful login so each session gets a fresh token
252
+ authToken = crypto.randomBytes(32).toString('hex');
253
+ res.json({ token: authToken });
254
+ } catch (err) {
255
+ console.error('Auth login error:', err.message);
256
+ res.status(500).json({ error: 'Internal server error' });
257
+ }
258
+ });
259
+
260
+ // --- Unauthenticated utility endpoints (before auth middleware) ---
261
+ app.get('/api/health', (req, res) => {
262
+ res.json({
263
+ status: 'healthy',
264
+ timestamp: new Date().toISOString(),
265
+ watchers: {
266
+ teams: !!teamWatcher,
267
+ tasks: !!taskWatcher,
268
+ inboxes: !!inboxWatcher,
269
+ outputs: !!outputWatcher
270
+ }
271
+ });
272
+ });
273
+
274
+ app.get('/api/version', (req, res) => {
275
+ res.json({ version: require('./package.json').version, node: process.version });
276
+ });
277
+
278
+ // --- Auth middleware for protected /api/* routes (skip /api/auth/*) ---
279
+ app.use('/api/', (req, res, next) => {
280
+ // Skip auth routes
281
+ if (req.path.startsWith('/auth/')) {
282
+ return next();
283
+ }
284
+
285
+ // Check Authorization header
286
+ const authHeader = req.headers.authorization;
287
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
288
+ return res.status(401).json({ error: 'Authentication required' });
289
+ }
290
+
291
+ const token = authHeader.slice(7);
292
+
293
+ // Constant-time comparison
294
+ const tokenBuffer = Buffer.from(token);
295
+ const expectedBuffer = Buffer.from(authToken);
296
+
297
+ if (tokenBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(tokenBuffer, expectedBuffer)) {
298
+ return res.status(401).json({ error: 'Invalid token' });
299
+ }
300
+
301
+ next();
302
+ });
303
+
304
+ // Serve pre-built frontend from dist/ (used when installed as global npm package)
305
+ app.use(express.static(path.join(__dirname, 'dist')));
306
+
307
+ // Paths to Claude Code agent team files
308
+ const homeDir = os.homedir();
309
+ const TEAMS_DIR = path.join(homeDir, '.claude', 'teams');
310
+ const TASKS_DIR = path.join(homeDir, '.claude', 'tasks');
311
+ const PROJECTS_DIR = path.join(homeDir, '.claude', 'projects');
312
+ const TEMP_TASKS_DIR = path.join(os.tmpdir(), 'claude', 'D--agentdashboard', 'tasks');
313
+ const ARCHIVE_DIR = path.join(homeDir, '.claude', 'archive');
314
+
315
+ // Store connected clients
316
+ const clients = new Set();
317
+
318
+ // Team lifecycle tracking
319
+ const teamLifecycle = new Map(); // teamName -> { created, lastSeen, archived }
320
+
321
+ /**
322
+ * Archives team data to a JSON file before the team is deleted.
323
+ * Writes to ~/.claude/archive/<teamName>_<timestamp>.json.
324
+ * @param {string} teamName - Name of the team to archive
325
+ * @param {Object} teamData - Full team data including config, tasks, and name
326
+ * @returns {Promise<string|undefined>} Path to the archive file, or undefined on error
327
+ */
328
+ async function archiveTeam(teamName, teamData) {
329
+ try {
330
+ const sanitizedName = sanitizeTeamName(teamName);
331
+ const timestamp = new Date().toISOString().replace(/:/g, '-');
332
+ const archiveFile = path.join(ARCHIVE_DIR, `${sanitizedName}_${timestamp}.json`);
333
+
334
+ // Validate the archive file path is within ARCHIVE_DIR
335
+ const validatedArchivePath = validatePath(archiveFile, ARCHIVE_DIR);
336
+
337
+ // Ensure archive directory exists
338
+ await fs.mkdir(ARCHIVE_DIR, { recursive: true });
339
+
340
+ // Create natural language summary
341
+ const summary = {
342
+ teamName: sanitizedName,
343
+ archivedAt: new Date().toISOString(),
344
+ summary: generateTeamSummary(teamData),
345
+ rawData: teamData
346
+ };
347
+
348
+ await fs.writeFile(validatedArchivePath, JSON.stringify(summary, null, 2));
349
+ console.log(`šŸ“¦ Team archived: ${sanitizedName} → ${validatedArchivePath}`);
350
+
351
+ return archiveFile;
352
+ } catch (error) {
353
+ console.error(`Error archiving team ${sanitizeForLog(teamName)}:`, error.message);
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Generates a natural language summary of team activity for archival.
359
+ * @param {Object} teamData - Team data with config, tasks, and name fields
360
+ * @returns {Object} Summary with overview, created, members, accomplishments, and duration
361
+ */
362
+ function generateTeamSummary(teamData) {
363
+ const members = teamData.config?.members || [];
364
+ const tasks = teamData.tasks || [];
365
+ const completedTasks = tasks.filter(t => t.status === 'completed').length;
366
+ const totalTasks = tasks.length;
367
+
368
+ const createdDate = teamData.config?.createdAt
369
+ ? new Date(teamData.config.createdAt).toLocaleDateString()
370
+ : 'Unknown';
371
+
372
+ return {
373
+ overview: `Team "${teamData.name}" with ${members.length} members worked on ${totalTasks} tasks and completed ${completedTasks}.`,
374
+ created: `Started on ${createdDate}`,
375
+ members: members.map(m => `${m.name} (${m.agentType})`),
376
+ accomplishments: tasks
377
+ .filter(t => t.status === 'completed')
378
+ .map(t => `āœ… ${t.subject}`)
379
+ .slice(0, 10), // Top 10
380
+ duration: teamData.config?.createdAt
381
+ ? `Active for ${Math.round((Date.now() - teamData.config.createdAt) / 1000 / 60)} minutes`
382
+ : 'Unknown duration'
383
+ };
384
+ }
385
+
386
+ /**
387
+ * Broadcasts a JSON message to all connected WebSocket clients.
388
+ * Automatically cleans up dead/closed connections.
389
+ * @param {Object} data - The data object to JSON-serialize and send
390
+ */
391
+ function broadcast(data) {
392
+ const message = JSON.stringify(data);
393
+ const deadClients = new Set();
394
+
395
+ clients.forEach(client => {
396
+ if (client.readyState === WebSocket.OPEN) {
397
+ try {
398
+ client.send(message);
399
+ } catch (error) {
400
+ console.error('Error sending to client:', error.message);
401
+ deadClients.add(client);
402
+ }
403
+ } else {
404
+ deadClients.add(client);
405
+ }
406
+ });
407
+
408
+ // Remove dead connections to prevent memory leak
409
+ deadClients.forEach(client => clients.delete(client));
410
+ }
411
+
412
+ /**
413
+ * Sanitizes a team name to prevent path traversal attacks.
414
+ * Only allows alphanumeric characters, dashes, underscores, and dots.
415
+ * @param {string} teamName - The raw team name to sanitize
416
+ * @returns {string} The validated team name
417
+ * @throws {Error} If the name is invalid, too long, or contains traversal patterns
418
+ */
419
+ function sanitizeTeamName(teamName) {
420
+ if (!teamName || typeof teamName !== 'string') {
421
+ throw new Error('Invalid team name');
422
+ }
423
+ if (teamName.length > 100) {
424
+ throw new Error('Invalid team name: too long');
425
+ }
426
+ // Strict allowlist: alphanumeric, dash, underscore, dot
427
+ if (!/^[a-zA-Z0-9_.-]+$/.test(teamName)) {
428
+ throw new Error('Invalid team name format');
429
+ }
430
+ // Reject directory traversal even within allowlist
431
+ if (teamName === '.' || teamName === '..' || teamName.includes('..')) {
432
+ throw new Error('Invalid team name: relative paths not allowed');
433
+ }
434
+ return teamName;
435
+ }
436
+
437
+ /**
438
+ * Sanitizes an agent name using the same strict rules as team names.
439
+ * Only allows alphanumeric characters, dashes, underscores, and dots.
440
+ * @param {string} agentName - The raw agent name to sanitize
441
+ * @returns {string} The validated agent name
442
+ * @throws {Error} If the name is invalid, too long, or contains traversal patterns
443
+ */
444
+ function sanitizeAgentName(agentName) {
445
+ if (!agentName || typeof agentName !== 'string') {
446
+ throw new Error('Invalid agent name');
447
+ }
448
+ if (agentName.length > 100) {
449
+ throw new Error('Invalid agent name: too long');
450
+ }
451
+ if (!/^[a-zA-Z0-9_.-]+$/.test(agentName)) {
452
+ throw new Error('Invalid agent name format');
453
+ }
454
+ if (agentName === '.' || agentName === '..' || agentName.includes('..')) {
455
+ throw new Error('Invalid agent name: relative paths not allowed');
456
+ }
457
+ return agentName;
458
+ }
459
+
460
+ /**
461
+ * Sanitizes a string for safe logging by stripping control characters (CR, LF, tab, etc.)
462
+ * and truncating to 200 characters to prevent log injection attacks.
463
+ * @param {*} input - The value to sanitize (coerced to string)
464
+ * @returns {string} A safe-to-log string with no control characters, max 200 chars
465
+ */
466
+ function sanitizeForLog(input) {
467
+ return String(input ?? '').replace(/[\r\n\t\x00-\x1f\x7f]/g, ' ').slice(0, 200);
468
+ }
469
+
470
+ /**
471
+ * Sanitizes a filename to prevent path traversal by stripping separators,
472
+ * applying path.basename, and enforcing a strict alphanumeric/dot/dash/underscore allowlist.
473
+ * @param {string} fileName - The raw filename to sanitize
474
+ * @returns {string} The validated base filename
475
+ * @throws {Error} If the filename is invalid, too long, or contains disallowed characters
476
+ */
477
+ function sanitizeFileName(fileName) {
478
+ if (!fileName || typeof fileName !== 'string') {
479
+ throw new Error('Invalid file name');
480
+ }
481
+ if (fileName.length > 100) {
482
+ throw new Error('Invalid file name: too long');
483
+ }
484
+ // Strip any path separator characters
485
+ const stripped = fileName.replace(/[/\\]/g, '');
486
+ // Use basename as additional safety layer
487
+ const baseName = path.basename(stripped);
488
+ // Reject directory traversal
489
+ if (baseName === '.' || baseName === '..' || baseName.includes('..')) {
490
+ throw new Error('Invalid file name: relative paths not allowed');
491
+ }
492
+ // Only allow safe characters (whitelist approach)
493
+ if (!/^[a-zA-Z0-9_.-]+$/.test(baseName)) {
494
+ throw new Error('Invalid file name format');
495
+ }
496
+ return baseName;
497
+ }
498
+
499
+ /**
500
+ * Validates that a file path resolves within an allowed directory to prevent path traversal.
501
+ * @param {string} filePath - The file path to validate
502
+ * @param {string} allowedDir - The directory the path must reside within
503
+ * @returns {string} The normalized absolute path
504
+ * @throws {Error} If the path escapes the allowed directory
505
+ */
506
+ function validatePath(filePath, allowedDir) {
507
+ const normalizedPath = path.resolve(filePath);
508
+ const normalizedDir = path.resolve(allowedDir);
509
+
510
+ // Use relative path to detect traversal attempts
511
+ const relativePath = path.relative(normalizedDir, normalizedPath);
512
+
513
+ // Check if relative path tries to go outside (starts with .. or is absolute)
514
+ if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
515
+ throw new Error('Path traversal attempt detected');
516
+ }
517
+
518
+ return normalizedPath;
519
+ }
520
+
521
+ /**
522
+ * Reads and parses the config.json for a given team.
523
+ * @param {string} teamName - Name of the team directory
524
+ * @returns {Promise<Object|null>} Parsed team config, or null if not found
525
+ */
526
+ async function readTeamConfig(teamName) {
527
+ try {
528
+ const sanitizedName = sanitizeTeamName(teamName);
529
+ // Build path from sanitized components only - no user input in final path
530
+ const teamDir = path.join(TEAMS_DIR, sanitizedName);
531
+ const configPath = path.join(teamDir, 'config.json');
532
+
533
+ // Double-check the constructed path is within allowed directory
534
+ const validatedPath = validatePath(configPath, TEAMS_DIR);
535
+ // lgtm[js/path-injection] - Path is constructed from sanitized teamName that only allows [a-zA-Z0-9_-]
536
+ const data = await fs.readFile(validatedPath, 'utf8');
537
+ return JSON.parse(data);
538
+ } catch (error) {
539
+ // ENOENT is expected for team directories without a config.json (e.g. UUID-named or legacy dirs)
540
+ if (error.code !== 'ENOENT') {
541
+ console.error('Error reading team config:', {
542
+ team: sanitizeForLog(teamName),
543
+ error: error.message
544
+ });
545
+ }
546
+ return null;
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Reads all task JSON files for a team, sorted by creation time.
552
+ * @param {string} teamName - Name of the team directory
553
+ * @returns {Promise<Array<Object>>} Array of task objects with id fields injected
554
+ */
555
+ async function readTasks(teamName) {
556
+ try {
557
+ const sanitizedName = sanitizeTeamName(teamName);
558
+ const tasksPath = path.join(TASKS_DIR, sanitizedName);
559
+ const validatedTasksPath = validatePath(tasksPath, TASKS_DIR);
560
+ // lgtm[js/path-injection] - Path is constructed from sanitized teamName that only allows [a-zA-Z0-9_-]
561
+ const files = await fs.readdir(validatedTasksPath);
562
+
563
+ // Use Promise.all for parallel file reads (performance improvement)
564
+ const taskPromises = files
565
+ .filter(file => file.endsWith('.json'))
566
+ .map(async file => {
567
+ try {
568
+ // Sanitize file name to prevent path traversal
569
+ const sanitizedFile = sanitizeFileName(file);
570
+ const taskPath = path.join(validatedTasksPath, sanitizedFile);
571
+ const validatedPath = validatePath(taskPath, TASKS_DIR);
572
+ // lgtm[js/path-injection] - Path is constructed from sanitized fileName that only allows [a-zA-Z0-9_.-]
573
+ const data = await fs.readFile(validatedPath, 'utf8');
574
+ const task = JSON.parse(data);
575
+ return { ...task, id: path.basename(sanitizedFile, '.json') };
576
+ } catch (fileError) {
577
+ console.error('Error reading task file:', {
578
+ file: sanitizeForLog(file),
579
+ error: fileError.message
580
+ });
581
+ return null;
582
+ }
583
+ });
584
+
585
+ const tasks = (await Promise.all(taskPromises))
586
+ .filter(task => task !== null)
587
+ .sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
588
+ return tasks;
589
+ } catch (error) {
590
+ // ENOENT is expected for teams without a tasks directory
591
+ if (error.code !== 'ENOENT') {
592
+ console.error('Error reading tasks:', {
593
+ team: sanitizeForLog(teamName),
594
+ error: error.message
595
+ });
596
+ }
597
+ return [];
598
+ }
599
+ }
600
+
601
+ // In-memory cache for expensive operations
602
+ const cache = new Map();
603
+ const CACHE_TTL = 5000; // 5 seconds
604
+
605
+ // Async-aware cache: stores resolved values, not Promises.
606
+ // Rejected promises are not cached (they fall through to re-fetch).
607
+ async function getCached(key, asyncFn) {
608
+ const cached = cache.get(key);
609
+ if (cached && Date.now() - cached.time < CACHE_TTL) return cached.value;
610
+ const value = await asyncFn();
611
+ cache.set(key, { value, time: Date.now() });
612
+ return value;
613
+ }
614
+
615
+ // Cached wrapper for getActiveTeams
616
+ function getCachedActiveTeams() {
617
+ return getCached('activeTeams', () => getActiveTeams());
618
+ }
619
+
620
+ /**
621
+ * Returns all currently active agent teams by reading team config and task files.
622
+ * Reads all team configs concurrently for performance.
623
+ * @returns {Promise<Array<Object>>} Array of team objects with name, config, tasks, and lastUpdated
624
+ */
625
+ async function getActiveTeams() {
626
+ try {
627
+ await fs.access(TEAMS_DIR);
628
+ const teams = await fs.readdir(TEAMS_DIR);
629
+
630
+ // Read all team configs concurrently (fixes N+1 sequential reads)
631
+ const teamDataList = await Promise.all(
632
+ teams.map(async (teamName) => {
633
+ try {
634
+ const config = await readTeamConfig(teamName);
635
+ if (!config) return null;
636
+ const tasks = await readTasks(teamName);
637
+ return {
638
+ name: teamName,
639
+ config,
640
+ tasks,
641
+ lastUpdated: new Date().toISOString()
642
+ };
643
+ } catch {
644
+ return null;
645
+ }
646
+ })
647
+ );
648
+
649
+ return teamDataList.filter(Boolean);
650
+ } catch (error) {
651
+ if (error.code === 'ENOENT') {
652
+ console.log('Teams directory does not exist yet');
653
+ return [];
654
+ }
655
+ console.error('Error reading teams:', error.message);
656
+ return [];
657
+ }
658
+ }
659
+
660
+ /**
661
+ * Calculates aggregate statistics across all teams.
662
+ * @param {Array<Object>} teams - Array of team objects from getActiveTeams()
663
+ * @returns {Object} Stats with totalTeams, totalAgents, totalTasks, pendingTasks, inProgressTasks, completedTasks, blockedTasks
664
+ */
665
+ function calculateTeamStats(teams) {
666
+ const stats = {
667
+ totalTeams: teams.length,
668
+ totalAgents: 0,
669
+ totalTasks: 0,
670
+ pendingTasks: 0,
671
+ inProgressTasks: 0,
672
+ completedTasks: 0,
673
+ blockedTasks: 0
674
+ };
675
+
676
+ teams.forEach(team => {
677
+ stats.totalAgents += (team.config.members || []).length;
678
+ stats.totalTasks += team.tasks.length;
679
+
680
+ team.tasks.forEach(task => {
681
+ switch (task.status) {
682
+ case 'pending':
683
+ stats.pendingTasks++;
684
+ if (task.blockedBy && task.blockedBy.length > 0) {
685
+ stats.blockedTasks++;
686
+ }
687
+ break;
688
+ case 'in_progress':
689
+ stats.inProgressTasks++;
690
+ break;
691
+ case 'completed':
692
+ stats.completedTasks++;
693
+ break;
694
+ }
695
+ });
696
+ });
697
+
698
+ return stats;
699
+ }
700
+
701
+ // Get team history (all teams including past ones)
702
+ async function getTeamHistory() {
703
+ try {
704
+ await fs.access(TEAMS_DIR);
705
+ const teamNames = await fs.readdir(TEAMS_DIR);
706
+ const history = [];
707
+
708
+ for (const teamName of teamNames) {
709
+ try {
710
+ const config = await readTeamConfig(teamName);
711
+ const tasks = await readTasks(teamName);
712
+
713
+ if (config) {
714
+ // Get team directory stats for timestamps
715
+ const teamDir = path.join(TEAMS_DIR, sanitizeTeamName(teamName));
716
+ const validatedTeamDir = validatePath(teamDir, TEAMS_DIR);
717
+ const stats = await fs.stat(validatedTeamDir);
718
+
719
+ history.push({
720
+ name: teamName,
721
+ config,
722
+ tasks,
723
+ createdAt: stats.birthtime,
724
+ lastModified: stats.mtime,
725
+ isActive: true
726
+ });
727
+ }
728
+ } catch (error) {
729
+ console.error(`Error reading team history for ${sanitizeForLog(teamName)}:`, error.message);
730
+ }
731
+ }
732
+
733
+ // Sort by last modified (most recent first)
734
+ return history.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
735
+ } catch (error) {
736
+ if (error.code === 'ENOENT') {
737
+ return [];
738
+ }
739
+ console.error('Error reading team history:', error.message);
740
+ return [];
741
+ }
742
+ }
743
+
744
+ // Get agent output files
745
+ async function getAgentOutputs() {
746
+ try {
747
+ await fs.access(TEMP_TASKS_DIR);
748
+ const files = await fs.readdir(TEMP_TASKS_DIR);
749
+ const outputs = [];
750
+
751
+ for (const file of files) {
752
+ if (file.endsWith('.output')) {
753
+ try {
754
+ const sanitizedFile = sanitizeFileName(file);
755
+ const filePath = validatePath(path.join(TEMP_TASKS_DIR, sanitizedFile), TEMP_TASKS_DIR);
756
+ const content = await fs.readFile(filePath, 'utf8');
757
+ const stats = await fs.stat(filePath);
758
+
759
+ outputs.push({
760
+ taskId: file.replace('.output', ''),
761
+ content: content.split('\n').slice(-100).join('\n'), // Last 100 lines
762
+ lastModified: stats.mtime,
763
+ size: stats.size
764
+ });
765
+ } catch (error) {
766
+ console.error(`Error reading output file ${sanitizeForLog(file)}:`, error.message);
767
+ }
768
+ }
769
+ }
770
+
771
+ return outputs.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
772
+ } catch (error) {
773
+ if (error.code === 'ENOENT') {
774
+ return [];
775
+ }
776
+ console.error('Error reading agent outputs:', error.message);
777
+ return [];
778
+ }
779
+ }
780
+
781
+ // Sanitize project path to prevent path traversal
782
+ function sanitizeProjectPath(projectPath) {
783
+ if (!projectPath || typeof projectPath !== 'string') {
784
+ throw new Error('Invalid project path');
785
+ }
786
+
787
+ // Reject any absolute paths
788
+ if (path.isAbsolute(projectPath)) {
789
+ throw new Error('Invalid project path: absolute paths not allowed');
790
+ }
791
+
792
+ // Reject parent directory references
793
+ if (projectPath.includes('..') || projectPath.startsWith('.')) {
794
+ throw new Error('Invalid project path: relative paths not allowed');
795
+ }
796
+
797
+ // Reject any path separators (only allow single directory name)
798
+ if (projectPath.includes('/') || projectPath.includes('\\')) {
799
+ throw new Error('Invalid project path: nested paths not allowed');
800
+ }
801
+
802
+ // Only allow alphanumeric, dash, underscore (whitelist approach)
803
+ if (!/^[a-zA-Z0-9_-]+$/.test(projectPath)) {
804
+ throw new Error('Invalid project path format');
805
+ }
806
+
807
+ return projectPath;
808
+ }
809
+
810
+ // Get session history
811
+ async function getSessionHistory(projectPath) {
812
+ try {
813
+ const sanitizedPath = sanitizeProjectPath(projectPath);
814
+ // lgtm[js/path-injection] - Path is sanitized via sanitizeProjectPath with whitelist validation
815
+ const projectDir = path.join(PROJECTS_DIR, sanitizedPath);
816
+
817
+ // Validate the constructed path is within allowed directory
818
+ const validatedDir = validatePath(projectDir, PROJECTS_DIR);
819
+
820
+ // lgtm[js/path-injection] - Path is validated to be within PROJECTS_DIR
821
+ await fs.access(validatedDir);
822
+ // lgtm[js/path-injection] - Path is validated to be within PROJECTS_DIR
823
+ const files = await fs.readdir(validatedDir);
824
+ const sessions = [];
825
+
826
+ for (const file of files) {
827
+ if (file.endsWith('.jsonl')) {
828
+ try {
829
+ // Sanitize file name to prevent path traversal
830
+ const sanitizedFile = sanitizeFileName(file);
831
+ // lgtm[js/path-injection] - Path uses sanitized filename with whitelist validation
832
+ const filePath = path.join(validatedDir, sanitizedFile);
833
+
834
+ // Validate file path is within project directory
835
+ const validatedPath = validatePath(filePath, PROJECTS_DIR);
836
+
837
+ // lgtm[js/path-injection] Path is validated to be within PROJECTS_DIR
838
+ const content = await fs.readFile(validatedPath, 'utf8');
839
+ const lines = content.trim().split('\n').filter(l => l.trim());
840
+
841
+ if (lines.length > 0) {
842
+ const firstLine = JSON.parse(lines[0]);
843
+ const lastLine = JSON.parse(lines[lines.length - 1]);
844
+
845
+ // Get stats after successful read to avoid TOCTOU race condition
846
+ // lgtm[js/path-injection] Path is validated to be within PROJECTS_DIR
847
+ const stats = await fs.stat(validatedPath);
848
+
849
+ sessions.push({
850
+ sessionId: file.replace('.jsonl', ''),
851
+ startTime: firstLine.timestamp || stats.birthtime,
852
+ endTime: lastLine.timestamp || stats.mtime,
853
+ messageCount: lines.length,
854
+ size: stats.size
855
+ });
856
+ }
857
+ } catch (error) {
858
+ console.error(`Error reading session file ${sanitizeForLog(file)}:`, error.message);
859
+ }
860
+ }
861
+ }
862
+
863
+ return sessions.sort((a, b) => new Date(b.startTime) - new Date(a.startTime));
864
+ } catch (error) {
865
+ if (error.code === 'ENOENT') {
866
+ return [];
867
+ }
868
+ console.error('Error reading session history:', error.message);
869
+ return [];
870
+ }
871
+ }
872
+
873
+ // Read all inboxes for a specific team
874
+ async function readTeamInboxes(teamName) {
875
+ try {
876
+ const sanitizedName = sanitizeTeamName(teamName);
877
+ const inboxesDir = path.join(TEAMS_DIR, sanitizedName, 'inboxes');
878
+ const validatedDir = validatePath(inboxesDir, TEAMS_DIR);
879
+
880
+ await fs.access(validatedDir);
881
+ const files = await fs.readdir(validatedDir);
882
+ const inboxes = {};
883
+
884
+ await Promise.all(
885
+ files
886
+ .filter(f => f.endsWith('.json'))
887
+ .map(async (file) => {
888
+ try {
889
+ const sanitizedFile = sanitizeFileName(file);
890
+ const filePath = path.join(validatedDir, sanitizedFile);
891
+ const validatedPath = validatePath(filePath, TEAMS_DIR);
892
+ const content = await fs.readFile(validatedPath, 'utf8');
893
+ const data = JSON.parse(content);
894
+ const messages = Array.isArray(data) ? data : (data.messages || []);
895
+ const agentName = path.basename(sanitizedFile, '.json');
896
+ inboxes[agentName] = { messages, messageCount: messages.length };
897
+ } catch (err) {
898
+ console.error(`Error reading inbox ${sanitizeForLog(file)}:`, err.message);
899
+ }
900
+ })
901
+ );
902
+
903
+ return inboxes;
904
+ } catch (error) {
905
+ if (error.code === 'ENOENT') return {};
906
+ console.error(`Error reading inboxes for team ${sanitizeForLog(teamName)}:`, error.message);
907
+ return {};
908
+ }
909
+ }
910
+
911
+ // Read all inboxes across all active teams
912
+ async function readAllInboxes() {
913
+ try {
914
+ await fs.access(TEAMS_DIR);
915
+ const teamNames = await fs.readdir(TEAMS_DIR);
916
+ const allInboxes = {};
917
+
918
+ await Promise.all(
919
+ teamNames.map(async (teamName) => {
920
+ const inboxes = await readTeamInboxes(teamName);
921
+ if (Object.keys(inboxes).length > 0) {
922
+ allInboxes[teamName] = inboxes;
923
+ }
924
+ })
925
+ );
926
+
927
+ return allInboxes;
928
+ } catch (error) {
929
+ if (error.code === 'ENOENT') return {};
930
+ console.error('Error reading all inboxes:', error.message);
931
+ return {};
932
+ }
933
+ }
934
+
935
+ // Debounced broadcast — prevents redundant broadcasts from rapid file changes
936
+ let broadcastTeamsDebounceTimer = null;
937
+ let broadcastTasksDebounceTimer = null;
938
+
939
+ function debouncedTeamsBroadcast(eventType) {
940
+ if (broadcastTeamsDebounceTimer) clearTimeout(broadcastTeamsDebounceTimer);
941
+ broadcastTeamsDebounceTimer = setTimeout(async () => {
942
+ try {
943
+ cache.delete('activeTeams');
944
+ const teams = await getActiveTeams();
945
+ broadcast({ type: eventType || 'teams_update', data: teams, stats: calculateTeamStats(teams) });
946
+ } catch (err) {
947
+ console.error('[DEBOUNCE] Error broadcasting teams:', err.message);
948
+ }
949
+ }, 300);
950
+ }
951
+
952
+ function debouncedTasksBroadcast() {
953
+ if (broadcastTasksDebounceTimer) clearTimeout(broadcastTasksDebounceTimer);
954
+ broadcastTasksDebounceTimer = setTimeout(async () => {
955
+ try {
956
+ cache.delete('activeTeams');
957
+ const teams = await getActiveTeams();
958
+ broadcast({ type: 'task_update', data: teams, stats: calculateTeamStats(teams) });
959
+ } catch (err) {
960
+ console.error('[DEBOUNCE] Error broadcasting tasks:', err.message);
961
+ }
962
+ }, 300);
963
+ }
964
+
965
+ // Watch for file system changes
966
+ let teamWatcher = null;
967
+ let teamDirWatcher = null;
968
+ let taskWatcher = null;
969
+ let outputWatcher = null;
970
+ let inboxWatcher = null;
971
+
972
+ function setupWatchers() {
973
+ console.log('\nšŸ” Setting up file watchers to track changes...');
974
+
975
+ const watchOptions = {
976
+ persistent: config.WATCH_CONFIG.PERSISTENT,
977
+ ignoreInitial: config.WATCH_CONFIG.IGNORE_INITIAL,
978
+ usePolling: config.WATCH_CONFIG.USE_POLLING,
979
+ interval: config.WATCH_CONFIG.INTERVAL,
980
+ binaryInterval: config.WATCH_CONFIG.BINARY_INTERVAL,
981
+ depth: config.WATCH_CONFIG.DEPTH,
982
+ awaitWriteFinish: config.WATCH_CONFIG.AWAIT_WRITE_FINISH,
983
+ followSymlinks: false
984
+ };
985
+
986
+ // Watch team config files only (not inbox files — those have their own watcher)
987
+ teamWatcher = chokidar.watch(path.join(TEAMS_DIR, '*/config.json'), watchOptions);
988
+
989
+ teamWatcher
990
+ .on('ready', () => {
991
+ console.log(' āœ“ Team watcher is ready - I\'ll notify you when teams change');
992
+ })
993
+ .on('add', async (filePath) => {
994
+ const teamName = path.basename(path.dirname(filePath));
995
+ console.log(`šŸŽ‰ New team created: ${sanitizeForLog(teamName)}`);
996
+ teamLifecycle.set(teamName, {
997
+ created: Date.now(),
998
+ lastSeen: Date.now()
999
+ });
1000
+ debouncedTeamsBroadcast('teams_update');
1001
+ })
1002
+ .on('change', async (filePath) => {
1003
+ const teamName = path.basename(path.dirname(filePath));
1004
+ console.log(`šŸ”„ Team active: ${sanitizeForLog(teamName)}`);
1005
+ if (teamLifecycle.has(teamName)) {
1006
+ teamLifecycle.get(teamName).lastSeen = Date.now();
1007
+ }
1008
+ debouncedTeamsBroadcast('teams_update');
1009
+ })
1010
+ .on('unlink', async (filePath) => {
1011
+ const teamName = path.basename(path.dirname(filePath));
1012
+ console.log(`šŸ‘‹ Team completed: ${sanitizeForLog(teamName)} - archiving for reference...`);
1013
+ try {
1014
+ cache.delete('activeTeams');
1015
+ // Try to get team data before it's gone
1016
+ const teams = await getActiveTeams();
1017
+ const teamData = teams.find(t => t.name === teamName);
1018
+
1019
+ if (teamData) {
1020
+ await archiveTeam(teamName, teamData);
1021
+ const lifecycle = teamLifecycle.get(teamName);
1022
+ if (lifecycle) {
1023
+ const duration = Math.round((Date.now() - lifecycle.created) / 1000 / 60);
1024
+ console.log(` šŸ“Š Team "${sanitizeForLog(teamName)}" was active for ${duration} minutes`);
1025
+ }
1026
+ }
1027
+
1028
+ teamLifecycle.delete(teamName);
1029
+ debouncedTeamsBroadcast('teams_update');
1030
+ } catch (err) {
1031
+ console.error('[TEAM] Error on unlink:', err.message);
1032
+ }
1033
+ })
1034
+ .on('error', error => {
1035
+ console.error('[TEAM] Watcher error:', error);
1036
+ });
1037
+
1038
+ // Watch for team directory deletions (TeamDelete removes the whole dir, not just config.json)
1039
+ // chokidar fires 'unlinkDir' instead of 'unlink' when a directory is removed
1040
+ teamDirWatcher = chokidar.watch(TEAMS_DIR, { ...watchOptions, depth: 0 })
1041
+ .on('unlinkDir', async (dirPath) => {
1042
+ if (path.resolve(dirPath) === path.resolve(TEAMS_DIR)) return; // ignore root dir
1043
+ const teamName = path.basename(dirPath);
1044
+ console.log(`šŸ—‘ļø Team directory removed: ${sanitizeForLog(teamName)}`);
1045
+ teamLifecycle.delete(teamName);
1046
+ debouncedTeamsBroadcast('teams_update');
1047
+ });
1048
+
1049
+ // Watch inbox files — ~/.claude/teams/*/inboxes/*.json
1050
+ inboxWatcher = chokidar.watch(path.join(TEAMS_DIR, '*/inboxes/*.json'), watchOptions);
1051
+
1052
+ inboxWatcher
1053
+ .on('ready', () => {
1054
+ console.log(' āœ“ Inbox watcher is ready - tracking all agent messages');
1055
+ })
1056
+ .on('add', async (filePath) => {
1057
+ // filePath: ~/.claude/teams/<team>/inboxes/<agent>.json
1058
+ const agentName = path.basename(filePath, '.json');
1059
+ const teamName = path.basename(path.dirname(path.dirname(filePath)));
1060
+ console.log(`šŸ“¬ New inbox: ${sanitizeForLog(teamName)}/${sanitizeForLog(agentName)}`);
1061
+ try {
1062
+ const inboxes = await readTeamInboxes(teamName);
1063
+ broadcast({ type: 'inbox_update', teamName, inboxes });
1064
+ } catch (err) {
1065
+ console.error('[INBOX] Error on add:', err.message);
1066
+ }
1067
+ })
1068
+ .on('change', async (filePath) => {
1069
+ const agentName = path.basename(filePath, '.json');
1070
+ const teamName = path.basename(path.dirname(path.dirname(filePath)));
1071
+ console.log(`šŸ’¬ Message received: ${sanitizeForLog(teamName)} → ${sanitizeForLog(agentName)}`);
1072
+ try {
1073
+ const inboxes = await readTeamInboxes(teamName);
1074
+ broadcast({ type: 'inbox_update', teamName, inboxes });
1075
+ } catch (err) {
1076
+ console.error('[INBOX] Error on change:', err.message);
1077
+ }
1078
+ })
1079
+ .on('unlink', async (filePath) => {
1080
+ const agentName = path.basename(filePath, '.json');
1081
+ const teamName = path.basename(path.dirname(path.dirname(filePath)));
1082
+ console.log(`šŸ—‘ļø Inbox removed: ${sanitizeForLog(teamName)}/${sanitizeForLog(agentName)}`);
1083
+ try {
1084
+ const inboxes = await readTeamInboxes(teamName);
1085
+ broadcast({ type: 'inbox_update', teamName, inboxes });
1086
+ } catch (err) {
1087
+ console.error('[INBOX] Error on unlink:', err.message);
1088
+ }
1089
+ })
1090
+ .on('error', error => {
1091
+ console.error('[INBOX] Watcher error:', error);
1092
+ });
1093
+
1094
+ // Watch tasks directory - watch all JSON files recursively
1095
+ taskWatcher = chokidar.watch(path.join(TASKS_DIR, '**/*.json'), watchOptions);
1096
+
1097
+ taskWatcher
1098
+ .on('ready', () => {
1099
+ console.log(' āœ“ Task watcher is ready - tracking all your agent tasks');
1100
+ })
1101
+ .on('add', (filePath) => {
1102
+ console.log(`✨ New task created: ${sanitizeForLog(path.basename(filePath))}`);
1103
+ debouncedTasksBroadcast();
1104
+ })
1105
+ .on('change', (filePath) => {
1106
+ console.log(`šŸ“ Task updated: ${sanitizeForLog(path.basename(filePath))}`);
1107
+ debouncedTasksBroadcast();
1108
+ })
1109
+ .on('unlink', (filePath) => {
1110
+ console.log(`āœ… Task completed/removed: ${sanitizeForLog(path.basename(filePath))}`);
1111
+ debouncedTasksBroadcast();
1112
+ })
1113
+ .on('error', error => {
1114
+ console.error('[TASK] Watcher error:', error);
1115
+ });
1116
+
1117
+ // Watch agent output files
1118
+ outputWatcher = chokidar.watch(
1119
+ path.join(TEMP_TASKS_DIR, '*.output'),
1120
+ watchOptions
1121
+ );
1122
+
1123
+ outputWatcher
1124
+ .on('ready', () => {
1125
+ console.log(' āœ“ Output watcher is ready - monitoring agent activity\n');
1126
+ })
1127
+ .on('change', async (filePath) => {
1128
+ console.log(`šŸ’¬ Agent is working: ${sanitizeForLog(path.basename(filePath))}`);
1129
+ try {
1130
+ const outputs = await getAgentOutputs();
1131
+ broadcast({ type: 'agent_outputs_update', outputs });
1132
+ } catch (err) {
1133
+ console.error('[OUTPUT] Error on change:', err.message);
1134
+ }
1135
+ })
1136
+ .on('add', async (filePath) => {
1137
+ console.log(`šŸŽÆ Agent started: ${sanitizeForLog(path.basename(filePath))}`);
1138
+ try {
1139
+ const outputs = await getAgentOutputs();
1140
+ broadcast({ type: 'agent_outputs_update', outputs });
1141
+ } catch (err) {
1142
+ console.error('[OUTPUT] Error on add:', err.message);
1143
+ }
1144
+ })
1145
+ .on('error', error => {
1146
+ console.error('[OUTPUT] Watcher error:', error);
1147
+ });
1148
+ }
1149
+
1150
+ // WebSocket security constants
1151
+ const WS_HEARTBEAT_INTERVAL = 30000; // 30 seconds between pings
1152
+ const WS_PONG_TIMEOUT = 10000; // 10 seconds to respond with pong
1153
+ const WS_MAX_MESSAGE_SIZE = 65536; // 64KB max message size
1154
+ const WS_RATE_LIMIT_MAX = 50; // max messages per second
1155
+ const WS_RATE_LIMIT_WINDOW = 1000; // 1 second window
1156
+
1157
+ // WebSocket connection handler
1158
+ wss.on('connection', async (ws, req) => {
1159
+ // Always require a valid token in the URL query string
1160
+ const url = new URL(req.url, `http://${req.headers.host}`);
1161
+ const token = url.searchParams.get('token');
1162
+
1163
+ if (!token) {
1164
+ ws.close(4001, 'Authentication required');
1165
+ return;
1166
+ }
1167
+
1168
+ const tokenBuffer = Buffer.from(token);
1169
+ const expectedBuffer = Buffer.from(authToken);
1170
+
1171
+ // timingSafeEqual requires equal-length buffers; check length first
1172
+ if (tokenBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(tokenBuffer, expectedBuffer)) {
1173
+ ws.close(4001, 'Invalid token');
1174
+ return;
1175
+ }
1176
+
1177
+ // Connection audit logging
1178
+ const clientIp = req.socket.remoteAddress || 'unknown';
1179
+ console.log(`WS connected: ${sanitizeForLog(clientIp)}`);
1180
+ clients.add(ws);
1181
+
1182
+ // --- Ping/pong heartbeat ---
1183
+ ws.isAlive = true;
1184
+ let pongTimeout = null;
1185
+
1186
+ ws.on('pong', () => {
1187
+ ws.isAlive = true;
1188
+ if (pongTimeout) {
1189
+ clearTimeout(pongTimeout);
1190
+ pongTimeout = null;
1191
+ }
1192
+ });
1193
+
1194
+ const heartbeatInterval = setInterval(() => {
1195
+ if (!ws.isAlive) {
1196
+ console.log(`WS heartbeat timeout, terminating: ${sanitizeForLog(clientIp)}`);
1197
+ clearInterval(heartbeatInterval);
1198
+ ws.terminate();
1199
+ return;
1200
+ }
1201
+ ws.isAlive = false;
1202
+ ws.ping();
1203
+ pongTimeout = setTimeout(() => {
1204
+ if (!ws.isAlive) {
1205
+ console.log(`WS pong timeout, terminating: ${sanitizeForLog(clientIp)}`);
1206
+ clearInterval(heartbeatInterval);
1207
+ ws.terminate();
1208
+ }
1209
+ }, WS_PONG_TIMEOUT);
1210
+ }, WS_HEARTBEAT_INTERVAL);
1211
+
1212
+ // --- Per-connection message rate limiting ---
1213
+ let messageCount = 0;
1214
+ let rateWindowStart = Date.now();
1215
+
1216
+ // --- Message handler with size validation and rate limiting ---
1217
+ ws.on('message', (data) => {
1218
+ const messageSize = Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data);
1219
+ if (messageSize > WS_MAX_MESSAGE_SIZE) {
1220
+ console.log(`WS message too large (${messageSize} bytes)`);
1221
+ ws.close(1009, 'Message too big');
1222
+ return;
1223
+ }
1224
+
1225
+ const now = Date.now();
1226
+ if (now - rateWindowStart >= WS_RATE_LIMIT_WINDOW) {
1227
+ messageCount = 0;
1228
+ rateWindowStart = now;
1229
+ }
1230
+ messageCount++;
1231
+ if (messageCount > WS_RATE_LIMIT_MAX) {
1232
+ console.log(`WS rate limit exceeded from: ${sanitizeForLog(clientIp)}`);
1233
+ ws.close(1008, 'Policy violation: rate limit exceeded');
1234
+ return;
1235
+ }
1236
+ });
1237
+
1238
+ // Send initial data
1239
+ try {
1240
+ const teams = await getActiveTeams();
1241
+ const stats = calculateTeamStats(teams);
1242
+ const teamHistory = await getTeamHistory();
1243
+ const agentOutputs = await getAgentOutputs();
1244
+ const allInboxes = await readAllInboxes();
1245
+
1246
+ ws.send(JSON.stringify({
1247
+ type: 'initial_data',
1248
+ data: teams,
1249
+ stats,
1250
+ teamHistory,
1251
+ agentOutputs,
1252
+ allInboxes
1253
+ }));
1254
+ } catch (error) {
1255
+ console.error('Failed to send initial WS data:', error.message);
1256
+ try {
1257
+ ws.send(JSON.stringify({ type: 'error', message: 'Failed to load initial data' }));
1258
+ } catch {
1259
+ // Client may have already disconnected
1260
+ }
1261
+ }
1262
+
1263
+ ws.on('close', () => {
1264
+ console.log(`WS disconnected: ${sanitizeForLog(clientIp)}`);
1265
+ clearInterval(heartbeatInterval);
1266
+ if (pongTimeout) clearTimeout(pongTimeout);
1267
+ clients.delete(ws);
1268
+ });
1269
+
1270
+ ws.on('error', (error) => {
1271
+ console.error(`WebSocket error from ${sanitizeForLog(clientIp)}:`, error.message);
1272
+ clearInterval(heartbeatInterval);
1273
+ if (pongTimeout) clearTimeout(pongTimeout);
1274
+ clients.delete(ws);
1275
+ });
1276
+ });
1277
+
1278
+ // REST API endpoints
1279
+ app.get('/api/teams', async (req, res) => {
1280
+ try {
1281
+ const teams = await getCachedActiveTeams();
1282
+ const stats = calculateTeamStats(teams);
1283
+ const body = JSON.stringify({ teams, stats });
1284
+
1285
+ // ETag support — hash the response body for conditional requests
1286
+ const etag = '"' + crypto.createHash('md5').update(body).digest('hex') + '"';
1287
+ res.set('ETag', etag);
1288
+ res.set('Cache-Control', 'public, max-age=5');
1289
+
1290
+ if (req.headers['if-none-match'] === etag) {
1291
+ return res.status(304).end();
1292
+ }
1293
+
1294
+ res.type('json').send(body);
1295
+ } catch (error) {
1296
+ res.status(500).json({ error: 'Internal server error' });
1297
+ }
1298
+ });
1299
+
1300
+ app.get('/api/teams/:teamName', async (req, res) => {
1301
+ try {
1302
+ const teamName = sanitizeTeamName(req.params.teamName);
1303
+ if (teamName !== req.params.teamName) {
1304
+ return res.status(400).json({ error: 'Invalid parameter' });
1305
+ }
1306
+ const config = await readTeamConfig(teamName);
1307
+ const tasks = await readTasks(teamName);
1308
+
1309
+ if (!config) {
1310
+ return res.status(404).json({ error: 'Team not found' });
1311
+ }
1312
+
1313
+ res.json({ config, tasks });
1314
+ } catch (error) {
1315
+ if (error.message.includes('Invalid')) {
1316
+ return res.status(400).json({ error: 'Invalid parameter' });
1317
+ }
1318
+ res.status(500).json({ error: 'Internal server error' });
1319
+ }
1320
+ });
1321
+
1322
+ // Get team inbox messages
1323
+ app.get('/api/teams/:teamName/inboxes', async (req, res) => {
1324
+ try {
1325
+ const teamName = sanitizeTeamName(req.params.teamName);
1326
+ if (teamName !== req.params.teamName) {
1327
+ return res.status(400).json({ error: 'Invalid parameter' });
1328
+ }
1329
+ const inboxes = await readTeamInboxes(teamName);
1330
+ res.json({ inboxes });
1331
+ } catch (error) {
1332
+ if (error.message.includes('Invalid')) {
1333
+ return res.status(400).json({ error: 'Invalid parameter' });
1334
+ }
1335
+ console.error('Error fetching team inboxes:', error.message);
1336
+ res.status(500).json({ error: 'Internal server error' });
1337
+ }
1338
+ });
1339
+
1340
+ // Get specific agent's inbox
1341
+ app.get('/api/teams/:teamName/inboxes/:agentName', async (req, res) => {
1342
+ try {
1343
+ const teamName = sanitizeTeamName(req.params.teamName);
1344
+ if (teamName !== req.params.teamName) {
1345
+ return res.status(400).json({ error: 'Invalid parameter' });
1346
+ }
1347
+ const agentName = sanitizeAgentName(req.params.agentName);
1348
+ if (agentName !== req.params.agentName) {
1349
+ return res.status(400).json({ error: 'Invalid parameter' });
1350
+ }
1351
+ const inboxPath = path.join(TEAMS_DIR, teamName, 'inboxes', `${agentName}.json`);
1352
+ const validatedInboxPath = validatePath(inboxPath, TEAMS_DIR);
1353
+
1354
+ try {
1355
+ const content = await fs.readFile(validatedInboxPath, 'utf8');
1356
+ const data = JSON.parse(content);
1357
+ const messages = Array.isArray(data) ? data : (data.messages || []);
1358
+ res.json({
1359
+ agent: agentName,
1360
+ messages: messages,
1361
+ messageCount: messages.length
1362
+ });
1363
+ } catch (error) {
1364
+ if (error.code === 'ENOENT') {
1365
+ return res.json({ agent: agentName, messages: [], messageCount: 0 });
1366
+ }
1367
+ throw error;
1368
+ }
1369
+ } catch (error) {
1370
+ if (error.message.includes('Invalid')) {
1371
+ return res.status(400).json({ error: 'Invalid parameter' });
1372
+ }
1373
+ res.status(500).json({ error: 'Internal server error' });
1374
+ }
1375
+ });
1376
+
1377
+ // Get paginated tasks for a specific team
1378
+ app.get('/api/teams/:teamName/tasks', async (req, res) => {
1379
+ try {
1380
+ const teamName = sanitizeTeamName(req.params.teamName);
1381
+ if (teamName !== req.params.teamName) {
1382
+ return res.status(400).json({ error: 'Invalid parameter' });
1383
+ }
1384
+ const page = Math.max(1, parseInt(req.query.page) || 1);
1385
+ const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 50));
1386
+ const statusFilter = req.query.status || 'all';
1387
+
1388
+ const tasks = await readTasks(teamName);
1389
+
1390
+ // Filter by status if specified
1391
+ const validStatuses = ['pending', 'in_progress', 'completed'];
1392
+ const filteredTasks = statusFilter === 'all'
1393
+ ? tasks
1394
+ : validStatuses.includes(statusFilter)
1395
+ ? tasks.filter(t => t.status === statusFilter)
1396
+ : tasks;
1397
+
1398
+ const count = filteredTasks.length;
1399
+ const totalPages = Math.ceil(count / limit);
1400
+ const start = (page - 1) * limit;
1401
+ const paginatedTasks = filteredTasks.slice(start, start + limit);
1402
+
1403
+ res.json({ tasks: paginatedTasks, count, page, limit, totalPages, status: statusFilter });
1404
+ } catch (error) {
1405
+ if (error.message.includes('Invalid')) {
1406
+ return res.status(400).json({ error: 'Invalid parameter' });
1407
+ }
1408
+ console.error('Error fetching team tasks:', error.message);
1409
+ res.status(500).json({ error: 'Internal server error' });
1410
+ }
1411
+ });
1412
+
1413
+ // Get archived teams (paginated)
1414
+ app.get('/api/archive', async (req, res) => {
1415
+ try {
1416
+ const page = Math.max(1, parseInt(req.query.page) || 1);
1417
+ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 20));
1418
+
1419
+ const archives = [];
1420
+
1421
+ try {
1422
+ const files = await fs.readdir(ARCHIVE_DIR);
1423
+
1424
+ for (const file of files) {
1425
+ if (file.endsWith('.json')) {
1426
+ try {
1427
+ const sanitizedFile = sanitizeFileName(file);
1428
+ const filePath = validatePath(path.join(ARCHIVE_DIR, sanitizedFile), ARCHIVE_DIR);
1429
+ const content = await fs.readFile(filePath, 'utf8');
1430
+ const data = JSON.parse(content);
1431
+ archives.push({
1432
+ filename: sanitizedFile,
1433
+ ...data.summary,
1434
+ archivedAt: data.archivedAt,
1435
+ // fullPath intentionally excluded (would leak server filesystem paths)
1436
+ });
1437
+ } catch (fileErr) {
1438
+ console.error(`Error reading archive file ${sanitizeForLog(file)}:`, fileErr.message);
1439
+ }
1440
+ }
1441
+ }
1442
+ } catch (err) {
1443
+ // Archive directory doesn't exist yet
1444
+ if (err.code !== 'ENOENT') throw err;
1445
+ }
1446
+
1447
+ // Sort by archived date (newest first)
1448
+ archives.sort((a, b) => new Date(b.archivedAt) - new Date(a.archivedAt));
1449
+
1450
+ const count = archives.length;
1451
+ const totalPages = Math.ceil(count / limit);
1452
+ const start = (page - 1) * limit;
1453
+ const paginatedArchives = archives.slice(start, start + limit);
1454
+
1455
+ res.json({ archives: paginatedArchives, count, page, limit, totalPages });
1456
+ } catch (error) {
1457
+ console.error('Error fetching archives:', error.message);
1458
+ res.status(500).json({ error: 'Internal server error' });
1459
+ }
1460
+ });
1461
+
1462
+ // Get specific archived team details
1463
+ app.get('/api/archive/:filename', async (req, res) => {
1464
+ try {
1465
+ const filename = sanitizeFileName(req.params.filename);
1466
+ if (filename !== req.params.filename) {
1467
+ return res.status(400).json({ error: 'Invalid parameter' });
1468
+ }
1469
+ const filePath = validatePath(path.join(ARCHIVE_DIR, filename), ARCHIVE_DIR);
1470
+
1471
+ let content;
1472
+ try {
1473
+ content = await fs.readFile(filePath, 'utf8');
1474
+ } catch (readError) {
1475
+ return res.status(404).json({ error: 'Archive not found' });
1476
+ }
1477
+
1478
+ let data;
1479
+ try {
1480
+ data = JSON.parse(content);
1481
+ } catch {
1482
+ return res.status(500).json({ error: 'Archive file is corrupt' });
1483
+ }
1484
+
1485
+ res.json(data);
1486
+ } catch (error) {
1487
+ console.error('Error fetching archive:', error.message);
1488
+ res.status(400).json({ error: 'Invalid archive filename' });
1489
+ }
1490
+ });
1491
+
1492
+ // Pre-computed stats endpoint
1493
+ app.get('/api/stats', async (req, res) => {
1494
+ try {
1495
+ const teams = await getCachedActiveTeams();
1496
+ const stats = calculateTeamStats(teams);
1497
+ res.set('Cache-Control', 'public, max-age=5');
1498
+ res.json({ stats, timestamp: new Date().toISOString() });
1499
+ } catch (error) {
1500
+ res.status(500).json({ error: 'Internal server error' });
1501
+ }
1502
+ });
1503
+
1504
+ // Get team history
1505
+ app.get('/api/team-history', async (req, res) => {
1506
+ try {
1507
+ const history = await getTeamHistory();
1508
+ res.json({ history });
1509
+ } catch (error) {
1510
+ res.status(500).json({ error: 'Internal server error' });
1511
+ }
1512
+ });
1513
+
1514
+ // Get all inboxes across all teams
1515
+ app.get('/api/inboxes', async (req, res) => {
1516
+ try {
1517
+ const allInboxes = await readAllInboxes();
1518
+ res.set('Cache-Control', 'public, max-age=2');
1519
+ res.json({ inboxes: allInboxes });
1520
+ } catch (error) {
1521
+ res.status(500).json({ error: 'Internal server error' });
1522
+ }
1523
+ });
1524
+
1525
+ // Get paginated messages across all teams (or specific team)
1526
+ app.get('/api/inboxes/messages', async (req, res) => {
1527
+ try {
1528
+ const page = Math.max(1, parseInt(req.query.page) || 1);
1529
+ const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 50));
1530
+ const teamFilter = req.query.team || null;
1531
+
1532
+ let allInboxes;
1533
+ if (teamFilter) {
1534
+ const sanitizedTeam = sanitizeTeamName(teamFilter);
1535
+ if (sanitizedTeam !== teamFilter) {
1536
+ return res.status(400).json({ error: 'Invalid parameter' });
1537
+ }
1538
+ const inboxes = await readTeamInboxes(sanitizedTeam);
1539
+ allInboxes = Object.keys(inboxes).length > 0 ? { [sanitizedTeam]: inboxes } : {};
1540
+ } else {
1541
+ allInboxes = await readAllInboxes();
1542
+ }
1543
+
1544
+ // Flatten all messages into a single array with metadata
1545
+ const allMessages = [];
1546
+ for (const [teamName, teamInboxes] of Object.entries(allInboxes)) {
1547
+ for (const [agentName, inbox] of Object.entries(teamInboxes)) {
1548
+ for (const msg of (inbox.messages || [])) {
1549
+ const text = typeof msg === 'string' ? msg : (msg.message || msg.content || msg.text || '');
1550
+ const timestamp = msg.timestamp || null;
1551
+ allMessages.push({
1552
+ team: teamName,
1553
+ agent: agentName,
1554
+ message: text.substring(0, 500),
1555
+ timestamp,
1556
+ _sortTime: timestamp ? new Date(timestamp).getTime() : 0
1557
+ });
1558
+ }
1559
+ }
1560
+ }
1561
+
1562
+ // Sort newest first
1563
+ allMessages.sort((a, b) => b._sortTime - a._sortTime);
1564
+
1565
+ // Remove internal sort key
1566
+ const count = allMessages.length;
1567
+ const totalPages = Math.ceil(count / limit);
1568
+ const start = (page - 1) * limit;
1569
+ const paginatedMessages = allMessages.slice(start, start + limit).map(({ _sortTime, ...rest }) => rest);
1570
+
1571
+ res.json({ messages: paginatedMessages, count, page, limit, totalPages });
1572
+ } catch (error) {
1573
+ console.error('Error fetching paginated inboxes:', error.message);
1574
+ res.status(500).json({ error: 'Internal server error' });
1575
+ }
1576
+ });
1577
+
1578
+ // Get agent outputs
1579
+ app.get('/api/agent-outputs', async (req, res) => {
1580
+ try {
1581
+ const outputs = await getAgentOutputs();
1582
+ res.json({ outputs });
1583
+ } catch (error) {
1584
+ res.status(500).json({ error: 'Internal server error' });
1585
+ }
1586
+ });
1587
+
1588
+ // Get specific agent output
1589
+ app.get('/api/agent-outputs/:taskId', async (req, res) => {
1590
+ try {
1591
+ // Validate taskId: strict allowlist, reject if sanitized !== original
1592
+ const rawTaskId = req.params.taskId;
1593
+ if (!rawTaskId || rawTaskId.length > 100) {
1594
+ return res.status(400).json({ error: 'Invalid task ID' });
1595
+ }
1596
+ const taskId = rawTaskId.replace(/[^a-zA-Z0-9_.-]/g, '');
1597
+ if (!taskId || taskId !== rawTaskId) {
1598
+ return res.status(400).json({ error: 'Invalid parameter' });
1599
+ }
1600
+
1601
+ // Construct file path with sanitized taskId
1602
+ const fileName = `${taskId}.output`;
1603
+ const filePath = path.join(TEMP_TASKS_DIR, fileName);
1604
+
1605
+ // Validate the constructed path is within allowed directory
1606
+ const validatedPath = validatePath(filePath, TEMP_TASKS_DIR);
1607
+
1608
+ // Read the output file
1609
+ const content = await fs.readFile(validatedPath, 'utf8');
1610
+ res.json({ taskId, content });
1611
+ } catch (error) {
1612
+ if (error.code === 'ENOENT') {
1613
+ res.status(404).json({ error: 'Output file not found' });
1614
+ } else {
1615
+ console.error('Error reading agent output:', error.message);
1616
+ res.status(500).json({ error: 'Failed to read output file' });
1617
+ }
1618
+ }
1619
+ });
1620
+
1621
+ // Get session history
1622
+ app.get('/api/sessions', async (req, res) => {
1623
+ try {
1624
+ const projectPath = req.query.project || 'D--agentdashboard';
1625
+ const sessions = await getSessionHistory(projectPath);
1626
+ res.json({ sessions });
1627
+ } catch (error) {
1628
+ res.status(500).json({ error: 'Internal server error' });
1629
+ }
1630
+ });
1631
+
1632
+ // Search rate limiter — 10 requests per minute per IP
1633
+ const searchLimiter = rateLimit({
1634
+ windowMs: 60 * 1000,
1635
+ max: 10,
1636
+ message: 'Too many search requests, please try again shortly.',
1637
+ standardHeaders: true,
1638
+ legacyHeaders: false
1639
+ });
1640
+
1641
+ // GET /api/search?q=query — search across teams, agents, tasks, messages
1642
+ app.get('/api/search', searchLimiter, async (req, res) => {
1643
+ try {
1644
+ const q = req.query.q;
1645
+ if (!q || typeof q !== 'string' || q.trim().length < 2) {
1646
+ return res.status(400).json({ error: 'Query parameter "q" is required and must be at least 2 characters.' });
1647
+ }
1648
+ if (q.length > 200) {
1649
+ return res.status(400).json({ error: 'Query too long (max 200 characters).' });
1650
+ }
1651
+
1652
+ // Use simple string indexOf matching (no regex) to prevent ReDoS
1653
+ const query = q.trim().toLowerCase();
1654
+ const MAX_RESULTS = 5;
1655
+
1656
+ // Optional types filter: ?types=teams,tasks,messages,agents (default: all)
1657
+ const validTypes = ['teams', 'tasks', 'messages', 'agents'];
1658
+ const typesParam = req.query.types;
1659
+ const enabledTypes = typesParam
1660
+ ? typesParam.split(',').map(t => t.trim().toLowerCase()).filter(t => validTypes.includes(t))
1661
+ : validTypes;
1662
+
1663
+ const teams = await getCachedActiveTeams();
1664
+ const allInboxes = enabledTypes.includes('messages') ? await readAllInboxes() : {};
1665
+
1666
+ // Search teams by name or config.description
1667
+ const matchedTeams = !enabledTypes.includes('teams') ? [] : teams
1668
+ .filter(t =>
1669
+ t.name.toLowerCase().includes(query) ||
1670
+ (t.config.description && t.config.description.toLowerCase().includes(query))
1671
+ )
1672
+ .slice(0, MAX_RESULTS)
1673
+ .map(t => ({ name: t.name, description: t.config.description || null, memberCount: (t.config.members || []).length }));
1674
+
1675
+ // Search agents by member.name across all teams
1676
+ const matchedAgents = [];
1677
+ if (enabledTypes.includes('agents')) {
1678
+ const seenAgents = new Set();
1679
+ for (const team of teams) {
1680
+ for (const member of (team.config.members || [])) {
1681
+ if (member.name && member.name.toLowerCase().includes(query) && !seenAgents.has(member.name)) {
1682
+ seenAgents.add(member.name);
1683
+ matchedAgents.push({ name: member.name, team: team.name, agentType: member.agentType || null });
1684
+ if (matchedAgents.length >= MAX_RESULTS) break;
1685
+ }
1686
+ }
1687
+ if (matchedAgents.length >= MAX_RESULTS) break;
1688
+ }
1689
+ }
1690
+
1691
+ // Search tasks by subject or description
1692
+ const matchedTasks = [];
1693
+ if (enabledTypes.includes('tasks')) {
1694
+ for (const team of teams) {
1695
+ for (const task of (team.tasks || [])) {
1696
+ if (
1697
+ (task.subject && task.subject.toLowerCase().includes(query)) ||
1698
+ (task.description && task.description.toLowerCase().includes(query))
1699
+ ) {
1700
+ matchedTasks.push({ id: task.id, subject: task.subject, status: task.status, team: team.name });
1701
+ if (matchedTasks.length >= MAX_RESULTS) break;
1702
+ }
1703
+ }
1704
+ if (matchedTasks.length >= MAX_RESULTS) break;
1705
+ }
1706
+ }
1707
+
1708
+ // Search messages by message text from inboxes
1709
+ const matchedMessages = [];
1710
+ for (const [teamName, teamInboxes] of Object.entries(allInboxes)) {
1711
+ for (const [agentName, inbox] of Object.entries(teamInboxes)) {
1712
+ for (const msg of (inbox.messages || [])) {
1713
+ const text = typeof msg === 'string' ? msg : (msg.message || msg.content || msg.text || '');
1714
+ if (text.toLowerCase().includes(query)) {
1715
+ matchedMessages.push({
1716
+ team: teamName,
1717
+ agent: agentName,
1718
+ preview: text.substring(0, 200),
1719
+ timestamp: msg.timestamp || null
1720
+ });
1721
+ if (matchedMessages.length >= MAX_RESULTS) break;
1722
+ }
1723
+ }
1724
+ if (matchedMessages.length >= MAX_RESULTS) break;
1725
+ }
1726
+ if (matchedMessages.length >= MAX_RESULTS) break;
1727
+ }
1728
+
1729
+ res.json({ teams: matchedTeams, agents: matchedAgents, tasks: matchedTasks, messages: matchedMessages });
1730
+ } catch (error) {
1731
+ console.error('Search error:', error.message);
1732
+ res.status(500).json({ error: 'Internal server error' });
1733
+ }
1734
+ });
1735
+
1736
+ // GET /api/metrics — aggregate metrics across all teams
1737
+ app.get('/api/metrics', async (req, res) => {
1738
+ try {
1739
+ const teams = await getCachedActiveTeams();
1740
+ const allInboxes = await readAllInboxes();
1741
+
1742
+ const now = Date.now();
1743
+ const thirtyMinutesAgo = now - 30 * 60 * 1000;
1744
+ const twentyFourHoursAgo = now - 24 * 60 * 60 * 1000;
1745
+
1746
+ let totalMessages = 0;
1747
+ const activeAgentSet = new Set();
1748
+ const teamsWithActivitySet = new Set();
1749
+
1750
+ for (const [teamName, teamInboxes] of Object.entries(allInboxes)) {
1751
+ for (const [agentName, inbox] of Object.entries(teamInboxes)) {
1752
+ const messages = inbox.messages || [];
1753
+ totalMessages += messages.length;
1754
+
1755
+ for (const msg of messages) {
1756
+ const ts = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
1757
+ if (ts > thirtyMinutesAgo) {
1758
+ activeAgentSet.add(agentName);
1759
+ }
1760
+ if (ts > twentyFourHoursAgo) {
1761
+ teamsWithActivitySet.add(teamName);
1762
+ }
1763
+ }
1764
+ }
1765
+ }
1766
+
1767
+ let totalAgents = 0;
1768
+ let totalTasks = 0;
1769
+ for (const team of teams) {
1770
+ totalAgents += (team.config.members || []).length;
1771
+ totalTasks += (team.tasks || []).length;
1772
+ }
1773
+
1774
+ res.json({
1775
+ totalTeams: teams.length,
1776
+ totalAgents,
1777
+ totalTasks,
1778
+ totalMessages,
1779
+ activeAgents: activeAgentSet.size,
1780
+ teamsWithActivity: teamsWithActivitySet.size
1781
+ });
1782
+ } catch (error) {
1783
+ console.error('Metrics error:', error.message);
1784
+ res.status(500).json({ error: 'Internal server error' });
1785
+ }
1786
+ });
1787
+
1788
+ // GET /api/agents — all unique agents across all teams with stats
1789
+ app.get('/api/agents', async (req, res) => {
1790
+ try {
1791
+ const teams = await getCachedActiveTeams();
1792
+ const allInboxes = await readAllInboxes();
1793
+
1794
+ // Build agent map: name -> { teams, messageCount, lastSeen }
1795
+ const agentMap = new Map();
1796
+
1797
+ // Collect agents from team configs
1798
+ for (const team of teams) {
1799
+ for (const member of (team.config.members || [])) {
1800
+ if (!member.name) continue;
1801
+ if (!agentMap.has(member.name)) {
1802
+ agentMap.set(member.name, { name: member.name, teams: [], messageCount: 0, lastSeen: null });
1803
+ }
1804
+ const entry = agentMap.get(member.name);
1805
+ if (!entry.teams.includes(team.name)) {
1806
+ entry.teams.push(team.name);
1807
+ }
1808
+ }
1809
+ }
1810
+
1811
+ // Enrich with inbox data
1812
+ for (const [teamName, teamInboxes] of Object.entries(allInboxes)) {
1813
+ for (const [agentName, inbox] of Object.entries(teamInboxes)) {
1814
+ if (!agentMap.has(agentName)) {
1815
+ agentMap.set(agentName, { name: agentName, teams: [teamName], messageCount: 0, lastSeen: null });
1816
+ }
1817
+ const entry = agentMap.get(agentName);
1818
+ if (!entry.teams.includes(teamName)) {
1819
+ entry.teams.push(teamName);
1820
+ }
1821
+ const messages = inbox.messages || [];
1822
+ entry.messageCount += messages.length;
1823
+
1824
+ for (const msg of messages) {
1825
+ if (msg.timestamp) {
1826
+ const ts = new Date(msg.timestamp).getTime();
1827
+ if (!entry.lastSeen || ts > entry.lastSeen) {
1828
+ entry.lastSeen = ts;
1829
+ }
1830
+ }
1831
+ }
1832
+ }
1833
+ }
1834
+
1835
+ const now = Date.now();
1836
+ const thirtyMinutesAgo = now - 30 * 60 * 1000;
1837
+
1838
+ const agents = Array.from(agentMap.values()).map(a => ({
1839
+ name: a.name,
1840
+ teams: a.teams,
1841
+ messageCount: a.messageCount,
1842
+ lastSeen: a.lastSeen ? new Date(a.lastSeen).toISOString() : null,
1843
+ status: a.lastSeen && a.lastSeen > thirtyMinutesAgo ? 'active' : 'idle'
1844
+ }));
1845
+
1846
+ res.json({ agents });
1847
+ } catch (error) {
1848
+ console.error('Agents error:', error.message);
1849
+ res.status(500).json({ error: 'Internal server error' });
1850
+ }
1851
+ });
1852
+
1853
+ // GET /api/stats/live — real-time counts for teams, tasks, messages, agents
1854
+ app.get('/api/stats/live', async (req, res) => {
1855
+ try {
1856
+ const teams = await getCachedActiveTeams();
1857
+ const allInboxes = await readAllInboxes();
1858
+
1859
+ let totalMessages = 0;
1860
+ let activeAgents = 0;
1861
+ const now = Date.now();
1862
+ const thirtyMinutesAgo = now - 30 * 60 * 1000;
1863
+ const activeSet = new Set();
1864
+
1865
+ for (const [, teamInboxes] of Object.entries(allInboxes)) {
1866
+ for (const [agentName, inbox] of Object.entries(teamInboxes)) {
1867
+ const messages = inbox.messages || [];
1868
+ totalMessages += messages.length;
1869
+ for (const msg of messages) {
1870
+ const ts = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
1871
+ if (ts > thirtyMinutesAgo) {
1872
+ activeSet.add(agentName);
1873
+ }
1874
+ }
1875
+ }
1876
+ }
1877
+ activeAgents = activeSet.size;
1878
+
1879
+ let totalAgents = 0;
1880
+ let totalTasks = 0;
1881
+ let pendingTasks = 0;
1882
+ let inProgressTasks = 0;
1883
+ let completedTasks = 0;
1884
+ for (const team of teams) {
1885
+ totalAgents += (team.config.members || []).length;
1886
+ for (const task of (team.tasks || [])) {
1887
+ totalTasks++;
1888
+ if (task.status === 'pending') pendingTasks++;
1889
+ else if (task.status === 'in_progress') inProgressTasks++;
1890
+ else if (task.status === 'completed') completedTasks++;
1891
+ }
1892
+ }
1893
+
1894
+ res.set('Cache-Control', 'public, max-age=2');
1895
+ res.json({
1896
+ teams: teams.length,
1897
+ agents: totalAgents,
1898
+ activeAgents,
1899
+ tasks: totalTasks,
1900
+ pendingTasks,
1901
+ inProgressTasks,
1902
+ completedTasks,
1903
+ messages: totalMessages,
1904
+ timestamp: new Date().toISOString()
1905
+ });
1906
+ } catch (error) {
1907
+ console.error('Stats/live error:', error.message);
1908
+ res.status(500).json({ error: 'Internal server error' });
1909
+ }
1910
+ });
1911
+
1912
+ /**
1913
+ * Registers SIGTERM/SIGINT handlers to gracefully close the HTTP server,
1914
+ * WebSocket connections, and file watchers before exiting.
1915
+ */
1916
+ function setupGracefulShutdown() {
1917
+ let isShuttingDown = false;
1918
+
1919
+ const shutdown = async (signal) => {
1920
+ if (isShuttingDown) return;
1921
+ isShuttingDown = true;
1922
+
1923
+ console.log(`\n\nšŸ‘‹ Shutting down gracefully...`);
1924
+
1925
+ // Stop accepting new connections
1926
+ server.close(() => {
1927
+ console.log(' āœ“ Stopped accepting new connections');
1928
+ });
1929
+
1930
+ // Close WebSocket connections
1931
+ const closePromises = [];
1932
+ clients.forEach(client => {
1933
+ if (client.readyState === WebSocket.OPEN) {
1934
+ closePromises.push(
1935
+ new Promise(resolve => {
1936
+ client.close(1001, 'Server shutting down');
1937
+ resolve();
1938
+ })
1939
+ );
1940
+ }
1941
+ });
1942
+ await Promise.all(closePromises);
1943
+ console.log(' āœ“ All viewers disconnected');
1944
+
1945
+ // Close file watchers
1946
+ try {
1947
+ if (teamWatcher) await teamWatcher.close();
1948
+ if (teamDirWatcher) await teamDirWatcher.close();
1949
+ if (taskWatcher) await taskWatcher.close();
1950
+ if (outputWatcher) await outputWatcher.close();
1951
+ if (inboxWatcher) await inboxWatcher.close();
1952
+ console.log(' āœ“ Stopped monitoring files');
1953
+ } catch (error) {
1954
+ console.error('Error closing watchers:', error.message);
1955
+ }
1956
+
1957
+ console.log('\n✨ Dashboard shut down successfully. See you next time!\n');
1958
+ process.exit(0);
1959
+ };
1960
+
1961
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
1962
+ process.on('SIGINT', () => shutdown('SIGINT'));
1963
+ }
1964
+
1965
+ // Error handling middleware — never leak internal error details to clients
1966
+ app.use((err, req, res, next) => {
1967
+ console.error('API Error:', err.message);
1968
+ res.status(err.status || 500).json({ error: 'Internal server error' });
1969
+ });
1970
+
1971
+ // SPA fallback — serve index.html for all non-API routes (Express 5 compatible)
1972
+ app.use((req, res) => {
1973
+ res.sendFile(path.join(__dirname, 'dist', 'index.html'));
1974
+ });
1975
+
1976
+ // Global error handlers — prevent crashes from async watcher callbacks
1977
+ process.on('uncaughtException', (err) => {
1978
+ console.error('Uncaught exception:', err);
1979
+ process.exit(1);
1980
+ });
1981
+ process.on('unhandledRejection', (reason) => {
1982
+ console.error('Unhandled promise rejection:', reason);
1983
+ });
1984
+
1985
+ // Start server
1986
+ server.listen(config.PORT, () => {
1987
+ console.log(`\nšŸš€ Dashboard is live and ready!`);
1988
+ console.log(` You can view it at: http://localhost:${config.PORT}`);
1989
+ console.log(`\nšŸ“” Real-time updates enabled - your teams will sync automatically`);
1990
+ console.log(`\nšŸ‘€ Watching for activity:`);
1991
+ console.log(` Teams: ${TEAMS_DIR}`);
1992
+ console.log(` Tasks: ${TASKS_DIR}`);
1993
+ console.log(` Inboxes: ${path.join(TEAMS_DIR, '*/inboxes/*.json')}`);
1994
+ setupWatchers();
1995
+ setupGracefulShutdown();
1996
+ });