@vibebrowser/mcp 0.2.5 → 0.2.7

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 CHANGED
@@ -13,6 +13,7 @@ import { writeFileSync, readFileSync, existsSync, unlinkSync, mkdirSync } from '
13
13
  import { join } from 'path';
14
14
  import { homedir } from 'os';
15
15
  import { EventEmitter } from 'events';
16
+ import { DevtoolsFallbackConnection } from './devtools-fallback.js';
16
17
  function parseEnvPort(name, fallback) {
17
18
  const raw = process.env[name];
18
19
  if (!raw) {
@@ -24,6 +25,9 @@ function parseEnvPort(name, fallback) {
24
25
  }
25
26
  return port;
26
27
  }
28
+ function normalizeToolName(value) {
29
+ return value.replace(/[-\s]/g, '_').toLowerCase();
30
+ }
27
31
  // Ports (19888/19889 to avoid conflict with Playwriter MCP which uses 19988/19989)
28
32
  export const EXTENSION_PORT = parseEnvPort('VIBE_MCP_EXTENSION_PORT', 19889);
29
33
  export const AGENT_PORT = parseEnvPort('VIBE_MCP_AGENT_PORT', 19888);
@@ -33,20 +37,27 @@ const PID_FILE = join(VIBE_DIR, 'relay.pid');
33
37
  const LOG_FILE = join(VIBE_DIR, 'relay.log');
34
38
  /**
35
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.
36
45
  */
37
46
  export class RelayServer extends EventEmitter {
38
47
  extensionWss = null;
39
48
  agentWss = null;
40
- extensionWs = null;
49
+ extensionSessions = new Map();
50
+ socketToSessionId = new Map();
41
51
  agents = new Map();
42
52
  pendingRequests = new Map();
43
- tools = [];
44
53
  requestIdCounter = 0;
54
+ anonymousSessionCounter = 0;
45
55
  debug;
46
- toolsSyncTimer = null;
56
+ devtoolsFallback;
47
57
  constructor(debug = false) {
48
58
  super();
49
59
  this.debug = debug;
60
+ this.devtoolsFallback = new DevtoolsFallbackConnection(debug);
50
61
  }
51
62
  /**
52
63
  * Start the relay server
@@ -60,6 +71,19 @@ export class RelayServer extends EventEmitter {
60
71
  await this.startExtensionServer();
61
72
  // Start agent WebSocket server
62
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
+ });
63
87
  // Write PID file
64
88
  writeFileSync(PID_FILE, String(process.pid));
65
89
  this.log(`Relay started (PID: ${process.pid})`);
@@ -109,17 +133,8 @@ export class RelayServer extends EventEmitter {
109
133
  * Handle extension connection
110
134
  */
111
135
  handleExtensionConnection(ws) {
112
- const previousWs = this.extensionWs;
113
- const replacedConnection = previousWs !== null && previousWs !== ws;
114
- // Replace prior extension connection (background/service worker can reconnect).
115
- if (previousWs && previousWs !== ws) {
116
- previousWs.close();
117
- }
118
- this.log('Extension connected');
119
- this.extensionWs = ws;
136
+ this.log('Extension socket connected; waiting for session handshake');
120
137
  ws.on('message', (data) => {
121
- if (this.extensionWs !== ws)
122
- return;
123
138
  try {
124
139
  const message = JSON.parse(data.toString());
125
140
  this.handleExtensionMessage(ws, message);
@@ -130,25 +145,29 @@ export class RelayServer extends EventEmitter {
130
145
  });
131
146
  ws.on('close', (code, reasonBuffer) => {
132
147
  const reason = reasonBuffer?.toString() || '';
133
- this.log(`Extension disconnected (code=${code}${reason ? `, reason=${reason}` : ''})`);
134
- // Ignore stale close events from replaced sockets.
135
- if (this.extensionWs !== ws) {
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);
136
156
  return;
137
157
  }
138
- this.extensionWs = null;
139
- this.tools = [];
140
- this.stopToolsSyncLoop();
141
- // Notify all agents
142
- this.broadcastToAgents({ type: 'extension_disconnected' });
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
+ }
143
167
  });
144
168
  ws.on('error', (error) => {
145
169
  this.log(`Extension WebSocket error: ${error.message}`);
146
170
  });
147
- // Request tools list, with retries in case the extension wasn't ready yet.
148
- this.startToolsSyncLoop();
149
- if (replacedConnection) {
150
- this.replayPendingRequests(ws);
151
- }
152
171
  }
153
172
  /**
154
173
  * Handle agent connection
@@ -162,19 +181,19 @@ export class RelayServer extends EventEmitter {
162
181
  };
163
182
  this.agents.set(agentId, agent);
164
183
  this.log(`Agent connected: ${agentId} (total: ${this.agents.size})`);
165
- // Send current tools list
166
- if (this.tools.length > 0) {
167
- ws.send(JSON.stringify({ type: 'tools_list', data: this.tools }));
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 }));
168
189
  }
169
- // Send extension status
170
- ws.send(JSON.stringify({
171
- type: 'extension_status',
172
- connected: this.extensionWs !== null
173
- }));
174
190
  ws.on('message', (data) => {
175
191
  try {
176
192
  const message = JSON.parse(data.toString());
177
- this.handleAgentMessage(agentId, message);
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
+ });
178
197
  }
179
198
  catch (error) {
180
199
  this.log(`Failed to parse agent message: ${error}`);
@@ -198,26 +217,18 @@ export class RelayServer extends EventEmitter {
198
217
  * Handle message from extension
199
218
  */
200
219
  handleExtensionMessage(sourceWs, message) {
201
- if (this.extensionWs !== sourceWs) {
202
- this.log(`Ignoring stale extension message: ${message.type}`);
220
+ const session = this.ensureSessionForSocket(sourceWs, message);
221
+ if (!session) {
222
+ this.log(`Ignoring extension message before handshake: ${message.type}`);
203
223
  return;
204
224
  }
205
- this.log(`Extension message: ${message.type}`);
225
+ this.log(`Extension message (${session.sessionId}): ${message.type}`);
206
226
  if (message.type === 'connected') {
207
- // Local extension clients announce their websocket handshake with a
208
- // standalone `connected` message. That is not itself a failure signal,
209
- // and rejecting pending requests here breaks otherwise healthy reconnects.
210
- return;
211
- }
212
- // Handle tools list
213
- if (message.type === 'tools_list') {
214
- this.tools = message.data;
215
- this.stopToolsSyncLoop();
216
- // Broadcast to all agents
217
- this.broadcastToAgents({ type: 'tools_list', data: this.tools });
218
227
  return;
219
228
  }
220
- // Handle response to a request
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.
221
232
  if (message.requestId) {
222
233
  const pending = this.pendingRequests.get(message.requestId);
223
234
  if (pending) {
@@ -225,59 +236,212 @@ export class RelayServer extends EventEmitter {
225
236
  // Forward response to the requesting agent
226
237
  const agent = this.agents.get(pending.agentId);
227
238
  if (agent) {
228
- agent.ws.send(JSON.stringify({
229
- ...message,
230
- requestId: pending.originalRequestId,
231
- }));
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();
232
262
  }
233
263
  return;
234
264
  }
235
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
+ }
236
274
  // Broadcast other messages to all agents
237
- this.broadcastToAgents(message);
275
+ this.broadcastToAgents({ ...message, sessionId: session.sessionId });
238
276
  }
239
277
  /**
240
278
  * Handle message from an agent
241
279
  */
242
- handleAgentMessage(agentId, message) {
243
- this.log(`Agent ${agentId} message: ${message.type}`);
244
- if (!this.extensionWs) {
245
- // No extension connected, send error back
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);
246
296
  const agent = this.agents.get(agentId);
247
- if (agent && message.requestId) {
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
+ }
248
381
  agent.ws.send(JSON.stringify({
249
382
  type: 'error',
250
383
  requestId: message.requestId,
251
- error: 'No extension connected',
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}`,
252
433
  }));
253
434
  }
254
- return;
255
435
  }
256
- // Generate relay request ID
257
- const relayRequestId = `relay_${++this.requestIdCounter}`;
258
- const forwardMessage = {
259
- ...message,
260
- requestId: relayRequestId,
261
- };
262
- // Store pending request mapping so it can be replayed if the extension
263
- // swaps sockets mid-flight.
264
- this.pendingRequests.set(relayRequestId, {
265
- agentId,
266
- originalRequestId: message.requestId,
267
- lastSentAt: Date.now(),
268
- forwardMessage,
269
- });
270
- // Forward to extension with relay request ID
271
- this.extensionWs.send(JSON.stringify(forwardMessage));
272
436
  }
273
437
  /**
274
438
  * Request tools list from extension
275
439
  */
276
- requestToolsFromExtension() {
277
- if (!this.extensionWs)
440
+ requestToolsFromExtension(session) {
441
+ if (session.ws.readyState !== WebSocket.OPEN)
278
442
  return;
279
443
  const requestId = `relay_${++this.requestIdCounter}`;
280
- this.extensionWs.send(JSON.stringify({
444
+ session.ws.send(JSON.stringify({
281
445
  type: 'list_tools',
282
446
  requestId,
283
447
  }));
@@ -289,16 +453,19 @@ export class RelayServer extends EventEmitter {
289
453
  * same relay request ID lets us survive a transient socket swap without
290
454
  * dropping the original MCP call or double-running it under normal reconnects.
291
455
  */
292
- replayPendingRequests(targetWs) {
456
+ replayPendingRequests(session) {
293
457
  if (this.pendingRequests.size === 0)
294
458
  return;
295
- if (targetWs.readyState !== WebSocket.OPEN)
459
+ if (session.ws.readyState !== WebSocket.OPEN)
296
460
  return;
297
- this.log(`Replaying ${this.pendingRequests.size} pending request(s) on replacement connection`);
298
- for (const [relayRequestId, pending] of this.pendingRequests) {
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) {
299
466
  pending.lastSentAt = Date.now();
300
467
  try {
301
- targetWs.send(JSON.stringify(pending.forwardMessage));
468
+ session.ws.send(JSON.stringify(pending.forwardMessage));
302
469
  }
303
470
  catch (error) {
304
471
  this.log(`Failed to replay ${relayRequestId}: ${error}`);
@@ -308,29 +475,31 @@ export class RelayServer extends EventEmitter {
308
475
  /**
309
476
  * Keep requesting tools until extension responds with tools_list.
310
477
  */
311
- startToolsSyncLoop() {
312
- this.stopToolsSyncLoop();
313
- this.requestToolsFromExtension();
314
- this.toolsSyncTimer = setInterval(() => {
315
- if (!this.extensionWs) {
316
- this.stopToolsSyncLoop();
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);
317
484
  return;
318
485
  }
319
- this.requestToolsFromExtension();
486
+ this.requestToolsFromExtension(session);
320
487
  }, 1_000);
321
488
  }
322
- stopToolsSyncLoop() {
323
- if (this.toolsSyncTimer) {
324
- clearInterval(this.toolsSyncTimer);
325
- this.toolsSyncTimer = null;
489
+ stopToolsSyncLoop(session) {
490
+ if (session.toolsSyncTimer) {
491
+ clearInterval(session.toolsSyncTimer);
492
+ session.toolsSyncTimer = null;
326
493
  }
327
494
  }
328
495
  /**
329
496
  * Broadcast message to all connected agents
330
497
  */
331
- broadcastToAgents(message) {
498
+ broadcastToAgents(message, excludeAgentId) {
332
499
  const payload = JSON.stringify(message);
333
500
  for (const agent of this.agents.values()) {
501
+ if (agent.id === excludeAgentId)
502
+ continue;
334
503
  try {
335
504
  agent.ws.send(payload);
336
505
  }
@@ -363,12 +532,19 @@ export class RelayServer extends EventEmitter {
363
532
  }
364
533
  }
365
534
  this.agents.clear();
366
- // Close extension connection
367
- if (this.extensionWs) {
368
- this.extensionWs.close();
369
- this.extensionWs = null;
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);
370
544
  }
371
- this.stopToolsSyncLoop();
545
+ this.extensionSessions.clear();
546
+ this.socketToSessionId.clear();
547
+ await this.devtoolsFallback.stop();
372
548
  // Close servers
373
549
  if (this.agentWss) {
374
550
  this.agentWss.close();
@@ -398,6 +574,175 @@ export class RelayServer extends EventEmitter {
398
574
  // Ignore log errors
399
575
  }
400
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
+ }
401
746
  }
402
747
  /**
403
748
  * Check if relay is already running