agent-relay 1.0.7 → 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 (140) hide show
  1. package/README.md +176 -6
  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.d.ts +2 -0
  27. package/dist/cli/index.d.ts.map +1 -1
  28. package/dist/cli/index.js +906 -6
  29. package/dist/cli/index.js.map +1 -1
  30. package/dist/daemon/agent-registry.d.ts +60 -0
  31. package/dist/daemon/agent-registry.d.ts.map +1 -0
  32. package/dist/daemon/agent-registry.js +163 -0
  33. package/dist/daemon/agent-registry.js.map +1 -0
  34. package/dist/daemon/connection.d.ts +33 -1
  35. package/dist/daemon/connection.d.ts.map +1 -1
  36. package/dist/daemon/connection.js +86 -11
  37. package/dist/daemon/connection.js.map +1 -1
  38. package/dist/daemon/index.d.ts +2 -0
  39. package/dist/daemon/index.d.ts.map +1 -1
  40. package/dist/daemon/index.js +2 -0
  41. package/dist/daemon/index.js.map +1 -1
  42. package/dist/daemon/registry.d.ts +9 -0
  43. package/dist/daemon/registry.d.ts.map +1 -0
  44. package/dist/daemon/registry.js +9 -0
  45. package/dist/daemon/registry.js.map +1 -0
  46. package/dist/daemon/router.d.ts +61 -2
  47. package/dist/daemon/router.d.ts.map +1 -1
  48. package/dist/daemon/router.js +219 -4
  49. package/dist/daemon/router.js.map +1 -1
  50. package/dist/daemon/server.d.ts +9 -0
  51. package/dist/daemon/server.d.ts.map +1 -1
  52. package/dist/daemon/server.js +135 -16
  53. package/dist/daemon/server.js.map +1 -1
  54. package/dist/dashboard/metrics.d.ts +105 -0
  55. package/dist/dashboard/metrics.d.ts.map +1 -0
  56. package/dist/dashboard/metrics.js +192 -0
  57. package/dist/dashboard/metrics.js.map +1 -0
  58. package/dist/dashboard/needs-attention.d.ts +24 -0
  59. package/dist/dashboard/needs-attention.d.ts.map +1 -0
  60. package/dist/dashboard/needs-attention.js +78 -0
  61. package/dist/dashboard/needs-attention.js.map +1 -0
  62. package/dist/dashboard/public/bridge.html +1272 -0
  63. package/dist/dashboard/public/index.html +2094 -347
  64. package/dist/dashboard/public/js/app.js +184 -0
  65. package/dist/dashboard/public/js/app.js.map +7 -0
  66. package/dist/dashboard/public/metrics.html +999 -0
  67. package/dist/dashboard/server.d.ts +14 -1
  68. package/dist/dashboard/server.d.ts.map +1 -1
  69. package/dist/dashboard/server.js +689 -16
  70. package/dist/dashboard/server.js.map +1 -1
  71. package/dist/dashboard/start.js +1 -1
  72. package/dist/dashboard/start.js.map +1 -1
  73. package/dist/dashboard-v2/index.d.ts +10 -0
  74. package/dist/dashboard-v2/index.d.ts.map +1 -0
  75. package/dist/dashboard-v2/index.js +54 -0
  76. package/dist/dashboard-v2/index.js.map +1 -0
  77. package/dist/dashboard-v2/lib/api.d.ts +95 -0
  78. package/dist/dashboard-v2/lib/api.d.ts.map +1 -0
  79. package/dist/dashboard-v2/lib/api.js +270 -0
  80. package/dist/dashboard-v2/lib/api.js.map +1 -0
  81. package/dist/dashboard-v2/lib/colors.d.ts +61 -0
  82. package/dist/dashboard-v2/lib/colors.d.ts.map +1 -0
  83. package/dist/dashboard-v2/lib/colors.js +198 -0
  84. package/dist/dashboard-v2/lib/colors.js.map +1 -0
  85. package/dist/dashboard-v2/lib/hierarchy.d.ts +74 -0
  86. package/dist/dashboard-v2/lib/hierarchy.d.ts.map +1 -0
  87. package/dist/dashboard-v2/lib/hierarchy.js +196 -0
  88. package/dist/dashboard-v2/lib/hierarchy.js.map +1 -0
  89. package/dist/dashboard-v2/types/index.d.ts +154 -0
  90. package/dist/dashboard-v2/types/index.d.ts.map +1 -0
  91. package/dist/dashboard-v2/types/index.js +6 -0
  92. package/dist/dashboard-v2/types/index.js.map +1 -0
  93. package/dist/index.d.ts +1 -0
  94. package/dist/index.d.ts.map +1 -1
  95. package/dist/protocol/types.d.ts +15 -1
  96. package/dist/protocol/types.d.ts.map +1 -1
  97. package/dist/storage/adapter.d.ts +74 -1
  98. package/dist/storage/adapter.d.ts.map +1 -1
  99. package/dist/storage/adapter.js +39 -0
  100. package/dist/storage/adapter.js.map +1 -1
  101. package/dist/storage/sqlite-adapter.d.ts +92 -1
  102. package/dist/storage/sqlite-adapter.d.ts.map +1 -1
  103. package/dist/storage/sqlite-adapter.js +615 -47
  104. package/dist/storage/sqlite-adapter.js.map +1 -1
  105. package/dist/utils/agent-config.d.ts +45 -0
  106. package/dist/utils/agent-config.d.ts.map +1 -0
  107. package/dist/utils/agent-config.js +118 -0
  108. package/dist/utils/agent-config.js.map +1 -0
  109. package/dist/utils/project-namespace.d.ts.map +1 -1
  110. package/dist/utils/project-namespace.js +22 -1
  111. package/dist/utils/project-namespace.js.map +1 -1
  112. package/dist/wrapper/client.d.ts +30 -3
  113. package/dist/wrapper/client.d.ts.map +1 -1
  114. package/dist/wrapper/client.js +85 -9
  115. package/dist/wrapper/client.js.map +1 -1
  116. package/dist/wrapper/parser.d.ts +127 -4
  117. package/dist/wrapper/parser.d.ts.map +1 -1
  118. package/dist/wrapper/parser.js +622 -86
  119. package/dist/wrapper/parser.js.map +1 -1
  120. package/dist/wrapper/tmux-wrapper.d.ts +136 -10
  121. package/dist/wrapper/tmux-wrapper.d.ts.map +1 -1
  122. package/dist/wrapper/tmux-wrapper.js +599 -79
  123. package/dist/wrapper/tmux-wrapper.js.map +1 -1
  124. package/docs/AGENTS.md +132 -27
  125. package/docs/ARCHITECTURE_DECISIONS.md +175 -0
  126. package/docs/CHANGELOG.md +1 -1
  127. package/docs/COMPETITIVE_ANALYSIS.md +897 -0
  128. package/docs/DESIGN_BRIDGE_STAFFING.md +878 -0
  129. package/docs/DESIGN_V2.md +1079 -0
  130. package/docs/INTEGRATION-GUIDE.md +926 -0
  131. package/docs/MONETIZATION.md +1679 -0
  132. package/docs/PROPOSAL-trajectories.md +1582 -0
  133. package/docs/PROTOCOL.md +3 -3
  134. package/docs/SCALING_ANALYSIS.md +280 -0
  135. package/docs/TMUX_IMPLEMENTATION_NOTES.md +9 -9
  136. package/docs/TMUX_IMPROVEMENTS.md +968 -0
  137. package/docs/agent-relay-snippet.md +61 -0
  138. package/docs/competitive-analysis-mcp-agent-mail.md +389 -0
  139. package/docs/dashboard-v2-plan.md +179 -0
  140. package/package.json +10 -3
