claude-team-dashboard 1.2.2

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.

Potentially problematic release.


This version of claude-team-dashboard might be problematic. Click here for more details.

Files changed (49) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/LICENSE +21 -0
  3. package/README.md +722 -0
  4. package/cleanup.js +73 -0
  5. package/config.js +50 -0
  6. package/dist/assets/icons-Ijf8rQIc.js +1 -0
  7. package/dist/assets/index-Cqc1m1x_.css +1 -0
  8. package/dist/assets/index-jGy3ms0W.js +9 -0
  9. package/dist/assets/react-vendor-DbmSkCAF.js +1 -0
  10. package/dist/index.html +16 -0
  11. package/index.html +13 -0
  12. package/package.json +93 -0
  13. package/server.js +953 -0
  14. package/src/App.jsx +372 -0
  15. package/src/animations-enhanced.css +929 -0
  16. package/src/animations.css +783 -0
  17. package/src/components/ActivityFeed.jsx +289 -0
  18. package/src/components/AgentActivity.jsx +104 -0
  19. package/src/components/AgentCard.jsx +163 -0
  20. package/src/components/AgentOutputViewer.jsx +334 -0
  21. package/src/components/ArchiveViewer.jsx +283 -0
  22. package/src/components/ConnectionStatus.jsx +124 -0
  23. package/src/components/DetailedTaskProgress.jsx +126 -0
  24. package/src/components/ErrorBoundary.jsx +132 -0
  25. package/src/components/Header.jsx +154 -0
  26. package/src/components/LiveAgentStream.jsx +176 -0
  27. package/src/components/LiveCommunication.jsx +326 -0
  28. package/src/components/LiveMetrics.jsx +100 -0
  29. package/src/components/RealTimeMessages.jsx +298 -0
  30. package/src/components/SkeletonLoader.jsx +384 -0
  31. package/src/components/StatsOverview.jsx +209 -0
  32. package/src/components/SystemStatus.jsx +57 -0
  33. package/src/components/TaskList.jsx +306 -0
  34. package/src/components/TeamCard.jsx +126 -0
  35. package/src/components/TeamHistory.jsx +204 -0
  36. package/src/components/__tests__/ConnectionStatus.test.jsx +54 -0
  37. package/src/components/__tests__/StatsOverview.test.jsx +66 -0
  38. package/src/config/constants.js +59 -0
  39. package/src/hooks/useCounterAnimation.js +219 -0
  40. package/src/hooks/useWebSocket.js +76 -0
  41. package/src/index.css +1818 -0
  42. package/src/main.jsx +17 -0
  43. package/src/polish-enhancements.css +303 -0
  44. package/src/premium-visual-polish.css +830 -0
  45. package/src/responsive-enhancements.css +666 -0
  46. package/src/styles/theme.css +395 -0
  47. package/src/test/setup.js +19 -0
  48. package/start.js +36 -0
  49. package/vite.config.js +37 -0
