agent-relay 1.0.8 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +158 -0
  2. package/dist/bridge/config.d.ts +41 -0
  3. package/dist/bridge/config.d.ts.map +1 -0
  4. package/dist/bridge/config.js +143 -0
  5. package/dist/bridge/config.js.map +1 -0
  6. package/dist/bridge/index.d.ts +10 -0
  7. package/dist/bridge/index.d.ts.map +1 -0
  8. package/dist/bridge/index.js +10 -0
  9. package/dist/bridge/index.js.map +1 -0
  10. package/dist/bridge/multi-project-client.d.ts +99 -0
  11. package/dist/bridge/multi-project-client.d.ts.map +1 -0
  12. package/dist/bridge/multi-project-client.js +386 -0
  13. package/dist/bridge/multi-project-client.js.map +1 -0
  14. package/dist/bridge/spawner.d.ts +46 -0
  15. package/dist/bridge/spawner.d.ts.map +1 -0
  16. package/dist/bridge/spawner.js +223 -0
  17. package/dist/bridge/spawner.js.map +1 -0
  18. package/dist/bridge/types.d.ts +55 -0
  19. package/dist/bridge/types.d.ts.map +1 -0
  20. package/dist/bridge/types.js +6 -0
  21. package/dist/bridge/types.js.map +1 -0
  22. package/dist/bridge/utils.d.ts +30 -0
  23. package/dist/bridge/utils.d.ts.map +1 -0
  24. package/dist/bridge/utils.js +54 -0
  25. package/dist/bridge/utils.js.map +1 -0
  26. package/dist/cli/index.js +564 -5
  27. package/dist/cli/index.js.map +1 -1
  28. package/dist/daemon/agent-registry.d.ts.map +1 -1
  29. package/dist/daemon/agent-registry.js +6 -1
  30. package/dist/daemon/agent-registry.js.map +1 -1
  31. package/dist/daemon/connection.d.ts +22 -0
  32. package/dist/daemon/connection.d.ts.map +1 -1
  33. package/dist/daemon/connection.js +59 -13
  34. package/dist/daemon/connection.js.map +1 -1
  35. package/dist/daemon/router.d.ts +27 -0
  36. package/dist/daemon/router.d.ts.map +1 -1
  37. package/dist/daemon/router.js +108 -3
  38. package/dist/daemon/router.js.map +1 -1
  39. package/dist/daemon/server.d.ts +8 -0
  40. package/dist/daemon/server.d.ts.map +1 -1
  41. package/dist/daemon/server.js +95 -23
  42. package/dist/daemon/server.js.map +1 -1
  43. package/dist/dashboard/metrics.d.ts +105 -0
  44. package/dist/dashboard/metrics.d.ts.map +1 -0
  45. package/dist/dashboard/metrics.js +192 -0
  46. package/dist/dashboard/metrics.js.map +1 -0
  47. package/dist/dashboard/needs-attention.d.ts +24 -0
  48. package/dist/dashboard/needs-attention.d.ts.map +1 -0
  49. package/dist/dashboard/needs-attention.js +78 -0
  50. package/dist/dashboard/needs-attention.js.map +1 -0
  51. package/dist/dashboard/public/bridge.html +1272 -0
  52. package/dist/dashboard/public/index.html +2017 -879
  53. package/dist/dashboard/public/js/app.js +184 -0
  54. package/dist/dashboard/public/js/app.js.map +7 -0
  55. package/dist/dashboard/public/metrics.html +999 -0
  56. package/dist/dashboard/server.d.ts +13 -0
  57. package/dist/dashboard/server.d.ts.map +1 -1
  58. package/dist/dashboard/server.js +568 -13
  59. package/dist/dashboard/server.js.map +1 -1
  60. package/dist/dashboard/start.js +1 -1
  61. package/dist/dashboard/start.js.map +1 -1
  62. package/dist/dashboard-v2/index.d.ts +10 -0
  63. package/dist/dashboard-v2/index.d.ts.map +1 -0
  64. package/dist/dashboard-v2/index.js +54 -0
  65. package/dist/dashboard-v2/index.js.map +1 -0
  66. package/dist/dashboard-v2/lib/api.d.ts +95 -0
  67. package/dist/dashboard-v2/lib/api.d.ts.map +1 -0
  68. package/dist/dashboard-v2/lib/api.js +270 -0
  69. package/dist/dashboard-v2/lib/api.js.map +1 -0
  70. package/dist/dashboard-v2/lib/colors.d.ts +61 -0
  71. package/dist/dashboard-v2/lib/colors.d.ts.map +1 -0
  72. package/dist/dashboard-v2/lib/colors.js +198 -0
  73. package/dist/dashboard-v2/lib/colors.js.map +1 -0
  74. package/dist/dashboard-v2/lib/hierarchy.d.ts +74 -0
  75. package/dist/dashboard-v2/lib/hierarchy.d.ts.map +1 -0
  76. package/dist/dashboard-v2/lib/hierarchy.js +196 -0
  77. package/dist/dashboard-v2/lib/hierarchy.js.map +1 -0
  78. package/dist/dashboard-v2/types/index.d.ts +154 -0
  79. package/dist/dashboard-v2/types/index.d.ts.map +1 -0
  80. package/dist/dashboard-v2/types/index.js +6 -0
  81. package/dist/dashboard-v2/types/index.js.map +1 -0
  82. package/dist/storage/adapter.d.ts +21 -1
  83. package/dist/storage/adapter.d.ts.map +1 -1
  84. package/dist/storage/adapter.js +36 -0
  85. package/dist/storage/adapter.js.map +1 -1
  86. package/dist/storage/sqlite-adapter.d.ts +34 -0
  87. package/dist/storage/sqlite-adapter.d.ts.map +1 -1
  88. package/dist/storage/sqlite-adapter.js +253 -12
  89. package/dist/storage/sqlite-adapter.js.map +1 -1
  90. package/dist/utils/agent-config.d.ts +45 -0
  91. package/dist/utils/agent-config.d.ts.map +1 -0
  92. package/dist/utils/agent-config.js +118 -0
  93. package/dist/utils/agent-config.js.map +1 -0
  94. package/dist/wrapper/client.d.ts +8 -0
  95. package/dist/wrapper/client.d.ts.map +1 -1
  96. package/dist/wrapper/client.js +26 -0
  97. package/dist/wrapper/client.js.map +1 -1
  98. package/dist/wrapper/parser.d.ts +17 -0
  99. package/dist/wrapper/parser.d.ts.map +1 -1
  100. package/dist/wrapper/parser.js +334 -10
  101. package/dist/wrapper/parser.js.map +1 -1
  102. package/dist/wrapper/tmux-wrapper.d.ts +37 -2
  103. package/dist/wrapper/tmux-wrapper.d.ts.map +1 -1
  104. package/dist/wrapper/tmux-wrapper.js +178 -18
  105. package/dist/wrapper/tmux-wrapper.js.map +1 -1
  106. package/docs/AGENTS.md +105 -0
  107. package/docs/ARCHITECTURE_DECISIONS.md +175 -0
  108. package/docs/COMPETITIVE_ANALYSIS.md +897 -0
  109. package/docs/DESIGN_BRIDGE_STAFFING.md +878 -0
  110. package/docs/MONETIZATION.md +1679 -0
  111. package/docs/agent-relay-snippet.md +61 -0
  112. package/docs/dashboard-v2-plan.md +179 -0
  113. package/package.json +5 -2
