@ulpi/browse 0.7.5 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulpi/browse",
3
- "version": "0.7.5",
3
+ "version": "0.10.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/ulpi-io/browse"
package/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: browse
3
- version: 2.5.1
3
+ version: 2.7.0
4
4
  description: |
5
5
  Fast web browsing for AI coding agents via persistent headless Chromium daemon. Navigate to any URL,
6
6
  read page content, click elements, fill forms, run JavaScript, take screenshots,
@@ -75,13 +75,21 @@ If the file is missing or does not contain browse permission rules in `permissio
75
75
  "Bash(browse frame:*)",
76
76
  "Bash(browse sessions:*)", "Bash(browse session-close:*)",
77
77
  "Bash(browse state:*)", "Bash(browse auth:*)", "Bash(browse har:*)", "Bash(browse video:*)",
78
+ "Bash(browse record:*)",
78
79
  "Bash(browse route:*)", "Bash(browse offline:*)",
79
80
  "Bash(browse status:*)", "Bash(browse stop:*)", "Bash(browse restart:*)",
80
81
  "Bash(browse cookie:*)", "Bash(browse header:*)",
81
82
  "Bash(browse useragent:*)",
82
83
  "Bash(browse clipboard:*)", "Bash(browse screenshot-diff:*)",
83
84
  "Bash(browse find:*)", "Bash(browse inspect:*)",
84
- "Bash(browse instances:*)", "Bash(browse --headed:*)"
85
+ "Bash(browse instances:*)", "Bash(browse --headed:*)",
86
+ "Bash(browse rightclick:*)", "Bash(browse tap:*)",
87
+ "Bash(browse swipe:*)", "Bash(browse mouse:*)",
88
+ "Bash(browse keyboard:*)", "Bash(browse scrollinto:*)",
89
+ "Bash(browse scrollintoview:*)", "Bash(browse set:*)",
90
+ "Bash(browse box:*)", "Bash(browse errors:*)",
91
+ "Bash(browse doctor:*)", "Bash(browse upgrade:*)",
92
+ "Bash(browse --max-output:*)"
85
93
  ```
86
94
 
87
95
  ## IMPORTANT
@@ -189,6 +197,28 @@ browse --allowed-domains example.com,*.cdn.example.com goto https://example.com
189
197
  # State persistence
190
198
  browse state save mysite
191
199
  browse state load mysite
200
+ browse state clean # delete states older than 7 days
201
+ browse state clean --older-than 30 # custom threshold
202
+
203
+ # Cookie management
204
+ browse cookie clear # clear all cookies
205
+ browse cookie set auth token --domain .example.com # set with options
206
+ browse cookie export ./cookies.json # export to file
207
+ browse cookie import ./cookies.json # import from file
208
+
209
+ # Cookie import from real browsers (macOS — Chrome, Arc, Brave, Edge)
210
+ browse cookie-import --list # show installed browsers
211
+ browse cookie-import chrome --domain .example.com # import cookies for a domain
212
+ browse cookie-import arc --domain .github.com # import from Arc
213
+ browse cookie-import chrome --profile "Profile 1" --domain .site.com # specific Chrome profile
214
+
215
+ # Session auto-persistence (named sessions survive restarts)
216
+ browse --session myapp goto https://app.com/login # login...
217
+ browse session-close myapp # state auto-saved (encrypted if BROWSE_ENCRYPTION_KEY set)
218
+ browse --session myapp goto https://app.com/dashboard # cookies auto-restored
219
+
220
+ # Load state at launch
221
+ browse --state auth.json goto https://app.com # load cookies before first command
192
222
 
193
223
  # Auth vault (credentials never visible to LLM)
194
224
  browse auth save github https://github.com/login user pass123
@@ -199,11 +229,31 @@ browse har start
199
229
  browse goto https://example.com
200
230
  browse har stop ./recording.har
201
231
 