@@ -3,11 +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';
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';
8
14
  const __filename = fileURLToPath(import.meta.url);
9
15
  const __dirname = path.dirname(__filename);
10
- export async function startDashboard(port, dataDir, 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;
11
22
  console.log('Starting dashboard...');
12
23
  console.log('__dirname:', __dirname);
13
24
  const publicDir = path.join(__dirname, 'public');
@@ -15,6 +26,10 @@ export async function startDashboard(port, dataDir, dbPath) {
15
26
  const storage = dbPath
16
27
  ? new SqliteStorageAdapter({ dbPath })
17
28
  : undefined;
29
+ // Initialize spawner if enabled
30
+ const spawner = enableSpawner
31
+ ? new AgentSpawner(projectRoot || dataDir, tmuxSession)
32
+ : undefined;
18
33
  process.on('uncaughtException', (err) => {
19
34
  console.error('Uncaught Exception:', err);
20
35
  });
@@ -23,16 +38,199 @@ export async function startDashboard(port, dataDir, dbPath) {
23
38
  });
24
39
  const app = express();
25
40
  const server = http.createServer(app);
26
- 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
+ });
27
82
  if (storage) {
28
83
  await storage.init();
29
84
  }
30
85
  // Serve static files from public directory
31
86
  app.use(express.static(publicDir));