@@ -3,12 +3,22 @@ import { WebSocketServer, WebSocket } from 'ws';
3
3
  import http from 'http';
4
4
  import path from 'path';
5
5
  import fs from 'fs';
6
+ import crypto from 'crypto';
6
7
  import { fileURLToPath } from 'url';
7
8
  import { SqliteStorageAdapter } from '../storage/sqlite-adapter.js';
8
9
  import { RelayClient } from '../wrapper/client.js';
10
+ import { computeNeedsAttention } from './needs-attention.js';
11
+ import { computeSystemMetrics, formatPrometheusMetrics } from './metrics.js';
12
+ import { MultiProjectClient } from '../bridge/multi-project-client.js';
13
+ import { AgentSpawner } from '../bridge/spawner.js';
9
14
  const __filename = fileURLToPath(import.meta.url);
10
15
  const __dirname = path.dirname(__filename);
11
- export async function startDashboard(port, dataDir, teamDir, dbPath) {
16
+ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPathArg) {
17
+ // Handle overloaded signatures
18
+ const options = typeof portOrOptions === 'number'
19
+ ? { port: portOrOptions, dataDir: dataDirArg, teamDir: teamDirArg, dbPath: dbPathArg }
20
+ : portOrOptions;
21
+ const { port, dataDir, teamDir, dbPath, enableSpawner, projectRoot, tmuxSession } = options;
12
22
  console.log('Starting dashboard...');
13
23
  console.log('__dirname:', __dirname);
14
24
  const publicDir = path.join(__dirname, 'public');
@@ -16,6 +26,10 @@ export async function startDashboard(port, dataDir, teamDir, dbPath) {
16
26
  const storage = dbPath
17
27
  ? new SqliteStorageAdapter({ dbPath })
18
28
  : undefined;
29
+ // Initialize spawner if enabled
30
+ const spawner = enableSpawner
31
+ ? new AgentSpawner(projectRoot || dataDir, tmuxSession)
32
+ : undefined;
19
33
  process.on('uncaughtException', (err) => {
20
34
  console.error('Uncaught Exception:', err);
21
35
  });
@@ -24,7 +38,47 @@ export async function startDashboard(port, dataDir, teamDir, dbPath) {
24
38
  });
25
39
  const app = express();
26
40
  const server = http.createServer(app);
27
- const wss = new WebSocketServer({ server, path: '/ws' });
41
+ // Use noServer mode to manually route upgrade requests
42
+ // This prevents the bug where multiple WebSocketServers attached to the same
43
+ // HTTP server cause conflicts - each one's upgrade handler fires and the ones
44
+ // that don't match the path call abortHandshake(400), writing raw HTTP to the socket
45
+ const wss = new WebSocketServer({
46
+ noServer: true,
47
+ perMessageDeflate: false,
48
+ skipUTF8Validation: true,
49
+ maxPayload: 100 * 1024 * 1024 // 100MB
50
+ });
51
+ const wssBridge = new WebSocketServer({
52
+ noServer: true,
53
+ perMessageDeflate: false,
54
+ skipUTF8Validation: true,
55
+ maxPayload: 100 * 1024 * 1024
56
+ });
57
+ // Manually handle upgrade requests and route to correct WebSocketServer
58
+ server.on('upgrade', (request, socket, head) => {
59
+ const pathname = new URL(request.url || '', `http://${request.headers.host}`).pathname;
60
+ if (pathname === '/ws') {
61
+ wss.handleUpgrade(request, socket, head, (ws) => {
62
+ wss.emit('connection', ws, request);
63
+ });
64
+ }
65
+ else if (pathname === '/ws/bridge') {
66
+ wssBridge.handleUpgrade(request, socket, head, (ws) => {
67
+ wssBridge.emit('connection', ws, request);
68
+ });
69
+ }
70
+ else {
71
+ // Unknown path - destroy socket
72
+ socket.destroy();
73
+ }
74
+ });
75
+ // Server-level error handlers
76
+ wss.on('error', (err) => {
77
+ console.error('[dashboard] WebSocket server error:', err);
78
+ });
79
+ wssBridge.on('error', (err) => {
80
+ console.error('[dashboard] Bridge WebSocket server error:', err);
81
+ });
28
82
  if (storage) {
29
83
  await storage.init();
30
84
  }
@@ -64,9 +118,65 @@ export async function startDashboard(port, dataDir, teamDir, dbPath) {
64
118
  };
65
119
  // Start relay client connection (non-blocking)
66
120
  connectRelayClient().catch(() => { });
121
+ // Bridge client for cross-project messaging
122
+ let bridgeClient;
123
+ let bridgeClientConnecting = false;
124
+ const connectBridgeClient = async () => {
125
+ if (bridgeClient || bridgeClientConnecting)
126
+ return;
127
+ // Check if bridge-state.json exists and has projects
128
+ const bridgeStatePath = path.join(dataDir, 'bridge-state.json');
129
+ if (!fs.existsSync(bridgeStatePath)) {
130
+ return;
131
+ }
132
+ try {
133
+ const bridgeState = JSON.parse(fs.readFileSync(bridgeStatePath, 'utf-8'));
134
+ if (!bridgeState.connected || !bridgeState.projects?.length) {
135
+ return;
136
+ }
137
+ bridgeClientConnecting = true;
138
+ // Build project configs from bridge state
139
+ const projectConfigs = bridgeState.projects.map((p) => {
140
+ // Compute socket path for each project
141
+ const projectHash = crypto.createHash('sha256').update(p.path).digest('hex').slice(0, 12);
142
+ const projectDataDir = path.join(path.dirname(dataDir), projectHash);
143
+ const socketPath = path.join(projectDataDir, 'relay.sock');
144
+ return {
145
+ id: p.id,
146
+ path: p.path,
147
+ socketPath,
148
+ leadName: p.lead?.name || 'Lead',
149
+ cli: 'dashboard-bridge',
150
+ };
151
+ });
152
+ // Filter to projects with existing sockets
153
+ const validConfigs = projectConfigs.filter((p) => fs.existsSync(p.socketPath));
154
+ if (validConfigs.length === 0) {
155
+ bridgeClientConnecting = false;
156
+ return;
157
+ }
158
+ bridgeClient = new MultiProjectClient(validConfigs, {
159
+ agentName: '__DashboardBridge__', // Unique name to avoid conflict with CLI bridge
160
+ reconnect: true,
161
+ });
162
+ bridgeClient.onProjectStateChange = (projectId, connected) => {
163
+ console.log(`[dashboard-bridge] Project ${projectId} ${connected ? 'connected' : 'disconnected'}`);
164
+ };
165
+ await bridgeClient.connect();
166
+ console.log('[dashboard] Bridge client connected to', validConfigs.length, 'project(s)');
167
+ bridgeClientConnecting = false;
168
+ }
169
+ catch (err) {
170
+ console.error('[dashboard] Failed to connect bridge client:', err);
171
+ bridgeClient = undefined;
172
+ bridgeClientConnecting = false;
173
+ }
174
+ };
175
+ // Start bridge client connection (non-blocking)
176
+ connectBridgeClient().catch(() => { });
67
177
  // API endpoint to send messages
68
178
  app.post('/api/send', async (req, res) => {
69
- const { to, message } = req.body;
179
+ const { to, message, thread } = req.body;
70
180
  if (!to || !message) {
71
181
  return res.status(400).json({ error: 'Missing "to" or "message" field' });
72
182
  }
@@ -78,7 +188,7 @@ export async function startDashboard(port, dataDir, teamDir, dbPath) {
78
188
  }
79
189
  }
80
190
  try {
81
- const sent = relayClient.sendMessage(to, message);
191
+ const sent = relayClient.sendMessage(to, message, 'message', undefined, thread);
82
192
  if (sent) {
83
193
  res.json({ success: true });
84
194
  }
@@ -91,6 +201,33 @@ export async function startDashboard(port, dataDir, teamDir, dbPath) {
91
201
  res.status(500).json({ error: 'Failed to send message' });
92
202
  }
93
203
  });
204
+ // API endpoint to send messages via bridge (cross-project)
205
+ app.post('/api/bridge/send', async (req, res) => {
206
+ const { projectId, to, message } = req.body;
207
+ if (!projectId || !to || !message) {
208
+ return res.status(400).json({ error: 'Missing "projectId", "to", or "message" field' });
209
+ }
210
+ // Try to connect bridge client if not connected
211
+ if (!bridgeClient) {
212
+ await connectBridgeClient();
213
+ if (!bridgeClient) {
214
+ return res.status(503).json({ error: 'Bridge not connected. Is the bridge command running?' });
215
+ }
216
+ }
217
+ try {
218
+ const sent = bridgeClient.sendToProject(projectId, to, message);
219
+ if (sent) {
220
+ res.json({ success: true });
221
+ }
222
+ else {
223
+ res.status(500).json({ error: `Failed to send message to ${projectId}:${to}` });
224
+ }
225
+ }
226
+ catch (err) {
227
+ console.error('[dashboard] Failed to send bridge message:', err);
228
+ res.status(500).json({ error: 'Failed to send bridge message' });
229
+ }
230
+ });
94
231
  const getTeamData = () => {
95
232
  // Try team.json first (file-based team mode)
96
233
  const teamPath = path.join(teamDir, 'team.json');
@@ -172,10 +309,11 @@ export async function startDashboard(port, dataDir, teamDir, dbPath) {
172
309
  timestamp: new Date(row.ts).toISOString(),
173
310
  id: row.id,
174
311
  thread: row.thread,
312
+ isBroadcast: row.is_broadcast,
175
313
  }));
