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.
Files changed (44) hide show
  1. package/PLAN.md +14 -3
  2. package/dist/{chunk-UWE5WVQI.js → chunk-KX7ITO55.js} +20 -11
  3. package/dist/index.js +1791 -572
  4. package/dist/{job-runner-N4XAAWLJ.js → job-runner-P2L6MOOX.js} +1 -1
  5. package/package.json +5 -3
  6. package/src/agent/job-runner.ts +9 -13
  7. package/src/agent/mcp-servers.ts +6 -1020
  8. package/src/agent/memory.ts +2 -11
  9. package/src/agent/processor.ts +18 -108
  10. package/src/agent/scheduler.ts +2 -3
  11. package/src/agent/session.ts +20 -36
  12. package/src/agent/skills.ts +167 -61
  13. package/src/agent/system-prompt.ts +126 -0
  14. package/src/browser/chrome-launcher.ts +555 -0
  15. package/src/browser/controller.ts +1386 -0
  16. package/src/browser/types.ts +70 -0
  17. package/src/commands/credential.ts +190 -0
  18. package/src/commands/job.ts +14 -45
  19. package/src/commands/memory.ts +16 -29
  20. package/src/commands/schedule.ts +15 -37
  21. package/src/commands/start.ts +11 -43
  22. package/src/credentials/credential-store.test.ts +162 -0
  23. package/src/credentials/credential-store.ts +266 -0
  24. package/src/credentials/encryption.test.ts +98 -0
  25. package/src/credentials/encryption.ts +82 -0
  26. package/src/credentials/index.ts +15 -0
  27. package/src/credentials/local-store.ts +89 -0
  28. package/src/db/action.ts +19 -0
  29. package/src/db/api-client.ts +3 -32
  30. package/src/db/auth-store.ts +41 -0
  31. package/src/db/auth.ts +38 -0
  32. package/src/db/conversation.ts +39 -0
  33. package/src/db/event.ts +52 -0
  34. package/src/db/job-poll.ts +18 -0
  35. package/src/db/session.ts +60 -0
  36. package/src/db/supabase.ts +40 -383
  37. package/src/db/task.ts +69 -0
  38. package/src/db/types.ts +54 -0
  39. package/src/index.ts +2 -0
  40. package/src/mcp/agent-tools-server.ts +1047 -0
  41. package/src/mcp/browser-server.ts +258 -0
  42. package/src/tools/browser.ts +28 -1208
  43. package/src/tools/index.ts +32 -263
  44. package/src/tools/web.ts +0 -73
@@ -1,1209 +1,29 @@
1
1
  /**
2
- * CDP Browser Controller
3
- *
4
- * Connects to the user's REAL Chrome browser via Chrome DevTools Protocol.
5
- * This means the AI shares the user's cookies, sessions, and login state.
6
- *
7
- * Auto-launch: If Chrome isn't running with CDP enabled, the CLI will
8
- * automatically find Chrome, (re)launch it with --remote-debugging-port,
9
- * and connect — no manual setup required.
10
- *
11
- * Key difference from Playwright/Puppeteer:
12
- * - NOT a headless browser in a sandbox
13
- * - The user's ACTUAL browser with all their accounts logged in
14
- * - AI and user see the same tabs, same state
15
- */
16
-
17
- import { WebSocket } from "ws";
18
- import { execSync, spawn, type ChildProcess } from "node:child_process";
19
- import { platform, homedir } from "node:os";
20
- import { existsSync, unlinkSync, mkdirSync, cpSync, readdirSync } from "node:fs";
21
- import { join } from "node:path";
22
- import { log } from "../utils/logger.js";
23
-
24
- interface CDPTab {
25
- id: string;
26
- title: string;
27
- url: string;
28
- type: string;
29
- webSocketDebuggerUrl?: string;
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";