32
87
  app.use(express.json());
88
+ // Relay client for sending messages from dashboard
89
+ const socketPath = path.join(dataDir, 'relay.sock');
90
+ let relayClient;
91
+ const connectRelayClient = async () => {
92
+ // Only attempt connection if socket exists (daemon is running)
93
+ if (!fs.existsSync(socketPath)) {
94
+ console.log('[dashboard] Relay socket not found, messaging disabled');
95
+ return;
96
+ }
97
+ relayClient = new RelayClient({
98
+ socketPath,
99
+ agentName: 'Dashboard',
100
+ cli: 'dashboard',
101
+ reconnect: true,
102
+ maxReconnectAttempts: 5,
103
+ });
104
+ relayClient.onError = (err) => {
105
+ console.error('[dashboard] Relay client error:', err.message);
106
+ };
107
+ relayClient.onStateChange = (state) => {
108
+ console.log(`[dashboard] Relay client state: ${state}`);
109
+ };
110
+ try {
111
+ await relayClient.connect();
112
+ console.log('[dashboard] Connected to relay daemon');
113
+ }
114
+ catch (err) {
115
+ console.error('[dashboard] Failed to connect to relay daemon:', err);
116
+ relayClient = undefined;
117
+ }
118
+ };
119
+ // Start relay client connection (non-blocking)
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(() => { });
177
+ // API endpoint to send messages
178
+ app.post('/api/send', async (req, res) => {
179
+ const { to, message, thread } = req.body;
180
+ if (!to || !message) {
181
+ return res.status(400).json({ error: 'Missing "to" or "message" field' });
182
+ }
183
+ if (!relayClient || relayClient.state !== 'READY') {
184
+ // Try to reconnect
185
+ await connectRelayClient();
186
+ if (!relayClient || relayClient.state !== 'READY') {
187
+ return res.status(503).json({ error: 'Relay daemon not connected' });
188
+ }
189
+ }
190
+ try {
191
+ const sent = relayClient.sendMessage(to, message, 'message', undefined, thread);
192
+ if (sent) {
193
+ res.json({ success: true });
194
+ }
195
+ else {
196
+ res.status(500).json({ error: 'Failed to send message' });
197
+ }
198
+ }
199
+ catch (err) {
200
+ console.error('[dashboard] Failed to send message:', err);
201
+ res.status(500).json({ error: 'Failed to send message' });
202
+ }
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
+ });
33
231
  const getTeamData = () => {
34
232
  // Try team.json first (file-based team mode)
35
- const teamPath = path.join(dataDir, 'team.json');
233
+ const teamPath = path.join(teamDir, 'team.json');
36
234
  if (fs.existsSync(teamPath)) {
37
235
  try {
38
236
  return JSON.parse(fs.readFileSync(teamPath, 'utf-8'));
@@ -42,7 +240,7 @@ export async function startDashboard(port, dataDir, dbPath) {
42
240
  }
43
241
  }
44
242
  // Fall back to agents.json (daemon mode - live connected agents)
45
- const agentsPath = path.join(dataDir, 'agents.json');
243
+ const agentsPath = path.join(teamDir, 'agents.json');
46
244
  if (fs.existsSync(agentsPath)) {
47
245
  try {
48
246
  const data = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
@@ -52,6 +250,8 @@ export async function startDashboard(port, dataDir, dbPath) {
52
250
  name: a.name,
53
251
  role: 'Agent',
54
252
  cli: a.cli ?? 'Unknown',
253
+ lastSeen: a.lastSeen ?? a.connectedAt,
254
+ lastActive: a.lastSeen ?? a.connectedAt,
55
255
  })),
56
256
  };
57
257
  }
