assistme 0.3.0 → 0.3.2
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/PLAN.md +14 -3
- package/dist/{chunk-UWE5WVQI.js → chunk-KX7ITO55.js} +20 -11
- package/dist/index.js +1791 -572
- package/dist/{job-runner-N4XAAWLJ.js → job-runner-P2L6MOOX.js} +1 -1
- package/package.json +5 -3
- package/src/agent/job-runner.ts +9 -13
- package/src/agent/mcp-servers.ts +6 -1020
- package/src/agent/memory.ts +2 -11
- package/src/agent/processor.ts +18 -108
- package/src/agent/scheduler.ts +2 -3
- package/src/agent/session.ts +20 -36
- package/src/agent/skills.ts +167 -61
- package/src/agent/system-prompt.ts +126 -0
- package/src/browser/chrome-launcher.ts +555 -0
- package/src/browser/controller.ts +1386 -0
- package/src/browser/types.ts +70 -0
- package/src/commands/credential.ts +190 -0
- package/src/commands/job.ts +14 -45
- package/src/commands/memory.ts +16 -29
- package/src/commands/schedule.ts +15 -37
- package/src/commands/start.ts +11 -43
- package/src/credentials/credential-store.test.ts +162 -0
- package/src/credentials/credential-store.ts +266 -0
- package/src/credentials/encryption.test.ts +98 -0
- package/src/credentials/encryption.ts +82 -0
- package/src/credentials/index.ts +15 -0
- package/src/credentials/local-store.ts +89 -0
- package/src/db/action.ts +19 -0
- package/src/db/api-client.ts +3 -32
- package/src/db/auth-store.ts +41 -0
- package/src/db/auth.ts +38 -0
- package/src/db/conversation.ts +39 -0
- package/src/db/event.ts +52 -0
- package/src/db/job-poll.ts +18 -0
- package/src/db/session.ts +60 -0
- package/src/db/supabase.ts +40 -383
- package/src/db/task.ts +69 -0
- package/src/db/types.ts +54 -0
- package/src/index.ts +2 -0
- package/src/mcp/agent-tools-server.ts +1047 -0
- package/src/mcp/browser-server.ts +258 -0
- package/src/tools/browser.ts +28 -1208
- package/src/tools/index.ts +32 -263
- package/src/tools/web.ts +0 -73
package/src/tools/browser.ts
CHANGED
|
@@ -1,1209 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
interface CDPResponse {
|
|
33
|
-
id: number;
|
|
34
|
-
result?: Record<string, unknown>;
|
|
35
|
-
error?: { code: number; message: string };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Shape returned by Runtime.evaluate CDP calls */
|
|
39
|
-
interface CDPEvalResult {
|
|
40
|
-
result?: { value?: unknown; description?: string };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Shape returned by Page.captureScreenshot CDP calls */
|
|
44
|
-
interface CDPScreenshotResult {
|
|
45
|
-
data?: string;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export class BrowserController {
|
|
49
|
-
private ws: WebSocket | null = null;
|
|
50
|
-
private debugPort: number;
|
|
51
|
-
private messageId = 0;
|
|
52
|
-
private callbacks = new Map<number, (response: CDPResponse) => void>();
|
|
53
|
-
private connected = false;
|
|
54
|
-
private currentTabId: string | null = null;
|
|
55
|
-
|
|
56
|
-
constructor(port = 9222) {
|
|
57
|
-
this.debugPort = port;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// ── Connection ──────────────────────────────────────────────────
|
|
61
|
-
|
|
62
|
-
async isAvailable(): Promise<boolean> {
|
|
63
|
-
try {
|
|
64
|
-
const res = await fetch(`http://127.0.0.1:${this.debugPort}/json/version`, {
|
|
65
|
-
signal: AbortSignal.timeout(2000),
|
|
66
|
-
});
|
|
67
|
-
return res.ok;
|
|
68
|
-
} catch {
|
|
69
|
-
return false;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async connect(tabIndex?: number): Promise<string> {
|
|
74
|
-
// Reuse existing connection if still open and targeting the same tab
|
|
75
|
-
if (this.connected && this.ws?.readyState === WebSocket.OPEN) {
|
|
76
|
-
if (tabIndex === undefined) {
|
|
77
|
-
return "Already connected to browser.";
|
|
78
|
-
}
|
|
79
|
-
// If a specific tab is requested, check if we're already on it
|
|
80
|
-
const tabs = await this.getTabs();
|
|
81
|
-
const pageTabs = tabs.filter((t) => t.type === "page");
|
|
82
|
-
const targetTab = pageTabs[tabIndex];
|
|
83
|
-
if (targetTab && targetTab.id === this.currentTabId) {
|
|
84
|
-
return `Already connected to tab: "${targetTab.title}"`;
|
|
85
|
-
}
|
|
86
|
-
// Need to switch — disconnect first
|
|
87
|
-
await this.disconnect();
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const available = await this.isAvailable();
|
|
91
|
-
if (!available) {
|
|
92
|
-
throw new Error(
|
|
93
|
-
`Cannot connect to browser on port ${this.debugPort}. ` +
|
|
94
|
-
"Chrome remote debugging is not reachable. " +
|
|
95
|
-
"Please ensure Chrome is running with remote debugging enabled."
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const tabs = await this.getTabs();
|
|
100
|
-
const pageTabs = tabs.filter((t) => t.type === "page");
|
|
101
|
-
|
|
102
|
-
if (pageTabs.length === 0) {
|
|
103
|
-
throw new Error("No browser tabs found. Please open at least one tab.");
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const targetTab = pageTabs[tabIndex ?? 0];
|
|
107
|
-
if (!targetTab.webSocketDebuggerUrl) {
|
|
108
|
-
throw new Error("Tab does not expose a WebSocket debugger URL.");
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
this.currentTabId = targetTab.id;
|
|
112
|
-
|
|
113
|
-
return new Promise((resolve, reject) => {
|
|
114
|
-
let settled = false;
|
|
115
|
-
this.ws = new WebSocket(targetTab.webSocketDebuggerUrl!);
|
|
116
|
-
|
|
117
|
-
const connectTimeout = setTimeout(() => {
|
|
118
|
-
if (!settled) {
|
|
119
|
-
settled = true;
|
|
120
|
-
this.ws?.close();
|
|
121
|
-
reject(new Error("Connection timeout (5s)"));
|
|
122
|
-
}
|
|
123
|
-
}, 5000);
|
|
124
|
-
|
|
125
|
-
this.ws.on("open", () => {
|
|
126
|
-
if (settled) return;
|
|
127
|
-
settled = true;
|
|
128
|
-
clearTimeout(connectTimeout);
|
|
129
|
-
this.connected = true;
|
|
130
|
-
// Enable required domains
|
|
131
|
-
this.send("Page.enable").catch(() => {});
|
|
132
|
-
this.send("Runtime.enable").catch(() => {});
|
|
133
|
-
this.send("DOM.enable").catch(() => {});
|
|
134
|
-
resolve(`Connected to tab: "${targetTab.title}" (${targetTab.url})`);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
this.ws.on("message", (data) => {
|
|
138
|
-
try {
|
|
139
|
-
const msg = JSON.parse(data.toString()) as CDPResponse;
|
|
140
|
-
if (msg.id !== undefined && this.callbacks.has(msg.id)) {
|
|
141
|
-
this.callbacks.get(msg.id)!(msg);
|
|
142
|
-
this.callbacks.delete(msg.id);
|
|
143
|
-
}
|
|
144
|
-
} catch {
|
|
145
|
-
// Ignore non-JSON messages (events)
|
|
146
|
-
}
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
this.ws.on("error", (err) => {
|
|
150
|
-
this.connected = false;
|
|
151
|
-
if (!settled) {
|
|
152
|
-
settled = true;
|
|
153
|
-
clearTimeout(connectTimeout);
|
|
154
|
-
reject(new Error(`WebSocket error: ${err.message}`));
|
|
155
|
-
}
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
this.ws.on("close", () => {
|
|
159
|
-
this.connected = false;
|
|
160
|
-
this.ws = null;
|
|
161
|
-
// Reject pending CDP commands so they don't hang forever
|
|
162
|
-
for (const [id, cb] of this.callbacks) {
|
|
163
|
-
cb({ id, error: { code: -1, message: "WebSocket closed" } });
|
|
164
|
-
}
|
|
165
|
-
this.callbacks.clear();
|
|
166
|
-
});
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
async disconnect(): Promise<string> {
|
|
171
|
-
if (this.ws) {
|
|
172
|
-
this.ws.close();
|
|
173
|
-
this.ws = null;
|
|
174
|
-
this.connected = false;
|
|
175
|
-
}
|
|
176
|
-
return "Disconnected from browser.";
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// ── CDP Protocol ────────────────────────────────────────────────
|
|
180
|
-
|
|
181
|
-
private async getTabs(): Promise<CDPTab[]> {
|
|
182
|
-
const res = await fetch(`http://127.0.0.1:${this.debugPort}/json`, {
|
|
183
|
-
signal: AbortSignal.timeout(3000),
|
|
184
|
-
});
|
|
185
|
-
return (await res.json()) as CDPTab[];
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
private send(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
189
|
-
return new Promise((resolve, reject) => {
|
|
190
|
-
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
191
|
-
reject(new Error("Not connected to browser. Call browser_connect first."));
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const id = ++this.messageId;
|
|
196
|
-
const timeout = setTimeout(() => {
|
|
197
|
-
this.callbacks.delete(id);
|
|
198
|
-
reject(new Error(`CDP command timed out: ${method}`));
|
|
199
|
-
}, 15000);
|
|
200
|
-
|
|
201
|
-
this.callbacks.set(id, (response) => {
|
|
202
|
-
clearTimeout(timeout);
|
|
203
|
-
if (response.error) {
|
|
204
|
-
reject(new Error(`CDP error: ${response.error.message}`));
|
|
205
|
-
} else {
|
|
206
|
-
resolve(response.result || {});
|
|
207
|
-
}
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
this.ws.send(JSON.stringify({ id, method, params }));
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
private ensureConnected() {
|
|
215
|
-
if (!this.connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
216
|
-
throw new Error("Not connected to browser. Use browser_connect tool first.");
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// ── Navigation ──────────────────────────────────────────────────
|
|
221
|
-
|
|
222
|
-
async navigate(url: string): Promise<string> {
|
|
223
|
-
this.ensureConnected();
|
|
224
|
-
await this.send("Page.navigate", { url });
|
|
225
|
-
// Wait for load
|
|
226
|
-
await this.waitForLoad();
|
|
227
|
-
const info = await this.getPageInfo();
|
|
228
|
-
return `Navigated to: ${info.title}\nURL: ${info.url}`;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
async goBack(): Promise<string> {
|
|
232
|
-
this.ensureConnected();
|
|
233
|
-
await this.send("Page.navigateToHistoryEntry", {
|
|
234
|
-
entryId: -1,
|
|
235
|
-
}).catch(() => {});
|
|
236
|
-
// Fallback: use JS
|
|
237
|
-
await this.evaluate("window.history.back()");
|
|
238
|
-
await this.waitForLoad();
|
|
239
|
-
const info = await this.getPageInfo();
|
|
240
|
-
return `Went back to: ${info.title}`;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
async reload(): Promise<string> {
|
|
244
|
-
this.ensureConnected();
|
|
245
|
-
await this.send("Page.reload");
|
|
246
|
-
await this.waitForLoad();
|
|
247
|
-
return "Page reloaded.";
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// ── Page Reading ────────────────────────────────────────────────
|
|
251
|
-
|
|
252
|
-
async readPage(): Promise<string> {
|
|
253
|
-
this.ensureConnected();
|
|
254
|
-
const result = await this.send("Runtime.evaluate", {
|
|
255
|
-
expression: `
|
|
256
|
-
(function() {
|
|
257
|
-
// Get page title and URL
|
|
258
|
-
let output = "Title: " + document.title + "\\n";
|
|
259
|
-
output += "URL: " + window.location.href + "\\n\\n";
|
|
260
|
-
|
|
261
|
-
// Get main text content, cleaned up
|
|
262
|
-
const body = document.body.cloneNode(true);
|
|
263
|
-
// Remove scripts, styles, navs that add noise
|
|
264
|
-
body.querySelectorAll('script, style, noscript, svg, iframe').forEach(el => el.remove());
|
|
265
|
-
|
|
266
|
-
const text = body.innerText
|
|
267
|
-
.split('\\n')
|
|
268
|
-
.map(line => line.trim())
|
|
269
|
-
.filter(line => line.length > 0)
|
|
270
|
-
.join('\\n');
|
|
271
|
-
|
|
272
|
-
output += text;
|
|
273
|
-
return output.slice(0, 30000);
|
|
274
|
-
})()
|
|
275
|
-
`,
|
|
276
|
-
returnByValue: true,
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
return ((result as CDPEvalResult).result?.value as string) || "Could not read page content.";
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
async readElement(selector: string): Promise<string> {
|
|
283
|
-
this.ensureConnected();
|
|
284
|
-
const selectorJS = JSON.stringify(selector);
|
|
285
|
-
const result = await this.send("Runtime.evaluate", {
|
|
286
|
-
expression: `
|
|
287
|
-
(function() {
|
|
288
|
-
const el = document.querySelector(${selectorJS});
|
|
289
|
-
if (!el) return 'Element not found: ' + ${selectorJS};
|
|
290
|
-
return el.innerText || el.textContent || el.value || '(empty)';
|
|
291
|
-
})()
|
|
292
|
-
`,
|
|
293
|
-
returnByValue: true,
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
return ((result as CDPEvalResult).result?.value as string) || "Element not found.";
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
async getPageInfo(): Promise<{ title: string; url: string }> {
|
|
300
|
-
const result = await this.send("Runtime.evaluate", {
|
|
301
|
-
expression: `JSON.stringify({ title: document.title, url: window.location.href })`,
|
|
302
|
-
returnByValue: true,
|
|
303
|
-
});
|
|
304
|
-
try {
|
|
305
|
-
return JSON.parse(((result as CDPEvalResult).result?.value as string) || "{}");
|
|
306
|
-
} catch {
|
|
307
|
-
return { title: "Unknown", url: "unknown" };
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// ── Screenshots (for Claude vision) ─────────────────────────────
|
|
312
|
-
|
|
313
|
-
async screenshot(): Promise<string> {
|
|
314
|
-
this.ensureConnected();
|
|
315
|
-
const result = await this.send("Page.captureScreenshot", {
|
|
316
|
-
format: "png",
|
|
317
|
-
quality: 80,
|
|
318
|
-
captureBeyondViewport: false,
|
|
319
|
-
});
|
|
320
|
-
// Returns base64-encoded PNG
|
|
321
|
-
return (result as CDPScreenshotResult).data || "";
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// ── Interactions ────────────────────────────────────────────────
|
|
325
|
-
|
|
326
|
-
async click(selector: string): Promise<string> {
|
|
327
|
-
this.ensureConnected();
|
|
328
|
-
const selectorJS = JSON.stringify(selector);
|
|
329
|
-
|
|
330
|
-
const result = await this.send("Runtime.evaluate", {
|
|
331
|
-
expression: `
|
|
332
|
-
(function() {
|
|
333
|
-
const el = document.querySelector(${selectorJS});
|
|
334
|
-
if (!el) return 'Element not found: ' + ${selectorJS};
|
|
335
|
-
|
|
336
|
-
// Scroll into view
|
|
337
|
-
el.scrollIntoView({ block: 'center', behavior: 'instant' });
|
|
338
|
-
|
|
339
|
-
// Click
|
|
340
|
-
el.click();
|
|
341
|
-
return 'Clicked: ' + (el.tagName || '') + ' ' + (el.textContent || '').slice(0, 50).trim();
|
|
342
|
-
})()
|
|
343
|
-
`,
|
|
344
|
-
returnByValue: true,
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
// Small delay for any resulting navigation/animation
|
|
348
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
349
|
-
return ((result as CDPEvalResult).result?.value as string) || "Click executed.";
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
async typeText(selector: string, text: string): Promise<string> {
|
|
353
|
-
this.ensureConnected();
|
|
354
|
-
// Use JSON.stringify for safe string interpolation into JS — handles all
|
|
355
|
-
// special characters (quotes, backslashes, newlines, unicode) correctly.
|
|
356
|
-
const selectorJS = JSON.stringify(selector);
|
|
357
|
-
const textJS = JSON.stringify(text);
|
|
358
|
-
|
|
359
|
-
const result = await this.send("Runtime.evaluate", {
|
|
360
|
-
expression: `
|
|
361
|
-
(function() {
|
|
362
|
-
const el = document.querySelector(${selectorJS});
|
|
363
|
-
if (!el) return 'Element not found: ' + ${selectorJS};
|
|
364
|
-
|
|
365
|
-
el.focus();
|
|
366
|
-
el.value = ${textJS};
|
|
367
|
-
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
368
|
-
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
369
|
-
return 'Typed into: ' + (el.tagName || '') + ' [' + (el.name || el.id || '') + ']';
|
|
370
|
-
})()
|
|
371
|
-
`,
|
|
372
|
-
returnByValue: true,
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
return ((result as CDPEvalResult).result?.value as string) || "Text entered.";
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
async pressKey(key: string): Promise<string> {
|
|
379
|
-
this.ensureConnected();
|
|
380
|
-
|
|
381
|
-
// Map common key names to CDP key codes
|
|
382
|
-
const keyMap: Record<string, { keyCode: number; code: string }> = {
|
|
383
|
-
Enter: { keyCode: 13, code: "Enter" },
|
|
384
|
-
Tab: { keyCode: 9, code: "Tab" },
|
|
385
|
-
Escape: { keyCode: 27, code: "Escape" },
|
|
386
|
-
Backspace: { keyCode: 8, code: "Backspace" },
|
|
387
|
-
ArrowDown: { keyCode: 40, code: "ArrowDown" },
|
|
388
|
-
ArrowUp: { keyCode: 38, code: "ArrowUp" },
|
|
389
|
-
};
|
|
390
|
-
|
|
391
|
-
const mapped = keyMap[key];
|
|
392
|
-
if (mapped) {
|
|
393
|
-
await this.send("Input.dispatchKeyEvent", {
|
|
394
|
-
type: "keyDown",
|
|
395
|
-
key,
|
|
396
|
-
code: mapped.code,
|
|
397
|
-
windowsVirtualKeyCode: mapped.keyCode,
|
|
398
|
-
nativeVirtualKeyCode: mapped.keyCode,
|
|
399
|
-
});
|
|
400
|
-
await this.send("Input.dispatchKeyEvent", {
|
|
401
|
-
type: "keyUp",
|
|
402
|
-
key,
|
|
403
|
-
code: mapped.code,
|
|
404
|
-
windowsVirtualKeyCode: mapped.keyCode,
|
|
405
|
-
nativeVirtualKeyCode: mapped.keyCode,
|
|
406
|
-
});
|
|
407
|
-
} else {
|
|
408
|
-
// Single character key
|
|
409
|
-
await this.send("Input.dispatchKeyEvent", {
|
|
410
|
-
type: "char",
|
|
411
|
-
text: key,
|
|
412
|
-
});
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
return `Pressed key: ${key}`;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
async scrollDown(): Promise<string> {
|
|
419
|
-
this.ensureConnected();
|
|
420
|
-
await this.send("Runtime.evaluate", {
|
|
421
|
-
expression: "window.scrollBy(0, window.innerHeight * 0.8)",
|
|
422
|
-
});
|
|
423
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
424
|
-
return "Scrolled down.";
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
async scrollUp(): Promise<string> {
|
|
428
|
-
this.ensureConnected();
|
|
429
|
-
await this.send("Runtime.evaluate", {
|
|
430
|
-
expression: "window.scrollBy(0, -window.innerHeight * 0.8)",
|
|
431
|
-
});
|
|
432
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
433
|
-
return "Scrolled up.";
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// ── JavaScript Evaluation ───────────────────────────────────────
|
|
437
|
-
|
|
438
|
-
async evaluate(expression: string): Promise<string> {
|
|
439
|
-
this.ensureConnected();
|
|
440
|
-
const result = await this.send("Runtime.evaluate", {
|
|
441
|
-
expression,
|
|
442
|
-
returnByValue: true,
|
|
443
|
-
awaitPromise: true,
|
|
444
|
-
});
|
|
445
|
-
|
|
446
|
-
const evalResult = (result as CDPEvalResult).result;
|
|
447
|
-
const value = evalResult?.value;
|
|
448
|
-
if (value === undefined) {
|
|
449
|
-
const desc = evalResult?.description;
|
|
450
|
-
return desc || "(undefined)";
|
|
451
|
-
}
|
|
452
|
-
return typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// ── Tab Management ──────────────────────────────────────────────
|
|
456
|
-
|
|
457
|
-
async listTabs(): Promise<string> {
|
|
458
|
-
const tabs = await this.getTabs();
|
|
459
|
-
const pageTabs = tabs.filter((t) => t.type === "page");
|
|
460
|
-
|
|
461
|
-
if (pageTabs.length === 0) return "No tabs open.";
|
|
462
|
-
|
|
463
|
-
return pageTabs
|
|
464
|
-
.map(
|
|
465
|
-
(t, i) =>
|
|
466
|
-
`[${i}] ${t.title.slice(0, 60)}${this.currentTabId === t.id ? " (active)" : ""}\n ${t.url}`
|
|
467
|
-
)
|
|
468
|
-
.join("\n\n");
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
async switchTab(index: number): Promise<string> {
|
|
472
|
-
const tabs = await this.getTabs();
|
|
473
|
-
const pageTabs = tabs.filter((t) => t.type === "page");
|
|
474
|
-
|
|
475
|
-
if (index < 0 || index >= pageTabs.length) {
|
|
476
|
-
return `Invalid tab index. Available: 0-${pageTabs.length - 1}`;
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
// Disconnect from current tab
|
|
480
|
-
await this.disconnect();
|
|
481
|
-
|
|
482
|
-
// Connect to new tab
|
|
483
|
-
return this.connect(index);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
async openNewTab(url?: string): Promise<string> {
|
|
487
|
-
const targetUrl = url || "about:blank";
|
|
488
|
-
const res = await fetch(
|
|
489
|
-
`http://127.0.0.1:${this.debugPort}/json/new?${encodeURIComponent(targetUrl)}`,
|
|
490
|
-
{ signal: AbortSignal.timeout(5000) }
|
|
491
|
-
);
|
|
492
|
-
const tab = (await res.json()) as CDPTab;
|
|
493
|
-
|
|
494
|
-
// Connect to the new tab
|
|
495
|
-
await this.disconnect();
|
|
496
|
-
const tabs = await this.getTabs();
|
|
497
|
-
const idx = tabs.filter((t) => t.type === "page").findIndex((t) => t.id === tab.id);
|
|
498
|
-
if (idx >= 0) {
|
|
499
|
-
await this.connect(idx);
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
return `Opened new tab: ${targetUrl}`;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// ── Helpers ─────────────────────────────────────────────────────
|
|
506
|
-
|
|
507
|
-
private async waitForLoad(timeoutMs = 8000): Promise<void> {
|
|
508
|
-
const start = Date.now();
|
|
509
|
-
while (Date.now() - start < timeoutMs) {
|
|
510
|
-
try {
|
|
511
|
-
const result = await this.send("Runtime.evaluate", {
|
|
512
|
-
expression: "document.readyState",
|
|
513
|
-
returnByValue: true,
|
|
514
|
-
});
|
|
515
|
-
const state = (result as CDPEvalResult).result?.value;
|
|
516
|
-
if (state === "complete" || state === "interactive") {
|
|
517
|
-
// Extra small wait for dynamic content
|
|
518
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
} catch {
|
|
522
|
-
// Tab might be navigating
|
|
523
|
-
}
|
|
524
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
/**
|
|
529
|
-
* Find interactive elements on the page for the AI to understand what's clickable
|
|
530
|
-
*/
|
|
531
|
-
async getInteractiveElements(): Promise<string> {
|
|
532
|
-
this.ensureConnected();
|
|
533
|
-
const result = await this.send("Runtime.evaluate", {
|
|
534
|
-
expression: `
|
|
535
|
-
(function() {
|
|
536
|
-
const elements = [];
|
|
537
|
-
const selectors = 'a, button, input, select, textarea, [role="button"], [onclick]';
|
|
538
|
-
const all = document.querySelectorAll(selectors);
|
|
539
|
-
for (let i = 0; i < all.length && elements.length < 50; i++) {
|
|
540
|
-
const el = all[i];
|
|
541
|
-
const rect = el.getBoundingClientRect();
|
|
542
|
-
if (rect.width === 0 || rect.height === 0) continue; // Skip hidden
|
|
543
|
-
|
|
544
|
-
// Build a reliable CSS selector
|
|
545
|
-
let selector;
|
|
546
|
-
if (el.id) {
|
|
547
|
-
selector = '#' + CSS.escape(el.id);
|
|
548
|
-
} else if (el.getAttribute('data-testid')) {
|
|
549
|
-
selector = '[data-testid="' + el.getAttribute('data-testid') + '"]';
|
|
550
|
-
} else {
|
|
551
|
-
// Build a path-based selector: find nth-of-type among siblings
|
|
552
|
-
const tag = el.tagName.toLowerCase();
|
|
553
|
-
const parent = el.parentElement;
|
|
554
|
-
if (parent) {
|
|
555
|
-
const siblings = parent.querySelectorAll(':scope > ' + tag);
|
|
556
|
-
const idx = Array.from(siblings).indexOf(el) + 1;
|
|
557
|
-
selector = tag + ':nth-of-type(' + idx + ')';
|
|
558
|
-
} else {
|
|
559
|
-
selector = tag;
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
elements.push({
|
|
564
|
-
tag: el.tagName.toLowerCase(),
|
|
565
|
-
text: (el.textContent || '').trim().slice(0, 80),
|
|
566
|
-
type: el.getAttribute('type') || '',
|
|
567
|
-
name: el.getAttribute('name') || '',
|
|
568
|
-
id: el.id || '',
|
|
569
|
-
href: el.getAttribute('href') || '',
|
|
570
|
-
placeholder: el.getAttribute('placeholder') || '',
|
|
571
|
-
selector: selector,
|
|
572
|
-
});
|
|
573
|
-
}
|
|
574
|
-
return JSON.stringify(elements, null, 2);
|
|
575
|
-
})()
|
|
576
|
-
`,
|
|
577
|
-
returnByValue: true,
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
return ((result as CDPEvalResult).result?.value as string) || "[]";
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
isConnected(): boolean {
|
|
584
|
-
return this.connected && this.ws?.readyState === WebSocket.OPEN;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
// ── Login Detection ────────────────────────────────────────────
|
|
588
|
-
|
|
589
|
-
/**
|
|
590
|
-
* Detect if the current page appears to be a login/authentication page.
|
|
591
|
-
* Checks URL patterns, password input fields, and login form actions.
|
|
592
|
-
*/
|
|
593
|
-
async detectLoginPage(): Promise<{ isLoginPage: boolean; reason: string }> {
|
|
594
|
-
try {
|
|
595
|
-
const result = await this.send("Runtime.evaluate", {
|
|
596
|
-
expression: `
|
|
597
|
-
(function() {
|
|
598
|
-
var url = window.location.href.toLowerCase();
|
|
599
|
-
|
|
600
|
-
// URL-based detection
|
|
601
|
-
var loginPatterns = [
|
|
602
|
-
'/login', '/signin', '/sign-in', '/sign_in',
|
|
603
|
-
'/auth/', '/sso/', '/oauth/', '/session/new',
|
|
604
|
-
'/accounts/login', '/users/sign_in',
|
|
605
|
-
'accounts.google.com', 'login.microsoftonline.com',
|
|
606
|
-
'github.com/login', 'github.com/session',
|
|
607
|
-
'login.live.com', 'appleid.apple.com'
|
|
608
|
-
];
|
|
609
|
-
for (var i = 0; i < loginPatterns.length; i++) {
|
|
610
|
-
if (url.indexOf(loginPatterns[i]) !== -1) {
|
|
611
|
-
return JSON.stringify({
|
|
612
|
-
isLoginPage: true,
|
|
613
|
-
reason: 'URL contains login pattern: ' + loginPatterns[i]
|
|
614
|
-
});
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
// Password input detection (visible only)
|
|
619
|
-
var passwordInputs = document.querySelectorAll('input[type="password"]');
|
|
620
|
-
for (var j = 0; j < passwordInputs.length; j++) {
|
|
621
|
-
var input = passwordInputs[j];
|
|
622
|
-
var rect = input.getBoundingClientRect();
|
|
623
|
-
var style = window.getComputedStyle(input);
|
|
624
|
-
if (rect.width > 0 && rect.height > 0 &&
|
|
625
|
-
style.display !== 'none' && style.visibility !== 'hidden') {
|
|
626
|
-
return JSON.stringify({
|
|
627
|
-
isLoginPage: true,
|
|
628
|
-
reason: 'Page contains visible password input field'
|
|
629
|
-
});
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
// Login form action detection
|
|
634
|
-
var formSelectors = [
|
|
635
|
-
'form[action*="login"]', 'form[action*="signin"]',
|
|
636
|
-
'form[action*="session"]', 'form[action*="auth"]',
|
|
637
|
-
'form[action*="authenticate"]'
|
|
638
|
-
];
|
|
639
|
-
var loginForms = document.querySelectorAll(formSelectors.join(','));
|
|
640
|
-
if (loginForms.length > 0) {
|
|
641
|
-
return JSON.stringify({
|
|
642
|
-
isLoginPage: true,
|
|
643
|
-
reason: 'Page contains login form'
|
|
644
|
-
});
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
return JSON.stringify({ isLoginPage: false, reason: '' });
|
|
648
|
-
})()
|
|
649
|
-
`,
|
|
650
|
-
returnByValue: true,
|
|
651
|
-
});
|
|
652
|
-
|
|
653
|
-
const value = (result as CDPEvalResult).result?.value as string;
|
|
654
|
-
return JSON.parse(value || '{"isLoginPage":false,"reason":""}');
|
|
655
|
-
} catch {
|
|
656
|
-
return { isLoginPage: false, reason: "" };
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
// ── Chrome Auto-Launch ──────────────────────────────────────────────
|
|
662
|
-
|
|
663
|
-
/**
|
|
664
|
-
* Find Chrome/Chromium binary path on the current platform.
|
|
665
|
-
*/
|
|
666
|
-
export function findChromePath(): string | null {
|
|
667
|
-
const os = platform();
|
|
668
|
-
|
|
669
|
-
if (os === "darwin") {
|
|
670
|
-
const paths = [
|
|
671
|
-
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
672
|
-
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
673
|
-
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
674
|
-
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
675
|
-
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
676
|
-
];
|
|
677
|
-
return paths.find((p) => existsSync(p)) ?? null;
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
if (os === "linux") {
|
|
681
|
-
const names = [
|
|
682
|
-
"google-chrome",
|
|
683
|
-
"google-chrome-stable",
|
|
684
|
-
"chromium-browser",
|
|
685
|
-
"chromium",
|
|
686
|
-
"microsoft-edge",
|
|
687
|
-
"microsoft-edge-stable",
|
|
688
|
-
"brave-browser",
|
|
689
|
-
];
|
|
690
|
-
for (const name of names) {
|
|
691
|
-
try {
|
|
692
|
-
return execSync(`which ${name}`, {
|
|
693
|
-
encoding: "utf-8",
|
|
694
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
695
|
-
}).trim();
|
|
696
|
-
} catch {
|
|
697
|
-
/* not found */
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
return null;
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
if (os === "win32") {
|
|
704
|
-
const prefixes = [
|
|
705
|
-
process.env.PROGRAMFILES,
|
|
706
|
-
process.env["PROGRAMFILES(X86)"],
|
|
707
|
-
process.env.LOCALAPPDATA,
|
|
708
|
-
].filter(Boolean) as string[];
|
|
709
|
-
|
|
710
|
-
const subPaths = [
|
|
711
|
-
"Google\\Chrome\\Application\\chrome.exe",
|
|
712
|
-
"Microsoft\\Edge\\Application\\msedge.exe",
|
|
713
|
-
"BraveSoftware\\Brave-Browser\\Application\\brave.exe",
|
|
714
|
-
];
|
|
715
|
-
|
|
716
|
-
for (const prefix of prefixes) {
|
|
717
|
-
for (const sub of subPaths) {
|
|
718
|
-
const p = `${prefix}\\${sub}`;
|
|
719
|
-
if (existsSync(p)) return p;
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
return null;
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
return null;
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
/**
|
|
729
|
-
* Check if a Chromium-based browser is currently running.
|
|
730
|
-
* Optionally pass the specific browser binary path for precise matching.
|
|
731
|
-
*/
|
|
732
|
-
export function isChromeRunning(chromePath?: string): boolean {
|
|
733
|
-
try {
|
|
734
|
-
if (platform() === "win32") {
|
|
735
|
-
// Check for any Chromium-based browser process
|
|
736
|
-
const out = execSync(
|
|
737
|
-
'tasklist /FI "IMAGENAME eq chrome.exe" /FI "IMAGENAME eq msedge.exe" /FI "IMAGENAME eq brave.exe" /NH',
|
|
738
|
-
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
|
|
739
|
-
);
|
|
740
|
-
return /chrome\.exe|msedge\.exe|brave\.exe/i.test(out);
|
|
741
|
-
}
|
|
742
|
-
if (platform() === "darwin") {
|
|
743
|
-
// If we know the exact binary, match it precisely
|
|
744
|
-
if (chromePath) {
|
|
745
|
-
const appDir = chromePath.replace(/\/Contents\/MacOS\/.*$/, "");
|
|
746
|
-
const out = execSync(`pgrep -f ${JSON.stringify(appDir)}`, {
|
|
747
|
-
encoding: "utf-8",
|
|
748
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
749
|
-
});
|
|
750
|
-
return out.trim().length > 0;
|
|
751
|
-
}
|
|
752
|
-
// Otherwise check all known Chromium browsers
|
|
753
|
-
const out = execSync(
|
|
754
|
-
'pgrep -f "(Google Chrome|Microsoft Edge|Brave Browser|Chromium).app/Contents/MacOS/"',
|
|
755
|
-
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
|
|
756
|
-
);
|
|
757
|
-
return out.trim().length > 0;
|
|
758
|
-
}
|
|
759
|
-
// Linux
|
|
760
|
-
if (chromePath) {
|
|
761
|
-
const out = execSync(`pgrep -f ${JSON.stringify(chromePath)} 2>/dev/null || true`, {
|
|
762
|
-
encoding: "utf-8",
|
|
763
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
764
|
-
});
|
|
765
|
-
return out.trim().length > 0;
|
|
766
|
-
}
|
|
767
|
-
const out = execSync("pgrep -f '(chrome|chromium|msedge|brave)' 2>/dev/null || true", {
|
|
768
|
-
encoding: "utf-8",
|
|
769
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
770
|
-
});
|
|
771
|
-
return out.trim().length > 0;
|
|
772
|
-
} catch {
|
|
773
|
-
return false;
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
/**
|
|
778
|
-
* Derive the macOS app name from a binary path inside a .app bundle.
|
|
779
|
-
*/
|
|
780
|
-
function macAppName(chromePath: string): string {
|
|
781
|
-
if (chromePath.includes("Brave Browser")) return "Brave Browser";
|
|
782
|
-
if (chromePath.includes("Microsoft Edge")) return "Microsoft Edge";
|
|
783
|
-
if (chromePath.includes("Chromium")) return "Chromium";
|
|
784
|
-
if (chromePath.includes("Canary")) return "Google Chrome Canary";
|
|
785
|
-
return "Google Chrome";
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
/**
|
|
789
|
-
* Gracefully quit the browser, then force-kill if it doesn't exit in time.
|
|
790
|
-
*/
|
|
791
|
-
async function killChromeGracefully(chromePath: string): Promise<void> {
|
|
792
|
-
const os = platform();
|
|
793
|
-
try {
|
|
794
|
-
if (os === "darwin") {
|
|
795
|
-
const app = macAppName(chromePath);
|
|
796
|
-
execSync(`osascript -e 'quit app "${app}"'`, {
|
|
797
|
-
timeout: 5000,
|
|
798
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
799
|
-
});
|
|
800
|
-
} else if (os === "linux") {
|
|
801
|
-
// Kill the specific browser binary, not all Chromium variants
|
|
802
|
-
execSync(`pkill -TERM -f ${JSON.stringify(chromePath)}`, {
|
|
803
|
-
timeout: 5000,
|
|
804
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
805
|
-
});
|
|
806
|
-
} else if (os === "win32") {
|
|
807
|
-
const exe = chromePath.split("\\").pop() || "chrome.exe";
|
|
808
|
-
execSync(`taskkill /IM "${exe}"`, {
|
|
809
|
-
timeout: 5000,
|
|
810
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
811
|
-
});
|
|
812
|
-
}
|
|
813
|
-
} catch {
|
|
814
|
-
/* may already be closed */
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
// Wait for browser to fully exit (up to 8s)
|
|
818
|
-
const start = Date.now();
|
|
819
|
-
while (Date.now() - start < 8000) {
|
|
820
|
-
if (!isChromeRunning(chromePath)) {
|
|
821
|
-
log.debug(`Browser exited after ${Date.now() - start}ms`);
|
|
822
|
-
return;
|
|
823
|
-
}
|
|
824
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
log.debug("Browser still running after graceful quit, force-killing...");
|
|
828
|
-
|
|
829
|
-
// Force kill if still alive
|
|
830
|
-
try {
|
|
831
|
-
if (os === "win32") {
|
|
832
|
-
const exe = chromePath.split("\\").pop() || "chrome.exe";
|
|
833
|
-
execSync(`taskkill /F /IM "${exe}"`, {
|
|
834
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
835
|
-
});
|
|
836
|
-
} else {
|
|
837
|
-
execSync(`pkill -9 -f ${JSON.stringify(chromePath)}`, {
|
|
838
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
839
|
-
});
|
|
840
|
-
}
|
|
841
|
-
} catch {
|
|
842
|
-
/* already dead */
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
// Wait for processes to fully terminate after SIGKILL
|
|
846
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
847
|
-
|
|
848
|
-
// Remove SingletonLock files that may linger after a force-kill
|
|
849
|
-
if (os !== "win32") {
|
|
850
|
-
const home = process.env.HOME;
|
|
851
|
-
if (home) {
|
|
852
|
-
const lockSuffixes = ["SingletonLock", "SingletonSocket", "SingletonCookie"];
|
|
853
|
-
const profileDirs =
|
|
854
|
-
os === "darwin"
|
|
855
|
-
? [
|
|
856
|
-
`${home}/Library/Application Support/Google/Chrome`,
|
|
857
|
-
`${home}/Library/Application Support/Microsoft Edge`,
|
|
858
|
-
`${home}/Library/Application Support/BraveSoftware/Brave-Browser`,
|
|
859
|
-
]
|
|
860
|
-
: [
|
|
861
|
-
`${home}/.config/google-chrome`,
|
|
862
|
-
`${home}/.config/chromium`,
|
|
863
|
-
`${home}/.config/microsoft-edge`,
|
|
864
|
-
`${home}/.config/BraveSoftware/Brave-Browser`,
|
|
865
|
-
];
|
|
866
|
-
for (const dir of profileDirs) {
|
|
867
|
-
for (const suffix of lockSuffixes) {
|
|
868
|
-
const lockPath = `${dir}/${suffix}`;
|
|
869
|
-
try {
|
|
870
|
-
if (existsSync(lockPath)) {
|
|
871
|
-
unlinkSync(lockPath);
|
|
872
|
-
log.debug(`Removed stale lock: ${lockPath}`);
|
|
873
|
-
}
|
|
874
|
-
} catch {
|
|
875
|
-
/* best effort */
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
/**
|
|
884
|
-
* Return the browser's default profile directory.
|
|
885
|
-
*/
|
|
886
|
-
function getDefaultProfileDir(chromePath: string): string {
|
|
887
|
-
const home = homedir();
|
|
888
|
-
const os = platform();
|
|
889
|
-
|
|
890
|
-
if (os === "darwin") {
|
|
891
|
-
if (chromePath.includes("Brave Browser"))
|
|
892
|
-
return join(home, "Library", "Application Support", "BraveSoftware", "Brave-Browser");
|
|
893
|
-
if (chromePath.includes("Microsoft Edge"))
|
|
894
|
-
return join(home, "Library", "Application Support", "Microsoft Edge");
|
|
895
|
-
if (chromePath.includes("Chromium"))
|
|
896
|
-
return join(home, "Library", "Application Support", "Chromium");
|
|
897
|
-
if (chromePath.includes("Canary"))
|
|
898
|
-
return join(home, "Library", "Application Support", "Google", "Chrome Canary");
|
|
899
|
-
return join(home, "Library", "Application Support", "Google", "Chrome");
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
if (os === "win32") {
|
|
903
|
-
const appData = process.env.LOCALAPPDATA || join(home, "AppData", "Local");
|
|
904
|
-
if (chromePath.includes("brave"))
|
|
905
|
-
return join(appData, "BraveSoftware", "Brave-Browser", "User Data");
|
|
906
|
-
if (chromePath.includes("msedge")) return join(appData, "Microsoft", "Edge", "User Data");
|
|
907
|
-
return join(appData, "Google", "Chrome", "User Data");
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
// Linux
|
|
911
|
-
if (chromePath.includes("brave")) return join(home, ".config", "BraveSoftware", "Brave-Browser");
|
|
912
|
-
if (chromePath.includes("microsoft-edge")) return join(home, ".config", "microsoft-edge");
|
|
913
|
-
if (chromePath.includes("chromium")) return join(home, ".config", "chromium");
|
|
914
|
-
return join(home, ".config", "google-chrome");
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
/**
|
|
918
|
-
* Return a dedicated debug profile directory for assistme.
|
|
919
|
-
*
|
|
920
|
-
* Chrome 136+ silently ignores --remote-debugging-port when launched with the
|
|
921
|
-
* DEFAULT user-data-dir (security hardening against cookie-stealing malware).
|
|
922
|
-
* It also ignores --user-data-dir pointing to the default path.
|
|
923
|
-
* The flag ONLY works with a NON-DEFAULT --user-data-dir.
|
|
924
|
-
*
|
|
925
|
-
* Strategy: use ~/.assistme/browser-profile as a dedicated debug profile.
|
|
926
|
-
* On first use, copy key files from the real profile (bookmarks, cookies,
|
|
927
|
-
* login data, preferences) so the user doesn't start completely fresh.
|
|
928
|
-
* Sessions accumulate in the debug profile from then on.
|
|
929
|
-
*
|
|
930
|
-
* See: https://developer.chrome.com/blog/remote-debugging-port
|
|
931
|
-
*/
|
|
932
|
-
function getDebugProfileDir(chromePath: string): string {
|
|
933
|
-
const home = homedir();
|
|
934
|
-
const debugDir = join(home, ".assistme", "browser-profile");
|
|
935
|
-
|
|
936
|
-
if (!existsSync(debugDir)) {
|
|
937
|
-
mkdirSync(debugDir, { recursive: true });
|
|
938
|
-
log.debug(`Created debug profile directory: ${debugDir}`);
|
|
939
|
-
|
|
940
|
-
// Seed from the real profile — copy lightweight files, skip caches
|
|
941
|
-
const realDir = getDefaultProfileDir(chromePath);
|
|
942
|
-
if (existsSync(realDir)) {
|
|
943
|
-
seedDebugProfile(realDir, debugDir);
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
return debugDir;
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
/**
|
|
951
|
-
* Copy essential profile data from the user's real Chrome profile to the
|
|
952
|
-
* debug profile. This preserves bookmarks, preferences, and (where possible)
|
|
953
|
-
* login state without copying multi-GB caches.
|
|
954
|
-
*
|
|
955
|
-
* Note: cookies/login data are encrypted with a key tied to the user-data-dir
|
|
956
|
-
* on Chrome 136+, so they won't decrypt in the debug profile. The user will
|
|
957
|
-
* need to log in once in the debug browser. After that, sessions persist.
|
|
958
|
-
*/
|
|
959
|
-
function seedDebugProfile(realDir: string, debugDir: string): void {
|
|
960
|
-
// Files to copy from the profile root
|
|
961
|
-
const rootFiles = ["Local State"];
|
|
962
|
-
// Files to copy from the "Default" sub-profile
|
|
963
|
-
const profileFiles = ["Bookmarks", "Preferences", "Favicons", "Top Sites", "Shortcuts"];
|
|
964
|
-
|
|
965
|
-
for (const file of rootFiles) {
|
|
966
|
-
const src = join(realDir, file);
|
|
967
|
-
const dest = join(debugDir, file);
|
|
968
|
-
try {
|
|
969
|
-
if (existsSync(src)) {
|
|
970
|
-
cpSync(src, dest, { force: true });
|
|
971
|
-
log.debug(`Seeded: ${file}`);
|
|
972
|
-
}
|
|
973
|
-
} catch {
|
|
974
|
-
/* best effort */
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
// Copy the Default profile sub-directory essentials
|
|
979
|
-
const srcProfile = join(realDir, "Default");
|
|
980
|
-
const destProfile = join(debugDir, "Default");
|
|
981
|
-
if (existsSync(srcProfile)) {
|
|
982
|
-
mkdirSync(destProfile, { recursive: true });
|
|
983
|
-
for (const file of profileFiles) {
|
|
984
|
-
const src = join(srcProfile, file);
|
|
985
|
-
const dest = join(destProfile, file);
|
|
986
|
-
try {
|
|
987
|
-
if (existsSync(src)) {
|
|
988
|
-
cpSync(src, dest, { force: true });
|
|
989
|
-
log.debug(`Seeded: Default/${file}`);
|
|
990
|
-
}
|
|
991
|
-
} catch {
|
|
992
|
-
/* best effort */
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
// Copy Extensions directory if it exists (preserves user's extensions)
|
|
997
|
-
const srcExt = join(srcProfile, "Extensions");
|
|
998
|
-
const destExt = join(destProfile, "Extensions");
|
|
999
|
-
try {
|
|
1000
|
-
if (existsSync(srcExt)) {
|
|
1001
|
-
cpSync(srcExt, destExt, { recursive: true, force: true });
|
|
1002
|
-
log.debug("Seeded: Default/Extensions");
|
|
1003
|
-
}
|
|
1004
|
-
} catch {
|
|
1005
|
-
/* best effort — extensions can be large */
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
/**
|
|
1011
|
-
* Spawn a Chromium-based browser with CDP enabled.
|
|
1012
|
-
* Returns the child process for exit-code monitoring.
|
|
1013
|
-
*
|
|
1014
|
-
* Key design decisions:
|
|
1015
|
-
* - Launches the binary directly (not via macOS `open -a`) so flags are
|
|
1016
|
-
* guaranteed to reach the process and the child stays alive.
|
|
1017
|
-
* - Uses a dedicated debug profile (not the default profile) so that:
|
|
1018
|
-
* (a) Chrome 136+ allows --remote-debugging-port
|
|
1019
|
-
* (b) Can run alongside the user's regular Chrome (different singleton)
|
|
1020
|
-
* - Callers should ensure no OTHER debug-profile Chrome is running, but
|
|
1021
|
-
* the user's regular Chrome can stay open.
|
|
1022
|
-
*/
|
|
1023
|
-
function spawnChrome(chromePath: string, port: number): ChildProcess {
|
|
1024
|
-
const profileDir = getDebugProfileDir(chromePath);
|
|
1025
|
-
const flags = [
|
|
1026
|
-
`--remote-debugging-port=${port}`,
|
|
1027
|
-
`--user-data-dir=${profileDir}`,
|
|
1028
|
-
"--restore-last-session",
|
|
1029
|
-
];
|
|
1030
|
-
|
|
1031
|
-
log.debug(`Spawning browser: ${chromePath} ${flags.join(" ")}`);
|
|
1032
|
-
|
|
1033
|
-
const child = spawn(chromePath, flags, {
|
|
1034
|
-
detached: true,
|
|
1035
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1036
|
-
});
|
|
1037
|
-
|
|
1038
|
-
// Capture stderr for diagnostics — Chrome prints errors here
|
|
1039
|
-
let stderr = "";
|
|
1040
|
-
child.stderr?.on("data", (chunk: Buffer) => {
|
|
1041
|
-
stderr += chunk.toString();
|
|
1042
|
-
});
|
|
1043
|
-
|
|
1044
|
-
child.on("error", (err) => {
|
|
1045
|
-
log.error(`Chrome spawn error: ${err.message}`);
|
|
1046
|
-
});
|
|
1047
|
-
|
|
1048
|
-
child.on("exit", (code, signal) => {
|
|
1049
|
-
if (code !== null && code !== 0) {
|
|
1050
|
-
log.debug(`Chrome exited with code ${code}${signal ? ` (signal: ${signal})` : ""}`);
|
|
1051
|
-
if (stderr) {
|
|
1052
|
-
// Log first few lines of stderr for diagnostics
|
|
1053
|
-
const lines = stderr.split("\n").filter(Boolean).slice(0, 5);
|
|
1054
|
-
for (const line of lines) {
|
|
1055
|
-
log.debug(` chrome stderr: ${line}`);
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
}
|
|
1059
|
-
});
|
|
1060
|
-
|
|
1061
|
-
child.unref();
|
|
1062
|
-
return child;
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
/**
|
|
1066
|
-
* Wait for CDP to become reachable.
|
|
1067
|
-
*/
|
|
1068
|
-
async function waitForCDP(browser: BrowserController, timeoutMs = 30000): Promise<boolean> {
|
|
1069
|
-
const start = Date.now();
|
|
1070
|
-
let attempts = 0;
|
|
1071
|
-
while (Date.now() - start < timeoutMs) {
|
|
1072
|
-
attempts++;
|
|
1073
|
-
if (await browser.isAvailable()) {
|
|
1074
|
-
log.debug(`CDP became reachable after ${attempts} attempts (${Date.now() - start}ms)`);
|
|
1075
|
-
return true;
|
|
1076
|
-
}
|
|
1077
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
1078
|
-
}
|
|
1079
|
-
log.debug(`CDP not reachable after ${attempts} attempts (${timeoutMs}ms timeout)`);
|
|
1080
|
-
return false;
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
/**
|
|
1084
|
-
* Check if a port is already in use by another process (not Chrome CDP).
|
|
1085
|
-
*/
|
|
1086
|
-
async function isPortInUse(port: number): Promise<boolean> {
|
|
1087
|
-
try {
|
|
1088
|
-
const res = await fetch(`http://127.0.0.1:${port}/json/version`, {
|
|
1089
|
-
signal: AbortSignal.timeout(1000),
|
|
1090
|
-
});
|
|
1091
|
-
// CDP /json/version returns a JSON object with "Browser" and "webSocketDebuggerUrl" keys.
|
|
1092
|
-
// All Chromium-based browsers (Chrome, Edge, Brave) include these.
|
|
1093
|
-
const body = await res.text();
|
|
1094
|
-
return !body.includes("webSocketDebuggerUrl");
|
|
1095
|
-
} catch {
|
|
1096
|
-
// Connection refused → port is free
|
|
1097
|
-
return false;
|
|
1098
|
-
}
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
/**
|
|
1102
|
-
* Result of an auto-launch attempt.
|
|
1103
|
-
*/
|
|
1104
|
-
export interface AutoLaunchResult {
|
|
1105
|
-
success: boolean;
|
|
1106
|
-
action: "already_available" | "launched" | "chrome_not_found" | "launch_failed" | "port_conflict";
|
|
1107
|
-
chromePath?: string;
|
|
1108
|
-
detail?: string;
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
/**
|
|
1112
|
-
* Ensure a Chromium browser is running with CDP enabled.
|
|
1113
|
-
*
|
|
1114
|
-
* Uses a SEPARATE debug profile (~/.assistme/browser-profile) so that:
|
|
1115
|
-
* - The user's regular Chrome can stay open — no killing required
|
|
1116
|
-
* - Chrome 136+ enables --remote-debugging-port (requires non-default dir)
|
|
1117
|
-
* - The debug browser has its own singleton — no conflicts
|
|
1118
|
-
*
|
|
1119
|
-
* Flow:
|
|
1120
|
-
* 1. CDP already reachable on the port → return immediately.
|
|
1121
|
-
* 2. Port occupied by a non-Chromium process → report conflict.
|
|
1122
|
-
* 3. Launch a new browser instance with the debug profile + CDP flag.
|
|
1123
|
-
*/
|
|
1124
|
-
export async function ensureBrowserAvailable(port = 9222): Promise<AutoLaunchResult> {
|
|
1125
|
-
const browser = getBrowser(port);
|
|
1126
|
-
|
|
1127
|
-
// Case 1: CDP already reachable
|
|
1128
|
-
if (await browser.isAvailable()) {
|
|
1129
|
-
log.debug("CDP already reachable — no launch needed");
|
|
1130
|
-
return { success: true, action: "already_available" };
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
// Case 2: Port occupied by something else
|
|
1134
|
-
if (await isPortInUse(port)) {
|
|
1135
|
-
log.debug(`Port ${port} is in use by a non-Chrome process`);
|
|
1136
|
-
return {
|
|
1137
|
-
success: false,
|
|
1138
|
-
action: "port_conflict",
|
|
1139
|
-
detail: `Port ${port} is already in use by another process. Try a different port or stop the conflicting process.`,
|
|
1140
|
-
};
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
// Find Chrome binary
|
|
1144
|
-
const chromePath = findChromePath();
|
|
1145
|
-
if (!chromePath) {
|
|
1146
|
-
log.debug("Chrome binary not found on this system");
|
|
1147
|
-
return { success: false, action: "chrome_not_found" };
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
log.debug(`Found Chrome at: ${chromePath}`);
|
|
1151
|
-
|
|
1152
|
-
// Launch a debug Chrome instance (separate profile — no need to kill the user's Chrome)
|
|
1153
|
-
spawnChrome(chromePath, port);
|
|
1154
|
-
|
|
1155
|
-
if (await waitForCDP(browser)) {
|
|
1156
|
-
return { success: true, action: "launched", chromePath };
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
// CDP didn't come up — check if the debug profile is locked by a previous
|
|
1160
|
-
// crashed assistme session (stale SingletonLock)
|
|
1161
|
-
const debugDir = getDebugProfileDir(chromePath);
|
|
1162
|
-
const lockPath = join(debugDir, "SingletonLock");
|
|
1163
|
-
if (existsSync(lockPath)) {
|
|
1164
|
-
log.debug("Found stale SingletonLock in debug profile — removing and retrying");
|
|
1165
|
-
try {
|
|
1166
|
-
unlinkSync(lockPath);
|
|
1167
|
-
// Also clean SingletonSocket/Cookie
|
|
1168
|
-
for (const f of ["SingletonSocket", "SingletonCookie"]) {
|
|
1169
|
-
try {
|
|
1170
|
-
unlinkSync(join(debugDir, f));
|
|
1171
|
-
} catch {
|
|
1172
|
-
/* ok */
|
|
1173
|
-
}
|
|
1174
|
-
}
|
|
1175
|
-
} catch {
|
|
1176
|
-
/* best effort */
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
// Retry spawn
|
|
1180
|
-
spawnChrome(chromePath, port);
|
|
1181
|
-
if (await waitForCDP(browser, 15000)) {
|
|
1182
|
-
return { success: true, action: "launched", chromePath };
|
|
1183
|
-
}
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
return {
|
|
1187
|
-
success: false,
|
|
1188
|
-
action: "launch_failed",
|
|
1189
|
-
chromePath,
|
|
1190
|
-
detail:
|
|
1191
|
-
"Could not start browser with remote debugging. Possible causes:\n" +
|
|
1192
|
-
" 1) Another assistme debug browser is already using port " +
|
|
1193
|
-
port +
|
|
1194
|
-
"\n" +
|
|
1195
|
-
" 2) The browser crashed on startup\n" +
|
|
1196
|
-
"Try: rm -rf ~/.assistme/browser-profile && assistme",
|
|
1197
|
-
};
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
// ── Singleton ───────────────────────────────────────────────────────
|
|
1201
|
-
|
|
1202
|
-
let browserInstance: BrowserController | null = null;
|
|
1203
|
-
|
|
1204
|
-
export function getBrowser(port = 9222): BrowserController {
|
|
1205
|
-
if (!browserInstance) {
|
|
1206
|
-
browserInstance = new BrowserController(port);
|
|
1207
|
-
}
|
|
1208
|
-
return browserInstance;
|
|
1209
|
-
}
|
|
2
|
+
* Re-export barrel — preserves backward compatibility while the actual logic
|
|
3
|
+
* lives in focused modules under browser/.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Types
|
|
7
|
+
export type {
|
|
8
|
+
CDPTab,
|
|
9
|
+
CDPResponse,
|
|
10
|
+
CDPEvalResult,
|
|
11
|
+
CDPScreenshotResult,
|
|
12
|
+
BoundingBox,
|
|
13
|
+
RefEntry,
|
|
14
|
+
SnapshotResult,
|
|
15
|
+
ActionSpec,
|
|
16
|
+
ActionResult,
|
|
17
|
+
AutoLaunchResult,
|
|
18
|
+
} from "../browser/types.js";
|
|
19
|
+
|
|
20
|
+
// Controller
|
|
21
|
+
export { BrowserController } from "../browser/controller.js";
|
|
22
|
+
|
|
23
|
+
// Chrome launcher & singleton
|
|
24
|
+
export {
|
|
25
|
+
findChromePath,
|
|
26
|
+
isChromeRunning,
|
|
27
|
+
ensureBrowserAvailable,
|
|
28
|
+
getBrowser,
|
|
29
|
+
} from "../browser/chrome-launcher.js";
|