@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.
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import { program } from 'commander';
3
+ import { registerStandaloneBrowserCli } from './browser-cli.js';
4
+ import { getPackageVersion } from './version.js';
5
+ program
6
+ .name('vibebrowser-cli')
7
+ .description('OpenClaw-compatible browser CLI (relay mode by default, or chrome-devtools with --devtools)')
8
+ .version(getPackageVersion());
9
+ registerStandaloneBrowserCli(program);
10
+ program.parse();
11
+ //# sourceMappingURL=browser-main.js.map
@@ -0,0 +1,536 @@
1
+ /**
2
+ * Vibe MCP Server - Relay Connection
3
+ *
4
+ * Connects to the relay server as a WebSocket client.
5
+ * Supports two modes:
6
+ * - Local: connects to local relay daemon at ws://127.0.0.1:19888
7
+ * - Remote: connects to public relay at wss://relay.api.vibebrowser.app/<uuid>
8
+ */
9
+ import WebSocket from 'ws';
10
+ import { EventEmitter } from 'events';
11
+ import { spawn } from 'child_process';
12
+ import { dirname, join } from 'path';
13
+ import { isRelayRunning, AGENT_PORT } from './relay.js';
14
+ const NO_CONNECTION_MESSAGE = `No connection to Vibe extension. Please:
15
+ 1. Install the Vibe AI Browser extension from https://vibebrowser.app
16
+ 2. Click the Vibe extension icon in Chrome
17
+ 3. Enable "MCP External Control" in Settings`;
18
+ const NO_CONNECTION_REMOTE_MESSAGE = `No connection to Vibe extension via remote relay. Please:
19
+ 1. Install the Vibe AI Browser extension from https://vibebrowser.app
20
+ 2. Open extension Settings > MCP External
21
+ 3. Select "Remote" mode and note your Extension UUID
22
+ 4. Make sure the extension is connected to the relay`;
23
+ const RELAY_CONNECT_TIMEOUT = 10000;
24
+ const RELAY_RECONNECT_DELAY = 2000;
25
+ const DEFAULT_RELAY_URL = 'wss://relay.api.vibebrowser.app';
26
+ function isRemoteRelayUrl(value) {
27
+ return /^wss?:\/\//i.test(value);
28
+ }
29
+ export function parseRemoteRelayUrl(value) {
30
+ let parsed;
31
+ try {
32
+ parsed = new URL(value);
33
+ }
34
+ catch (error) {
35
+ const message = error instanceof Error ? error.message : String(error);
36
+ throw new Error(`Invalid remote relay URL: ${message}`);
37
+ }
38
+ if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') {
39
+ throw new Error('Invalid remote relay URL: protocol must be ws:// or wss://');
40
+ }
41
+ const pathSegments = parsed.pathname.split('/').filter(Boolean);
42
+ const uuid = pathSegments[pathSegments.length - 1];
43
+ if (!uuid) {
44
+ throw new Error('Invalid remote relay URL: missing UUID path segment');
45
+ }
46
+ pathSegments.pop();
47
+ parsed.pathname = pathSegments.length > 0 ? `/${pathSegments.join('/')}` : '';
48
+ parsed.search = '';
49
+ parsed.hash = '';
50
+ return {
51
+ relayUrl: parsed.toString().replace(/\/$/, ''),
52
+ uuid,
53
+ };
54
+ }
55
+ export function normalizeRemoteConfig(remote) {
56
+ if (!remote) {
57
+ return undefined;
58
+ }
59
+ const relayUrl = remote.relayUrl?.replace(/\/$/, '');
60
+ if (!isRemoteRelayUrl(remote.uuid)) {
61
+ return { uuid: remote.uuid, relayUrl };
62
+ }
63
+ const parsed = parseRemoteRelayUrl(remote.uuid);
64
+ if (relayUrl && relayUrl !== parsed.relayUrl) {
65
+ throw new Error(`Remote relay URL mismatch: remote URL includes ${parsed.relayUrl}, but configured relay URL is ${relayUrl}`);
66
+ }
67
+ return {
68
+ uuid: parsed.uuid,
69
+ relayUrl: relayUrl || parsed.relayUrl,
70
+ };
71
+ }
72
+ /**
73
+ * Relay connection manager
74
+ *
75
+ * Connects to a relay server (local or remote) to reach the extension.
76
+ */
77
+ export class ExtensionConnection extends EventEmitter {
78
+ ws = null;
79
+ status = 'disconnected';
80
+ pendingRequests = new Map();
81
+ requestIdCounter = 0;
82
+ port;
83
+ debug;
84
+ tools = [];
85
+ sessions = [];
86
+ reconnectTimer = null;
87
+ extensionConnected = false;
88
+ remoteConfig = null;
89
+ localSessionConfig;
90
+ stopping = false;
91
+ constructor(port = AGENT_PORT, debug = false, remote, localSessionConfig) {
92
+ super();
93
+ this.port = port;
94
+ this.debug = debug;
95
+ this.remoteConfig = normalizeRemoteConfig(remote) || null;
96
+ this.localSessionConfig = localSessionConfig || {};
97
+ }
98
+ /**
99
+ * Start connection to relay server.
100
+ * In local mode: spawns relay daemon if needed, then connects.
101
+ * In remote mode: connects directly to public relay.
102
+ */
103
+ async start() {
104
+ this.stopping = false;
105
+ if (this.remoteConfig) {
106
+ this.log(`Remote mode: connecting to relay for UUID ${this.remoteConfig.uuid}`);
107
+ await this.connectToRelay();
108
+ return;
109
+ }
110
+ // Local mode: check if relay is already running
111
+ if (!isRelayRunning()) {
112
+ this.log('Starting relay daemon...');
113
+ await this.spawnRelay();
114
+ // Wait for relay to start
115
+ await this.waitForRelay();
116
+ }
117
+ // Connect to relay
118
+ await this.connectToRelay();
119
+ }
120
+ async setRemoteUrl(url) {
121
+ const parsed = parseRemoteRelayUrl(url);
122
+ this.stopping = true;
123
+ this.clearReconnectTimer();
124
+ this.rejectPendingRequests(new Error('Remote relay changed'));
125
+ this.closeSocket();
126
+ this.remoteConfig = { uuid: parsed.uuid, relayUrl: parsed.relayUrl };
127
+ this.tools = [];
128
+ this.sessions = [];
129
+ this.extensionConnected = false;
130
+ this.status = 'disconnected';
131
+ this.emit('tools_updated', this.tools);
132
+ this.emit('extension_status', false);
133
+ this.stopping = false;
134
+ await this.connectToRelay();
135
+ return parsed;
136
+ }
137
+ /**
138
+ * Spawn relay daemon as detached process (local mode only)
139
+ */
140
+ async spawnRelay() {
141
+ // Use __dirname equivalent for ESM
142
+ const relayScript = join(dirname(new URL(import.meta.url).pathname), 'relay-daemon.js');
143
+ const child = spawn(process.execPath, [relayScript, this.debug ? '--debug' : ''], {
144
+ detached: true,
145
+ stdio: 'ignore',
146
+ env: process.env,
147
+ });
148
+ child.unref();
149
+ this.log('Relay daemon spawned');
150
+ }
151
+ /**
152
+ * Wait for local relay to become available
153
+ */
154
+ async waitForRelay() {
155
+ const startTime = Date.now();
156
+ while (Date.now() - startTime < RELAY_CONNECT_TIMEOUT) {
157
+ try {
158
+ // Try to connect
159
+ await new Promise((resolve, reject) => {
160
+ const ws = new WebSocket(`ws://127.0.0.1:${this.port}`);
161
+ const timeout = setTimeout(() => {
162
+ ws.removeAllListeners();
163
+ ws.terminate();
164
+ reject(new Error('Timeout'));
165
+ }, 1000);
166
+ ws.on('open', () => {
167
+ clearTimeout(timeout);
168
+ ws.removeAllListeners();
169
+ ws.terminate();
170
+ resolve();
171
+ });
172
+ ws.on('error', () => {
173
+ clearTimeout(timeout);
174
+ reject(new Error('Connection failed'));
175
+ });
176
+ });
177
+ this.log('Relay is ready');
178
+ return;
179
+ }
180
+ catch (error) {
181
+ // Relay not ready yet, wait and retry
182
+ await new Promise(r => setTimeout(r, 200));
183
+ }
184
+ }
185
+ throw new Error('Relay failed to start within timeout');
186
+ }
187
+ /**
188
+ * Get the WebSocket URL for connection.
189
+ * Local mode: ws://127.0.0.1:<port>
190
+ * Remote mode: wss://relay.api.vibebrowser.app/<uuid>
191
+ */
192
+ getRelayUrl() {
193
+ if (this.remoteConfig) {
194
+ const base = this.remoteConfig.relayUrl || DEFAULT_RELAY_URL;
195
+ return `${base}/${this.remoteConfig.uuid}`;
196
+ }
197
+ return `ws://127.0.0.1:${this.port}`;
198
+ }
199
+ getRemoteConfig() {
200
+ return this.remoteConfig ? { ...this.remoteConfig } : null;
201
+ }
202
+ /**
203
+ * Connect to the relay server (local or remote)
204
+ */
205
+ async connectToRelay() {
206
+ return new Promise((resolve, reject) => {
207
+ const url = this.getRelayUrl();
208
+ this.log(`Connecting to relay at ${url}...`);
209
+ try {
210
+ this.ws = new WebSocket(url);
211
+ this.ws.on('open', () => {
212
+ this.log('Connected to relay');
213
+ this.status = 'connected';
214
+ this.emit('connected');
215
+ resolve();
216
+ });
217
+ this.ws.on('message', (data) => {
218
+ try {
219
+ const message = JSON.parse(data.toString());
220
+ this.handleMessage(message);
221
+ }
222
+ catch (error) {
223
+ this.log(`Failed to parse message: ${error}`);
224
+ }
225
+ });
226
+ this.ws.on('close', () => {
227
+ this.log('Disconnected from relay');
228
+ this.ws = null;
229
+ this.status = 'disconnected';
230
+ // Reject all pending requests — responses will never arrive on a
231
+ // closed socket. Without this, requests sit until their individual
232
+ // timeouts fire, and if the server reconnects before that the MCP
233
+ // client may retry, causing duplicate tool execution.
234
+ for (const [id, request] of this.pendingRequests) {
235
+ clearTimeout(request.timeout);
236
+ request.reject(new Error('Relay connection lost'));
237
+ }
238
+ this.pendingRequests.clear();
239
+ this.emit('disconnected');
240
+ if (!this.stopping) {
241
+ // Schedule reconnect
242
+ this.scheduleReconnect();
243
+ }
244
+ });
245
+ this.ws.on('error', (error) => {
246
+ this.log(`WebSocket error: ${error.message}`);
247
+ reject(error);
248
+ });
249
+ }
250
+ catch (error) {
251
+ reject(error);
252
+ }
253
+ });
254
+ }
255
+ /**
256
+ * Schedule reconnection attempt
257
+ */
258
+ scheduleReconnect() {
259
+ if (this.reconnectTimer) {
260
+ clearTimeout(this.reconnectTimer);
261
+ }
262
+ this.reconnectTimer = setTimeout(async () => {
263
+ this.log('Attempting to reconnect to relay...');
264
+ try {
265
+ await this.connectToRelay();
266
+ }
267
+ catch (error) {
268
+ this.log(`Reconnect failed: ${error}`);
269
+ this.scheduleReconnect();
270
+ }
271
+ }, RELAY_RECONNECT_DELAY);
272
+ }
273
+ /**
274
+ * Stop the connection
275
+ */
276
+ async stop() {
277
+ this.stopping = true;
278
+ this.clearReconnectTimer();
279
+ this.rejectPendingRequests(new Error('Connection closed'));
280
+ this.closeSocket();
281
+ this.status = 'disconnected';
282
+ }
283
+ clearReconnectTimer() {
284
+ if (this.reconnectTimer) {
285
+ clearTimeout(this.reconnectTimer);
286
+ this.reconnectTimer = null;
287
+ }
288
+ }
289
+ rejectPendingRequests(error) {
290
+ for (const [, request] of this.pendingRequests) {
291
+ clearTimeout(request.timeout);
292
+ request.reject(error);
293
+ }
294
+ this.pendingRequests.clear();
295
+ }
296
+ closeSocket() {
297
+ if (!this.ws) {
298
+ return;
299
+ }
300
+ // Remove listeners so a deliberate reconnect does not trigger stale close
301
+ // handlers or schedule a reconnect against the previous relay URL.
302
+ this.ws.removeAllListeners();
303
+ this.ws.terminate();
304
+ this.ws = null;
305
+ }
306
+ /**
307
+ * Handle message from relay
308
+ */
309
+ handleMessage(message) {
310
+ this.log(`Received: ${message.type}`);
311
+ // Handle extension status updates
312
+ if (message.type === 'extension_status') {
313
+ this.extensionConnected = message.connected ?? false;
314
+ this.emit('extension_status', this.extensionConnected);
315
+ return;
316
+ }
317
+ if (message.type === 'extension_disconnected') {
318
+ this.extensionConnected = false;
319
+ this.tools = [];
320
+ this.sessions = [];
321
+ this.emit('extension_disconnected');
322
+ return;
323
+ }
324
+ // Handle responses to pending requests
325
+ if (message.requestId) {
326
+ const pending = this.pendingRequests.get(message.requestId);
327
+ if (pending) {
328
+ clearTimeout(pending.timeout);
329
+ this.pendingRequests.delete(message.requestId);
330
+ if (message.type === 'error') {
331
+ pending.reject(new Error(message.error || 'Unknown error'));
332
+ }
333
+ else {
334
+ let payload = message.data;
335
+ if (message.type === 'sessions_list') {
336
+ payload = Array.isArray(message.sessions) ? message.sessions : message.data;
337
+ this.sessions = Array.isArray(payload) ? payload : [];
338
+ if (!this.remoteConfig) {
339
+ const selected = this.resolveRequestedSessionId(this.sessions);
340
+ this.extensionConnected = this.sessions.some((session) => session.sessionId === selected && session.connected);
341
+ }
342
+ this.emit('sessions_updated', this.sessions);
343
+ }
344
+ pending.resolve(payload);
345
+ }
346
+ return;
347
+ }
348
+ }
349
+ if (message.type === 'sessions_list') {
350
+ this.sessions = Array.isArray(message.sessions)
351
+ ? message.sessions
352
+ : Array.isArray(message.data)
353
+ ? message.data
354
+ : [];
355
+ if (!this.remoteConfig) {
356
+ const selected = this.resolveRequestedSessionId(this.sessions);
357
+ this.extensionConnected = this.sessions.some((session) => session.sessionId === selected && session.connected);
358
+ }
359
+ this.emit('sessions_updated', this.sessions);
360
+ return;
361
+ }
362
+ // Handle unsolicited messages
363
+ switch (message.type) {
364
+ case 'tools_list':
365
+ this.tools = message.data;
366
+ if (this.remoteConfig) {
367
+ this.extensionConnected = true;
368
+ }
369
+ else if (this.sessions.length === 0) {
370
+ this.extensionConnected = true;
371
+ }
372
+ this.emit('tools_updated', this.tools);
373
+ break;
374
+ case 'error':
375
+ this.log(`Error: ${message.error}`);
376
+ break;
377
+ }
378
+ }
379
+ /**
380
+ * Send a message to the extension via relay and wait for response
381
+ */
382
+ async sendRequest(type, data, timeoutMs = 30000) {
383
+ if (!this.ws || this.status !== 'connected') {
384
+ throw new Error('Not connected to relay');
385
+ }
386
+ const requestId = `req_${++this.requestIdCounter}`;
387
+ return new Promise((resolve, reject) => {
388
+ const timeout = setTimeout(() => {
389
+ this.pendingRequests.delete(requestId);
390
+ reject(new Error(`Request timed out after ${timeoutMs}ms`));
391
+ }, timeoutMs);
392
+ this.pendingRequests.set(requestId, {
393
+ resolve: resolve,
394
+ reject,
395
+ timeout,
396
+ });
397
+ const enrichedData = this.withSessionSelection(data);
398
+ const message = { type, requestId, data: enrichedData };
399
+ this.ws.send(JSON.stringify(message));
400
+ this.log(`Sent: ${type} (${requestId})`);
401
+ });
402
+ }
403
+ withSessionSelection(data) {
404
+ if (this.remoteConfig) {
405
+ return data;
406
+ }
407
+ const sessionId = this.resolveRequestedSessionId(this.sessions);
408
+ if (!sessionId) {
409
+ return data;
410
+ }
411
+ return {
412
+ ...(data || {}),
413
+ sessionId,
414
+ };
415
+ }
416
+ resolveRequestedSessionId(sessions) {
417
+ if (this.localSessionConfig.sessionId) {
418
+ return this.localSessionConfig.sessionId;
419
+ }
420
+ const firstConnected = sessions.find((session) => session.connected);
421
+ return firstConnected?.sessionId;
422
+ }
423
+ getRequestedSessionConnectionError(sessions = this.sessions) {
424
+ if (this.remoteConfig || !this.localSessionConfig.sessionId || sessions.length === 0) {
425
+ return undefined;
426
+ }
427
+ const requested = this.localSessionConfig.sessionId;
428
+ const matched = sessions.find((session) => session.sessionId === requested);
429
+ if (!matched || !matched.connected) {
430
+ return `No browser session connected for sessionId=${requested}`;
431
+ }
432
+ return undefined;
433
+ }
434
+ getConnectionErrorMessage() {
435
+ if (this.remoteConfig) {
436
+ return NO_CONNECTION_REMOTE_MESSAGE;
437
+ }
438
+ return this.getRequestedSessionConnectionError() || NO_CONNECTION_MESSAGE;
439
+ }
440
+ /**
441
+ * Refresh available tools from extension
442
+ */
443
+ async refreshTools(timeoutMs = 30_000) {
444
+ const tools = await this.sendRequest('list_tools', undefined, timeoutMs);
445
+ this.tools = tools;
446
+ return tools;
447
+ }
448
+ async listSessions(timeoutMs = 5_000) {
449
+ if (this.remoteConfig) {
450
+ const session = {
451
+ sessionId: this.remoteConfig.uuid,
452
+ connected: this.extensionConnected,
453
+ toolCount: this.tools.length,
454
+ };
455
+ this.sessions = [session];
456
+ return this.sessions;
457
+ }
458
+ const sessions = await this.sendRequest('list_sessions', undefined, timeoutMs);
459
+ this.sessions = Array.isArray(sessions) ? sessions : [];
460
+ const selected = this.resolveRequestedSessionId(this.sessions);
461
+ this.extensionConnected = this.sessions.some((session) => session.sessionId === selected && session.connected);
462
+ return this.sessions;
463
+ }
464
+ /**
465
+ * Wait briefly for a tools update event without forcing a request.
466
+ * This keeps MCP startup responsive when extension announces tools asynchronously.
467
+ */
468
+ async waitForToolsUpdate(timeoutMs = 1_500) {
469
+ if (this.tools.length > 0) {
470
+ return this.tools;
471
+ }
472
+ return new Promise((resolve) => {
473
+ const onToolsUpdated = (tools) => {
474
+ cleanup();
475
+ resolve(tools);
476
+ };
477
+ const onExtensionDisconnected = () => {
478
+ cleanup();
479
+ resolve(this.tools);
480
+ };
481
+ const timer = setTimeout(() => {
482
+ cleanup();
483
+ resolve(this.tools);
484
+ }, timeoutMs);
485
+ const cleanup = () => {
486
+ clearTimeout(timer);
487
+ this.off('tools_updated', onToolsUpdated);
488
+ this.off('extension_disconnected', onExtensionDisconnected);
489
+ };
490
+ this.on('tools_updated', onToolsUpdated);
491
+ this.on('extension_disconnected', onExtensionDisconnected);
492
+ });
493
+ }
494
+ /**
495
+ * Get cached list of available tools
496
+ */
497
+ getTools() {
498
+ return this.tools;
499
+ }
500
+ getSessions() {
501
+ return this.sessions;
502
+ }
503
+ /**
504
+ * Call a tool on the extension
505
+ */
506
+ async callTool(name, args, timeoutMs) {
507
+ return this.sendRequest('call_tool', { name, arguments: args }, timeoutMs);
508
+ }
509
+ /**
510
+ * Check if extension is connected (via relay)
511
+ */
512
+ isConnected() {
513
+ return this.status === 'connected' && this.extensionConnected;
514
+ }
515
+ /**
516
+ * Get connection status
517
+ */
518
+ getStatus() {
519
+ return this.status;
520
+ }
521
+ /**
522
+ * Check if extension is connected to relay
523
+ */
524
+ isExtensionConnected() {
525
+ return this.extensionConnected;
526
+ }
527
+ /**
528
+ * Log message if debug is enabled
529
+ */
530
+ log(message) {
531
+ if (this.debug) {
532
+ console.error(`[vibebrowser-mcp] ${message}`);
533
+ }
534
+ }
535
+ }
536
+ //# sourceMappingURL=connection.js.map