@vibebrowser/cli 0.2.8
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 +13 -0
- package/dist/browser-cli.js +1881 -0
- package/dist/browser-main.js +11 -0
- package/dist/connection.js +536 -0
- package/dist/devtools-fallback.js +172 -0
- package/dist/relay-daemon.js +13 -0
- package/dist/relay.js +813 -0
- package/dist/types.js +22 -0
- package/dist/version.js +15 -0
- package/package.json +56 -0
package/dist/relay.js
ADDED
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vibe MCP Relay Server
|
|
3
|
+
*
|
|
4
|
+
* Daemon that multiplexes multiple MCP agents to a single browser extension.
|
|
5
|
+
* - Listens on port 19889 for extension connection (one client)
|
|
6
|
+
* - Listens on port 19888 for MCP agent connections (multiple clients)
|
|
7
|
+
* - Routes tool calls from agents to extension, responses back to agents
|
|
8
|
+
*
|
|
9
|
+
* Note: Using 19888/19889 to avoid conflict with Playwriter MCP (uses 19988/19989)
|
|
10
|
+
*/
|
|
11
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
12
|
+
import { writeFileSync, readFileSync, existsSync, unlinkSync, mkdirSync } from 'fs';
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
import { homedir } from 'os';
|
|
15
|
+
import { EventEmitter } from 'events';
|
|
16
|
+
import { DevtoolsFallbackConnection } from './devtools-fallback.js';
|
|
17
|
+
function parseEnvPort(name, fallback) {
|
|
18
|
+
const raw = process.env[name];
|
|
19
|
+
if (!raw) {
|
|
20
|
+
return fallback;
|
|
21
|
+
}
|
|
22
|
+
const port = Number.parseInt(raw, 10);
|
|
23
|
+
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
|
24
|
+
throw new Error(`Invalid ${name} value: ${raw}`);
|
|
25
|
+
}
|
|
26
|
+
return port;
|
|
27
|
+
}
|
|
28
|
+
function normalizeToolName(value) {
|
|
29
|
+
return value.replace(/[-\s]/g, '_').toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
// Ports (19888/19889 to avoid conflict with Playwriter MCP which uses 19988/19989)
|
|
32
|
+
export const EXTENSION_PORT = parseEnvPort('VIBE_MCP_EXTENSION_PORT', 19889);
|
|
33
|
+
export const AGENT_PORT = parseEnvPort('VIBE_MCP_AGENT_PORT', 19888);
|
|
34
|
+
// PID file location
|
|
35
|
+
const VIBE_DIR = process.env.VIBE_MCP_STATE_DIR || join(homedir(), '.vibe-mcp');
|
|
36
|
+
const PID_FILE = join(VIBE_DIR, 'relay.pid');
|
|
37
|
+
const LOG_FILE = join(VIBE_DIR, 'relay.log');
|
|
38
|
+
/**
|
|
39
|
+
* Vibe MCP Relay Server
|
|
40
|
+
*
|
|
41
|
+
* Owns exactly one shared chrome-devtools fallback backend process per relay
|
|
42
|
+
* daemon instance. All connected MCP clients (vibebrowser-mcp and
|
|
43
|
+
* vibebrowser-cli) route through this relay, so fallback lifecycle and tool
|
|
44
|
+
* routing stay deterministic across concurrent agents.
|
|
45
|
+
*/
|
|
46
|
+
export class RelayServer extends EventEmitter {
|
|
47
|
+
extensionWss = null;
|
|
48
|
+
agentWss = null;
|
|
49
|
+
extensionSessions = new Map();
|
|
50
|
+
socketToSessionId = new Map();
|
|
51
|
+
agents = new Map();
|
|
52
|
+
pendingRequests = new Map();
|
|
53
|
+
requestIdCounter = 0;
|
|
54
|
+
anonymousSessionCounter = 0;
|
|
55
|
+
debug;
|
|
56
|
+
devtoolsFallback;
|
|
57
|
+
constructor(debug = false) {
|
|
58
|
+
super();
|
|
59
|
+
this.debug = debug;
|
|
60
|
+
this.devtoolsFallback = new DevtoolsFallbackConnection(debug);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Start the relay server
|
|
64
|
+
*/
|
|
65
|
+
async start() {
|
|
66
|
+
// Ensure directory exists
|
|
67
|
+
if (!existsSync(VIBE_DIR)) {
|
|
68
|
+
mkdirSync(VIBE_DIR, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
// Start extension WebSocket server
|
|
71
|
+
await this.startExtensionServer();
|
|
72
|
+
// Start agent WebSocket server
|
|
73
|
+
await this.startAgentServer();
|
|
74
|
+
await this.devtoolsFallback.start();
|
|
75
|
+
this.devtoolsFallback.on('tools_updated', () => {
|
|
76
|
+
this.broadcastDefaultToolsToAgents();
|
|
77
|
+
this.broadcastSessionState();
|
|
78
|
+
});
|
|
79
|
+
this.devtoolsFallback.on('connected', () => {
|
|
80
|
+
this.broadcastDefaultToolsToAgents();
|
|
81
|
+
this.broadcastSessionState();
|
|
82
|
+
});
|
|
83
|
+
this.devtoolsFallback.on('unavailable', () => {
|
|
84
|
+
this.broadcastDefaultToolsToAgents();
|
|
85
|
+
this.broadcastSessionState();
|
|
86
|
+
});
|
|
87
|
+
// Write PID file
|
|
88
|
+
writeFileSync(PID_FILE, String(process.pid));
|
|
89
|
+
this.log(`Relay started (PID: ${process.pid})`);
|
|
90
|
+
// Handle shutdown
|
|
91
|
+
process.on('SIGINT', () => this.shutdown());
|
|
92
|
+
process.on('SIGTERM', () => this.shutdown());
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Start WebSocket server for extension connection
|
|
96
|
+
*/
|
|
97
|
+
async startExtensionServer() {
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
this.extensionWss = new WebSocketServer({ port: EXTENSION_PORT, host: '127.0.0.1' });
|
|
100
|
+
this.extensionWss.on('listening', () => {
|
|
101
|
+
this.log(`Extension server listening on ws://127.0.0.1:${EXTENSION_PORT}`);
|
|
102
|
+
resolve();
|
|
103
|
+
});
|
|
104
|
+
this.extensionWss.on('connection', (ws) => {
|
|
105
|
+
this.handleExtensionConnection(ws);
|
|
106
|
+
});
|
|
107
|
+
this.extensionWss.on('error', (error) => {
|
|
108
|
+
this.log(`Extension server error: ${error.message}`);
|
|
109
|
+
reject(error);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Start WebSocket server for agent connections
|
|
115
|
+
*/
|
|
116
|
+
async startAgentServer() {
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
this.agentWss = new WebSocketServer({ port: AGENT_PORT, host: '127.0.0.1' });
|
|
119
|
+
this.agentWss.on('listening', () => {
|
|
120
|
+
this.log(`Agent server listening on ws://127.0.0.1:${AGENT_PORT}`);
|
|
121
|
+
resolve();
|
|
122
|
+
});
|
|
123
|
+
this.agentWss.on('connection', (ws) => {
|
|
124
|
+
this.handleAgentConnection(ws);
|
|
125
|
+
});
|
|
126
|
+
this.agentWss.on('error', (error) => {
|
|
127
|
+
this.log(`Agent server error: ${error.message}`);
|
|
128
|
+
reject(error);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Handle extension connection
|
|
134
|
+
*/
|
|
135
|
+
handleExtensionConnection(ws) {
|
|
136
|
+
this.log('Extension socket connected; waiting for session handshake');
|
|
137
|
+
ws.on('message', (data) => {
|
|
138
|
+
try {
|
|
139
|
+
const message = JSON.parse(data.toString());
|
|
140
|
+
this.handleExtensionMessage(ws, message);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
this.log(`Failed to parse extension message: ${error}`);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
ws.on('close', (code, reasonBuffer) => {
|
|
147
|
+
const reason = reasonBuffer?.toString() || '';
|
|
148
|
+
const sessionId = this.socketToSessionId.get(ws);
|
|
149
|
+
this.log(`Extension disconnected${sessionId ? ` (${sessionId})` : ''} (code=${code}${reason ? `, reason=${reason}` : ''})`);
|
|
150
|
+
if (!sessionId) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const session = this.extensionSessions.get(sessionId);
|
|
154
|
+
if (!session || session.ws !== ws) {
|
|
155
|
+
this.socketToSessionId.delete(ws);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
this.socketToSessionId.delete(ws);
|
|
159
|
+
this.extensionSessions.delete(sessionId);
|
|
160
|
+
this.stopToolsSyncLoop(session);
|
|
161
|
+
this.rejectPendingRequestsForSession(sessionId, `Extension session disconnected: ${sessionId}`);
|
|
162
|
+
this.broadcastSessionState();
|
|
163
|
+
if (this.extensionSessions.size === 0) {
|
|
164
|
+
this.broadcastToAgents({ type: 'extension_disconnected' });
|
|
165
|
+
this.broadcastDefaultToolsToAgents();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
ws.on('error', (error) => {
|
|
169
|
+
this.log(`Extension WebSocket error: ${error.message}`);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Handle agent connection
|
|
174
|
+
*/
|
|
175
|
+
handleAgentConnection(ws) {
|
|
176
|
+
const agentId = `agent_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
177
|
+
const agent = {
|
|
178
|
+
ws,
|
|
179
|
+
id: agentId,
|
|
180
|
+
connectedAt: Date.now(),
|
|
181
|
+
};
|
|
182
|
+
this.agents.set(agentId, agent);
|
|
183
|
+
this.log(`Agent connected: ${agentId} (total: ${this.agents.size})`);
|
|
184
|
+
this.sendSessionStateToAgent(ws);
|
|
185
|
+
const defaultSession = this.getDefaultSession();
|
|
186
|
+
const tools = this.getToolsForSession(defaultSession);
|
|
187
|
+
if (tools.length > 0) {
|
|
188
|
+
ws.send(JSON.stringify({ type: 'tools_list', data: tools, sessionId: defaultSession?.sessionId }));
|
|
189
|
+
}
|
|
190
|
+
ws.on('message', (data) => {
|
|
191
|
+
try {
|
|
192
|
+
const message = JSON.parse(data.toString());
|
|
193
|
+
this.handleAgentMessage(agentId, message).catch((error) => {
|
|
194
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
195
|
+
this.log(`Unhandled agent message failure (${agentId}): ${reason}`);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
this.log(`Failed to parse agent message: ${error}`);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
ws.on('close', () => {
|
|
203
|
+
this.agents.delete(agentId);
|
|
204
|
+
this.log(`Agent disconnected: ${agentId} (total: ${this.agents.size})`);
|
|
205
|
+
// Clean up pending requests for this agent
|
|
206
|
+
for (const [relayId, pending] of this.pendingRequests) {
|
|
207
|
+
if (pending.agentId === agentId) {
|
|
208
|
+
this.pendingRequests.delete(relayId);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
ws.on('error', (error) => {
|
|
213
|
+
this.log(`Agent WebSocket error: ${error.message}`);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Handle message from extension
|
|
218
|
+
*/
|
|
219
|
+
handleExtensionMessage(sourceWs, message) {
|
|
220
|
+
const session = this.ensureSessionForSocket(sourceWs, message);
|
|
221
|
+
if (!session) {
|
|
222
|
+
this.log(`Ignoring extension message before handshake: ${message.type}`);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
this.log(`Extension message (${session.sessionId}): ${message.type}`);
|
|
226
|
+
if (message.type === 'connected') {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
// Handle response to a pending request first — this must run before the
|
|
230
|
+
// tools_list broadcast so that `refreshTools()` resolves promptly instead
|
|
231
|
+
// of waiting for its 30 s timeout.
|
|
232
|
+
if (message.requestId) {
|
|
233
|
+
const pending = this.pendingRequests.get(message.requestId);
|
|
234
|
+
if (pending) {
|
|
235
|
+
this.pendingRequests.delete(message.requestId);
|
|
236
|
+
// Forward response to the requesting agent
|
|
237
|
+
const agent = this.agents.get(pending.agentId);
|
|
238
|
+
if (agent) {
|
|
239
|
+
if (message.type === 'tools_list') {
|
|
240
|
+
agent.ws.send(JSON.stringify({
|
|
241
|
+
type: 'tools_list',
|
|
242
|
+
requestId: pending.originalRequestId,
|
|
243
|
+
data: this.getToolsForSession(this.extensionSessions.get(pending.sessionId) || null),
|
|
244
|
+
sessionId: pending.sessionId,
|
|
245
|
+
}));
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
agent.ws.send(JSON.stringify({
|
|
249
|
+
...message,
|
|
250
|
+
sessionId: session.sessionId,
|
|
251
|
+
requestId: pending.originalRequestId,
|
|
252
|
+
}));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// For tools_list we still want to cache + broadcast to *other* agents
|
|
256
|
+
// so they stay in sync, but the requesting agent already got its copy.
|
|
257
|
+
if (message.type === 'tools_list') {
|
|
258
|
+
session.tools = this.parseToolDefinitions(message.data);
|
|
259
|
+
this.stopToolsSyncLoop(session);
|
|
260
|
+
this.broadcastDefaultToolsToAgents(pending.agentId);
|
|
261
|
+
this.broadcastSessionState();
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// Handle unsolicited tools list (e.g. extension announces on connect)
|
|
267
|
+
if (message.type === 'tools_list') {
|
|
268
|
+
session.tools = this.parseToolDefinitions(message.data);
|
|
269
|
+
this.stopToolsSyncLoop(session);
|
|
270
|
+
this.broadcastDefaultToolsToAgents();
|
|
271
|
+
this.broadcastSessionState();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// Broadcast other messages to all agents
|
|
275
|
+
this.broadcastToAgents({ ...message, sessionId: session.sessionId });
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Handle message from an agent
|
|
279
|
+
*/
|
|
280
|
+
async handleAgentMessage(agentId, message) {
|
|
281
|
+
try {
|
|
282
|
+
this.log(`Agent ${agentId} message: ${message.type}`);
|
|
283
|
+
if (message.type === 'list_sessions') {
|
|
284
|
+
const agent = this.agents.get(agentId);
|
|
285
|
+
if (agent && message.requestId) {
|
|
286
|
+
agent.ws.send(JSON.stringify({
|
|
287
|
+
type: 'sessions_list',
|
|
288
|
+
requestId: message.requestId,
|
|
289
|
+
sessions: this.buildSessionsList(),
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const requestedSessionId = typeof message.data?.sessionId === 'string' ? message.data.sessionId : undefined;
|
|
295
|
+
const targetSession = this.resolveTargetSession(requestedSessionId);
|
|
296
|
+
const agent = this.agents.get(agentId);
|
|
297
|
+
if (!agent || !message.requestId) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (message.type === 'list_tools') {
|
|
301
|
+
if (requestedSessionId && !targetSession) {
|
|
302
|
+
agent.ws.send(JSON.stringify({
|
|
303
|
+
type: 'error',
|
|
304
|
+
requestId: message.requestId,
|
|
305
|
+
error: `No browser session connected for sessionId=${requestedSessionId}`,
|
|
306
|
+
}));
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
agent.ws.send(JSON.stringify({
|
|
310
|
+
type: 'tools_list',
|
|
311
|
+
requestId: message.requestId,
|
|
312
|
+
data: this.getToolsForSession(targetSession),
|
|
313
|
+
sessionId: targetSession?.sessionId,
|
|
314
|
+
}));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (message.type === 'call_tool') {
|
|
318
|
+
const requestedToolName = message.data?.name;
|
|
319
|
+
if (typeof requestedToolName !== 'string' || requestedToolName.trim().length === 0) {
|
|
320
|
+
agent.ws.send(JSON.stringify({
|
|
321
|
+
type: 'error',
|
|
322
|
+
requestId: message.requestId,
|
|
323
|
+
error: 'Tool name is required',
|
|
324
|
+
}));
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (requestedSessionId && !targetSession) {
|
|
328
|
+
agent.ws.send(JSON.stringify({
|
|
329
|
+
type: 'error',
|
|
330
|
+
requestId: message.requestId,
|
|
331
|
+
error: `No browser session connected for sessionId=${requestedSessionId}`,
|
|
332
|
+
}));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const extensionTool = this.findExtensionTool(targetSession, requestedToolName);
|
|
336
|
+
if (extensionTool && targetSession) {
|
|
337
|
+
const relayRequestId = `relay_${++this.requestIdCounter}`;
|
|
338
|
+
const cleanData = message.data ? { ...message.data } : undefined;
|
|
339
|
+
if (cleanData && 'sessionId' in cleanData) {
|
|
340
|
+
delete cleanData.sessionId;
|
|
341
|
+
}
|
|
342
|
+
const forwardMessage = {
|
|
343
|
+
...message,
|
|
344
|
+
requestId: relayRequestId,
|
|
345
|
+
...(cleanData ? { data: cleanData } : {}),
|
|
346
|
+
};
|
|
347
|
+
this.pendingRequests.set(relayRequestId, {
|
|
348
|
+
agentId,
|
|
349
|
+
originalRequestId: message.requestId,
|
|
350
|
+
lastSentAt: Date.now(),
|
|
351
|
+
forwardMessage,
|
|
352
|
+
sessionId: targetSession.sessionId,
|
|
353
|
+
});
|
|
354
|
+
targetSession.ws.send(JSON.stringify(forwardMessage));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (!this.hasConnectedExtensionSession() && this.findFallbackTool(requestedToolName)) {
|
|
358
|
+
try {
|
|
359
|
+
const args = message.data?.arguments && typeof message.data.arguments === 'object'
|
|
360
|
+
? message.data.arguments
|
|
361
|
+
: {};
|
|
362
|
+
const result = await this.devtoolsFallback.callTool(requestedToolName, args);
|
|
363
|
+
agent.ws.send(JSON.stringify({
|
|
364
|
+
type: 'tool_result',
|
|
365
|
+
requestId: message.requestId,
|
|
366
|
+
data: result,
|
|
367
|
+
sessionId: targetSession?.sessionId,
|
|
368
|
+
}));
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
372
|
+
agent.ws.send(JSON.stringify({
|
|
373
|
+
type: 'error',
|
|
374
|
+
requestId: message.requestId,
|
|
375
|
+
error: reason,
|
|
376
|
+
sessionId: targetSession?.sessionId,
|
|
377
|
+
}));
|
|
378
|
+
}
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
agent.ws.send(JSON.stringify({
|
|
382
|
+
type: 'error',
|
|
383
|
+
requestId: message.requestId,
|
|
384
|
+
error: targetSession
|
|
385
|
+
? `Tool not found: ${requestedToolName}`
|
|
386
|
+
: 'No extension connected',
|
|
387
|
+
}));
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (!targetSession) {
|
|
391
|
+
// No extension connected, send error back
|
|
392
|
+
agent.ws.send(JSON.stringify({
|
|
393
|
+
type: 'error',
|
|
394
|
+
requestId: message.requestId,
|
|
395
|
+
error: requestedSessionId
|
|
396
|
+
? `No browser session connected for sessionId=${requestedSessionId}`
|
|
397
|
+
: 'No extension connected',
|
|
398
|
+
}));
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
// Generate relay request ID
|
|
402
|
+
const relayRequestId = `relay_${++this.requestIdCounter}`;
|
|
403
|
+
const cleanData = message.data ? { ...message.data } : undefined;
|
|
404
|
+
if (cleanData && 'sessionId' in cleanData) {
|
|
405
|
+
delete cleanData.sessionId;
|
|
406
|
+
}
|
|
407
|
+
const forwardMessage = {
|
|
408
|
+
...message,
|
|
409
|
+
requestId: relayRequestId,
|
|
410
|
+
...(cleanData ? { data: cleanData } : {}),
|
|
411
|
+
};
|
|
412
|
+
// Store pending request mapping so it can be replayed if the extension
|
|
413
|
+
// swaps sockets mid-flight.
|
|
414
|
+
this.pendingRequests.set(relayRequestId, {
|
|
415
|
+
agentId,
|
|
416
|
+
originalRequestId: message.requestId,
|
|
417
|
+
lastSentAt: Date.now(),
|
|
418
|
+
forwardMessage,
|
|
419
|
+
sessionId: targetSession.sessionId,
|
|
420
|
+
});
|
|
421
|
+
// Forward to extension with relay request ID
|
|
422
|
+
targetSession.ws.send(JSON.stringify(forwardMessage));
|
|
423
|
+
}
|
|
424
|
+
catch (error) {
|
|
425
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
426
|
+
this.log(`Failed to handle agent message (${agentId}, ${message.type}): ${reason}`);
|
|
427
|
+
const agent = this.agents.get(agentId);
|
|
428
|
+
if (agent && message.requestId && agent.ws.readyState === WebSocket.OPEN) {
|
|
429
|
+
agent.ws.send(JSON.stringify({
|
|
430
|
+
type: 'error',
|
|
431
|
+
requestId: message.requestId,
|
|
432
|
+
error: `Relay error: ${reason}`,
|
|
433
|
+
}));
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Request tools list from extension
|
|
439
|
+
*/
|
|
440
|
+
requestToolsFromExtension(session) {
|
|
441
|
+
if (session.ws.readyState !== WebSocket.OPEN)
|
|
442
|
+
return;
|
|
443
|
+
const requestId = `relay_${++this.requestIdCounter}`;
|
|
444
|
+
session.ws.send(JSON.stringify({
|
|
445
|
+
type: 'list_tools',
|
|
446
|
+
requestId,
|
|
447
|
+
}));
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Replay pending requests on a replacement extension connection.
|
|
451
|
+
*
|
|
452
|
+
* The browser-side client deduplicates repeated request IDs, so keeping the
|
|
453
|
+
* same relay request ID lets us survive a transient socket swap without
|
|
454
|
+
* dropping the original MCP call or double-running it under normal reconnects.
|
|
455
|
+
*/
|
|
456
|
+
replayPendingRequests(session) {
|
|
457
|
+
if (this.pendingRequests.size === 0)
|
|
458
|
+
return;
|
|
459
|
+
if (session.ws.readyState !== WebSocket.OPEN)
|
|
460
|
+
return;
|
|
461
|
+
const pendingForSession = [...this.pendingRequests.entries()].filter(([, pending]) => pending.sessionId === session.sessionId);
|
|
462
|
+
if (pendingForSession.length === 0)
|
|
463
|
+
return;
|
|
464
|
+
this.log(`Replaying ${pendingForSession.length} pending request(s) on replacement connection for ${session.sessionId}`);
|
|
465
|
+
for (const [relayRequestId, pending] of pendingForSession) {
|
|
466
|
+
pending.lastSentAt = Date.now();
|
|
467
|
+
try {
|
|
468
|
+
session.ws.send(JSON.stringify(pending.forwardMessage));
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
this.log(`Failed to replay ${relayRequestId}: ${error}`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Keep requesting tools until extension responds with tools_list.
|
|
477
|
+
*/
|
|
478
|
+
startToolsSyncLoop(session) {
|
|
479
|
+
this.stopToolsSyncLoop(session);
|
|
480
|
+
this.requestToolsFromExtension(session);
|
|
481
|
+
session.toolsSyncTimer = setInterval(() => {
|
|
482
|
+
if (session.ws.readyState !== WebSocket.OPEN) {
|
|
483
|
+
this.stopToolsSyncLoop(session);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
this.requestToolsFromExtension(session);
|
|
487
|
+
}, 1_000);
|
|
488
|
+
}
|
|
489
|
+
stopToolsSyncLoop(session) {
|
|
490
|
+
if (session.toolsSyncTimer) {
|
|
491
|
+
clearInterval(session.toolsSyncTimer);
|
|
492
|
+
session.toolsSyncTimer = null;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Broadcast message to all connected agents
|
|
497
|
+
*/
|
|
498
|
+
broadcastToAgents(message, excludeAgentId) {
|
|
499
|
+
const payload = JSON.stringify(message);
|
|
500
|
+
for (const agent of this.agents.values()) {
|
|
501
|
+
if (agent.id === excludeAgentId)
|
|
502
|
+
continue;
|
|
503
|
+
try {
|
|
504
|
+
agent.ws.send(payload);
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
this.log(`Failed to send to agent ${agent.id}: ${error}`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Shutdown the relay server
|
|
513
|
+
*/
|
|
514
|
+
async shutdown() {
|
|
515
|
+
this.log('Shutting down relay...');
|
|
516
|
+
// Clean up PID file
|
|
517
|
+
try {
|
|
518
|
+
if (existsSync(PID_FILE)) {
|
|
519
|
+
unlinkSync(PID_FILE);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
// Ignore
|
|
524
|
+
}
|
|
525
|
+
// Close all agent connections
|
|
526
|
+
for (const agent of this.agents.values()) {
|
|
527
|
+
try {
|
|
528
|
+
agent.ws.close();
|
|
529
|
+
}
|
|
530
|
+
catch (error) {
|
|
531
|
+
// Ignore
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
this.agents.clear();
|
|
535
|
+
// Close extension connections
|
|
536
|
+
for (const session of this.extensionSessions.values()) {
|
|
537
|
+
try {
|
|
538
|
+
session.ws.close();
|
|
539
|
+
}
|
|
540
|
+
catch (error) {
|
|
541
|
+
// Ignore
|
|
542
|
+
}
|
|
543
|
+
this.stopToolsSyncLoop(session);
|
|
544
|
+
}
|
|
545
|
+
this.extensionSessions.clear();
|
|
546
|
+
this.socketToSessionId.clear();
|
|
547
|
+
await this.devtoolsFallback.stop();
|
|
548
|
+
// Close servers
|
|
549
|
+
if (this.agentWss) {
|
|
550
|
+
this.agentWss.close();
|
|
551
|
+
this.agentWss = null;
|
|
552
|
+
}
|
|
553
|
+
if (this.extensionWss) {
|
|
554
|
+
this.extensionWss.close();
|
|
555
|
+
this.extensionWss = null;
|
|
556
|
+
}
|
|
557
|
+
process.exit(0);
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Log message
|
|
561
|
+
*/
|
|
562
|
+
log(message) {
|
|
563
|
+
const timestamp = new Date().toISOString();
|
|
564
|
+
const line = `[${timestamp}] ${message}`;
|
|
565
|
+
if (this.debug) {
|
|
566
|
+
console.error(`[relay] ${message}`);
|
|
567
|
+
}
|
|
568
|
+
// Also append to log file
|
|
569
|
+
try {
|
|
570
|
+
const fs = require('fs');
|
|
571
|
+
fs.appendFileSync(LOG_FILE, line + '\n');
|
|
572
|
+
}
|
|
573
|
+
catch (error) {
|
|
574
|
+
// Ignore log errors
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
ensureSessionForSocket(ws, message) {
|
|
578
|
+
const existingSessionId = this.socketToSessionId.get(ws);
|
|
579
|
+
if (existingSessionId) {
|
|
580
|
+
return this.extensionSessions.get(existingSessionId) || null;
|
|
581
|
+
}
|
|
582
|
+
const announcedSessionId = this.extractAnnouncedSessionId(message) || `session_${++this.anonymousSessionCounter}`;
|
|
583
|
+
const existing = this.extensionSessions.get(announcedSessionId);
|
|
584
|
+
if (existing) {
|
|
585
|
+
const previousWs = existing.ws;
|
|
586
|
+
if (previousWs !== ws) {
|
|
587
|
+
this.log(`Extension session ${announcedSessionId} reconnecting, replacing previous socket`);
|
|
588
|
+
this.socketToSessionId.delete(previousWs);
|
|
589
|
+
existing.ws = ws;
|
|
590
|
+
existing.connectedAt = Date.now();
|
|
591
|
+
this.socketToSessionId.set(ws, announcedSessionId);
|
|
592
|
+
this.broadcastSessionState();
|
|
593
|
+
this.startToolsSyncLoop(existing);
|
|
594
|
+
this.replayPendingRequests(existing);
|
|
595
|
+
try {
|
|
596
|
+
previousWs.close();
|
|
597
|
+
}
|
|
598
|
+
catch (error) {
|
|
599
|
+
// ignore close errors on replaced sockets
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return existing;
|
|
603
|
+
}
|
|
604
|
+
const session = {
|
|
605
|
+
ws,
|
|
606
|
+
sessionId: announcedSessionId,
|
|
607
|
+
connectedAt: Date.now(),
|
|
608
|
+
tools: [],
|
|
609
|
+
toolsSyncTimer: null,
|
|
610
|
+
};
|
|
611
|
+
this.extensionSessions.set(announcedSessionId, session);
|
|
612
|
+
this.socketToSessionId.set(ws, announcedSessionId);
|
|
613
|
+
this.broadcastSessionState();
|
|
614
|
+
this.startToolsSyncLoop(session);
|
|
615
|
+
return session;
|
|
616
|
+
}
|
|
617
|
+
extractAnnouncedSessionId(message) {
|
|
618
|
+
if (typeof message.sessionId === 'string' && message.sessionId.trim().length > 0) {
|
|
619
|
+
return message.sessionId.trim();
|
|
620
|
+
}
|
|
621
|
+
if (message.data && typeof message.data === 'object') {
|
|
622
|
+
const candidate = message.data.sessionId;
|
|
623
|
+
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
|
624
|
+
return candidate.trim();
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return undefined;
|
|
628
|
+
}
|
|
629
|
+
buildSessionsList() {
|
|
630
|
+
return [...this.extensionSessions.values()].map((session) => ({
|
|
631
|
+
sessionId: session.sessionId,
|
|
632
|
+
connected: session.ws.readyState === WebSocket.OPEN,
|
|
633
|
+
connectedAt: session.connectedAt,
|
|
634
|
+
toolCount: session.tools.length,
|
|
635
|
+
}));
|
|
636
|
+
}
|
|
637
|
+
getDefaultSession() {
|
|
638
|
+
for (const session of this.extensionSessions.values()) {
|
|
639
|
+
if (session.ws.readyState === WebSocket.OPEN) {
|
|
640
|
+
return session;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
resolveTargetSession(requestedSessionId) {
|
|
646
|
+
if (requestedSessionId) {
|
|
647
|
+
const session = this.extensionSessions.get(requestedSessionId);
|
|
648
|
+
if (session && session.ws.readyState === WebSocket.OPEN) {
|
|
649
|
+
return session;
|
|
650
|
+
}
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
return this.getDefaultSession();
|
|
654
|
+
}
|
|
655
|
+
parseToolDefinitions(data) {
|
|
656
|
+
if (!Array.isArray(data)) {
|
|
657
|
+
return [];
|
|
658
|
+
}
|
|
659
|
+
return data
|
|
660
|
+
.filter((entry) => Boolean(entry) && typeof entry === 'object' && typeof entry.name === 'string')
|
|
661
|
+
.map((entry) => entry);
|
|
662
|
+
}
|
|
663
|
+
getToolsForSession(session) {
|
|
664
|
+
if (session) {
|
|
665
|
+
return [...session.tools];
|
|
666
|
+
}
|
|
667
|
+
if (this.hasConnectedExtensionSession()) {
|
|
668
|
+
return [];
|
|
669
|
+
}
|
|
670
|
+
return [...this.devtoolsFallback.getTools()];
|
|
671
|
+
}
|
|
672
|
+
findExtensionTool(session, toolName) {
|
|
673
|
+
if (!session) {
|
|
674
|
+
return undefined;
|
|
675
|
+
}
|
|
676
|
+
const key = normalizeToolName(toolName);
|
|
677
|
+
return session.tools.find((tool) => normalizeToolName(tool.name) === key);
|
|
678
|
+
}
|
|
679
|
+
findFallbackTool(toolName) {
|
|
680
|
+
const key = normalizeToolName(toolName);
|
|
681
|
+
return this.devtoolsFallback.getTools().find((tool) => normalizeToolName(tool.name) === key);
|
|
682
|
+
}
|
|
683
|
+
broadcastDefaultToolsToAgents(excludeAgentId) {
|
|
684
|
+
const defaultSession = this.getDefaultSession();
|
|
685
|
+
const tools = this.getToolsForSession(defaultSession);
|
|
686
|
+
this.broadcastToAgents({
|
|
687
|
+
type: 'tools_list',
|
|
688
|
+
data: tools,
|
|
689
|
+
sessionId: defaultSession?.sessionId,
|
|
690
|
+
}, excludeAgentId);
|
|
691
|
+
}
|
|
692
|
+
hasConnectedExtensionSession() {
|
|
693
|
+
return this.getDefaultSession() !== null;
|
|
694
|
+
}
|
|
695
|
+
sendSessionStateToAgent(ws) {
|
|
696
|
+
const sessions = this.buildSessionsList();
|
|
697
|
+
const defaultSession = this.getDefaultSession();
|
|
698
|
+
ws.send(JSON.stringify({
|
|
699
|
+
type: 'sessions_list',
|
|
700
|
+
sessions,
|
|
701
|
+
sessionIds: sessions.map((session) => session.sessionId),
|
|
702
|
+
connected: sessions.some((session) => session.connected),
|
|
703
|
+
sessionId: defaultSession?.sessionId,
|
|
704
|
+
}));
|
|
705
|
+
ws.send(JSON.stringify({
|
|
706
|
+
type: 'extension_status',
|
|
707
|
+
connected: sessions.some((session) => session.connected),
|
|
708
|
+
sessionIds: sessions.map((session) => session.sessionId),
|
|
709
|
+
sessionId: defaultSession?.sessionId,
|
|
710
|
+
}));
|
|
711
|
+
}
|
|
712
|
+
broadcastSessionState() {
|
|
713
|
+
const sessions = this.buildSessionsList();
|
|
714
|
+
const defaultSession = this.getDefaultSession();
|
|
715
|
+
this.broadcastToAgents({
|
|
716
|
+
type: 'sessions_list',
|
|
717
|
+
sessions,
|
|
718
|
+
sessionIds: sessions.map((session) => session.sessionId),
|
|
719
|
+
connected: sessions.some((session) => session.connected),
|
|
720
|
+
sessionId: defaultSession?.sessionId,
|
|
721
|
+
});
|
|
722
|
+
this.broadcastToAgents({
|
|
723
|
+
type: 'extension_status',
|
|
724
|
+
connected: sessions.some((session) => session.connected),
|
|
725
|
+
sessionIds: sessions.map((session) => session.sessionId),
|
|
726
|
+
sessionId: defaultSession?.sessionId,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
rejectPendingRequestsForSession(sessionId, errorMessage) {
|
|
730
|
+
for (const [relayId, pending] of this.pendingRequests) {
|
|
731
|
+
if (pending.sessionId !== sessionId) {
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
this.pendingRequests.delete(relayId);
|
|
735
|
+
const agent = this.agents.get(pending.agentId);
|
|
736
|
+
if (agent && agent.ws.readyState === WebSocket.OPEN) {
|
|
737
|
+
agent.ws.send(JSON.stringify({
|
|
738
|
+
type: 'error',
|
|
739
|
+
requestId: pending.originalRequestId,
|
|
740
|
+
error: errorMessage,
|
|
741
|
+
sessionId,
|
|
742
|
+
}));
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Check if relay is already running
|
|
749
|
+
*/
|
|
750
|
+
export function isRelayRunning() {
|
|
751
|
+
if (!existsSync(PID_FILE)) {
|
|
752
|
+
return false;
|
|
753
|
+
}
|
|
754
|
+
try {
|
|
755
|
+
const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10);
|
|
756
|
+
// Check if process is alive
|
|
757
|
+
process.kill(pid, 0);
|
|
758
|
+
return true;
|
|
759
|
+
}
|
|
760
|
+
catch (error) {
|
|
761
|
+
// Process not running, clean up stale PID file
|
|
762
|
+
try {
|
|
763
|
+
unlinkSync(PID_FILE);
|
|
764
|
+
}
|
|
765
|
+
catch (e) {
|
|
766
|
+
// Ignore
|
|
767
|
+
}
|
|
768
|
+
return false;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Get relay PID if running
|
|
773
|
+
*/
|
|
774
|
+
export function getRelayPid() {
|
|
775
|
+
if (!existsSync(PID_FILE)) {
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
try {
|
|
779
|
+
const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10);
|
|
780
|
+
process.kill(pid, 0);
|
|
781
|
+
return pid;
|
|
782
|
+
}
|
|
783
|
+
catch (error) {
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Start relay as a detached daemon
|
|
789
|
+
*/
|
|
790
|
+
export function spawnRelayDaemon(debug = false) {
|
|
791
|
+
const { spawn } = require('child_process');
|
|
792
|
+
const { dirname } = require('path');
|
|
793
|
+
// Get path to this module (relay.js after compilation)
|
|
794
|
+
const relayScript = join(dirname(__dirname), 'dist', 'relay-daemon.js');
|
|
795
|
+
// Spawn detached process
|
|
796
|
+
const child = spawn(process.execPath, [relayScript, debug ? '--debug' : ''], {
|
|
797
|
+
detached: true,
|
|
798
|
+
stdio: 'ignore',
|
|
799
|
+
cwd: VIBE_DIR,
|
|
800
|
+
env: process.env,
|
|
801
|
+
});
|
|
802
|
+
child.unref();
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Main entry point for relay daemon
|
|
806
|
+
*/
|
|
807
|
+
export async function startRelayDaemon(debug = false) {
|
|
808
|
+
const relay = new RelayServer(debug);
|
|
809
|
+
await relay.start();
|
|
810
|
+
// Keep process alive
|
|
811
|
+
console.error(`[relay] Daemon running (PID: ${process.pid})`);
|
|
812
|
+
}
|
|
813
|
+
//# sourceMappingURL=relay.js.map
|