@@ -108,10 +308,12 @@ export async function startDashboard(port, dataDir, dbPath) {
108
308
  content: row.body,
109
309
  timestamp: new Date(row.ts).toISOString(),
110
310
  id: row.id,
311
+ thread: row.thread,
312
+ isBroadcast: row.is_broadcast,
111
313
  }));
112
314
  const getMessages = async (agents) => {
113
315
  if (storage) {
114
- const rows = await storage.getMessages({ limit: 500, order: 'desc' });
316
+ const rows = await storage.getMessages({ limit: 100, order: 'desc' });
115
317
  // Dashboard expects oldest first
116
318
  return mapStoredMessages(rows).reverse();
117
319
  }
@@ -123,10 +325,51 @@ export async function startDashboard(port, dataDir, dbPath) {
123
325
  });
124
326
  return allMessages;
125
327
  };
328
+ const formatDuration = (startMs, endMs) => {
329
+ const end = endMs ?? Date.now();
330
+ const durationMs = end - startMs;
331
+ const minutes = Math.floor(durationMs / 60000);
332
+ const hours = Math.floor(minutes / 60);
333
+ if (hours > 0) {
334
+ return `${hours}h ${minutes % 60}m`;
335
+ }
336
+ return `${minutes}m`;
337
+ };
338
+ const getRecentSessions = async () => {
339
+ if (storage && storage instanceof SqliteStorageAdapter) {
340
+ const sessions = await storage.getRecentSessions(20);
341
+ return sessions.map(s => ({
342
+ id: s.id,
343
+ agentName: s.agentName,
344
+ cli: s.cli,
345
+ startedAt: new Date(s.startedAt).toISOString(),
346
+ endedAt: s.endedAt ? new Date(s.endedAt).toISOString() : undefined,
347
+ duration: formatDuration(s.startedAt, s.endedAt),
348
+ messageCount: s.messageCount,
349
+ summary: s.summary,
350
+ isActive: !s.endedAt, // Active if no end time
351
+ closedBy: s.closedBy,
352
+ }));
353
+ }
354
+ return [];
355
+ };
356
+ const getAgentSummaries = async () => {
357
+ if (storage && storage instanceof SqliteStorageAdapter) {
358
+ const summaries = await storage.getAllAgentSummaries();
359
+ return summaries.map(s => ({
360
+ agentName: s.agentName,
361
+ lastUpdated: new Date(s.lastUpdated).toISOString(),
362
+ currentTask: s.currentTask,
363
+ completedTasks: s.completedTasks,
364
+ context: s.context,
365
+ }));
366
+ }
367
+ return [];
368
+ };
126
369
  const getAllData = async () => {
127
370
  const team = getTeamData();
128
371
  if (!team)
129
- return { agents: [], messages: [], activity: [] };
372
+ return { agents: [], messages: [], activity: [], sessions: [], summaries: [] };
130
373
  const agentsMap = new Map();
131
374
  const allMessages = await getMessages(team.agents);
132
375
  // Initialize agents from config
@@ -136,7 +379,10 @@ export async function startDashboard(port, dataDir, dbPath) {
136
379
  role: a.role,
137
380
  cli: a.cli ?? 'Unknown',
138
381
  messageCount: 0,
139
- status: 'Idle'
382
+ status: 'Idle',
383
+ lastSeen: a.lastSeen,
384
+ lastActive: a.lastActive,
385
+ needsAttention: false,
140
386
  });
