@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/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