202
- # Video recording
232
+ # Video recording (watch a .webm of the session)
233
+ browse video start ./videos
234
+ browse goto https://example.com
235
+ browse click @e3
236
+ browse video stop
237
+
238
+ # Command recording (export replayable scripts)
239
+ browse record start
240
+ browse goto https://example.com
241
+ browse click "a"
242
+ browse fill "[id=search]" "test query"
243
+ browse record stop
244
+ browse record export replay ./recording.json # replay with: npx @puppeteer/replay ./recording.json
245
+ browse record export browse ./steps.json # replay with: cat steps.json | browse chain
246
+
247
+ # Both together (video + replayable script)
203
248
  browse video start ./videos
249
+ browse record start
204
250
  browse goto https://example.com
251
+ browse snapshot -i
205
252
  browse click @e3
253
+ browse fill "[id=email]" "user@test.com"
254
+ browse record stop
206
255
  browse video stop
256
+ browse record export replay ./recording.json
207
257
 
208
258
  # Device emulation
209
259
  browse emulate iphone
@@ -286,11 +336,13 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
286
336
  ```
287
337
  browse click <selector> Click element (CSS selector or @ref)
288
338
  browse click <x>,<y> Click at page coordinates (e.g. 590,461)
339
+ browse rightclick <selector> Right-click element (context menu)
289
340
  browse dblclick <selector> Double-click element
290
341
  browse fill <selector> <value> Fill input field
291
342
  browse select <selector> <val> Select dropdown value
292
343
  browse hover <selector> Hover over element
293
344
  browse focus <selector> Focus element
345
+ browse tap <selector> Tap element (requires touch context via emulate)
294
346
  browse check <selector> Check checkbox
295
347
  browse uncheck <selector> Uncheck checkbox
296
348
  browse drag <src> <tgt> Drag source to target
@@ -298,8 +350,24 @@ browse type <text> Type into focused element
298
350
  browse press <key> Press key (Enter, Tab, Escape, etc.)
299
351
  browse keydown <key> Hold key down
300
352
  browse keyup <key> Release key
353
+ browse keyboard inserttext <t> Insert text without key events
301
354
  browse scroll [sel|up|down] Scroll element/viewport/bottom
302
- browse wait <sel|--url|--network-idle> Wait for element, URL, or network
355
+ browse scrollinto <sel> Scroll element into view (explicit)
356
+ browse swipe <dir> [px] Swipe up/down/left/right (touch events)
357
+ browse mouse move <x> <y> Move mouse to coordinates
358
+ browse mouse down [button] Press mouse button (left/right/middle)
359
+ browse mouse up [button] Release mouse button
360
+ browse mouse wheel <dy> [dx] Scroll wheel
361
+ browse wait <sel> Wait for element to appear
362
+ browse wait <sel> --state hidden Wait for element to disappear
363
+ browse wait <ms> Wait for milliseconds
364
+ browse wait --text "..." Wait for text to appear in page
365
+ browse wait --fn "expr" Wait for JavaScript condition
366
+ browse wait --load <state> Wait for load state
367
+ browse wait --url <pattern> Wait for URL match
368
+ browse wait --network-idle Wait for network idle
369
+ browse set geo <lat> <lng> Set geolocation
370
+ browse set media <scheme> Set color scheme (dark/light/no-preference)
303
371
  browse viewport <WxH> Set viewport size (e.g. 375x812)
304
372
  browse upload <sel> <files> Upload file(s) to a file input
305
373
  browse highlight <selector> Highlight element (visual debugging)
@@ -327,8 +395,10 @@ browse attrs <selector> Get element attributes as JSON
327
395
  browse element-state <selector> Element state (visible/enabled/checked/focused)
328
396
  browse value <selector> Get input field value
329
397
  browse count <selector> Count matching elements
398
+ browse box <selector> Get bounding box as JSON {x, y, width, height}
330
399
  browse dialog Last dialog info or "(no dialog detected)"
331
400
  browse console [--clear] View/clear console messages
401
+ browse errors [--clear] View/clear page errors (filtered from console)
332
402
  browse network [--clear] View/clear network requests
333
403
  browse cookies Dump all cookies as JSON
334
404
  browse storage [set <k> <v>] View/set localStorage
@@ -342,6 +412,8 @@ browse clipboard write <text> Write text to system clipboard
342
412
  ```
343
413
  browse screenshot [path] Viewport screenshot (default: .browse/sessions/{id}/screenshot.png)
