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.
- package/README.md +176 -6
- 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.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +906 -6
- package/dist/cli/index.js.map +1 -1
- package/dist/daemon/agent-registry.d.ts +60 -0
- package/dist/daemon/agent-registry.d.ts.map +1 -0
- package/dist/daemon/agent-registry.js +163 -0
- package/dist/daemon/agent-registry.js.map +1 -0
- package/dist/daemon/connection.d.ts +33 -1
- package/dist/daemon/connection.d.ts.map +1 -1
- package/dist/daemon/connection.js +86 -11
- package/dist/daemon/connection.js.map +1 -1
- package/dist/daemon/index.d.ts +2 -0
- package/dist/daemon/index.d.ts.map +1 -1
- package/dist/daemon/index.js +2 -0
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/registry.d.ts +9 -0
- package/dist/daemon/registry.d.ts.map +1 -0
- package/dist/daemon/registry.js +9 -0
- package/dist/daemon/registry.js.map +1 -0
- package/dist/daemon/router.d.ts +61 -2
- package/dist/daemon/router.d.ts.map +1 -1
- package/dist/daemon/router.js +219 -4
- package/dist/daemon/router.js.map +1 -1
- package/dist/daemon/server.d.ts +9 -0
- package/dist/daemon/server.d.ts.map +1 -1
- package/dist/daemon/server.js +135 -16
- 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 +2094 -347
- 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 +14 -1
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +689 -16
- 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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/protocol/types.d.ts +15 -1
- package/dist/protocol/types.d.ts.map +1 -1
- package/dist/storage/adapter.d.ts +74 -1
- package/dist/storage/adapter.d.ts.map +1 -1
- package/dist/storage/adapter.js +39 -0
- package/dist/storage/adapter.js.map +1 -1
- package/dist/storage/sqlite-adapter.d.ts +92 -1
- package/dist/storage/sqlite-adapter.d.ts.map +1 -1
- package/dist/storage/sqlite-adapter.js +615 -47
- 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/utils/project-namespace.d.ts.map +1 -1
- package/dist/utils/project-namespace.js +22 -1
- package/dist/utils/project-namespace.js.map +1 -1
- package/dist/wrapper/client.d.ts +30 -3
- package/dist/wrapper/client.d.ts.map +1 -1
- package/dist/wrapper/client.js +85 -9
- package/dist/wrapper/client.js.map +1 -1
- package/dist/wrapper/parser.d.ts +127 -4
- package/dist/wrapper/parser.d.ts.map +1 -1
- package/dist/wrapper/parser.js +622 -86
- package/dist/wrapper/parser.js.map +1 -1
- package/dist/wrapper/tmux-wrapper.d.ts +136 -10
- package/dist/wrapper/tmux-wrapper.d.ts.map +1 -1
- package/dist/wrapper/tmux-wrapper.js +599 -79
- package/dist/wrapper/tmux-wrapper.js.map +1 -1
- package/docs/AGENTS.md +132 -27
- package/docs/ARCHITECTURE_DECISIONS.md +175 -0
- package/docs/CHANGELOG.md +1 -1
- package/docs/COMPETITIVE_ANALYSIS.md +897 -0
- package/docs/DESIGN_BRIDGE_STAFFING.md +878 -0
- package/docs/DESIGN_V2.md +1079 -0
- package/docs/INTEGRATION-GUIDE.md +926 -0
- package/docs/MONETIZATION.md +1679 -0
- package/docs/PROPOSAL-trajectories.md +1582 -0
- package/docs/PROTOCOL.md +3 -3
- package/docs/SCALING_ANALYSIS.md +280 -0
- package/docs/TMUX_IMPLEMENTATION_NOTES.md +9 -9
- package/docs/TMUX_IMPROVEMENTS.md +968 -0
- package/docs/agent-relay-snippet.md +61 -0
- package/docs/competitive-analysis-mcp-agent-mail.md +389 -0
- package/docs/dashboard-v2-plan.md +179 -0
- package/package.json +10 -3
package/dist/dashboard/server.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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:
|
|
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:
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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);
|