@shawnowen/comet-mcp 2.3.0

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,867 @@
1
+ // CDP Client wrapper for Comet browser control
2
+ // Supports macOS, Windows, and WSL
3
+ import CDP from "chrome-remote-interface";
4
+ import { spawn, execSync } from "child_process";
5
+ import { platform } from "os";
6
+ import { existsSync } from "fs";
7
+ // ============ PLATFORM DETECTION ============
8
+ /**
9
+ * Detect if running in WSL (Windows Subsystem for Linux)
10
+ */
11
+ function isWSL() {
12
+ if (platform() !== 'linux')
13
+ return false;
14
+ try {
15
+ const release = execSync('uname -r', { encoding: 'utf8' }).toLowerCase();
16
+ return release.includes('microsoft') || release.includes('wsl');
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ const IS_WSL = isWSL();
23
+ const IS_WINDOWS = platform() === "win32" || IS_WSL;
24
+ /**
25
+ * Get the appropriate Comet executable path for the current platform
26
+ */
27
+ function getCometPath() {
28
+ // Allow override via environment variable
29
+ if (process.env.COMET_PATH) {
30
+ return process.env.COMET_PATH;
31
+ }
32
+ const os = platform();
33
+ if (os === "darwin") {
34
+ return "/Applications/Comet.app/Contents/MacOS/Comet";
35
+ }
36
+ if (os === "win32" || IS_WSL) {
37
+ // Common Windows installation paths for Comet
38
+ const possiblePaths = [
39
+ `${process.env.LOCALAPPDATA}\\Perplexity\\Comet\\Application\\comet.exe`,
40
+ `${process.env.APPDATA}\\Perplexity\\Comet\\Application\\comet.exe`,
41
+ "C:\\Program Files\\Perplexity\\Comet\\Application\\comet.exe",
42
+ "C:\\Program Files (x86)\\Perplexity\\Comet\\Application\\comet.exe",
43
+ ];
44
+ for (const p of possiblePaths) {
45
+ if (p && existsSync(p)) {
46
+ return p;
47
+ }
48
+ }
49
+ // Default to LOCALAPPDATA path
50
+ return `${process.env.LOCALAPPDATA}\\Perplexity\\Comet\\Application\\comet.exe`;
51
+ }
52
+ // Fallback for other platforms
53
+ return "/Applications/Comet.app/Contents/MacOS/Comet";
54
+ }
55
+ const COMET_PATH = getCometPath();
56
+ const DEFAULT_PORT = 9222;
57
+ // ============ WSL NETWORK HELPERS ============
58
+ /**
59
+ * Check if WSL can directly connect to Windows localhost (mirrored networking)
60
+ */
61
+ async function canConnectToWindowsLocalhost(port) {
62
+ if (!IS_WSL)
63
+ return true;
64
+ const net = await import('net');
65
+ return new Promise((resolve) => {
66
+ const client = net.createConnection({ port, host: '127.0.0.1' }, () => {
67
+ client.destroy();
68
+ resolve(true);
69
+ });
70
+ client.on('error', () => {
71
+ resolve(false);
72
+ });
73
+ client.setTimeout(2000, () => {
74
+ client.destroy();
75
+ resolve(false);
76
+ });
77
+ });
78
+ }
79
+ /**
80
+ * Get the port to use for CDP WebSocket connection from WSL
81
+ * Throws helpful error if mirrored networking is not enabled
82
+ */
83
+ async function getWSLConnectPort(targetPort) {
84
+ if (!IS_WSL)
85
+ return targetPort;
86
+ const canConnect = await canConnectToWindowsLocalhost(targetPort);
87
+ if (canConnect) {
88
+ return targetPort;
89
+ }
90
+ throw new Error(`WSL cannot connect to Windows localhost:${targetPort}.\n\n` +
91
+ `To fix this, enable WSL mirrored networking:\n` +
92
+ `1. Create/edit %USERPROFILE%\\.wslconfig with:\n` +
93
+ ` [wsl2]\n` +
94
+ ` networkingMode=mirrored\n` +
95
+ `2. Run: wsl --shutdown\n` +
96
+ `3. Restart WSL and try again\n\n` +
97
+ `Alternatively, run Claude Code from Windows PowerShell instead of WSL.`);
98
+ }
99
+ /**
100
+ * Windows/WSL-compatible fetch using PowerShell
101
+ * On WSL, native fetch connects to WSL's localhost, not Windows where Comet runs
102
+ */
103
+ async function windowsFetch(url, method = 'GET') {
104
+ // Use native fetch on macOS/Linux (non-WSL)
105
+ if (platform() !== 'win32' && !IS_WSL) {
106
+ const response = await fetch(url, { method });
107
+ return response;
108
+ }
109
+ // On Windows or WSL, use PowerShell to reach Windows localhost
110
+ try {
111
+ const psCommand = method === 'PUT'
112
+ ? `Invoke-WebRequest -Uri '${url}' -Method PUT -UseBasicParsing | Select-Object -ExpandProperty Content`
113
+ : `Invoke-WebRequest -Uri '${url}' -UseBasicParsing | Select-Object -ExpandProperty Content`;
114
+ const result = execSync(`powershell.exe -NoProfile -Command "${psCommand}"`, {
115
+ encoding: 'utf8',
116
+ timeout: 10000,
117
+ windowsHide: true,
118
+ });
119
+ return {
120
+ ok: true,
121
+ status: 200,
122
+ json: async () => JSON.parse(result.trim())
123
+ };
124
+ }
125
+ catch (error) {
126
+ return {
127
+ ok: false,
128
+ status: 0,
129
+ json: async () => { throw error; }
130
+ };
131
+ }
132
+ }
133
+ export class CometCDPClient {
134
+ client = null;
135
+ cometProcess = null;
136
+ state = {
137
+ connected: false,
138
+ port: DEFAULT_PORT,
139
+ };
140
+ lastTargetId;
141
+ reconnectAttempts = 0;
142
+ maxReconnectAttempts = 5;
143
+ isReconnecting = false;
144
+ get isConnected() {
145
+ return this.state.connected && this.client !== null;
146
+ }
147
+ /**
148
+ * Health check - verify connection is actually alive (not just "connected" in state)
149
+ * This catches cases where WebSocket died silently
150
+ */
151
+ async isHealthy() {
152
+ if (!this.client || !this.state.connected)
153
+ return false;
154
+ try {
155
+ // Simple evaluation that should always work if connected
156
+ const result = await Promise.race([
157
+ this.client.Runtime.evaluate({ expression: '1+1', returnByValue: true }),
158
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Health check timeout')), 3000))
159
+ ]);
160
+ return result?.result?.value === 2;
161
+ }
162
+ catch {
163
+ // Connection is dead
164
+ this.state.connected = false;
165
+ return false;
166
+ }
167
+ }
168
+ /**
169
+ * Ensure we have a healthy connection, reconnecting if needed
170
+ * Call this before any CDP operation
171
+ */
172
+ async ensureHealthyConnection() {
173
+ const healthy = await this.isHealthy();
174
+ if (!healthy) {
175
+ await this.reconnect();
176
+ }
177
+ }
178
+ get currentState() {
179
+ return { ...this.state };
180
+ }
181
+ /**
182
+ * Auto-reconnect wrapper for operations with exponential backoff
183
+ */
184
+ async withAutoReconnect(operation) {
185
+ if (this.isReconnecting) {
186
+ await new Promise(resolve => setTimeout(resolve, 1000));
187
+ }
188
+ try {
189
+ const result = await operation();
190
+ this.reconnectAttempts = 0;
191
+ return result;
192
+ }
193
+ catch (error) {
194
+ const errorMessage = error instanceof Error ? error.message : String(error);
195
+ const connectionErrors = [
196
+ 'WebSocket', 'CLOSED', 'not open', 'disconnected',
197
+ 'ECONNREFUSED', 'ECONNRESET', 'Protocol error', 'Target closed', 'Session closed'
198
+ ];
199
+ if (connectionErrors.some(e => errorMessage.includes(e)) &&
200
+ this.reconnectAttempts < this.maxReconnectAttempts) {
201
+ this.reconnectAttempts++;
202
+ this.isReconnecting = true;
203
+ try {
204
+ const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts - 1), 5000);
205
+ await new Promise(resolve => setTimeout(resolve, delay));
206
+ await this.reconnect();
207
+ this.isReconnecting = false;
208
+ return await operation();
209
+ }
210
+ catch (reconnectError) {
211
+ this.isReconnecting = false;
212
+ throw reconnectError;
213
+ }
214
+ }
215
+ throw error;
216
+ }
217
+ }
218
+ /**
219
+ * Reconnect to the last connected tab
220
+ */
221
+ async reconnect() {
222
+ if (this.client) {
223
+ try {
224
+ await this.client.close();
225
+ }
226
+ catch { /* ignore */ }
227
+ }
228
+ this.state.connected = false;
229
+ this.client = null;
230
+ // Verify Comet is running
231
+ try {
232
+ await this.getVersion();
233
+ }
234
+ catch {
235
+ try {
236
+ await this.startComet(this.state.port);
237
+ await new Promise(resolve => setTimeout(resolve, 2000));
238
+ }
239
+ catch {
240
+ throw new Error('Cannot connect to Comet. Ensure Comet is running with --remote-debugging-port=9222');
241
+ }
242
+ }
243
+ // Try to reconnect to last target
244
+ if (this.lastTargetId) {
245
+ try {
246
+ const targets = await this.listTargets();
247
+ if (targets.find(t => t.id === this.lastTargetId)) {
248
+ return await this.connect(this.lastTargetId);
249
+ }
250
+ }
251
+ catch { /* target gone */ }
252
+ }
253
+ // Find best target
254
+ const targets = await this.listTargets();
255
+ const target = targets.find(t => t.type === 'page' && t.url.includes('perplexity.ai')) ||
256
+ targets.find(t => t.type === 'page' && t.url !== 'about:blank');
257
+ if (target) {
258
+ return await this.connect(target.id);
259
+ }
260
+ throw new Error('No suitable tab found for reconnection');
261
+ }
262
+ /**
263
+ * List tabs with categorization
264
+ */
265
+ async listTabsCategorized() {
266
+ const targets = await this.listTargets();
267
+ return {
268
+ main: targets.find(t => t.type === 'page' && t.url.includes('perplexity.ai') && !t.url.includes('sidecar')) || null,
269
+ sidecar: targets.find(t => t.type === 'page' && t.url.includes('sidecar')) || null,
270
+ agentBrowsing: targets.find(t => t.type === 'page' &&
271
+ !t.url.includes('perplexity.ai') &&
272
+ !t.url.includes('chrome-extension') &&
273
+ !t.url.includes('chrome://') &&
274
+ t.url !== 'about:blank') || null,
275
+ overlay: targets.find(t => t.url.includes('chrome-extension') && t.url.includes('overlay')) || null,
276
+ others: targets.filter(t => t.type === 'page' &&
277
+ !t.url.includes('perplexity.ai') &&
278
+ !t.url.includes('chrome-extension')),
279
+ };
280
+ }
281
+ /**
282
+ * Check if Comet process is running
283
+ */
284
+ async isCometProcessRunning() {
285
+ return new Promise((resolve) => {
286
+ if (IS_WINDOWS) {
287
+ // Windows: use tasklist to check for comet.exe
288
+ const check = spawn('tasklist', ['/FI', 'IMAGENAME eq comet.exe', '/NH']);
289
+ let output = '';
290
+ check.stdout?.on('data', (data) => { output += data.toString(); });
291
+ check.on('close', () => {
292
+ resolve(output.toLowerCase().includes('comet.exe'));
293
+ });
294
+ check.on('error', () => resolve(false));
295
+ }
296
+ else {
297
+ // macOS/Linux: use pgrep
298
+ const check = spawn('pgrep', ['-f', 'Comet.app']);
299
+ check.on('close', (code) => resolve(code === 0));
300
+ check.on('error', () => resolve(false));
301
+ }
302
+ });
303
+ }
304
+ /**
305
+ * Kill any running Comet process
306
+ */
307
+ async killComet() {
308
+ return new Promise((resolve) => {
309
+ if (IS_WINDOWS) {
310
+ // Windows: use taskkill to kill comet.exe
311
+ const kill = spawn('taskkill', ['/F', '/IM', 'comet.exe']);
312
+ kill.on('close', () => setTimeout(resolve, 1000));
313
+ kill.on('error', () => setTimeout(resolve, 1000));
314
+ }
315
+ else {
316
+ // macOS/Linux: use pkill
317
+ const kill = spawn('pkill', ['-f', 'Comet.app']);
318
+ kill.on('close', () => setTimeout(resolve, 1000));
319
+ kill.on('error', () => setTimeout(resolve, 1000));
320
+ }
321
+ });
322
+ }
323
+ /**
324
+ * Start Comet browser with remote debugging enabled
325
+ * Handles macOS, Windows, and WSL environments
326
+ */
327
+ async startComet(port = DEFAULT_PORT) {
328
+ this.state.port = port;
329
+ // ========== WSL: Use PowerShell to communicate with Windows ==========
330
+ if (IS_WSL) {
331
+ // Check if Comet is already running via PowerShell HTTP
332
+ try {
333
+ const response = await windowsFetch(`http://127.0.0.1:${port}/json/version`);
334
+ if (response.ok) {
335
+ const version = await response.json();
336
+ return `Comet already running on Windows host, port: ${port} (${version.Browser})`;
337
+ }
338
+ }
339
+ catch {
340
+ // Comet not accessible, need to launch
341
+ }
342
+ // Get Windows LOCALAPPDATA path and construct Comet path
343
+ let cometPath = '';
344
+ try {
345
+ const localAppData = execSync('cmd.exe /c echo %LOCALAPPDATA%', { encoding: 'utf8' })
346
+ .trim().replace(/\r?\n/g, '');
347
+ cometPath = `${localAppData}\\Perplexity\\Comet\\Application\\Comet.exe`;
348
+ }
349
+ catch {
350
+ cometPath = 'C:\\Users\\' + (process.env.USER || 'user') +
351
+ '\\AppData\\Local\\Perplexity\\Comet\\Application\\Comet.exe';
352
+ }
353
+ try {
354
+ // Launch Comet via PowerShell (Set-Location avoids UNC path issues)
355
+ const psCommand = `Set-Location C:\\; Start-Process -FilePath '${cometPath}' -ArgumentList '--remote-debugging-port=${port}'`;
356
+ spawn('powershell.exe', ['-NoProfile', '-Command', psCommand], {
357
+ detached: true,
358
+ stdio: 'ignore',
359
+ }).unref();
360
+ // Wait for Comet to start
361
+ return new Promise((resolve, reject) => {
362
+ const maxAttempts = 40;
363
+ let attempts = 0;
364
+ const checkReady = async () => {
365
+ attempts++;
366
+ try {
367
+ const response = await windowsFetch(`http://127.0.0.1:${port}/json/version`);
368
+ if (response.ok) {
369
+ resolve(`Comet started via WSL->PowerShell on port ${port}`);
370
+ return;
371
+ }
372
+ }
373
+ catch { /* keep trying */ }
374
+ if (attempts < maxAttempts) {
375
+ setTimeout(checkReady, 500);
376
+ }
377
+ else {
378
+ reject(new Error(`Timeout waiting for Comet. Tried to launch: ${cometPath}\n` +
379
+ `Try manually: powershell.exe -Command "Start-Process '${cometPath}' -ArgumentList '--remote-debugging-port=${port}'"`));
380
+ }
381
+ };
382
+ setTimeout(checkReady, 2000);
383
+ });
384
+ }
385
+ catch (launchError) {
386
+ throw new Error(`Cannot connect to or launch Comet browser.\n` +
387
+ `Tried path: ${cometPath}\n` +
388
+ `Error: ${launchError instanceof Error ? launchError.message : String(launchError)}`);
389
+ }
390
+ }
391
+ // ========== Native Windows: Use windowsFetch for HTTP ==========
392
+ if (platform() === 'win32') {
393
+ try {
394
+ const response = await windowsFetch(`http://127.0.0.1:${port}/json/version`);
395
+ if (response.ok) {
396
+ const version = await response.json();
397
+ return `Comet already running with debug port: ${version.Browser}`;
398
+ }
399
+ }
400
+ catch {
401
+ const isRunning = await this.isCometProcessRunning();
402
+ if (isRunning) {
403
+ await this.killComet();
404
+ }
405
+ }
406
+ // Start Comet on Windows
407
+ return new Promise((resolve, reject) => {
408
+ this.cometProcess = spawn(COMET_PATH, [`--remote-debugging-port=${port}`], {
409
+ detached: true,
410
+ stdio: "ignore",
411
+ });
412
+ this.cometProcess.unref();
413
+ const maxAttempts = 40;
414
+ let attempts = 0;
415
+ const checkReady = async () => {
416
+ attempts++;
417
+ try {
418
+ const response = await windowsFetch(`http://127.0.0.1:${port}/json/version`);
419
+ if (response.ok) {
420
+ resolve(`Comet started with debug port ${port}`);
421
+ return;
422
+ }
423
+ }
424
+ catch { /* keep trying */ }
425
+ if (attempts < maxAttempts) {
426
+ setTimeout(checkReady, 500);
427
+ }
428
+ else {
429
+ reject(new Error(`Timeout waiting for Comet. Try: "${COMET_PATH}" --remote-debugging-port=${port}`));
430
+ }
431
+ };
432
+ setTimeout(checkReady, 1500);
433
+ });
434
+ }
435
+ // ========== macOS/Linux: Original approach ==========
436
+ try {
437
+ const controller = new AbortController();
438
+ const timeoutId = setTimeout(() => controller.abort(), 2000);
439
+ const response = await fetch(`http://localhost:${port}/json/version`, { signal: controller.signal });
440
+ clearTimeout(timeoutId);
441
+ if (response.ok) {
442
+ const version = await response.json();
443
+ return `Comet already running with debug port: ${version.Browser}`;
444
+ }
445
+ }
446
+ catch {
447
+ const isRunning = await this.isCometProcessRunning();
448
+ if (isRunning) {
449
+ await this.killComet();
450
+ }
451
+ }
452
+ // Start Comet on macOS/Linux
453
+ return new Promise((resolve, reject) => {
454
+ this.cometProcess = spawn(COMET_PATH, [`--remote-debugging-port=${port}`], {
455
+ detached: true,
456
+ stdio: "ignore",
457
+ });
458
+ this.cometProcess.unref();
459
+ const maxAttempts = 40;
460
+ let attempts = 0;
461
+ const checkReady = async () => {
462
+ attempts++;
463
+ try {
464
+ const controller = new AbortController();
465
+ const timeoutId = setTimeout(() => controller.abort(), 2000);
466
+ const response = await fetch(`http://localhost:${port}/json/version`, { signal: controller.signal });
467
+ clearTimeout(timeoutId);
468
+ if (response.ok) {
469
+ const version = await response.json();
470
+ resolve(`Comet started with debug port ${port}: ${version.Browser}`);
471
+ return;
472
+ }
473
+ }
474
+ catch { /* keep trying */ }
475
+ if (attempts < maxAttempts) {
476
+ setTimeout(checkReady, 500);
477
+ }
478
+ else {
479
+ reject(new Error(`Timeout waiting for Comet. Try: ${COMET_PATH} --remote-debugging-port=${port}`));
480
+ }
481
+ };
482
+ setTimeout(checkReady, 1500);
483
+ });
484
+ }
485
+ /**
486
+ * Get CDP version info
487
+ */
488
+ async getVersion() {
489
+ const response = await windowsFetch(`http://127.0.0.1:${this.state.port}/json/version`);
490
+ if (!response.ok)
491
+ throw new Error(`Failed to get version: ${response.status}`);
492
+ return response.json();
493
+ }
494
+ /**
495
+ * List all available tabs/targets
496
+ */
497
+ async listTargets() {
498
+ // Try /json/list first; some Comet/Chromium builds return empty from it,
499
+ // so fall back to /json which is equivalent but more reliable.
500
+ const response = await windowsFetch(`http://127.0.0.1:${this.state.port}/json/list`);
501
+ if (response.ok) {
502
+ const targets = await response.json();
503
+ if (targets.length > 0)
504
+ return targets;
505
+ }
506
+ const fallback = await windowsFetch(`http://127.0.0.1:${this.state.port}/json`);
507
+ if (!fallback.ok)
508
+ throw new Error(`Failed to list targets: ${fallback.status}`);
509
+ return fallback.json();
510
+ }
511
+ /**
512
+ * Connect to a specific tab
513
+ */
514
+ async connect(targetId) {
515
+ if (this.client) {
516
+ await this.disconnect();
517
+ }
518
+ // On WSL, verify mirrored networking is available for WebSocket connection
519
+ const connectPort = await getWSLConnectPort(this.state.port);
520
+ const options = { port: connectPort, host: '127.0.0.1' };
521
+ if (targetId)
522
+ options.target = targetId;
523
+ this.client = await CDP(options);
524
+ await Promise.all([
525
+ this.client.Page.enable(),
526
+ this.client.Runtime.enable(),
527
+ this.client.DOM.enable(),
528
+ this.client.Network.enable(),
529
+ this.client.Accessibility.enable(),
530
+ ]);
531
+ // Position window fullscreen on top display (agents workspace)
532
+ // See: ~/.claude/workflows/display-browser-config.md
533
+ try {
534
+ const { windowId } = await this.client.Browser.getWindowForTarget({ targetId });
535
+ await this.client.Browser.setWindowBounds({
536
+ windowId,
537
+ bounds: { left: 0, top: -1080, width: 1920, height: 1080, windowState: 'normal' },
538
+ });
539
+ }
540
+ catch {
541
+ try {
542
+ await this.client.Emulation.setDeviceMetricsOverride({
543
+ width: 1920, height: 1080, deviceScaleFactor: 1, mobile: false,
544
+ });
545
+ }
546
+ catch { /* continue */ }
547
+ }
548
+ this.state.connected = true;
549
+ this.state.activeTabId = targetId;
550
+ this.lastTargetId = targetId;
551
+ this.reconnectAttempts = 0;
552
+ const { result } = await this.client.Runtime.evaluate({ expression: "window.location.href" });
553
+ this.state.currentUrl = result.value;
554
+ return `Connected to tab: ${this.state.currentUrl}`;
555
+ }
556
+ /**
557
+ * Disconnect from current tab
558
+ */
559
+ async disconnect() {
560
+ if (this.client) {
561
+ await this.client.close();
562
+ this.client = null;
563
+ this.state.connected = false;
564
+ this.state.activeTabId = undefined;
565
+ }
566
+ }
567
+ /**
568
+ * Navigate to a URL
569
+ */
570
+ async navigate(url, waitForLoad = true, waitForNetworkIdle = false) {
571
+ this.ensureConnected();
572
+ const result = await this.client.Page.navigate({ url });
573
+ if (waitForLoad)
574
+ await this.client.Page.loadEventFired();
575
+ this.state.currentUrl = url;
576
+ let networkIdle;
577
+ if (waitForNetworkIdle) {
578
+ networkIdle = await this.waitForNetworkIdle({ idleTime: 1500, timeout: 10000 });
579
+ }
580
+ return { ...result, networkIdle };
581
+ }
582
+ /**
583
+ * Capture screenshot
584
+ */
585
+ async screenshot(format = "png") {
586
+ this.ensureConnected();
587
+ return this.client.Page.captureScreenshot({ format });
588
+ }
589
+ /**
590
+ * Execute JavaScript in the page context
591
+ */
592
+ async evaluate(expression) {
593
+ this.ensureConnected();
594
+ return this.client.Runtime.evaluate({
595
+ expression,
596
+ awaitPromise: true,
597
+ returnByValue: true,
598
+ });
599
+ }
600
+ /**
601
+ * Execute JavaScript with auto-reconnect on connection loss
602
+ * This is the PREFERRED method - always use this instead of evaluate()
603
+ */
604
+ async safeEvaluate(expression) {
605
+ // Always check health first to catch silently dead connections
606
+ await this.ensureHealthyConnection();
607
+ return this.withAutoReconnect(async () => {
608
+ this.ensureConnected();
609
+ return this.client.Runtime.evaluate({
610
+ expression,
611
+ awaitPromise: true,
612
+ returnByValue: true,
613
+ });
614
+ });
615
+ }
616
+ /**
617
+ * Press a key
618
+ */
619
+ async pressKey(key) {
620
+ this.ensureConnected();
621
+ await this.client.Input.dispatchKeyEvent({ type: "keyDown", key });
622
+ await this.client.Input.dispatchKeyEvent({ type: "keyUp", key });
623
+ }
624
+ /**
625
+ * Create a new tab
626
+ */
627
+ async newTab(url) {
628
+ const response = await windowsFetch(`http://127.0.0.1:${this.state.port}/json/new${url ? `?${url}` : ""}`, 'PUT');
629
+ if (!response.ok)
630
+ throw new Error(`Failed to create new tab: ${response.status}`);
631
+ return response.json();
632
+ }
633
+ /**
634
+ * Close a tab
635
+ */
636
+ async closeTab(targetId) {
637
+ try {
638
+ if (this.client) {
639
+ const result = await this.client.Target.closeTarget({ targetId });
640
+ return result.success;
641
+ }
642
+ }
643
+ catch { /* fallback to HTTP */ }
644
+ try {
645
+ const response = await windowsFetch(`http://127.0.0.1:${this.state.port}/json/close/${targetId}`);
646
+ return response.ok;
647
+ }
648
+ catch {
649
+ return false;
650
+ }
651
+ }
652
+ /**
653
+ * Get the page's accessibility tree as a compact text representation
654
+ */
655
+ async getAccessibilityTree(maxDepth = 5, maxLength = 12000) {
656
+ this.ensureConnected();
657
+ const { nodes } = await this.client.Accessibility.getFullAXTree({ depth: maxDepth });
658
+ const lines = [];
659
+ let totalLength = 0;
660
+ for (let i = 0; i < nodes.length; i++) {
661
+ const node = nodes[i];
662
+ if (node.ignored)
663
+ continue;
664
+ const role = node.role?.value || '';
665
+ if (role === 'none' || role === 'generic' || role === 'InlineTextBox')
666
+ continue;
667
+ const name = node.name?.value || '';
668
+ const value = node.value?.value || '';
669
+ const description = node.description?.value || '';
670
+ // Calculate depth from parent chain
671
+ let depth = 0;
672
+ let parentId = node.parentId;
673
+ const seen = new Set();
674
+ while (parentId && !seen.has(parentId)) {
675
+ seen.add(parentId);
676
+ const parent = nodes.find((n) => n.nodeId === parentId);
677
+ if (!parent)
678
+ break;
679
+ depth++;
680
+ parentId = parent.parentId;
681
+ }
682
+ const indent = ' '.repeat(Math.min(depth, 10));
683
+ let line = `${indent}[${role}`;
684
+ // Add ref for interactive elements
685
+ const interactiveRoles = ['button', 'link', 'textbox', 'checkbox', 'radio', 'combobox', 'menuitem', 'tab', 'switch'];
686
+ if (interactiveRoles.includes(role)) {
687
+ line += ` ref_${i}`;
688
+ }
689
+ line += ']';
690
+ if (name)
691
+ line += ` "${name}"`;
692
+ if (value)
693
+ line += ` value: "${value}"`;
694
+ if (description)
695
+ line += ` (${description})`;
696
+ // Add relevant properties
697
+ if (node.properties) {
698
+ for (const prop of node.properties) {
699
+ if (prop.name === 'checked' && prop.value?.value)
700
+ line += ` (checked)`;
701
+ if (prop.name === 'expanded' && prop.value?.value === false)
702
+ line += ` (collapsed)`;
703
+ if (prop.name === 'disabled' && prop.value?.value)
704
+ line += ` (disabled)`;
705
+ if (prop.name === 'required' && prop.value?.value)
706
+ line += ` (required)`;
707
+ if (prop.name === 'selected' && prop.value?.value)
708
+ line += ` (selected)`;
709
+ }
710
+ }
711
+ totalLength += line.length + 1;
712
+ if (totalLength > maxLength) {
713
+ lines.push('... (truncated)');
714
+ break;
715
+ }
716
+ lines.push(line);
717
+ }
718
+ return lines.join('\n');
719
+ }
720
+ /**
721
+ * Extract clean readable text from the current page
722
+ */
723
+ async getPageText(maxLength = 12000) {
724
+ this.ensureConnected();
725
+ const result = await this.safeEvaluate(`
726
+ (() => {
727
+ const clone = document.body.cloneNode(true);
728
+
729
+ // Remove non-content elements
730
+ const removeSelectors = ['script', 'style', 'noscript', 'svg', 'nav', 'footer', 'header', '[role="navigation"]', '[role="banner"]', '[role="contentinfo"]'];
731
+ for (const sel of removeSelectors) {
732
+ clone.querySelectorAll(sel).forEach(el => el.remove());
733
+ }
734
+
735
+ const parts = [];
736
+
737
+ // Extract headings
738
+ clone.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(el => {
739
+ const level = parseInt(el.tagName[1]);
740
+ const prefix = '#'.repeat(level);
741
+ const text = el.innerText.trim();
742
+ if (text) parts.push({ order: 0, text: prefix + ' ' + text });
743
+ el.remove(); // Remove so we don't double-count
744
+ });
745
+
746
+ // Extract links with href
747
+ clone.querySelectorAll('a[href]').forEach(el => {
748
+ const text = el.innerText.trim();
749
+ const href = el.getAttribute('href');
750
+ if (text && href && !href.startsWith('#') && !href.startsWith('javascript:')) {
751
+ el.textContent = '[' + text + '](' + href + ')';
752
+ }
753
+ });
754
+
755
+ // Extract list items
756
+ clone.querySelectorAll('li').forEach(el => {
757
+ const text = el.innerText.trim();
758
+ if (text) {
759
+ el.textContent = '- ' + text;
760
+ }
761
+ });
762
+
763
+ // Get remaining text
764
+ const bodyText = clone.innerText || '';
765
+
766
+ // Collapse whitespace
767
+ const cleaned = bodyText
768
+ .replace(/[ \\t]+/g, ' ')
769
+ .replace(/\\n{3,}/g, '\\n\\n')
770
+ .trim();
771
+
772
+ return cleaned;
773
+ })()
774
+ `);
775
+ let text = result.result.value || '';
776
+ if (text.length > maxLength) {
777
+ text = text.substring(0, maxLength) + '\n... (truncated)';
778
+ }
779
+ return text;
780
+ }
781
+ /**
782
+ * Wait for network activity to settle (no pending requests for idleTime ms)
783
+ */
784
+ async waitForNetworkIdle(options) {
785
+ this.ensureConnected();
786
+ const idleTime = options?.idleTime ?? 1500;
787
+ const timeout = options?.timeout ?? 15000;
788
+ const startTime = Date.now();
789
+ return new Promise((resolve) => {
790
+ const pending = new Map();
791
+ let totalRequests = 0;
792
+ let totalCompleted = 0;
793
+ let totalFailed = 0;
794
+ let idleTimer = null;
795
+ let timeoutTimer = null;
796
+ let resolved = false;
797
+ const cleanup = () => {
798
+ if (idleTimer)
799
+ clearTimeout(idleTimer);
800
+ if (timeoutTimer)
801
+ clearTimeout(timeoutTimer);
802
+ try {
803
+ this.client.removeListener('Network.requestWillBeSent', onRequest);
804
+ this.client.removeListener('Network.loadingFinished', onFinished);
805
+ this.client.removeListener('Network.loadingFailed', onFailed);
806
+ }
807
+ catch { /* ignore listener removal errors */ }
808
+ };
809
+ const finish = (idle) => {
810
+ if (resolved)
811
+ return;
812
+ resolved = true;
813
+ cleanup();
814
+ resolve({
815
+ idle,
816
+ pendingRequests: pending.size,
817
+ totalRequests,
818
+ totalCompleted,
819
+ totalFailed,
820
+ waitedMs: Date.now() - startTime,
821
+ });
822
+ };
823
+ const resetIdleTimer = () => {
824
+ if (idleTimer)
825
+ clearTimeout(idleTimer);
826
+ idleTimer = setTimeout(() => {
827
+ if (pending.size === 0) {
828
+ finish(true);
829
+ }
830
+ }, idleTime);
831
+ };
832
+ const onRequest = (params) => {
833
+ totalRequests++;
834
+ pending.set(params.requestId, Date.now());
835
+ resetIdleTimer();
836
+ };
837
+ const onFinished = (params) => {
838
+ pending.delete(params.requestId);
839
+ totalCompleted++;
840
+ if (pending.size === 0) {
841
+ resetIdleTimer();
842
+ }
843
+ };
844
+ const onFailed = (params) => {
845
+ pending.delete(params.requestId);
846
+ totalFailed++;
847
+ if (pending.size === 0) {
848
+ resetIdleTimer();
849
+ }
850
+ };
851
+ this.client.on('Network.requestWillBeSent', onRequest);
852
+ this.client.on('Network.loadingFinished', onFinished);
853
+ this.client.on('Network.loadingFailed', onFailed);
854
+ // Start idle timer immediately (page may already be idle)
855
+ resetIdleTimer();
856
+ // Overall timeout
857
+ timeoutTimer = setTimeout(() => finish(false), timeout);
858
+ });
859
+ }
860
+ ensureConnected() {
861
+ if (!this.client) {
862
+ throw new Error("Not connected to Comet. Call connect() first.");
863
+ }
864
+ }
865
+ }
866
+ export const cometClient = new CometCDPClient();
867
+ //# sourceMappingURL=cdp-client.js.map