package/server.js ADDED
@@ -0,0 +1,953 @@
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 config = require('./config');
12
+
13
+ const app = express();
14
+ const server = http.createServer(app);
15
+ const wss = new WebSocket.Server({
16
+ server,
17
+ verifyClient: (info) => {
18
+ // Validate WebSocket origin
19
+ const origin = info.origin || info.req.headers.origin;
20
+ return !origin || config.CORS_ORIGINS.includes(origin);
21
+ }
22
+ });
23
+
24
+ // Security middleware
25
+ app.use(helmet(config.HELMET_CONFIG));
26
+
27
+ // Rate limiting
28
+ const limiter = rateLimit({
29
+ windowMs: config.RATE_LIMIT.WINDOW_MS,
30
+ max: config.RATE_LIMIT.MAX_REQUESTS,
31
+ message: config.RATE_LIMIT.MESSAGE,
32
+ standardHeaders: true,
33
+ legacyHeaders: false
34
+ });
35
+
36
+ app.use('/api/', limiter);
37
+
38
+ // Restrict CORS to localhost only for security
39
+ app.use(cors({
40
+ origin: config.CORS_ORIGINS,
41
+ credentials: true
42
+ }));
43
+ app.use(express.json());
44
+
45
+ // Paths to Claude Code agent team files
46
+ const homeDir = os.homedir();
47
+ const TEAMS_DIR = path.join(homeDir, '.claude', 'teams');
48
+ const TASKS_DIR = path.join(homeDir, '.claude', 'tasks');
49
+ const PROJECTS_DIR = path.join(homeDir, '.claude', 'projects');
50
+ const TEMP_TASKS_DIR = path.join(os.tmpdir(), 'claude', 'D--agentdashboard', 'tasks');
51
+ const ARCHIVE_DIR = path.join(homeDir, '.claude', 'archive');
52
+
53
+ // Store connected clients
54
+ const clients = new Set();
55
+
56
+ // Team lifecycle tracking
57
+ const teamLifecycle = new Map(); // teamName -> { created, lastSeen, archived }
58
+
59
+ // Archive team data before deletion
60
+ async function archiveTeam(teamName, teamData) {
61
+ try {
62
+ const timestamp = new Date().toISOString().replace(/:/g, '-');
63
+ const archiveFile = path.join(ARCHIVE_DIR, `${teamName}_${timestamp}.json`);
64
+
65
+ // Ensure archive directory exists
66
+ await fs.mkdir(ARCHIVE_DIR, { recursive: true });
67
+
68
+ // Create natural language summary
69
+ const summary = {
70
+ teamName,
71
+ archivedAt: new Date().toISOString(),
72
+ summary: generateTeamSummary(teamData),
73
+ rawData: teamData
74
+ };
75
+
76
+ await fs.writeFile(archiveFile, JSON.stringify(summary, null, 2));
77
+ console.log(`šŸ“¦ Team archived: ${teamName} → ${archiveFile}`);
78
+
79
+ return archiveFile;
80
+ } catch (error) {
81
+ console.error(`Error archiving team ${teamName}:`, error);
82
+ }
83
+ }
84
+
85
+ // Generate natural language summary of team activity
86
+ function generateTeamSummary(teamData) {
87
+ const members = teamData.config?.members || [];
88
+ const tasks = teamData.tasks || [];
89
+ const completedTasks = tasks.filter(t => t.status === 'completed').length;
90
+ const totalTasks = tasks.length;
91
+
92
+ const createdDate = teamData.config?.createdAt
93
+ ? new Date(teamData.config.createdAt).toLocaleDateString()
94
+ : 'Unknown';
95
+
96
+ return {
97
+ overview: `Team "${teamData.name}" with ${members.length} members worked on ${totalTasks} tasks and completed ${completedTasks}.`,
98
+ created: `Started on ${createdDate}`,
99
+ members: members.map(m => `${m.name} (${m.agentType})`),
100
+ accomplishments: tasks
101
+ .filter(t => t.status === 'completed')
102
+ .map(t => `āœ… ${t.subject}`)
103
+ .slice(0, 10), // Top 10
104
+ duration: teamData.config?.createdAt
105
+ ? `Active for ${Math.round((Date.now() - teamData.config.createdAt) / 1000 / 60)} minutes`
106
+ : 'Unknown duration'
107
+ };
108
+ }
109
+
110
+ // Broadcast to all connected clients (with dead client cleanup)
111
+ function broadcast(data) {
112
+ const message = JSON.stringify(data);
113
+ const deadClients = new Set();
114
+
115
+ clients.forEach(client => {
116
+ if (client.readyState === WebSocket.OPEN) {
117
+ try {
118
+ client.send(message);
119
+ } catch (error) {
120
+ console.error('Error sending to client:', error.message);
121
+ deadClients.add(client);
122
+ }
123
+ } else {
124
+ deadClients.add(client);
125
+ }
126
+ });
127
+
128
+ // Remove dead connections to prevent memory leak
129
+ deadClients.forEach(client => clients.delete(client));
130
+ }
131
+
132
+ // Sanitize team name to prevent path traversal attacks
133
+ function sanitizeTeamName(teamName) {
134
+ if (!teamName || typeof teamName !== 'string') {
135
+ throw new Error('Invalid team name');
136
+ }
137
+
138
+ // Reject any path separators to prevent traversal
139
+ if (teamName.includes('/') || teamName.includes('\\') || teamName.includes(path.sep)) {
140
+ throw new Error('Invalid team name: path separators not allowed');
141
+ }
142
+
143
+ // Reject parent directory references
144
+ if (teamName.includes('..') || teamName.startsWith('.')) {
145
+ throw new Error('Invalid team name: relative paths not allowed');
146
+ }
147
+
148
+ // Only allow alphanumeric, dash, underscore (whitelist approach)
149
+ if (!/^[a-zA-Z0-9_-]+$/.test(teamName)) {
150
+ throw new Error('Invalid team name format');
151
+ }
152
+
153
+ // Return the sanitized team name (now guaranteed safe for path operations)
154
+ return teamName;
155
+ }
156
+
157
+ // Sanitize string for logging to prevent log injection
158
+ function sanitizeForLog(input) {
159
+ if (typeof input !== 'string') {
160
+ return String(input);
161
+ }
162
+ // Remove control characters that could be used for log injection
163
+ return input.replace(/[\x00-\x1F\x7F-\x9F]/g, '');
164
+ }
165
+
166
+ // Sanitize filename to prevent path traversal
167
+ function sanitizeFileName(fileName) {
168
+ if (!fileName || typeof fileName !== 'string') {
169
+ throw new Error('Invalid file name');
170
+ }
171
+
172
+ // Reject any path separators
173
+ if (fileName.includes('/') || fileName.includes('\\') || fileName.includes(path.sep)) {
174
+ throw new Error('Invalid file name: path separators not allowed');
175
+ }
176
+
177
+ // Reject parent directory references
178
+ if (fileName.includes('..')) {
179
+ throw new Error('Invalid file name: relative paths not allowed');
180
+ }
181
+
182
+ // Use basename as additional safety layer
183
+ const baseName = path.basename(fileName);
184
+
185
+ // Only allow safe characters (whitelist approach)
186
+ if (!/^[a-zA-Z0-9_.-]+$/.test(baseName)) {
187
+ throw new Error('Invalid file name format');
188
+ }
189
+
190
+ // Return the sanitized filename (now guaranteed safe for path operations)
191
+ return baseName;
192
+ }
193
+
194
+ // Validate path is within allowed directory
195
+ function validatePath(filePath, allowedDir) {
196
+ const normalizedPath = path.resolve(filePath);
197
+ const normalizedDir = path.resolve(allowedDir);
198
+
199
+ // Use relative path to detect traversal attempts
200
+ const relativePath = path.relative(normalizedDir, normalizedPath);
201
+
202
+ // Check if relative path tries to go outside (starts with .. or is absolute)
203
+ if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
204
+ throw new Error('Path traversal attempt detected');
205
+ }
206
+
207
+ return normalizedPath;
208
+ }
209
+
210
+ // Read team configuration
211
+ async function readTeamConfig(teamName) {
212
+ try {
213
+ const sanitizedName = sanitizeTeamName(teamName);
214
+ // Build path from sanitized components only - no user input in final path
215
+ const teamDir = path.join(TEAMS_DIR, sanitizedName);
216
+ const configPath = path.join(teamDir, 'config.json');
217
+
218
+ // Double-check the constructed path is within allowed directory
219
+ const validatedPath = validatePath(configPath, TEAMS_DIR);
220
+ // lgtm[js/path-injection] - Path is constructed from sanitized teamName that only allows [a-zA-Z0-9_-]
221
+ const data = await fs.readFile(validatedPath, 'utf8');
222
+ return JSON.parse(data);
223
+ } catch (error) {
224
+ console.error('Error reading team config:', {
225
+ team: sanitizeForLog(teamName),
226
+ error: error.message
227
+ });
228
+ return null;
229
+ }
230
+ }
231
+
232
+ // Read all tasks for a team
233
+ async function readTasks(teamName) {
234
+ try {
235
+ const sanitizedName = sanitizeTeamName(teamName);
236
+ const tasksPath = path.join(TASKS_DIR, sanitizedName);
237
+ const validatedTasksPath = validatePath(tasksPath, TASKS_DIR);
238
+ // lgtm[js/path-injection] - Path is constructed from sanitized teamName that only allows [a-zA-Z0-9_-]
239
+ const files = await fs.readdir(validatedTasksPath);
240
+
241
+ // Use Promise.all for parallel file reads (performance improvement)
242
+ const taskPromises = files
243
+ .filter(file => file.endsWith('.json'))
244
+ .map(async file => {
245
+ try {
246
+ // Sanitize file name to prevent path traversal
247
+ const sanitizedFile = sanitizeFileName(file);
248
+ const taskPath = path.join(validatedTasksPath, sanitizedFile);
249
+ const validatedPath = validatePath(taskPath, TASKS_DIR);
250
+ // lgtm[js/path-injection] - Path is constructed from sanitized fileName that only allows [a-zA-Z0-9_.-]
251
+ const data = await fs.readFile(validatedPath, 'utf8');
252
+ const task = JSON.parse(data);
253
+ return { ...task, id: path.basename(sanitizedFile, '.json') };
254
+ } catch (fileError) {
255
+ console.error('Error reading task file:', {
256
+ file: sanitizeForLog(file),
257
+ error: fileError.message
258
+ });
259
+ return null;
260
+ }
261
+ });
262
+
263
+ const tasks = (await Promise.all(taskPromises))
264
+ .filter(task => task !== null)
265
+ .sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
266
+ return tasks;
267
+ } catch (error) {
268
+ console.error('Error reading tasks:', {
269
+ team: sanitizeForLog(teamName),
270
+ error: error.message
271
+ });
272
+ return [];
273
+ }
274
+ }
275
+
276
+ // Get all active teams
277
+ async function getActiveTeams() {
278
+ try {
279
+ await fs.access(TEAMS_DIR);
280
+ const teams = await fs.readdir(TEAMS_DIR);
281
+ const teamData = [];
282
+
283
+ for (const teamName of teams) {
284
+ const config = await readTeamConfig(teamName);
285
+ if (config) {
286
+ const tasks = await readTasks(teamName);
287
+ teamData.push({
288
+ name: teamName,
289
+ config,
290
+ tasks,
291
+ lastUpdated: new Date().toISOString()
292
+ });
293
+ }
294
+ }
295
+
296
+ return teamData;
297
+ } catch (error) {
298
+ if (error.code === 'ENOENT') {
299
+ console.log('Teams directory does not exist yet');
300
+ return [];
301
+ }
302
+ console.error('Error reading teams:', error.message);
303
+ return [];
304
+ }
305
+ }
306
+
307
+ // Calculate team statistics
308
+ function calculateTeamStats(teams) {
309
+ const stats = {
310
+ totalTeams: teams.length,
311
+ totalAgents: 0,
312
+ totalTasks: 0,
313
+ pendingTasks: 0,
314
+ inProgressTasks: 0,
315
+ completedTasks: 0,
316
+ blockedTasks: 0
317
+ };
318
+
319
+ teams.forEach(team => {
320
+ stats.totalAgents += (team.config.members || []).length;
321
+ stats.totalTasks += team.tasks.length;
322
+
323
+ team.tasks.forEach(task => {
324
+ switch (task.status) {
325
+ case 'pending':
326
+ stats.pendingTasks++;
327
+ if (task.blockedBy && task.blockedBy.length > 0) {
328
+ stats.blockedTasks++;
329
+ }
330
+ break;
331
+ case 'in_progress':
332
+ stats.inProgressTasks++;
333
+ break;
334
+ case 'completed':
335
+ stats.completedTasks++;
336
+ break;
337
+ }
338
+ });
339
+ });
340
+
341
+ return stats;
342
+ }
343
+
344
+ // Get team history (all teams including past ones)
345
+ async function getTeamHistory() {
346
+ try {
347
+ await fs.access(TEAMS_DIR);
348
+ const teamNames = await fs.readdir(TEAMS_DIR);
349
+ const history = [];
350
+
351
+ for (const teamName of teamNames) {
352
+ try {
353
+ const config = await readTeamConfig(teamName);
354
+ const tasks = await readTasks(teamName);
355
+
356
+ if (config) {
357
+ // Get team directory stats for timestamps
358
+ const teamDir = path.join(TEAMS_DIR, sanitizeTeamName(teamName));
359
+ const stats = await fs.stat(teamDir);
360
+
361
+ history.push({
362
+ name: teamName,
363
+ config,
364
+ tasks,
365
+ createdAt: stats.birthtime,
366
+ lastModified: stats.mtime,
367
+ isActive: true
368
+ });
369
+ }
370
+ } catch (error) {
371
+ console.error(`Error reading team history for ${teamName}:`, error.message);
372
+ }
373
+ }
374
+
375
+ // Sort by last modified (most recent first)
376
+ return history.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
377
+ } catch (error) {
378
+ if (error.code === 'ENOENT') {
379
+ return [];
380
+ }
381
+ console.error('Error reading team history:', error.message);
382
+ return [];
383
+ }
384
+ }
385
+
386
+ // Get agent output files
387
+ async function getAgentOutputs() {
388
+ try {
389
+ await fs.access(TEMP_TASKS_DIR);
390
+ const files = await fs.readdir(TEMP_TASKS_DIR);
391
+ const outputs = [];
392
+
393
+ for (const file of files) {
394
+ if (file.endsWith('.output')) {
395
+ try {
396
+ const filePath = path.join(TEMP_TASKS_DIR, file);
397
+ const content = await fs.readFile(filePath, 'utf8');
398
+ const stats = await fs.stat(filePath);
399
+
400
+ outputs.push({
401
+ taskId: file.replace('.output', ''),
402
+ content: content.split('\n').slice(-100).join('\n'), // Last 100 lines
403
+ lastModified: stats.mtime,
404
+ size: stats.size
405
+ });
406
+ } catch (error) {
407
+ console.error(`Error reading output file ${file}:`, error.message);
408
+ }
409
+ }
410
+ }
411
+
412
+ return outputs.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
413
+ } catch (error) {
414
+ if (error.code === 'ENOENT') {
415
+ return [];
416
+ }
417
+ console.error('Error reading agent outputs:', error.message);
418
+ return [];
419
+ }
420
+ }
421
+
422
+ // Sanitize project path to prevent path traversal
423
+ function sanitizeProjectPath(projectPath) {
424
+ if (!projectPath || typeof projectPath !== 'string') {
425
+ throw new Error('Invalid project path');
426
+ }
427
+
428
+ // Reject any absolute paths
429
+ if (path.isAbsolute(projectPath)) {
430
+ throw new Error('Invalid project path: absolute paths not allowed');
431
+ }
432
+
433
+ // Reject parent directory references
434
+ if (projectPath.includes('..') || projectPath.startsWith('.')) {
435
+ throw new Error('Invalid project path: relative paths not allowed');
436
+ }
437
+
438
+ // Reject any path separators (only allow single directory name)
439
+ if (projectPath.includes('/') || projectPath.includes('\\')) {
440
+ throw new Error('Invalid project path: nested paths not allowed');
441
+ }
442
+
443
+ // Only allow alphanumeric, dash, underscore (whitelist approach)
444
+ if (!/^[a-zA-Z0-9_-]+$/.test(projectPath)) {
445
+ throw new Error('Invalid project path format');
446
+ }
447
+
448
+ return projectPath;
449
+ }
450
+
451
+ // Get session history
452
+ async function getSessionHistory(projectPath) {
453
+ try {
454
+ const sanitizedPath = sanitizeProjectPath(projectPath);
455
+ // lgtm[js/path-injection] - Path is sanitized via sanitizeProjectPath with whitelist validation
456
+ const projectDir = path.join(PROJECTS_DIR, sanitizedPath);
457
+
458
+ // Validate the constructed path is within allowed directory
459
+ const validatedDir = validatePath(projectDir, PROJECTS_DIR);
460
+
461
+ // lgtm[js/path-injection] - Path is validated to be within PROJECTS_DIR
462
+ await fs.access(validatedDir);
463
+ // lgtm[js/path-injection] - Path is validated to be within PROJECTS_DIR
464
+ const files = await fs.readdir(validatedDir);
465
+ const sessions = [];
466
+
467
+ for (const file of files) {
468
+ if (file.endsWith('.jsonl')) {
469
+ try {
470
+ // Sanitize file name to prevent path traversal
471
+ const sanitizedFile = sanitizeFileName(file);
472
+ // lgtm[js/path-injection] - Path uses sanitized filename with whitelist validation
473
+ const filePath = path.join(validatedDir, sanitizedFile);
474
+
475
+ // Validate file path is within project directory
476
+ const validatedPath = validatePath(filePath, PROJECTS_DIR);
477
+
478
+ // lgtm[js/path-injection] Path is validated to be within PROJECTS_DIR
479
+ const content = await fs.readFile(validatedPath, 'utf8');
480
+ const lines = content.trim().split('\n').filter(l => l.trim());
481
+
482
+ if (lines.length > 0) {
483
+ const firstLine = JSON.parse(lines[0]);
484
+ const lastLine = JSON.parse(lines[lines.length - 1]);
485
+
486
+ // Get stats after successful read to avoid TOCTOU race condition
487
+ // lgtm[js/path-injection] Path is validated to be within PROJECTS_DIR
488
+ const stats = await fs.stat(validatedPath);
489
+
490
+ sessions.push({
491
+ sessionId: file.replace('.jsonl', ''),
492
+ startTime: firstLine.timestamp || stats.birthtime,
493
+ endTime: lastLine.timestamp || stats.mtime,
494
+ messageCount: lines.length,
495
+ size: stats.size
496
+ });
497
+ }
498
+ } catch (error) {
499
+ console.error(`Error reading session file ${file}:`, error.message);
500
+ }
501
+ }
502
+ }
503
+
504
+ return sessions.sort((a, b) => new Date(b.startTime) - new Date(a.startTime));
505
+ } catch (error) {
506
+ if (error.code === 'ENOENT') {
507
+ return [];
508
+ }
509
+ console.error('Error reading session history:', error.message);
510
+ return [];
511
+ }
512
+ }
513
+
514
+ // Watch for file system changes
515
+ let teamWatcher = null;
516
+ let taskWatcher = null;
517
+ let outputWatcher = null;
518
+
519
+ function setupWatchers() {
520
+ console.log('\nšŸ” Setting up file watchers to track changes...');
521
+
522
+ const watchOptions = {
523
+ persistent: config.WATCH_CONFIG.PERSISTENT,
524
+ ignoreInitial: config.WATCH_CONFIG.IGNORE_INITIAL,
525
+ usePolling: config.WATCH_CONFIG.USE_POLLING,
526
+ interval: config.WATCH_CONFIG.INTERVAL,
527
+ binaryInterval: config.WATCH_CONFIG.BINARY_INTERVAL,
528
+ depth: config.WATCH_CONFIG.DEPTH,
529
+ awaitWriteFinish: config.WATCH_CONFIG.AWAIT_WRITE_FINISH
530
+ };
531
+
532
+ // Watch teams directory - watch all JSON files recursively
533
+ teamWatcher = chokidar.watch(path.join(TEAMS_DIR, '**/*.json'), watchOptions);
534
+
535
+ teamWatcher
536
+ .on('ready', () => {
537
+ console.log(' āœ“ Team watcher is ready - I\'ll notify you when teams change');
538
+ })
539
+ .on('add', async (filePath) => {
540
+ const teamName = path.basename(path.dirname(filePath));
541
+ if (path.basename(filePath) === 'config.json') {
542
+ console.log(`šŸŽ‰ New team created: ${teamName}`);
543
+ teamLifecycle.set(teamName, {
544
+ created: Date.now(),
545
+ lastSeen: Date.now()
546
+ });
547
+ }
548
+ const teams = await getActiveTeams();
549
+ broadcast({ type: 'teams_update', data: teams, stats: calculateTeamStats(teams) });
550
+ })
551
+ .on('change', async (filePath) => {
552
+ const teamName = path.basename(path.dirname(filePath));
553
+ console.log(`šŸ”„ Team active: ${teamName}`);
554
+ if (teamLifecycle.has(teamName)) {
555
+ teamLifecycle.get(teamName).lastSeen = Date.now();
556
+ }
557
+ const teams = await getActiveTeams();
558
+ broadcast({ type: 'teams_update', data: teams, stats: calculateTeamStats(teams) });
559
+ })
560
+ .on('unlink', async (filePath) => {
561
+ const teamName = path.basename(path.dirname(filePath));
562
+ if (path.basename(filePath) === 'config.json') {
563
+ console.log(`šŸ‘‹ Team completed: ${teamName} - archiving for reference...`);
564
+
565
+ // Try to get team data before it's gone
566
+ const teams = await getActiveTeams();
567
+ const teamData = teams.find(t => t.name === teamName);
568
+
569
+ if (teamData) {
570
+ await archiveTeam(teamName, teamData);
571
+ const lifecycle = teamLifecycle.get(teamName);
572
+ if (lifecycle) {
573
+ const duration = Math.round((Date.now() - lifecycle.created) / 1000 / 60);
574
+ console.log(` šŸ“Š Team "${teamName}" was active for ${duration} minutes`);
575
+ }
576
+ }
577
+
578
+ teamLifecycle.delete(teamName);
579
+ }
580
+ const teams = await getActiveTeams();
581
+ broadcast({ type: 'teams_update', data: teams, stats: calculateTeamStats(teams) });
582
+ })
583
+ .on('error', error => {
584
+ console.error('[TEAM] Watcher error:', error);
585
+ });
586
+
587
+ // Watch tasks directory - watch all JSON files recursively
588
+ taskWatcher = chokidar.watch(path.join(TASKS_DIR, '**/*.json'), watchOptions);
589
+
590
+ taskWatcher
591
+ .on('ready', () => {
592
+ console.log(' āœ“ Task watcher is ready - tracking all your agent tasks');
593
+ })
594
+ .on('add', async (filePath) => {
595
+ console.log(`✨ New task created: ${path.basename(filePath)}`);
596
+ const teams = await getActiveTeams();
597
+ broadcast({ type: 'task_update', data: teams, stats: calculateTeamStats(teams) });
598
+ })
599
+ .on('change', async (filePath) => {
600
+ console.log(`šŸ“ Task updated: ${path.basename(filePath)}`);
601
+ const teams = await getActiveTeams();
602
+ broadcast({ type: 'task_update', data: teams, stats: calculateTeamStats(teams) });
603
+ })
604
+ .on('unlink', async (filePath) => {
605
+ console.log(`āœ… Task completed/removed: ${path.basename(filePath)}`);
606
+ const teams = await getActiveTeams();
607
+ broadcast({ type: 'task_update', data: teams, stats: calculateTeamStats(teams) });
608
+ })
609
+ .on('error', error => {
610
+ console.error('[TASK] Watcher error:', error);
611
+ });
612
+
613
+ // Watch agent output files
614
+ outputWatcher = chokidar.watch(
615
+ path.join(TEMP_TASKS_DIR, '*.output'),
616
+ watchOptions
617
+ );
618
+
619
+ outputWatcher
620
+ .on('ready', () => {
621
+ console.log(' āœ“ Output watcher is ready - monitoring agent activity\n');
622
+ })
623
+ .on('change', async (filePath) => {
624
+ console.log(`šŸ’¬ Agent is working: ${path.basename(filePath)}`);
625
+ const outputs = await getAgentOutputs();
626
+ broadcast({ type: 'agent_outputs_update', outputs });
627
+ })
628
+ .on('add', async (filePath) => {
629
+ console.log(`šŸŽÆ Agent started: ${path.basename(filePath)}`);
630
+ const outputs = await getAgentOutputs();
631
+ broadcast({ type: 'agent_outputs_update', outputs });
632
+ })
633
+ .on('error', error => {
634
+ console.error('[OUTPUT] Watcher error:', error);
635
+ });
636
+ }
637
+
638
+ // WebSocket connection handler
639
+ wss.on('connection', async (ws) => {
640
+ console.log('šŸ‘‹ A new viewer joined the dashboard');
641
+ clients.add(ws);
642
+
643
+ // Send initial data
644
+ try {
645
+ const teams = await getActiveTeams();
646
+ const stats = calculateTeamStats(teams);
647
+ const teamHistory = await getTeamHistory();
648
+ const agentOutputs = await getAgentOutputs();
649
+
650
+ ws.send(JSON.stringify({
651
+ type: 'initial_data',
652
+ data: teams,
653
+ stats,
654
+ teamHistory,
655
+ agentOutputs
656
+ }));
657
+ } catch (error) {
658
+ console.error('Error sending initial data:', error);
659
+ }
660
+
661
+ ws.on('close', () => {
662
+ console.log('šŸ‘‹ A viewer left the dashboard');
663
+ clients.delete(ws);
664
+ });
665
+
666
+ ws.on('error', (error) => {
667
+ console.error('WebSocket error:', error);
668
+ clients.delete(ws);
669
+ });
670
+ });
671
+
672
+ // REST API endpoints
673
+ app.get('/api/teams', async (req, res) => {
674
+ try {
675
+ const teams = await getActiveTeams();
676
+ const stats = calculateTeamStats(teams);
677
+ res.json({ teams, stats });
678
+ } catch (error) {
679
+ res.status(500).json({ error: error.message });
680
+ }
681
+ });
682
+
683
+ app.get('/api/teams/:teamName', async (req, res) => {
684
+ try {
685
+ const config = await readTeamConfig(req.params.teamName);
686
+ const tasks = await readTasks(req.params.teamName);
687
+
688
+ if (!config) {
689
+ return res.status(404).json({ error: 'Team not found' });
690
+ }
691
+
692
+ res.json({ config, tasks });
693
+ } catch (error) {
694
+ res.status(500).json({ error: error.message });
695
+ }
696
+ });
697
+
698
+ // Get team inbox messages
699
+ app.get('/api/teams/:teamName/inboxes', async (req, res) => {
700
+ try {
701
+ const teamName = sanitizeProjectPath(req.params.teamName);
702
+ const inboxesDir = path.join(TEAMS_DIR, teamName, 'inboxes');
703
+
704
+ // Check if inboxes directory exists
705
+ try {
706
+ await fs.access(inboxesDir);
707
+ } catch {
708
+ return res.json({ inboxes: [] });
709
+ }
710
+
711
+ // Read all inbox files
712
+ const files = await fs.readdir(inboxesDir);
713
+ const inboxFiles = files.filter(f => f.endsWith('.json'));
714
+
715
+ const inboxes = {};
716
+
717
+ for (const file of inboxFiles) {
718
+ const agentName = file.replace('.json', '');
719
+ const filePath = path.join(inboxesDir, file);
720
+
721
+ try {
722
+ const content = await fs.readFile(filePath, 'utf8');
723
+ const data = JSON.parse(content);
724
+ // Handle both array format (data is array) and object format (data.messages)
725
+ const messages = Array.isArray(data) ? data : (data.messages || []);
726
+ inboxes[agentName] = {
727
+ messages: messages,
728
+ messageCount: messages.length
729
+ };
730
+ } catch (error) {
731
+ console.error(`Error reading inbox ${file}:`, error.message);
732
+ inboxes[agentName] = { messages: [], messageCount: 0, error: error.message };
733
+ }
734
+ }
735
+
736
+ res.json({ inboxes });
737
+ } catch (error) {
738
+ res.status(500).json({ error: error.message });
739
+ }
740
+ });
741
+
742
+ // Get specific agent's inbox
743
+ app.get('/api/teams/:teamName/inboxes/:agentName', async (req, res) => {
744
+ try {
745
+ const teamName = sanitizeProjectPath(req.params.teamName);
746
+ const agentName = sanitizeFileName(req.params.agentName);
747
+ const inboxPath = path.join(TEAMS_DIR, teamName, 'inboxes', `${agentName}.json`);
748
+
749
+ try {
750
+ const content = await fs.readFile(inboxPath, 'utf8');
751
+ const data = JSON.parse(content);
752
+ // Handle both array format (data is array) and object format (data.messages)
753
+ const messages = Array.isArray(data) ? data : (data.messages || []);
754
+ res.json({
755
+ agent: agentName,
756
+ messages: messages,
757
+ messageCount: messages.length
758
+ });
759
+ } catch (error) {
760
+ if (error.code === 'ENOENT') {
761
+ return res.json({ agent: agentName, messages: [], messageCount: 0 });
762
+ }
763
+ throw error;
764
+ }
765
+ } catch (error) {
766
+ res.status(500).json({ error: error.message });
767
+ }
768
+ });
769
+
770
+ // Get archived teams
771
+ app.get('/api/archive', async (req, res) => {
772
+ try {
773
+ const archives = [];
774
+
775
+ try {
776
+ const files = await fs.readdir(ARCHIVE_DIR);
777
+
778
+ for (const file of files) {
779
+ if (file.endsWith('.json')) {
780
+ const filePath = path.join(ARCHIVE_DIR, file);
781
+ const content = await fs.readFile(filePath, 'utf8');
782
+ const data = JSON.parse(content);
783
+ archives.push({
784
+ filename: file,
785
+ ...data.summary,
786
+ archivedAt: data.archivedAt,
787
+ fullPath: filePath
788
+ });
789
+ }
790
+ }
791
+ } catch (err) {
792
+ // Archive directory doesn't exist yet
793
+ if (err.code !== 'ENOENT') throw err;
794
+ }
795
+
796
+ // Sort by archived date (newest first)
797
+ archives.sort((a, b) => new Date(b.archivedAt) - new Date(a.archivedAt));
798
+
799
+ res.json({ archives, count: archives.length });
800
+ } catch (error) {
801
+ console.error('Error fetching archives:', error);
802
+ res.status(500).json({ error: 'Failed to fetch archived teams' });
803
+ }
804
+ });
805
+
806
+ // Get specific archived team details
807
+ app.get('/api/archive/:filename', async (req, res) => {
808
+ try {
809
+ const filename = req.params.filename;
810
+ const filePath = path.join(ARCHIVE_DIR, filename);
811
+
812
+ const content = await fs.readFile(filePath, 'utf8');
813
+ const data = JSON.parse(content);
814
+
815
+ res.json(data);
816
+ } catch (error) {
817
+ console.error('Error fetching archive:', error);
818
+ res.status(404).json({ error: 'Archive not found' });
819
+ }
820
+ });
821
+
822
+ app.get('/api/health', (req, res) => {
823
+ res.json({
824
+ status: 'healthy',
825
+ timestamp: new Date().toISOString(),
826
+ watchers: {
827
+ teams: TEAMS_DIR,
828
+ tasks: TASKS_DIR
829
+ }
830
+ });
831
+ });
832
+
833
+ // Get team history
834
+ app.get('/api/team-history', async (req, res) => {
835
+ try {
836
+ const history = await getTeamHistory();
837
+ res.json({ history });
838
+ } catch (error) {
839
+ res.status(500).json({ error: error.message });
840
+ }
841
+ });
842
+
843
+ // Get agent outputs
844
+ app.get('/api/agent-outputs', async (req, res) => {
845
+ try {
846
+ const outputs = await getAgentOutputs();
847
+ res.json({ outputs });
848
+ } catch (error) {
849
+ res.status(500).json({ error: error.message });
850
+ }
851
+ });
852
+
853
+ // Get specific agent output
854
+ app.get('/api/agent-outputs/:taskId', async (req, res) => {
855
+ try {
856
+ // Sanitize taskId to prevent path traversal
857
+ const taskId = req.params.taskId.replace(/[^a-zA-Z0-9_-]/g, '');
858
+
859
+ // Validate taskId is not empty after sanitization
860
+ if (!taskId || taskId.length === 0) {
861
+ return res.status(400).json({ error: 'Invalid task ID' });
862
+ }
863
+
864
+ // Construct file path with sanitized taskId
865
+ const fileName = `${taskId}.output`;
866
+ const filePath = path.join(TEMP_TASKS_DIR, fileName);
867
+
868
+ // Validate the constructed path is within allowed directory
869
+ const validatedPath = validatePath(filePath, TEMP_TASKS_DIR);
870
+
871
+ // Read the output file
872
+ const content = await fs.readFile(validatedPath, 'utf8');
873
+ res.json({ taskId, content });
874
+ } catch (error) {
875
+ if (error.code === 'ENOENT') {
876
+ res.status(404).json({ error: 'Output file not found' });
877
+ } else {
878
+ console.error('Error reading agent output:', error.message);
879
+ res.status(500).json({ error: 'Failed to read output file' });
880
+ }
881
+ }
882
+ });
883
+
884
+ // Get session history
885
+ app.get('/api/sessions', async (req, res) => {
886
+ try {
887
+ const projectPath = req.query.project || 'D--agentdashboard';
888
+ const sessions = await getSessionHistory(projectPath);
889
+ res.json({ sessions });
890
+ } catch (error) {
891
+ res.status(500).json({ error: error.message });
892
+ }
893
+ });
894
+
895
+ // Graceful shutdown handler
896
+ function setupGracefulShutdown() {
897
+ let isShuttingDown = false;
898
+
899
+ const shutdown = async (signal) => {
900
+ if (isShuttingDown) return;
901
+ isShuttingDown = true;
902
+
903
+ console.log(`\n\nšŸ‘‹ Shutting down gracefully...`);
904
+
905
+ // Stop accepting new connections
906
+ server.close(() => {
907
+ console.log(' āœ“ Stopped accepting new connections');
908
+ });
909
+
910
+ // Close WebSocket connections
911
+ const closePromises = [];
912
+ clients.forEach(client => {
913
+ if (client.readyState === WebSocket.OPEN) {
914
+ closePromises.push(
915
+ new Promise(resolve => {
916
+ client.close(1001, 'Server shutting down');
917
+ resolve();
918
+ })
919
+ );
920
+ }
921
+ });
922
+ await Promise.all(closePromises);
923
+ console.log(' āœ“ All viewers disconnected');
924
+
925
+ // Close file watchers
926
+ try {
927
+ if (teamWatcher) await teamWatcher.close();
928
+ if (taskWatcher) await taskWatcher.close();
929
+ if (outputWatcher) await outputWatcher.close();
930
+ console.log(' āœ“ Stopped monitoring files');
931
+ } catch (error) {
932
+ console.error('Error closing watchers:', error.message);
933
+ }
934
+
935
+ console.log('\n✨ Dashboard shut down successfully. See you next time!\n');
936
+ process.exit(0);
937
+ };
938
+
939
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
940
+ process.on('SIGINT', () => shutdown('SIGINT'));
941
+ }
942
+
943
+ // Start server
944
+ server.listen(config.PORT, () => {
945
+ console.log(`\nšŸš€ Dashboard is live and ready!`);
946
+ console.log(` You can view it at: http://localhost:${config.PORT}`);
947
+ console.log(`\nšŸ“” Real-time updates enabled - your teams will sync automatically`);
948
+ console.log(`\nšŸ‘€ Watching for activity:`);
949
+ console.log(` Teams: ${TEAMS_DIR}`);
950
+ console.log(` Tasks: ${TASKS_DIR}`);
951
+ setupWatchers();
952
+ setupGracefulShutdown();
953
+ });