344
414
  browse screenshot --full [path] Full-page screenshot (entire scrollable page)
415
+ browse screenshot <sel|@ref> [path] Screenshot specific element
416
+ browse screenshot --clip x,y,w,h [path] Screenshot clipped region
345
417
  browse screenshot --annotate [path] Screenshot with numbered badges + legend
346
418
  browse pdf [path] Save as PDF
347
419
  browse responsive [prefix] Screenshots at mobile/tablet/desktop
@@ -360,6 +432,11 @@ browse find text <query> Find elements by text content
360
432
  browse find label <query> Find elements by label
361
433
  browse find placeholder <query> Find elements by placeholder
362
434
  browse find testid <query> Find elements by test ID
435
+ browse find alt <query> Find elements by alt text
436
+ browse find title <query> Find elements by title attribute
437
+ browse find first <sel> First matching element
438
+ browse find last <sel> Last matching element
439
+ browse find nth <n> <sel> Nth matching element (0-indexed)
363
440
  ```
364
441
 
365
442
  ### Compare
@@ -394,6 +471,15 @@ browse state save [name] Save cookies + localStorage (all origins)
394
471
  browse state load [name] Restore saved state
395
472
  browse state list List saved states
396
473
  browse state show [name] Show contents of saved state
474
+ browse state clean Delete states older than 7 days
475
+ browse state clean --older-than N Custom age threshold (days)
476
+ ```
477
+
478
+ ### Cookie import (macOS — borrow auth from real browsers)
479
+ ```
480
+ browse cookie-import --list List installed browsers
481
+ browse cookie-import <browser> --domain <d> Import cookies for a domain
482
+ browse cookie-import <browser> --profile <p> --domain <d> Specific Chrome profile
397
483
  ```
398
484
 
399
485
  ### Auth vault
@@ -417,11 +503,22 @@ browse video stop Stop recording and save video files
417
503
  browse video status Check if recording is active
418
504
  ```
419
505
 
506
+ ### Command recording & export
507
+ ```
508
+ browse record start Start recording commands
509
+ browse record stop Stop recording, keep steps for export
510
+ browse record status Recording state and step count
511
+ browse record export browse [path] Export as chain-compatible JSON (replay with browse chain)
512
+ browse record export replay [path] Export as Chrome DevTools Recorder (Playwright/Puppeteer)
513
+ ```
514
+
420
515
  ### Server management
421
516
  ```
422
517
  browse status Server health, uptime, session count
423
518
  browse instances List all running browse servers (instance, PID, port, status)
424
519
  browse version Print CLI version
520
+ browse doctor System check (Bun, Playwright, Chromium)
521
+ browse upgrade Self-update via npm
425
522
  browse stop Shutdown server
426
523
  browse restart Kill + restart server
427
524
  browse inspect Open DevTools (requires BROWSE_DEBUG_PORT)
@@ -431,10 +528,12 @@ browse inspect Open DevTools (requires BROWSE_DEBUG_PORT)
431
528
 
432
529
  | Flag | Description |
433
530
  |------|-------------|
434
- | `--session <id>` | Named session (isolates tabs, refs, cookies) |
531
+ | `--session <id>` | Named session (isolates tabs, refs, cookies — auto-persists on close) |
532
+ | `--state <path>` | Load state file (cookies/storage) before first command |
435
533
  | `--json` | Wrap output as `{success, data, command}` |
436
534
  | `--content-boundaries` | Wrap page content in nonce-delimited markers (prompt injection defense) |
437
535
  | `--allowed-domains <d,d>` | Block navigation/resources outside allowlist |
536
+ | `--max-output <n>` | Truncate output to N characters |
438
537
  | `--headed` | Run browser in headed (visible) mode |
439
538
  | `--runtime <name>` | Browser engine: playwright (default), rebrowser (stealth) |
440
539
 
@@ -480,6 +579,7 @@ browse inspect Open DevTools (requires BROWSE_DEBUG_PORT)
480
579
  | Auto-login | `auth save gh https://github.com/login user pass` → `auth login gh` |
481
580
  | Record network | `har start` → browse around → `har stop ./out.har` |
482
581
  | Record video | `video start ./vids` → browse around → `video stop` |
