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/LICENSE +21 -0
- package/README.md +898 -0
- package/cleanup.js +73 -0
- package/config.js +55 -0
- package/dist/assets/AgentNetworkGraph-D5Yt9AQ3.js +3 -0
- package/dist/assets/AnalyticsPanel-sfGbRsBJ.js +1 -0
- package/dist/assets/ArchiveViewer-CUZaBhkp.js +1 -0
- package/dist/assets/TaskDependencyGraph-DnviYlHT.js +1 -0
- package/dist/assets/charts-F7VEQUVb.js +36 -0
- package/dist/assets/d3-core-8TMKOAYc.js +1 -0
- package/dist/assets/icons-Bi3kLKdY.js +1 -0
- package/dist/assets/index-CMd_AsXc.js +43 -0
- package/dist/assets/index-D5qSH_Zn.css +1 -0
- package/dist/assets/utils-mxxv0KtI.js +177 -0
- package/dist/icons/icon-192.png +0 -0
- package/dist/icons/icon-512.png +0 -0
- package/dist/index.html +33 -0
- package/dist/manifest.json +15 -0
- package/dist/sw.js +105 -0
- package/package.json +81 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/manifest.json +15 -0
- package/public/sw.js +105 -0
- package/server.js +1996 -0
- package/start.js +24 -0
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
|
+
});
|