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.
- package/README.md +158 -0
- package/dist/bridge/config.d.ts +41 -0
- package/dist/bridge/config.d.ts.map +1 -0
- package/dist/bridge/config.js +143 -0
- package/dist/bridge/config.js.map +1 -0
- package/dist/bridge/index.d.ts +10 -0
- package/dist/bridge/index.d.ts.map +1 -0
- package/dist/bridge/index.js +10 -0
- package/dist/bridge/index.js.map +1 -0
- package/dist/bridge/multi-project-client.d.ts +99 -0
- package/dist/bridge/multi-project-client.d.ts.map +1 -0
- package/dist/bridge/multi-project-client.js +386 -0
- package/dist/bridge/multi-project-client.js.map +1 -0
- package/dist/bridge/spawner.d.ts +46 -0
- package/dist/bridge/spawner.d.ts.map +1 -0
- package/dist/bridge/spawner.js +223 -0
- package/dist/bridge/spawner.js.map +1 -0
- package/dist/bridge/types.d.ts +55 -0
- package/dist/bridge/types.d.ts.map +1 -0
- package/dist/bridge/types.js +6 -0
- package/dist/bridge/types.js.map +1 -0
- package/dist/bridge/utils.d.ts +30 -0
- package/dist/bridge/utils.d.ts.map +1 -0
- package/dist/bridge/utils.js +54 -0
- package/dist/bridge/utils.js.map +1 -0
- package/dist/cli/index.js +564 -5
- package/dist/cli/index.js.map +1 -1
- package/dist/daemon/agent-registry.d.ts.map +1 -1
- package/dist/daemon/agent-registry.js +6 -1
- package/dist/daemon/agent-registry.js.map +1 -1
- package/dist/daemon/connection.d.ts +22 -0
- package/dist/daemon/connection.d.ts.map +1 -1
- package/dist/daemon/connection.js +59 -13
- package/dist/daemon/connection.js.map +1 -1
- package/dist/daemon/router.d.ts +27 -0
- package/dist/daemon/router.d.ts.map +1 -1
- package/dist/daemon/router.js +108 -3
- package/dist/daemon/router.js.map +1 -1
- package/dist/daemon/server.d.ts +8 -0
- package/dist/daemon/server.d.ts.map +1 -1
- package/dist/daemon/server.js +95 -23
- package/dist/daemon/server.js.map +1 -1
- package/dist/dashboard/metrics.d.ts +105 -0
- package/dist/dashboard/metrics.d.ts.map +1 -0
- package/dist/dashboard/metrics.js +192 -0
- package/dist/dashboard/metrics.js.map +1 -0
- package/dist/dashboard/needs-attention.d.ts +24 -0
- package/dist/dashboard/needs-attention.d.ts.map +1 -0
- package/dist/dashboard/needs-attention.js +78 -0
- package/dist/dashboard/needs-attention.js.map +1 -0
- package/dist/dashboard/public/bridge.html +1272 -0
- package/dist/dashboard/public/index.html +2017 -879
- package/dist/dashboard/public/js/app.js +184 -0
- package/dist/dashboard/public/js/app.js.map +7 -0
- package/dist/dashboard/public/metrics.html +999 -0
- package/dist/dashboard/server.d.ts +13 -0
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +568 -13
- package/dist/dashboard/server.js.map +1 -1
- package/dist/dashboard/start.js +1 -1
- package/dist/dashboard/start.js.map +1 -1
- package/dist/dashboard-v2/index.d.ts +10 -0
- package/dist/dashboard-v2/index.d.ts.map +1 -0
- package/dist/dashboard-v2/index.js +54 -0
- package/dist/dashboard-v2/index.js.map +1 -0
- package/dist/dashboard-v2/lib/api.d.ts +95 -0
- package/dist/dashboard-v2/lib/api.d.ts.map +1 -0
- package/dist/dashboard-v2/lib/api.js +270 -0
- package/dist/dashboard-v2/lib/api.js.map +1 -0
- package/dist/dashboard-v2/lib/colors.d.ts +61 -0
- package/dist/dashboard-v2/lib/colors.d.ts.map +1 -0
- package/dist/dashboard-v2/lib/colors.js +198 -0
- package/dist/dashboard-v2/lib/colors.js.map +1 -0
- package/dist/dashboard-v2/lib/hierarchy.d.ts +74 -0
- package/dist/dashboard-v2/lib/hierarchy.d.ts.map +1 -0
- package/dist/dashboard-v2/lib/hierarchy.js +196 -0
- package/dist/dashboard-v2/lib/hierarchy.js.map +1 -0
- package/dist/dashboard-v2/types/index.d.ts +154 -0
- package/dist/dashboard-v2/types/index.d.ts.map +1 -0
- package/dist/dashboard-v2/types/index.js +6 -0
- package/dist/dashboard-v2/types/index.js.map +1 -0
- package/dist/storage/adapter.d.ts +21 -1
- package/dist/storage/adapter.d.ts.map +1 -1
- package/dist/storage/adapter.js +36 -0
- package/dist/storage/adapter.js.map +1 -1
- package/dist/storage/sqlite-adapter.d.ts +34 -0
- package/dist/storage/sqlite-adapter.d.ts.map +1 -1
- package/dist/storage/sqlite-adapter.js +253 -12
- package/dist/storage/sqlite-adapter.js.map +1 -1
- package/dist/utils/agent-config.d.ts +45 -0
- package/dist/utils/agent-config.d.ts.map +1 -0
- package/dist/utils/agent-config.js +118 -0
- package/dist/utils/agent-config.js.map +1 -0
- package/dist/wrapper/client.d.ts +8 -0
- package/dist/wrapper/client.d.ts.map +1 -1
- package/dist/wrapper/client.js +26 -0
- package/dist/wrapper/client.js.map +1 -1
- package/dist/wrapper/parser.d.ts +17 -0
- package/dist/wrapper/parser.d.ts.map +1 -1
- package/dist/wrapper/parser.js +334 -10
- package/dist/wrapper/parser.js.map +1 -1
- package/dist/wrapper/tmux-wrapper.d.ts +37 -2
- package/dist/wrapper/tmux-wrapper.d.ts.map +1 -1
- package/dist/wrapper/tmux-wrapper.js +178 -18
- package/dist/wrapper/tmux-wrapper.js.map +1 -1
- package/docs/AGENTS.md +105 -0
- package/docs/ARCHITECTURE_DECISIONS.md +175 -0
- package/docs/COMPETITIVE_ANALYSIS.md +897 -0
- package/docs/DESIGN_BRIDGE_STAFFING.md +878 -0
- package/docs/MONETIZATION.md +1679 -0
- package/docs/agent-relay-snippet.md +61 -0
- package/docs/dashboard-v2-plan.md +179 -0
- package/package.json +5 -2
package/dist/dashboard/server.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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 {
|