176
314
  const getMessages = async (agents) => {
177
315
  if (storage) {
178
- const rows = await storage.getMessages({ limit: 500, order: 'desc' });
316
+ const rows = await storage.getMessages({ limit: 100, order: 'desc' });
179
317
  // Dashboard expects oldest first
180
318
  return mapStoredMessages(rows).reverse();
181
319
  }
@@ -244,6 +382,7 @@ export async function startDashboard(port, dataDir, teamDir, dbPath) {
244
382
  status: 'Idle',
245
383
  lastSeen: a.lastSeen,
246
384
  lastActive: a.lastActive,
385
+ needsAttention: false,
247
386
  });
248
387
  });
249
388
  // Update inbox counts if fallback mode; if storage, count messages addressed to agent
@@ -263,52 +402,459 @@ export async function startDashboard(port, dataDir, teamDir, dbPath) {
263
402
  }
264
403
  // Derive status from messages sent BY agents
265
404
  // We scan all messages; if M is from A, we check if it is a STATUS message
405
+ // Note: lastActive is updated from messages, but lastSeen comes from the registry
406
+ // (heartbeat-based) and should NOT be overwritten by message timestamps
266
407
  allMessages.forEach(m => {
267
408
  const agent = agentsMap.get(m.from);
268
409
  if (agent) {
269
410
  agent.lastActive = m.timestamp;
270
- agent.lastSeen = m.timestamp;
411
+ // Don't overwrite lastSeen - it comes from registry (heartbeat/connection tracking)
271
412
  if (m.content.startsWith('STATUS:')) {
272
413
  agent.status = m.content.substring(7).trim(); // remove "STATUS:"
273
414
  }
274
415
  }
275
416
  });