582
+ | Export automation script | `record start` → browse around → `record export replay ./recording.json` |
483
583
  | Parallel agents | `--session agent-a <cmd>` / `--session agent-b <cmd>` |
484
584
  | Multi-step flow | `echo '[...]' \| browse chain` |
485
585
  | Secure browsing | `--allowed-domains example.com goto https://example.com` |
@@ -489,6 +589,14 @@ browse inspect Open DevTools (requires BROWSE_DEBUG_PORT)
489
589
  | Find by accessibility | `find role button` / `find text "Submit"` |
490
590
  | Visual regression | `screenshot-diff baseline.png` |
491
591
  | Debug with DevTools | `inspect` (set BROWSE_DEBUG_PORT first) |
592
+ | Get element position | `box @e3` |
593
+ | Check page errors | `errors` |
594
+ | Right-click context menu | `rightclick @e3` |
595
+ | Test mobile gestures | `emulate iphone` → `tap @e1` / `swipe down` |
596
+ | Set dark mode | `set media dark` |
597
+ | Test geolocation | `set geo 37.7 -122.4` → verify in page |
598
+ | Export/import cookies | `cookie export ./cookies.json` / `cookie import ./cookies.json` |
599
+ | Limit output size | `--max-output 5000 text` |
492
600
  | See the browser | `browse --headed goto <url>` |
493
601
  | Bypass bot detection | `--runtime rebrowser goto <url>` |
494
602
 
package/src/auth-vault.ts CHANGED
@@ -8,11 +8,11 @@
8
8
  * Password never returned in list/get — only hasPassword: true
9
9
  */
10
10
 
11
- import * as crypto from 'crypto';
12
11
  import * as fs from 'fs';
13
12
  import * as path from 'path';
14
13
  import type { BrowserManager } from './browser-manager';
15
14
  import { DEFAULTS } from './constants';
15
+ import { resolveEncryptionKey, encrypt, decrypt } from './encryption';
16
16
  import { sanitizeName } from './sanitize';
17
17
 
