@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.
- package/LICENSE +21 -0
- package/README.md +196 -0
- package/dist/cdp-client.d.ts +118 -0
- package/dist/cdp-client.d.ts.map +1 -0
- package/dist/cdp-client.js +867 -0
- package/dist/cdp-client.js.map +1 -0
- package/dist/comet-ai.d.ts +35 -0
- package/dist/comet-ai.d.ts.map +1 -0
- package/dist/comet-ai.js +396 -0
- package/dist/comet-ai.js.map +1 -0
- package/dist/http-server.d.ts +3 -0
- package/dist/http-server.d.ts.map +1 -0
- package/dist/http-server.js +463 -0
- package/dist/http-server.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1174 -0
- package/dist/index.js.map +1 -0
- package/dist/tab-group-archive.d.ts +13 -0
- package/dist/tab-group-archive.d.ts.map +1 -0
- package/dist/tab-group-archive.js +128 -0
- package/dist/tab-group-archive.js.map +1 -0
- package/dist/tab-groups.d.ts +86 -0
- package/dist/tab-groups.d.ts.map +1 -0
- package/dist/tab-groups.js +250 -0
- package/dist/tab-groups.js.map +1 -0
- package/dist/types.d.ts +70 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/extension/background.js +1094 -0
- package/extension/manifest.json +22 -0
- package/extension/sidepanel.css +1068 -0
- package/extension/sidepanel.html +145 -0
- package/extension/sidepanel.js +1186 -0
- package/package.json +59 -0
|
@@ -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
|