417
+ // Detect agents with unanswered inbound messages (needs attention)
418
+ const needsAttentionAgents = computeNeedsAttention(allMessages.map((m) => ({
419
+ from: m.from,
420
+ to: m.to,
421
+ timestamp: m.timestamp,
422
+ thread: m.thread,
423
+ isBroadcast: m.isBroadcast,
424
+ })));
425
+ needsAttentionAgents.forEach((agentName) => {
426
+ const agent = agentsMap.get(agentName);
427
+ if (agent) {
428
+ agent.needsAttention = true;
429
+ }
430
+ });
431
+ // Read processing state from daemon
432
+ const processingStatePath = path.join(dataDir, 'processing-state.json');
433
+ if (fs.existsSync(processingStatePath)) {
434
+ try {
435
+ const processingData = JSON.parse(fs.readFileSync(processingStatePath, 'utf-8'));
436
+ const processingAgents = processingData.processingAgents || {};
437
+ for (const [agentName, state] of Object.entries(processingAgents)) {
438
+ const agent = agentsMap.get(agentName);
439
+ if (agent && state && typeof state === 'object') {
440
+ agent.isProcessing = true;
441
+ agent.processingStartedAt = state.startedAt;
442
+ }
443
+ }
444
+ }
445
+ catch (err) {
446
+ // Ignore errors reading processing state - it's optional
447
+ }
448
+ }
276
449
  // Fetch sessions and summaries in parallel