141
387
  });
142
388
  // Update inbox counts if fallback mode; if storage, count messages addressed to agent
@@ -156,44 +402,459 @@ export async function startDashboard(port, dataDir, dbPath) {
156
402
  }
157
403
  // Derive status from messages sent BY agents
158
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
159
407
  allMessages.forEach(m => {
160
408
  const agent = agentsMap.get(m.from);
161
409
  if (agent) {
162
410
  agent.lastActive = m.timestamp;
411
+ // Don't overwrite lastSeen - it comes from registry (heartbeat/connection tracking)
163
412
  if (m.content.startsWith('STATUS:')) {
164
413
  agent.status = m.content.substring(7).trim(); // remove "STATUS:"
165
414
  }
166
415
  }
167
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
+ }
449
+ // Fetch sessions and summaries in parallel
450
+ const [sessions, summaries] = await Promise.all([
451
+ getRecentSessions(),
452
+ getAgentSummaries(),
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
+ });
168
474
  return {
169
- agents: Array.from(agentsMap.values()),
475
+ agents: filteredAgents,
170
476
  messages: allMessages,
171
- activity: allMessages // For now, activity log is just the message log
477
+ activity: allMessages, // For now, activity log is just the message log
478
+ sessions,
479
+ summaries,
172
480
  };
173
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();
174
485
  const broadcastData = async () => {
175
- const data = await getAllData();
176
- const payload = JSON.stringify(data);
177
- wss.clients.forEach(client => {
178
- if (client.readyState === WebSocket.OPEN) {
179
- 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;
180
493
  }
181
- });
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
+ }
182
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
+ });
183
650
  app.get('/api/data', (req, res) => {
184
651
  getAllData().then((data) => res.json(data)).catch((err) => {
185
652
  console.error('Failed to fetch dashboard data', err);
186
653
  res.status(500).json({ error: 'Failed to load data' });
187
654
  });
188
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
+ });
189
848
  // Watch for changes
190
849
  if (storage) {
191
850
  setInterval(() => {
192
851
  broadcastData().catch((err) => console.error('Broadcast failed', err));
852
+ broadcastBridgeData().catch((err) => console.error('Bridge broadcast failed', err));
193
853
  }, 1000);
194
854
  }
195
855
  else {
196
856
  let fsWait = null;
857
+ let bridgeFsWait = null;
197
858
  try {
198
859
  if (fs.existsSync(dataDir)) {
199
860
  console.log(`Watching ${dataDir} for changes...`);
@@ -207,6 +868,15 @@ export async function startDashboard(port, dataDir, dbPath) {
207
868
  broadcastData();
208
869
  }, 100);
209
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
+ }
210
880
  });
211
881
  }
212
882
  else {
@@ -238,11 +908,14 @@ export async function startDashboard(port, dataDir, dbPath) {
238
908
  throw new Error(`Could not find available port after trying ${startPort}-${startPort + maxAttempts - 1}`);
239
909
  };
240
910
  const availablePort = await findAvailablePort(port);
911
+ if (availablePort !== port) {
912
+ console.log(`Requested dashboard port ${port} is busy; switching to ${availablePort}.`);
913
+ }
241
914
  return new Promise((resolve, reject) => {
242
915
  server.listen(availablePort, () => {
243
916
  console.log(`Dashboard running at http://localhost:${availablePort}`);
244
917
  console.log(`Monitoring: ${dataDir}`);
245
- resolve();
918
+ resolve(availablePort);
246
919
  });
247
920
  server.on('error', (err) => {
248
921
  console.error('Server error:', err);