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.
- package/CHANGELOG.md +76 -0
- package/LICENSE +21 -0
- package/README.md +722 -0
- package/cleanup.js +73 -0
- package/config.js +50 -0
- package/dist/assets/icons-Ijf8rQIc.js +1 -0
- package/dist/assets/index-Cqc1m1x_.css +1 -0
- package/dist/assets/index-jGy3ms0W.js +9 -0
- package/dist/assets/react-vendor-DbmSkCAF.js +1 -0
- package/dist/index.html +16 -0
- package/index.html +13 -0
- package/package.json +93 -0
- package/server.js +953 -0
- package/src/App.jsx +372 -0
- package/src/animations-enhanced.css +929 -0
- package/src/animations.css +783 -0
- package/src/components/ActivityFeed.jsx +289 -0
- package/src/components/AgentActivity.jsx +104 -0
- package/src/components/AgentCard.jsx +163 -0
- package/src/components/AgentOutputViewer.jsx +334 -0
- package/src/components/ArchiveViewer.jsx +283 -0
- package/src/components/ConnectionStatus.jsx +124 -0
- package/src/components/DetailedTaskProgress.jsx +126 -0
- package/src/components/ErrorBoundary.jsx +132 -0
- package/src/components/Header.jsx +154 -0
- package/src/components/LiveAgentStream.jsx +176 -0
- package/src/components/LiveCommunication.jsx +326 -0
- package/src/components/LiveMetrics.jsx +100 -0
- package/src/components/RealTimeMessages.jsx +298 -0
- package/src/components/SkeletonLoader.jsx +384 -0
- package/src/components/StatsOverview.jsx +209 -0
- package/src/components/SystemStatus.jsx +57 -0
- package/src/components/TaskList.jsx +306 -0
- package/src/components/TeamCard.jsx +126 -0
- package/src/components/TeamHistory.jsx +204 -0
- package/src/components/__tests__/ConnectionStatus.test.jsx +54 -0
- package/src/components/__tests__/StatsOverview.test.jsx +66 -0
- package/src/config/constants.js +59 -0
- package/src/hooks/useCounterAnimation.js +219 -0
- package/src/hooks/useWebSocket.js +76 -0
- package/src/index.css +1818 -0
- package/src/main.jsx +17 -0
- package/src/polish-enhancements.css +303 -0
- package/src/premium-visual-polish.css +830 -0
- package/src/responsive-enhancements.css +666 -0
- package/src/styles/theme.css +395 -0
- package/src/test/setup.js +19 -0
- package/start.js +36 -0
- 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
|
+
});
|