277
450
  const [sessions, summaries] = await Promise.all([
278
451
  getRecentSessions(),
279
452
  getAgentSummaries(),
280
453
  ]);
454
+ // Filter agents:
455
+ // 1. Exclude "Dashboard" (internal agent, not a real team member)
456
+ // 2. Exclude offline agents (no lastSeen or lastSeen > 5 minutes ago)
457
+ const now = Date.now();
458
+ const OFFLINE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
459
+ const filteredAgents = Array.from(agentsMap.values()).filter(agent => {
460
+ // Exclude Dashboard
461
+ if (agent.name === 'Dashboard')
462
+ return false;
463
+ // Exclude agents starting with __ (internal/system agents)
464
+ if (agent.name.startsWith('__'))
465
+ return false;
466
+ // Exclude offline agents (no lastSeen or too old)
467
+ if (!agent.lastSeen)
468
+ return false;
469
+ const lastSeenTime = new Date(agent.lastSeen).getTime();
470
+ if (now - lastSeenTime > OFFLINE_THRESHOLD_MS)
471
+ return false;
472
+ return true;
473
+ });
281
474
  return {
282
- agents: Array.from(agentsMap.values()),
475
+ agents: filteredAgents,
283
476
  messages: allMessages,
284
477
  activity: allMessages, // For now, activity log is just the message log
285
478
  sessions,
286
479
  summaries,
287
480
  };
288
481
  };
