assistme 0.3.1 → 0.3.3

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 +1889 -583
  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 -952
  8. package/src/agent/memory.ts +2 -11
  9. package/src/agent/processor.ts +25 -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 +557 -0
  15. package/src/browser/controller.ts +1448 -0
  16. package/src/browser/types.ts +76 -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 +241 -0
  42. package/src/tools/browser.ts +29 -1208
  43. package/src/tools/index.ts +31 -265
  44. package/src/tools/web.ts +0 -73
@@ -0,0 +1,557 @@
1
+ import { execSync, spawn, type ChildProcess } from "node:child_process";
2
+ import { platform, homedir } from "node:os";
3
+ import { existsSync, unlinkSync, mkdirSync, cpSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { log } from "../utils/logger.js";
6
+ import { BrowserController } from "./controller.js";
7
+ import type { AutoLaunchResult } from "./types.js";
8
+
9
+ // ── Chrome Discovery ────────────────────────────────────────────────
10
+
11
+ /**
12
+ * Find Chrome/Chromium binary path on the current platform.
13
+ */
14
+ export function findChromePath(): string | null {
15
+ const os = platform();
16
+
17
+ if (os === "darwin") {
18
+ const paths = [
19
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
20
+ "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
21
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
22
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
23
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
24
+ ];
25
+ return paths.find((p) => existsSync(p)) ?? null;
26
+ }
27
+
28
+ if (os === "linux") {
29
+ const names = [
30
+ "google-chrome",
31
+ "google-chrome-stable",
32
+ "chromium-browser",
33
+ "chromium",
34
+ "microsoft-edge",
35
+ "microsoft-edge-stable",
36
+ "brave-browser",
37
+ ];
38
+ for (const name of names) {
39
+ try {
40
+ return execSync(`which ${name}`, {
41
+ encoding: "utf-8",
42
+ stdio: ["pipe", "pipe", "pipe"],
43
+ }).trim();
44
+ } catch {
45
+ /* not found */
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+
51
+ if (os === "win32") {
52
+ const prefixes = [
53
+ process.env.PROGRAMFILES,
54
+ process.env["PROGRAMFILES(X86)"],
55
+ process.env.LOCALAPPDATA,
56
+ ].filter(Boolean) as string[];
57
+
58
+ const subPaths = [
59
+ "Google\\Chrome\\Application\\chrome.exe",
60
+ "Microsoft\\Edge\\Application\\msedge.exe",
61
+ "BraveSoftware\\Brave-Browser\\Application\\brave.exe",
62
+ ];
63
+
64
+ for (const prefix of prefixes) {
65
+ for (const sub of subPaths) {
66
+ const p = `${prefix}\\${sub}`;
67
+ if (existsSync(p)) return p;
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Check if a Chromium-based browser is currently running.
78
+ * Optionally pass the specific browser binary path for precise matching.
79
+ */
80
+ export function isChromeRunning(chromePath?: string): boolean {
81
+ try {
82
+ if (platform() === "win32") {
83
+ // Check for any Chromium-based browser process
84
+ const out = execSync(
85
+ 'tasklist /FI "IMAGENAME eq chrome.exe" /FI "IMAGENAME eq msedge.exe" /FI "IMAGENAME eq brave.exe" /NH',
86
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
87
+ );
88
+ return /chrome\.exe|msedge\.exe|brave\.exe/i.test(out);
89
+ }
90
+ if (platform() === "darwin") {
91
+ // If we know the exact binary, match it precisely
92
+ if (chromePath) {
93
+ const appDir = chromePath.replace(/\/Contents\/MacOS\/.*$/, "");
94
+ const out = execSync(`pgrep -f ${JSON.stringify(appDir)}`, {
95
+ encoding: "utf-8",
96
+ stdio: ["pipe", "pipe", "pipe"],
97
+ });
98
+ return out.trim().length > 0;
99
+ }
100
+ // Otherwise check all known Chromium browsers
101
+ const out = execSync(
102
+ 'pgrep -f "(Google Chrome|Microsoft Edge|Brave Browser|Chromium).app/Contents/MacOS/"',
103
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
104
+ );
105
+ return out.trim().length > 0;
106
+ }
107
+ // Linux
108
+ if (chromePath) {
109
+ const out = execSync(`pgrep -f ${JSON.stringify(chromePath)} 2>/dev/null || true`, {
110
+ encoding: "utf-8",
111
+ stdio: ["pipe", "pipe", "pipe"],
112
+ });
113
+ return out.trim().length > 0;
114
+ }
115
+ const out = execSync("pgrep -f '(chrome|chromium|msedge|brave)' 2>/dev/null || true", {
116
+ encoding: "utf-8",
117
+ stdio: ["pipe", "pipe", "pipe"],
118
+ });
119
+ return out.trim().length > 0;
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ // ── Profile Management ──────────────────────────────────────────────
126
+
127
+ /**
128
+ * Derive the macOS app name from a binary path inside a .app bundle.
129
+ */
130
+ function macAppName(chromePath: string): string {
131
+ if (chromePath.includes("Brave Browser")) return "Brave Browser";
132
+ if (chromePath.includes("Microsoft Edge")) return "Microsoft Edge";
133
+ if (chromePath.includes("Chromium")) return "Chromium";
134
+ if (chromePath.includes("Canary")) return "Google Chrome Canary";
135
+ return "Google Chrome";
136
+ }
137
+
138
+ /**
139
+ * Gracefully quit the browser, then force-kill if it doesn't exit in time.
140
+ */
141
+ async function killChromeGracefully(chromePath: string): Promise<void> {
142
+ const os = platform();
143
+ try {
144
+ if (os === "darwin") {
145
+ const app = macAppName(chromePath);
146
+ execSync(`osascript -e 'quit app "${app}"'`, {
147
+ timeout: 5000,
148
+ stdio: ["pipe", "pipe", "pipe"],
149
+ });
150
+ } else if (os === "linux") {
151
+ // Kill the specific browser binary, not all Chromium variants
152
+ execSync(`pkill -TERM -f ${JSON.stringify(chromePath)}`, {
153
+ timeout: 5000,
154
+ stdio: ["pipe", "pipe", "pipe"],
155
+ });
156
+ } else if (os === "win32") {
157
+ const exe = chromePath.split("\\").pop() || "chrome.exe";
158
+ execSync(`taskkill /IM "${exe}"`, {
159
+ timeout: 5000,
160
+ stdio: ["pipe", "pipe", "pipe"],
161
+ });
162
+ }
163
+ } catch {
164
+ /* may already be closed */
165
+ }
166
+
167
+ // Wait for browser to fully exit (up to 8s)
168
+ const start = Date.now();
169
+ while (Date.now() - start < 8000) {
170
+ if (!isChromeRunning(chromePath)) {
171
+ log.debug(`Browser exited after ${Date.now() - start}ms`);
172
+ return;
173
+ }
174
+ await new Promise((r) => setTimeout(r, 500));
175
+ }
176
+
177
+ log.debug("Browser still running after graceful quit, force-killing...");
178
+
179
+ // Force kill if still alive
180
+ try {
181
+ if (os === "win32") {
182
+ const exe = chromePath.split("\\").pop() || "chrome.exe";
183
+ execSync(`taskkill /F /IM "${exe}"`, {
184
+ stdio: ["pipe", "pipe", "pipe"],
185
+ });
186
+ } else {
187
+ execSync(`pkill -9 -f ${JSON.stringify(chromePath)}`, {
188
+ stdio: ["pipe", "pipe", "pipe"],
189
+ });
190
+ }
191
+ } catch {
192
+ /* already dead */
193
+ }
194
+
195
+ // Wait for processes to fully terminate after SIGKILL
196
+ await new Promise((r) => setTimeout(r, 1000));
197
+
198
+ // Remove SingletonLock files that may linger after a force-kill
199
+ if (os !== "win32") {
200
+ const home = process.env.HOME;
201
+ if (home) {
202
+ const lockSuffixes = ["SingletonLock", "SingletonSocket", "SingletonCookie"];
203
+ const profileDirs =
204
+ os === "darwin"
205
+ ? [
206
+ `${home}/Library/Application Support/Google/Chrome`,
207
+ `${home}/Library/Application Support/Microsoft Edge`,
208
+ `${home}/Library/Application Support/BraveSoftware/Brave-Browser`,
209
+ ]
210
+ : [
211
+ `${home}/.config/google-chrome`,
212
+ `${home}/.config/chromium`,
213
+ `${home}/.config/microsoft-edge`,
214
+ `${home}/.config/BraveSoftware/Brave-Browser`,
215
+ ];
216
+ for (const dir of profileDirs) {
217
+ for (const suffix of lockSuffixes) {
218
+ const lockPath = `${dir}/${suffix}`;
219
+ try {
220
+ if (existsSync(lockPath)) {
221
+ unlinkSync(lockPath);
222
+ log.debug(`Removed stale lock: ${lockPath}`);
223
+ }
224
+ } catch {
225
+ /* best effort */
226
+ }
227
+ }
228
+ }
229
+ }
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Return the browser's default profile directory.
235
+ */
236
+ function getDefaultProfileDir(chromePath: string): string {
237
+ const home = homedir();
238
+ const os = platform();
239
+
240
+ if (os === "darwin") {
241
+ if (chromePath.includes("Brave Browser"))
242
+ return join(home, "Library", "Application Support", "BraveSoftware", "Brave-Browser");
243
+ if (chromePath.includes("Microsoft Edge"))
244
+ return join(home, "Library", "Application Support", "Microsoft Edge");
245
+ if (chromePath.includes("Chromium"))
246
+ return join(home, "Library", "Application Support", "Chromium");
247
+ if (chromePath.includes("Canary"))
248
+ return join(home, "Library", "Application Support", "Google", "Chrome Canary");
249
+ return join(home, "Library", "Application Support", "Google", "Chrome");
250
+ }
251
+
252
+ if (os === "win32") {
253
+ const appData = process.env.LOCALAPPDATA || join(home, "AppData", "Local");
254
+ if (chromePath.includes("brave"))
255
+ return join(appData, "BraveSoftware", "Brave-Browser", "User Data");
256
+ if (chromePath.includes("msedge")) return join(appData, "Microsoft", "Edge", "User Data");
257
+ return join(appData, "Google", "Chrome", "User Data");
258
+ }
259
+
260
+ // Linux
261
+ if (chromePath.includes("brave")) return join(home, ".config", "BraveSoftware", "Brave-Browser");
262
+ if (chromePath.includes("microsoft-edge")) return join(home, ".config", "microsoft-edge");
263
+ if (chromePath.includes("chromium")) return join(home, ".config", "chromium");
264
+ return join(home, ".config", "google-chrome");
265
+ }
266
+
267
+ /**
268
+ * Return a dedicated debug profile directory for assistme.
269
+ *
270
+ * Chrome 136+ silently ignores --remote-debugging-port when launched with the
271
+ * DEFAULT user-data-dir (security hardening against cookie-stealing malware).
272
+ * It also ignores --user-data-dir pointing to the default path.
273
+ * The flag ONLY works with a NON-DEFAULT --user-data-dir.
274
+ *
275
+ * Strategy: use ~/.assistme/browser-profile as a dedicated debug profile.
276
+ * On first use, copy key files from the real profile (bookmarks, cookies,
277
+ * login data, preferences) so the user doesn't start completely fresh.
278
+ * Sessions accumulate in the debug profile from then on.
279
+ *
280
+ * See: https://developer.chrome.com/blog/remote-debugging-port
281
+ */
282
+ function getDebugProfileDir(chromePath: string): string {
283
+ const home = homedir();
284
+ const debugDir = join(home, ".assistme", "browser-profile");
285
+
286
+ if (!existsSync(debugDir)) {
287
+ mkdirSync(debugDir, { recursive: true });
288
+ log.debug(`Created debug profile directory: ${debugDir}`);
289
+
290
+ // Seed from the real profile — copy lightweight files, skip caches
291
+ const realDir = getDefaultProfileDir(chromePath);
292
+ if (existsSync(realDir)) {
293
+ seedDebugProfile(realDir, debugDir);
294
+ }
295
+ }
296
+
297
+ return debugDir;
298
+ }
299
+
300
+ /**
301
+ * Copy essential profile data from the user's real Chrome profile to the
302
+ * debug profile. This preserves bookmarks, preferences, and (where possible)
303
+ * login state without copying multi-GB caches.
304
+ *
305
+ * Note: cookies/login data are encrypted with a key tied to the user-data-dir
306
+ * on Chrome 136+, so they won't decrypt in the debug profile. The user will
307
+ * need to log in once in the debug browser. After that, sessions persist.
308
+ */
309
+ function seedDebugProfile(realDir: string, debugDir: string): void {
310
+ // Files to copy from the profile root
311
+ const rootFiles = ["Local State"];
312
+ // Files to copy from the "Default" sub-profile
313
+ const profileFiles = ["Bookmarks", "Preferences", "Favicons", "Top Sites", "Shortcuts"];
314
+
315
+ for (const file of rootFiles) {
316
+ const src = join(realDir, file);
317
+ const dest = join(debugDir, file);
318
+ try {
319
+ if (existsSync(src)) {
320
+ cpSync(src, dest, { force: true });
321
+ log.debug(`Seeded: ${file}`);
322
+ }
323
+ } catch {
324
+ /* best effort */
325
+ }
326
+ }
327
+
328
+ // Copy the Default profile sub-directory essentials
329
+ const srcProfile = join(realDir, "Default");
330
+ const destProfile = join(debugDir, "Default");
331
+ if (existsSync(srcProfile)) {
332
+ mkdirSync(destProfile, { recursive: true });
333
+ for (const file of profileFiles) {
334
+ const src = join(srcProfile, file);
335
+ const dest = join(destProfile, file);
336
+ try {
337
+ if (existsSync(src)) {
338
+ cpSync(src, dest, { force: true });
339
+ log.debug(`Seeded: Default/${file}`);
340
+ }
341
+ } catch {
342
+ /* best effort */
343
+ }
344
+ }
345
+
346
+ // Copy Extensions directory if it exists (preserves user's extensions)
347
+ const srcExt = join(srcProfile, "Extensions");
348
+ const destExt = join(destProfile, "Extensions");
349
+ try {
350
+ if (existsSync(srcExt)) {
351
+ cpSync(srcExt, destExt, { recursive: true, force: true });
352
+ log.debug("Seeded: Default/Extensions");
353
+ }
354
+ } catch {
355
+ /* best effort — extensions can be large */
356
+ }
357
+ }
358
+ }
359
+
360
+ // ── Chrome Spawning ─────────────────────────────────────────────────
361
+
362
+ /**
363
+ * Spawn a Chromium-based browser with CDP enabled.
364
+ * Returns the child process for exit-code monitoring.
365
+ *
366
+ * Key design decisions:
367
+ * - Launches the binary directly (not via macOS `open -a`) so flags are
368
+ * guaranteed to reach the process and the child stays alive.
369
+ * - Uses a dedicated debug profile (not the default profile) so that:
370
+ * (a) Chrome 136+ allows --remote-debugging-port
371
+ * (b) Can run alongside the user's regular Chrome (different singleton)
372
+ * - Callers should ensure no OTHER debug-profile Chrome is running, but
373
+ * the user's regular Chrome can stay open.
374
+ */
375
+ function spawnChrome(chromePath: string, port: number): ChildProcess {
376
+ const profileDir = getDebugProfileDir(chromePath);
377
+ const flags = [
378
+ `--remote-debugging-port=${port}`,
379
+ `--user-data-dir=${profileDir}`,
380
+ "--restore-last-session",
381
+ ];
382
+
383
+ log.debug(`Spawning browser: ${chromePath} ${flags.join(" ")}`);
384
+
385
+ const child = spawn(chromePath, flags, {
386
+ detached: true,
387
+ stdio: ["ignore", "pipe", "pipe"],
388
+ });
389
+
390
+ // Capture stderr for diagnostics — Chrome prints errors here
391
+ let stderr = "";
392
+ child.stderr?.on("data", (chunk: Buffer) => {
393
+ stderr += chunk.toString();
394
+ });
395
+
396
+ child.on("error", (err) => {
397
+ log.error(`Chrome spawn error: ${err.message}`);
398
+ });
399
+
400
+ child.on("exit", (code, signal) => {
401
+ if (code !== null && code !== 0) {
402
+ log.debug(`Chrome exited with code ${code}${signal ? ` (signal: ${signal})` : ""}`);
403
+ if (stderr) {
404
+ // Log first few lines of stderr for diagnostics
405
+ const lines = stderr.split("\n").filter(Boolean).slice(0, 5);
406
+ for (const line of lines) {
407
+ log.debug(` chrome stderr: ${line}`);
408
+ }
409
+ }
410
+ }
411
+ });
412
+
413
+ child.unref();
414
+ return child;
415
+ }
416
+
417
+ // ── CDP Readiness ───────────────────────────────────────────────────
418
+
419
+ /**
420
+ * Wait for CDP to become reachable.
421
+ */
422
+ async function waitForCDP(browser: BrowserController, timeoutMs = 30000): Promise<boolean> {
423
+ const start = Date.now();
424
+ let attempts = 0;
425
+ while (Date.now() - start < timeoutMs) {
426
+ attempts++;
427
+ if (await browser.isAvailable()) {
428
+ log.debug(`CDP became reachable after ${attempts} attempts (${Date.now() - start}ms)`);
429
+ return true;
430
+ }
431
+ await new Promise((r) => setTimeout(r, 500));
432
+ }
433
+ log.debug(`CDP not reachable after ${attempts} attempts (${timeoutMs}ms timeout)`);
434
+ return false;
435
+ }
436
+
437
+ /**
438
+ * Check if a port is already in use by another process (not Chrome CDP).
439
+ */
440
+ async function isPortInUse(port: number): Promise<boolean> {
441
+ try {
442
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`, {
443
+ signal: AbortSignal.timeout(1000),
444
+ });
445
+ // CDP /json/version returns a JSON object with "Browser" and "webSocketDebuggerUrl" keys.
446
+ // All Chromium-based browsers (Chrome, Edge, Brave) include these.
447
+ const body = await res.text();
448
+ return !body.includes("webSocketDebuggerUrl");
449
+ } catch {
450
+ // Connection refused → port is free
451
+ return false;
452
+ }
453
+ }
454
+
455
+ // ── Public API ──────────────────────────────────────────────────────
456
+
457
+ /**
458
+ * Ensure a Chromium browser is running with CDP enabled.
459
+ *
460
+ * Uses a SEPARATE debug profile (~/.assistme/browser-profile) so that:
461
+ * - The user's regular Chrome can stay open — no killing required
462
+ * - Chrome 136+ enables --remote-debugging-port (requires non-default dir)
463
+ * - The debug browser has its own singleton — no conflicts
464
+ *
465
+ * Flow:
466
+ * 1. CDP already reachable on the port → return immediately.
467
+ * 2. Port occupied by a non-Chromium process → report conflict.
468
+ * 3. Launch a new browser instance with the debug profile + CDP flag.
469
+ */
470
+ export async function ensureBrowserAvailable(port = 9222): Promise<AutoLaunchResult> {
471
+ const browser = getBrowser(port);
472
+
473
+ // Case 1: CDP already reachable
474
+ if (await browser.isAvailable()) {
475
+ log.debug("CDP already reachable — no launch needed");
476
+ return { success: true, action: "already_available" };
477
+ }
478
+
479
+ // Case 2: Port occupied by something else
480
+ if (await isPortInUse(port)) {
481
+ log.debug(`Port ${port} is in use by a non-Chrome process`);
482
+ return {
483
+ success: false,
484
+ action: "port_conflict",
485
+ detail: `Port ${port} is already in use by another process. Try a different port or stop the conflicting process.`,
486
+ };
487
+ }
488
+
489
+ // Find Chrome binary
490
+ const chromePath = findChromePath();
491
+ if (!chromePath) {
492
+ log.debug("Chrome binary not found on this system");
493
+ return { success: false, action: "chrome_not_found" };
494
+ }
495
+
496
+ log.debug(`Found Chrome at: ${chromePath}`);
497
+
498
+ // Launch a debug Chrome instance (separate profile — no need to kill the user's Chrome)
499
+ spawnChrome(chromePath, port);
500
+
501
+ if (await waitForCDP(browser)) {
502
+ return { success: true, action: "launched", chromePath };
503
+ }
504
+
505
+ // CDP didn't come up — check if the debug profile is locked by a previous
506
+ // crashed assistme session (stale SingletonLock)
507
+ const debugDir = getDebugProfileDir(chromePath);
508
+ const lockPath = join(debugDir, "SingletonLock");
509
+ if (existsSync(lockPath)) {
510
+ log.debug("Found stale SingletonLock in debug profile — removing and retrying");
511
+ try {
512
+ unlinkSync(lockPath);
513
+ // Also clean SingletonSocket/Cookie
514
+ for (const f of ["SingletonSocket", "SingletonCookie"]) {
515
+ try {
516
+ unlinkSync(join(debugDir, f));
517
+ } catch {
518
+ /* ok */
519
+ }
520
+ }
521
+ } catch {
522
+ /* best effort */
523
+ }
524
+
525
+ // Retry spawn
526
+ spawnChrome(chromePath, port);
527
+ if (await waitForCDP(browser, 15000)) {
528
+ return { success: true, action: "launched", chromePath };
529
+ }
530
+ }
531
+
532
+ return {
533
+ success: false,
534
+ action: "launch_failed",
535
+ chromePath,
536
+ detail:
537
+ "Could not start browser with remote debugging. Possible causes:\n" +
538
+ " 1) Another assistme debug browser is already using port " +
539
+ port +
540
+ "\n" +
541
+ " 2) The browser crashed on startup\n" +
542
+ "Try: rm -rf ~/.assistme/browser-profile && assistme",
543
+ };
544
+ }
545
+
546
+ // ── Singleton ───────────────────────────────────────────────────────
547
+
548
+ const browserInstances = new Map<number, BrowserController>();
549
+
550
+ export function getBrowser(port = 9222): BrowserController {
551
+ let instance = browserInstances.get(port);
552
+ if (!instance) {
553
+ instance = new BrowserController(port);
554
+ browserInstances.set(port, instance);
555
+ }
556
+ return instance;
557
+ }