18
18
  interface StoredCredential {
@@ -44,55 +44,7 @@ export class AuthVault {
44
44
 
45
45
  constructor(localDir: string) {
46
46
  this.authDir = path.join(localDir, 'auth');
47
- this.encryptionKey = this.resolveKey(localDir);
48
- }
49
-
50
- private resolveKey(localDir: string): Buffer {
51
- // 1. Env var (64-char hex = 32 bytes)
52
- const envKey = process.env.BROWSE_ENCRYPTION_KEY;
53
- if (envKey) {
54
- if (envKey.length !== 64) {
55
- throw new Error('BROWSE_ENCRYPTION_KEY must be 64 hex characters (32 bytes)');
56
- }
57
- return Buffer.from(envKey, 'hex');
58
- }
59
-
60
- // 2. Key file
61
- const keyPath = path.join(localDir, '.encryption-key');
62
- if (fs.existsSync(keyPath)) {
63
- const hex = fs.readFileSync(keyPath, 'utf-8').trim();
64
- return Buffer.from(hex, 'hex');
65
- }
66
-
67
- // 3. Auto-generate
68
- const key = crypto.randomBytes(32);
69
- fs.writeFileSync(keyPath, key.toString('hex') + '\n', { mode: 0o600 });
70
- return key;
71
- }
72
-
73
- private encrypt(plaintext: string): { ciphertext: string; iv: string; authTag: string } {
74
- const iv = crypto.randomBytes(12);
75
- const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv);
76
- const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
77
- return {
78
- ciphertext: encrypted.toString('base64'),
79
- iv: iv.toString('base64'),
80
- authTag: cipher.getAuthTag().toString('base64'),
81
- };
82
- }
83
-
84
- private decrypt(ciphertext: string, iv: string, authTag: string): string {
85
- const decipher = crypto.createDecipheriv(
86
- 'aes-256-gcm',
87
- this.encryptionKey,
88
- Buffer.from(iv, 'base64'),
89
- );
90
- decipher.setAuthTag(Buffer.from(authTag, 'base64'));
91
- const decrypted = Buffer.concat([
92
- decipher.update(Buffer.from(ciphertext, 'base64')),
93
- decipher.final(),
94
- ]);
95
- return decrypted.toString('utf-8');
47
+ this.encryptionKey = resolveEncryptionKey(localDir);
96
48
  }
97
49
 
98
50
  save(
@@ -104,7 +56,7 @@ export class AuthVault {
104
56
  ): void {
105
57
  fs.mkdirSync(this.authDir, { recursive: true });
106
58
 
107
- const { ciphertext, iv, authTag } = this.encrypt(password);
59
+ const { ciphertext, iv, authTag } = encrypt(password, this.encryptionKey);
108
60
  const now = new Date().toISOString();
109
61
 
110
62
  const credential: StoredCredential = {
@@ -136,7 +88,7 @@ export class AuthVault {
136
88
 
137
89
  async login(name: string, bm: BrowserManager): Promise<string> {
138
90
  const cred = this.load(name);
139
- const password = this.decrypt(cred.data, cred.iv, cred.authTag);
91
+ const password = decrypt(cred.data, cred.iv, cred.authTag, this.encryptionKey);
140
92
  const page = bm.getPage();
141
93
 
142
94
  // Navigate to login URL
@@ -7,8 +7,7 @@
7
7
  * We do NOT try to self-heal — don't hide failure.
8
8
  */
9
9
 
10
- import { devices as playwrightDevices, type Browser, type BrowserContext, type Page, type Locator, type Frame, type FrameLocator, type Request as PlaywrightRequest } from 'playwright';
11
- import { getRuntime } from './runtime';
10
+ import { chromium, devices as playwrightDevices, type Browser, type BrowserContext, type Page, type Locator, type Frame, type FrameLocator, type Request as PlaywrightRequest } from 'playwright';
12
11
  import { SessionBuffers, type LogEntry, type NetworkEntry } from './buffers';
13
12
  import type { HarRecording } from './har';
14
13
  import type { DomainFilter } from './domain-filter';
@@ -203,9 +202,8 @@ export class BrowserManager {
203
202
  * Launch a new Chromium browser (single-session / multi-process mode).
204
203
  * This instance owns the browser and will close it on close().
205
204
  */
206
- async launch(onCrash?: () => void, runtimeName?: string) {
207
- const runtime = await getRuntime(runtimeName);
208
- this.browser = await runtime.chromium.launch({ headless: true });
205
+ async launch(onCrash?: () => void) {
206
+ this.browser = await chromium.launch({ headless: true });
209
207
  this.ownsBrowser = true;
210
208
 
211
209
  // Chromium crash → notify caller (server uses this to exit; tests ignore it)
@@ -485,6 +483,23 @@ export class BrowserManager {
485
483
  return { selector };
486
484
  }
487
485
 
486
+ /**
487
+ * Resolve a ref with staleness detection. Throws immediately if the ref's
488
+ * element no longer exists in the DOM, instead of waiting for action timeout.
489
+ */
490
+ async resolveRefChecked(selector: string): Promise<{ locator: Locator } | { selector: string }> {
491
+ const resolved = this.resolveRef(selector);
492
+ if ('locator' in resolved) {
493
+ const count = await resolved.locator.count();
494
+ if (count === 0) {
495
+ throw new Error(
496
+ `Ref ${selector} is stale (element no longer exists). Re-run 'snapshot' to get fresh refs.`
497
+ );
498
+ }
499
+ }
500
+ return resolved;
501
+ }
502
+
488
503
  getRefCount(): number {
489
504
  return this.refMap.size;
490
505
  }
package/src/bun.d.ts CHANGED
@@ -16,21 +16,9 @@ declare module 'bun' {
16
16
  stdio?: Array<'ignore' | 'pipe' | 'inherit'>;
17
17
  stdout?: 'pipe' | 'ignore' | 'inherit';
18
18
  stderr?: 'pipe' | 'ignore' | 'inherit';
19
- stdin?: 'pipe' | 'ignore' | 'inherit';
20
19
  env?: Record<string, string | undefined>;
21
20
  }): BunSubprocess;
22
21
 
23
- export function listen(options: {
24
- hostname: string;
25
- port: number;
26
- socket: {
27
- data: (...args: any[]) => void;
28
- open?: (...args: any[]) => void;
29
- close?: (...args: any[]) => void;
30
- error?: (...args: any[]) => void;
31
- };
32
- }): BunTCPServer;
33
-
34
22
  export function sleep(ms: number): Promise<void>;
35
23
 
36
24
  export const stdin: { text(): Promise<string> };
@@ -40,26 +28,33 @@ declare module 'bun' {
40
28
  stop(): void;
41
29
  }
42
30
 
43
- interface BunTCPServer {
44
- port: number;
45
- stop(): void;
46
- }
47
-
48
31
  interface BunSubprocess {
49
32
  pid: number;
50
- exitCode: number | null;
51
33
  stderr: ReadableStream<Uint8Array> | null;
52
34
  stdout: ReadableStream<Uint8Array> | null;
53
35
  exited: Promise<number>;
54
- kill(signal?: number): void;
55
36
  unref(): void;
37
+ kill(signal?: number): void;
38
+ }
39
+ }
40
+
41
+ declare module 'bun:sqlite' {
42
+ export class Database {
43
+ constructor(filename: string, options?: { readonly?: boolean; create?: boolean });
44
+ query(sql: string): Statement;
45
+ close(): void;
46
+ }
47
+
48
+ interface Statement {
49
+ all(...params: any[]): any[];
50
+ get(...params: any[]): any;
51
+ run(...params: any[]): void;
56
52
  }
57
53
  }
58
54
 
59
55
  declare var Bun: {
60
56
  serve: typeof import('bun').serve;
61
57
  spawn: typeof import('bun').spawn;
62
- listen: typeof import('bun').listen;
63
58
  sleep: typeof import('bun').sleep;
64
59
  stdin: typeof import('bun').stdin;
65
60
  };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Discover a running Chrome instance for CDP connection.
3
+ *
4
+ * Strategy (first match wins):
5
+ * 1. Read DevToolsActivePort file from known browser profile paths
6
+ * 2. Probe well-known CDP ports (9222, 9229)
7
+ * 3. Return null if nothing found
8
+ */
9
+
10
+ import * as os from 'os';
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+
14
+ const PROFILE_PATHS = [
15
+ 'Google/Chrome',
16
+ 'Arc/User Data',
17
+ 'BraveSoftware/Brave-Browser',
18
+ 'Microsoft Edge',
19
+ ];
20
+
21
+ const PROBE_PORTS = [9222, 9229];
22
+
23
+ /** Fetch the CDP WebSocket URL from a Chrome /json/version endpoint. */
24
+ async function fetchWsUrl(port: number): Promise<string | null> {
25
+ try {
26
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`, {
27
+ signal: AbortSignal.timeout(2000),
28
+ });
29
+ if (!res.ok) return null;
30
+ const data = (await res.json()) as { webSocketDebuggerUrl?: string };
31
+ return data.webSocketDebuggerUrl ?? null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /** Read a DevToolsActivePort file and extract the port number. */
38
+ function readDevToolsPort(filePath: string): number | null {
39
+ try {
40
+ const content = fs.readFileSync(filePath, 'utf-8');
41
+ const port = parseInt(content.split('\n')[0], 10);
42
+ return Number.isFinite(port) && port > 0 ? port : null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Discover a running Chrome instance and return its CDP WebSocket URL.
50
+ * Returns null if no reachable Chrome is found.
51
+ */
52
+ export async function discoverChrome(): Promise<string | null> {
53
+ const home = os.homedir();
54
+
55
+ // 1. Try DevToolsActivePort files
56
+ for (const profile of PROFILE_PATHS) {
57
+ const filePath = path.join(home, 'Library', 'Application Support', profile, 'DevToolsActivePort');
58
+ const port = readDevToolsPort(filePath);
59
+ if (port) {
60
+ const wsUrl = await fetchWsUrl(port);
61
+ if (wsUrl) return wsUrl;
62
+ }
63
+ }
64
+
65
+ // 2. Probe well-known ports
66
+ for (const port of PROBE_PORTS) {
67
+ const wsUrl = await fetchWsUrl(port);
68
+ if (wsUrl) return wsUrl;
69
+ }
70
+
71
+ // 3. Nothing found
72
+ return null;
73
+ }