482
+ // Track clients that are still initializing (haven't received first data yet)
483
+ // This prevents race conditions where broadcastData sends before initial data is sent
484
+ const initializingClients = new WeakSet();
289
485
  const broadcastData = async () => {
290
- const data = await getAllData();
291
- const payload = JSON.stringify(data);
292
- wss.clients.forEach(client => {
293
- if (client.readyState === WebSocket.OPEN) {
294
- client.send(payload);
486
+ try {
487
+ const data = await getAllData();
488
+ const payload = JSON.stringify(data);
489
+ // Guard against empty/invalid payloads
490
+ if (!payload || payload.length === 0) {
491
+ console.warn('[dashboard] Skipping broadcast - empty payload');
492
+ return;
295
493
  }
296
- });
494
+ wss.clients.forEach(client => {
495
+ // Skip clients that are still being initialized by the connection handler
496
+ if (initializingClients.has(client)) {
497
+ return;
498
+ }
499
+ if (client.readyState === WebSocket.OPEN) {
500
+ try {
501
+ client.send(payload);
502
+ }
503
+ catch (err) {
504
+ console.error('[dashboard] Failed to send to client:', err);
505
+ }
506
+ }
507
+ });
508
+ }
509
+ catch (err) {
510
+ console.error('[dashboard] Failed to broadcast data:', err);
511
+ }
512
+ };
513
+ // Bridge data functions - defined before connection handlers
514
+ const getBridgeData = async () => {
515
+ const bridgeStatePath = path.join(dataDir, 'bridge-state.json');
516
+ if (fs.existsSync(bridgeStatePath)) {
517
+ try {
518
+ const bridgeState = JSON.parse(fs.readFileSync(bridgeStatePath, 'utf-8'));
519
+ // Enrich each project with actual agent data from their team directories
520
+ if (bridgeState.projects && Array.isArray(bridgeState.projects)) {
521
+ for (const project of bridgeState.projects) {
522
+ if (project.path) {
523
+ // Get project's data directory
524
+ const crypto = await import('crypto');
525
+ const projectHash = crypto.createHash('sha256').update(project.path).digest('hex').slice(0, 12);
526
+ const projectDataDir = path.join(path.dirname(dataDir), projectHash);
527
+ const projectTeamDir = path.join(projectDataDir, 'team');
528
+ const agentsPath = path.join(projectTeamDir, 'agents.json');
529
+ // Read actual connected agents
530
+ if (fs.existsSync(agentsPath)) {
531
+ try {
532
+ const agentsData = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
533
+ if (agentsData.agents && Array.isArray(agentsData.agents)) {
534
+ // Filter to only show online agents (seen in last 5 minutes)
535
+ const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
536
+ project.agents = agentsData.agents
537
+ .filter((a) => {
538
+ if (!a.lastSeen)
539
+ return false;
540
+ return new Date(a.lastSeen).getTime() > fiveMinutesAgo;
541
+ })
542
+ .map((a) => ({
543
+ name: a.name,
544
+ status: 'active',
545
+ cli: a.cli,
546
+ lastSeen: a.lastSeen,
547
+ }));
548
+ // Update lead status based on actual agents
549
+ if (project.lead) {
550
+ const leadAgent = project.agents.find((a) => a.name.toLowerCase() === project.lead.name.toLowerCase());
551
+ project.lead.connected = !!leadAgent;
552
+ }
553
+ }
554
+ }
555
+ catch (e) {
556
+ console.error(`Failed to read agents for ${project.path}:`, e);
557
+ }
558
+ }
559
+ }
560
+ }
561
+ }
562
+ return bridgeState;
563
+ }
564
+ catch {
565
+ return { projects: [], messages: [], connected: false };
566
+ }
567
+ }
568
+ return { projects: [], messages: [], connected: false };
569
+ };
570
+ const broadcastBridgeData = async () => {
571
+ try {
572
+ const data = await getBridgeData();
573
+ const payload = JSON.stringify(data);
574
+ // Guard against empty/invalid payloads
575
+ if (!payload || payload.length === 0) {
576
+ console.warn('[dashboard] Skipping bridge broadcast - empty payload');
577
+ return;
578
+ }
579
+ wssBridge.clients.forEach(client => {
580
+ if (client.readyState === WebSocket.OPEN) {
581
+ try {
582
+ client.send(payload);
583
+ }
584
+ catch (err) {
585
+ console.error('[dashboard] Failed to send to bridge client:', err);
586
+ }
587
+ }
588
+ });
589
+ }
590
+ catch (err) {
591
+ console.error('[dashboard] Failed to broadcast bridge data:', err);
592
+ }
297
593
  };
594
+ // Handle new WebSocket connections - send initial data immediately
595
+ wss.on('connection', async (ws, req) => {
596
+ console.log('[dashboard] WebSocket client connected from:', req.socket.remoteAddress);
597
+ // Mark as initializing to prevent broadcastData from sending before we do
598
+ initializingClients.add(ws);
599
+ try {
600
+ const data = await getAllData();
601
+ const payload = JSON.stringify(data);
602
+ // Guard against empty/invalid payloads
603
+ if (!payload || payload.length === 0) {
604
+ console.warn('[dashboard] Skipping initial send - empty payload');
605
+ return;
606
+ }
607
+ if (ws.readyState === WebSocket.OPEN) {
608
+ console.log('[dashboard] Sending initial data, size:', payload.length, 'first 200 chars:', payload.substring(0, 200));
609
+ ws.send(payload);
610
+ console.log('[dashboard] Initial data sent successfully');
611
+ }
612
+ else {
613
+ console.warn('[dashboard] WebSocket not open, state:', ws.readyState);
614
+ }
615
+ }
616
+ catch (err) {
617
+ console.error('[dashboard] Failed to send initial data:', err);
618
+ }
619
+ finally {
620
+ // Now allow broadcastData to send to this client
621
+ initializingClients.delete(ws);
622
+ }
623
+ ws.on('error', (err) => {
624
+ console.error('[dashboard] WebSocket client error:', err);
625
+ });
626
+ ws.on('close', (code, reason) => {
627
+ console.log('[dashboard] WebSocket client disconnected, code:', code, 'reason:', reason?.toString() || 'none');
628
+ });
629
+ });
630
+ // Handle bridge WebSocket connections
631
+ wssBridge.on('connection', async (ws) => {
632
+ console.log('[dashboard] Bridge WebSocket client connected');
633
+ try {
634
+ const data = await getBridgeData();
635
+ const payload = JSON.stringify(data);
636
+ if (ws.readyState === WebSocket.OPEN) {
637
+ ws.send(payload);
638
+ }
639
+ }
640
+ catch (err) {
641
+ console.error('[dashboard] Failed to send initial bridge data:', err);
642
+ }
643
+ ws.on('error', (err) => {
644
+ console.error('[dashboard] Bridge WebSocket client error:', err);
645
+ });
646
+ ws.on('close', (code, reason) => {
647
+ console.log('[dashboard] Bridge WebSocket client disconnected, code:', code, 'reason:', reason?.toString() || 'none');
648
+ });
649
+ });
298
650
  app.get('/api/data', (req, res) => {
299
651
  getAllData().then((data) => res.json(data)).catch((err) => {
300
652
  console.error('Failed to fetch dashboard data', err);
301
653
  res.status(500).json({ error: 'Failed to load data' });
302
654
  });
303
655
  });
656
+ // ===== Metrics API =====
657
+ /**
658
+ * GET /api/metrics - JSON format metrics for dashboard
659
+ */
660
+ app.get('/api/metrics', async (req, res) => {
661
+ try {
662
+ // Read agent registry for message counts
663
+ const agentsPath = path.join(teamDir, 'agents.json');
664
+ let agentRecords = [];
665
+ if (fs.existsSync(agentsPath)) {
666
+ const data = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
667
+ agentRecords = (data.agents || []).map((a) => ({
668
+ name: a.name,
669
+ messagesSent: a.messagesSent ?? 0,
670
+ messagesReceived: a.messagesReceived ?? 0,
671
+ firstSeen: a.firstSeen ?? new Date().toISOString(),
672
+ lastSeen: a.lastSeen ?? new Date().toISOString(),
673
+ }));
674
+ }
675
+ // Get messages for throughput calculation
676
+ const team = getTeamData();
677
+ const messages = team ? await getMessages(team.agents) : [];
678
+ // Get session data for lifecycle metrics
679
+ const sessions = storage?.getSessions
680
+ ? await storage.getSessions({ limit: 100 })
681
+ : [];
682
+ const metrics = computeSystemMetrics(agentRecords, messages, sessions);
683
+ res.json(metrics);
684
+ }
685
+ catch (err) {
686
+ console.error('Failed to compute metrics', err);
687
+ res.status(500).json({ error: 'Failed to compute metrics' });
688
+ }
689
+ });
690
+ /**
691
+ * GET /api/metrics/prometheus - Prometheus exposition format
692
+ */
693
+ app.get('/api/metrics/prometheus', async (req, res) => {
694
+ try {
695
+ // Read agent registry for message counts
696
+ const agentsPath = path.join(teamDir, 'agents.json');
697
+ let agentRecords = [];
698
+ if (fs.existsSync(agentsPath)) {
699
+ const data = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
700
+ agentRecords = (data.agents || []).map((a) => ({
701
+ name: a.name,
702
+ messagesSent: a.messagesSent ?? 0,
703
+ messagesReceived: a.messagesReceived ?? 0,
704
+ firstSeen: a.firstSeen ?? new Date().toISOString(),
705
+ lastSeen: a.lastSeen ?? new Date().toISOString(),
706
+ }));
707
+ }
708
+ // Get messages for throughput calculation
709
+ const team = getTeamData();
710
+ const messages = team ? await getMessages(team.agents) : [];
711
+ // Get session data for lifecycle metrics
712
+ const sessions = storage?.getSessions
713
+ ? await storage.getSessions({ limit: 100 })
714
+ : [];
715
+ const metrics = computeSystemMetrics(agentRecords, messages, sessions);
716
+ const prometheusOutput = formatPrometheusMetrics(metrics);
717
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
718
+ res.send(prometheusOutput);
719
+ }
720
+ catch (err) {
721
+ console.error('Failed to compute Prometheus metrics', err);
722
+ res.status(500).send('# Error computing metrics\n');
723
+ }
724
+ });
725
+ // Metrics view route - serves metrics.html
726
+ app.get('/metrics', (req, res) => {
727
+ res.sendFile(path.join(publicDir, 'metrics.html'));
728
+ });
729
+ // Bridge view route - serves bridge.html
730
+ app.get('/bridge', (req, res) => {
731
+ res.sendFile(path.join(publicDir, 'bridge.html'));
732
+ });
733
+ // Bridge API endpoint - returns multi-project data
734
+ // This is a placeholder that returns empty data when not in bridge mode
735
+ // The actual bridge data comes from MultiProjectClient when running `agent-relay bridge`
736
+ app.get('/api/bridge', async (req, res) => {
737
+ try {
738
+ // Check if bridge state file exists (written by bridge command)
739
+ const bridgeStatePath = path.join(dataDir, 'bridge-state.json');
740
+ if (fs.existsSync(bridgeStatePath)) {
741
+ const bridgeData = JSON.parse(fs.readFileSync(bridgeStatePath, 'utf-8'));
742
+ res.json(bridgeData);
743
+ }
744
+ else {
745
+ // No bridge running - return empty state
746
+ res.json({
747
+ projects: [],
748
+ messages: [],
749
+ connected: false,
750
+ });
751
+ }
752
+ }
753
+ catch (err) {
754
+ console.error('Failed to fetch bridge data', err);
755
+ res.status(500).json({ error: 'Failed to load bridge data' });
756
+ }
757
+ });
758
+ // ===== Agent Spawn API =====
759
+ /**
760
+ * POST /api/spawn - Spawn a new agent
761
+ * Body: { name: string, cli?: string, task?: string }
762
+ */
763
+ app.post('/api/spawn', async (req, res) => {
764
+ if (!spawner) {
765
+ return res.status(503).json({
766
+ success: false,
767
+ error: 'Spawner not enabled. Start dashboard with enableSpawner: true',
768
+ });
769
+ }
770
+ const { name, cli = 'claude', task = '' } = req.body;
771
+ if (!name || typeof name !== 'string') {
772
+ return res.status(400).json({
773
+ success: false,
774
+ error: 'Missing required field: name',
775
+ });
776
+ }
777
+ try {
778
+ const request = {
779
+ name,
780
+ cli,
781
+ task,
782
+ requestedBy: 'api',
783
+ };
784
+ const result = await spawner.spawn(request);
785
+ if (result.success) {
786
+ // Broadcast update to WebSocket clients
787
+ broadcastData().catch(() => { });
788
+ }
789
+ res.json(result);
790
+ }
791
+ catch (err) {
792
+ console.error('[api] Spawn error:', err);
793
+ res.status(500).json({
794
+ success: false,
795
+ name,
796
+ error: err.message,
797
+ });
798
+ }
799
+ });
800
+ /**
801
+ * GET /api/spawned - List active spawned agents
802
+ */
803
+ app.get('/api/spawned', (req, res) => {
804
+ if (!spawner) {
805
+ return res.status(503).json({
806
+ success: false,
807
+ error: 'Spawner not enabled',
808
+ agents: [],
809
+ });
810
+ }
811
+ const agents = spawner.getActiveWorkers();
812
+ res.json({
813
+ success: true,
814
+ agents,
815
+ });
816
+ });
817
+ /**
818
+ * DELETE /api/spawned/:name - Release a spawned agent
819
+ */
820
+ app.delete('/api/spawned/:name', async (req, res) => {
821
+ if (!spawner) {
822
+ return res.status(503).json({
823
+ success: false,
824
+ error: 'Spawner not enabled',
825
+ });
826
+ }
827
+ const { name } = req.params;
828
+ try {
829
+ const released = await spawner.release(name);
830
+ if (released) {
831
+ broadcastData().catch(() => { });
832
+ }
833
+ res.json({
834
+ success: released,
835
+ name,
836
+ error: released ? undefined : `Agent ${name} not found`,
837
+ });
838
+ }
839
+ catch (err) {
840
+ console.error('[api] Release error:', err);
841
+ res.status(500).json({
842
+ success: false,
843
+ name,
844
+ error: err.message,
845
+ });
846
+ }
847
+ });
304
848
  // Watch for changes
305
849
  if (storage) {
306
850
  setInterval(() => {
307
851
  broadcastData().catch((err) => console.error('Broadcast failed', err));
852
+ broadcastBridgeData().catch((err) => console.error('Bridge broadcast failed', err));
308
853
  }, 1000);
309
854
  }
310
855
  else {
311
856
  let fsWait = null;
857
+ let bridgeFsWait = null;
312
858
  try {
313
859
  if (fs.existsSync(dataDir)) {
314
860
  console.log(`Watching ${dataDir} for changes...`);
@@ -322,6 +868,15 @@ export async function startDashboard(port, dataDir, teamDir, dbPath) {
322
868
  broadcastData();
323
869
  }, 100);
324
870
  }
871
+ // Watch for bridge state changes
872
+ if (filename && filename.endsWith('bridge-state.json')) {
873
+ if (bridgeFsWait)
874
+ return;
875
+ bridgeFsWait = setTimeout(() => {
876
+ bridgeFsWait = null;
877
+ broadcastBridgeData();
878
+ }, 100);
879
+ }
325
880
  });
326
881
  }
327
882
  else {