@ulpi/browse 0.1.0 → 0.2.1

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.
@@ -7,8 +7,10 @@
7
7
  * We do NOT try to self-heal — don't hide failure.
8
8
  */
9
9
 
10
- import { chromium, devices as playwrightDevices, type Browser, type BrowserContext, type Page, type Locator, type Request as PlaywrightRequest } from 'playwright';
10
+ import { chromium, devices as playwrightDevices, type Browser, type BrowserContext, type Page, type Locator, type Frame, type FrameLocator, type Request as PlaywrightRequest } from 'playwright';
11
11
  import { SessionBuffers, type LogEntry, type NetworkEntry } from './buffers';
12
+ import type { HarRecording } from './har';
13
+ import type { DomainFilter } from './domain-filter';
12
14
 
13
15
  /** Shorthand aliases for common devices → Playwright device names */
14
16
  const DEVICE_ALIASES: Record<string, string> = {
@@ -136,6 +138,9 @@ export class BrowserManager {
136
138
  private customUserAgent: string | null = null;
137
139
  private currentDevice: DeviceDescriptor | null = null;
138
140
 
141
+ // ─── iframe targeting ─────────────────────────────────────
142
+ private activeFrameSelector: string | null = null;
143
+
139
144
  // ─── Per-session buffers ──────────────────────────────────
140
145
  private buffers: SessionBuffers;
141
146
 
@@ -156,6 +161,21 @@ export class BrowserManager {
156
161
  // ─── Network Correlation ────────────────────────────────────
157
162
  private requestEntryMap = new WeakMap<PlaywrightRequest, NetworkEntry>();
158
163
 
164
+ // ─── Offline Mode ─────────────────────────────────────────
165
+ private offline = false;
166
+
167
+ // ─── HAR Recording ────────────────────────────────────────
168
+ private harRecording: HarRecording | null = null;
169
+
170
+ // ─── Init Script (domain filter JS injection) ─────────────
171
+ private initScript: string | null = null;
172
+
173
+ // ─── User Routes (survive context recreation) ─────────────
174
+ private userRoutes: Array<{pattern: string; action: 'block' | 'fulfill'; status?: number; body?: string}> = [];
175
+
176
+ // ─── Domain Filter (survive context recreation) ───────────
177
+ private domainFilter: DomainFilter | null = null;
178
+
159
179
  // Whether this instance owns (and should close) the Browser process
160
180
  private ownsBrowser = false;
161
181
 
@@ -167,6 +187,10 @@ export class BrowserManager {
167
187
  return this.buffers;
168
188
  }
169
189
 
190
+ getContext(): BrowserContext | null {
191
+ return this.context;
192
+ }
193
+
170
194
  /**
171
195
  * Launch a new Chromium browser (single-session / multi-process mode).
172
196
  * This instance owns the browser and will close it on close().
@@ -341,6 +365,67 @@ export class BrowserManager {
341
365
  }
342
366
  }
343
367
 
368
+ // ─── iframe Targeting ──────────────────────────────────────
369
+ /**
370
+ * Set the active frame by CSS selector (e.g., '#my-iframe', 'iframe[name="content"]').
371
+ * Subsequent commands that use resolveRef, getLocatorRoot, or getFrameContext
372
+ * will target this frame's content instead of the main page.
373
+ */
374
+ setFrame(selector: string) {
375
+ this.activeFrameSelector = selector;
376
+ }
377
+
378
+ /**
379
+ * Reset to main frame — clears the active frame selector.
380
+ */
381
+ resetFrame() {
382
+ this.activeFrameSelector = null;
383
+ }
384
+
385
+ /**
386
+ * Get the current active frame selector, or null if targeting main page.
387
+ */
388
+ getActiveFrameSelector(): string | null {
389
+ return this.activeFrameSelector;
390
+ }
391
+
392
+ /**
393
+ * Get a FrameLocator for the active frame.
394
+ * Returns null if no frame is active (targeting main page).
395
+ */
396
+ getFrameLocator(): FrameLocator | null {
397
+ if (!this.activeFrameSelector) return null;
398
+ return this.getPage().frameLocator(this.activeFrameSelector);
399
+ }
400
+
401
+ /**
402
+ * Get the Frame object for the active frame (needed for evaluate() calls).
403
+ * Returns null if no frame is active.
404
+ * Unlike FrameLocator, Frame supports evaluate(), querySelector, etc.
405
+ */
406
+ async getFrameContext(): Promise<Frame | null> {
407
+ if (!this.activeFrameSelector) return null;
408
+ const page = this.getPage();
409
+ const frameEl = page.locator(this.activeFrameSelector);
410
+ const handle = await frameEl.elementHandle({ timeout: 5000 });
411
+ if (!handle) throw new Error(`Frame element not found: ${this.activeFrameSelector}`);
412
+ const frame = await handle.contentFrame();
413
+ if (!frame) throw new Error(`Cannot access content of frame: ${this.activeFrameSelector}`);
414
+ return frame;
415
+ }
416
+
417
+ /**
418
+ * Get a locator root scoped to the active frame (if any) or the page.
419
+ * Use this to create locators that respect the current frame context.
420
+ * Example: bm.getLocatorRoot().locator('button.submit')
421
+ */
422
+ getLocatorRoot(): Page | FrameLocator {
423
+ if (this.activeFrameSelector) {
424
+ return this.getPage().frameLocator(this.activeFrameSelector);
425
+ }
426
+ return this.getPage();
427
+ }
428
+
344
429
  // ─── Ref Map ──────────────────────────────────────────────
345
430
  setRefMap(refs: Map<string, Locator>) {
346
431
  this.refMap = refs;
@@ -354,6 +439,10 @@ export class BrowserManager {
354
439
  /**
355
440
  * Resolve a selector that may be a @ref (e.g., "@e3") or a CSS selector.
356
441
  * Returns { locator } for refs or { selector } for CSS selectors.
442
+ *
443
+ * When a frame is active and a CSS selector is passed, returns { locator }
444
+ * scoped to the frame instead of { selector }, so callers automatically
445
+ * interact with elements inside the iframe.
357
446
  */
358
447
  resolveRef(selector: string): { locator: Locator } | { selector: string } {
359
448
  if (selector.startsWith('@e')) {
@@ -373,6 +462,11 @@ export class BrowserManager {
373
462
  }
374
463
  return { locator };
375
464
  }
465
+ // When a frame is active, scope CSS selectors through the frame
466
+ if (this.activeFrameSelector) {
467
+ const frame = this.getPage().frameLocator(this.activeFrameSelector);
468
+ return { locator: frame.locator(selector) };
469
+ }
376
470
  return { selector };
377
471
  }
378
472
 
@@ -473,6 +567,28 @@ export class BrowserManager {
473
567
  if (Object.keys(this.extraHeaders).length > 0) {
474
568
  await newContext.setExtraHTTPHeaders(this.extraHeaders);
475
569
  }
570
+ if (this.offline) {
571
+ await newContext.setOffline(true);
572
+ }
573
+ if (this.initScript) {
574
+ await newContext.addInitScript(this.initScript);
575
+ }
576
+ // Re-apply domain filter route
577
+ if (this.domainFilter) {
578
+ const df = this.domainFilter;
579
+ await newContext.route('**/*', (route) => {
580
+ const url = route.request().url();
581
+ if (df.isAllowed(url)) { route.continue(); } else { route.abort('blockedbyclient'); }
582
+ });
583
+ }
584
+ // Re-apply user routes
585
+ for (const r of this.userRoutes) {
586
+ if (r.action === 'block') {
587
+ await newContext.route(r.pattern, (route) => route.abort('blockedbyclient'));
588
+ } else {
589
+ await newContext.route(r.pattern, (route) => route.fulfill({ status: r.status || 200, body: r.body || '', contentType: 'text/plain' }));
590
+ }
591
+ }
476
592
  } catch (err) {
477
593
  await newContext.close().catch(() => {});
478
594
  throw err;
@@ -484,6 +600,7 @@ export class BrowserManager {
484
600
  const oldActiveTabId = this.activeTabId;
485
601
  const oldNextTabId = this.nextTabId;
486
602
  const oldTabSnapshots = new Map(this.tabSnapshots);
603
+ const oldRefMap = new Map(this.refMap);
487
604
 
488
605
  // Swap to new context
489
606
  this.context = newContext;
@@ -520,7 +637,7 @@ export class BrowserManager {
520
637
  this.activeTabId = oldActiveTabId;
521
638
  this.nextTabId = oldNextTabId;
522
639
  this.tabSnapshots = oldTabSnapshots;
523
- this.refMap.clear();
640
+ this.refMap = oldRefMap;
524
641
  throw err;
525
642
  }
526
643
 
@@ -600,6 +717,64 @@ export class BrowserManager {
600
717
  return this.currentDevice;
601
718
  }
602
719
 
720
+ // ─── Offline Mode ──────────────────────────────────────────
721
+ isOffline(): boolean {
722
+ return this.offline;
723
+ }
724
+
725
+ async setOffline(value: boolean): Promise<void> {
726
+ this.offline = value;
727
+ if (this.context) {
728
+ await this.context.setOffline(value);
729
+ }
730
+ }
731
+
732
+ // ─── HAR Recording ────────────────────────────────────────
733
+ startHarRecording(): void {
734
+ this.harRecording = { startTime: Date.now(), active: true };
735
+ }
736
+
737
+ stopHarRecording(): HarRecording | null {
738
+ const recording = this.harRecording;
739
+ this.harRecording = null;
740
+ return recording;
741
+ }
742
+
743
+ getHarRecording(): HarRecording | null {
744
+ return this.harRecording;
745
+ }
746
+
747
+ // ─── Init Script ───────────────────────────────────────────
748
+ setInitScript(script: string): void {
749
+ this.initScript = script;
750
+ }
751
+
752
+ getInitScript(): string | null {
753
+ return this.initScript;
754
+ }
755
+
756
+ // ─── User Routes ──────────────────────────────────────────
757
+ addUserRoute(pattern: string, action: 'block' | 'fulfill', status?: number, body?: string) {
758
+ this.userRoutes.push({pattern, action, status, body});
759
+ }
760
+
761
+ clearUserRoutes() {
762
+ this.userRoutes = [];
763
+ }
764
+
765
+ getUserRoutes() {
766
+ return this.userRoutes;
767
+ }
768
+
769
+ // ─── Domain Filter ────────────────────────────────────────
770
+ setDomainFilter(filter: DomainFilter) {
771
+ this.domainFilter = filter;
772
+ }
773
+
774
+ getDomainFilter(): DomainFilter | null {
775
+ return this.domainFilter;
776
+ }
777
+
603
778
  /**
604
779
  * Reverse-lookup: find the tab ID that owns this page.
605
780
  * Returns undefined if the page isn't committed to any tab yet (during newTab).
package/src/cli.ts CHANGED
@@ -12,9 +12,22 @@
12
12
  import * as fs from 'fs';
13
13
  import * as path from 'path';
14
14
  import { DEFAULTS } from './constants';
15
+ import { loadConfig } from './config';
16
+
17
+ // Global CLI flags — set in main(), used by sendCommand()
18
+ const cliFlags = {
19
+ json: false,
20
+ contentBoundaries: false,
21
+ allowedDomains: '' as string,
22
+ };
15
23
 
16
24
  const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10);
17
- const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : '';
25
+ // Instance isolation: each parent process (e.g., Claude Code) gets its own server.
26
+ // BROWSE_PORT takes precedence (explicit), then BROWSE_INSTANCE (env override), then PPID (auto).
27
+ // In compiled mode ($bunfs), PPID is unstable (shell forks per invocation) — skip it.
28
+ const IS_COMPILED = import.meta.dir.includes('$bunfs');
29
+ const BROWSE_INSTANCE = process.env.BROWSE_INSTANCE || (BROWSE_PORT || IS_COMPILED ? '' : String(process.ppid));
30
+ const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : (BROWSE_INSTANCE ? `-${BROWSE_INSTANCE}` : '');
18
31
 
19
32
  /**
20
33
  * Resolve the project-local .browse/ directory for state files, logs, screenshots.
@@ -69,6 +82,11 @@ export function resolveServerScript(
69
82
  }
70
83
  }
71
84
 
85
+ // Compiled binary ($bunfs): server is bundled, no external file needed
86
+ if (metaDir.includes('$bunfs')) {
87
+ return '__compiled__';
88
+ }
89
+
72
90
  throw new Error(
73
91
  '[browse] Cannot find server.ts. Set BROWSE_SERVER_SCRIPT env var to the path of server.ts.'
74
92
  );
@@ -170,10 +188,15 @@ async function startServer(): Promise<ServerState> {
170
188
  }
171
189
  } catch {}
172
190
 
173
- // Start server as detached background process
174
- const proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
191
+ // Start server as detached background process.
192
+ // Compiled binary: self-spawn with __BROWSE_SERVER_MODE=1
193
+ // Dev mode: spawn bun with server.ts
194
+ const spawnCmd = SERVER_SCRIPT === '__compiled__'
195
+ ? [process.execPath]
196
+ : ['bun', 'run', SERVER_SCRIPT];
197
+ const proc = Bun.spawn(spawnCmd, {
175
198
  stdio: ['ignore', 'pipe', 'pipe'],
176
- env: { ...process.env, BROWSE_LOCAL_DIR: LOCAL_DIR },
199
+ env: { ...process.env, __BROWSE_SERVER_MODE: '1', BROWSE_LOCAL_DIR: LOCAL_DIR, BROWSE_INSTANCE },
177
200
  });
178
201
 
179
202
  // Don't hold the CLI open
@@ -224,13 +247,60 @@ async function ensureServer(): Promise<ServerState> {
224
247
  } catch {
225
248
  // Health check failed — server is dead or unhealthy
226
249
  }
250
+
251
+ // Server is alive but unhealthy (shutting down, browser crashed).
252
+ // Kill it so we can start fresh.
253
+ try { process.kill(state.pid, 'SIGTERM'); } catch {}
254
+ // Brief wait for graceful exit
255
+ const deadline = Date.now() + 3000;
256
+ while (Date.now() < deadline && isProcessAlive(state.pid)) {
257
+ await Bun.sleep(100);
258
+ }
259
+ if (isProcessAlive(state.pid)) {
260
+ try { process.kill(state.pid, 'SIGKILL'); } catch {}
261
+ await Bun.sleep(200);
262
+ }
263
+ }
264
+
265
+ // Clean up stale state file
266
+ if (state) {
267
+ try { fs.unlinkSync(STATE_FILE); } catch {}
227
268
  }
228
269
 
270
+ // Clean up orphaned state files from other instances (e.g., old PPID-suffixed files)
271
+ cleanOrphanedServers();
272
+
229
273
  // Need to (re)start
230
274
  console.error('[browse] Starting server...');
231
275
  return startServer();
232
276
  }
233
277
 
278
+ /**
279
+ * Clean up orphaned browse servers:
280
+ * 1. Remove state files with dead PIDs
281
+ * 2. Kill live servers from other instances (old PPID-suffixed state files)
282
+ */
283
+ function cleanOrphanedServers(): void {
284
+ try {
285
+ const files = fs.readdirSync(LOCAL_DIR);
286
+ for (const file of files) {
287
+ if (!file.startsWith('browse-server') || !file.endsWith('.json') || file.endsWith('.lock')) continue;
288
+ const filePath = path.join(LOCAL_DIR, file);
289
+ if (filePath === STATE_FILE) continue; // Don't touch our own state file
290
+ try {
291
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
292
+ if (data.pid) {
293
+ if (isProcessAlive(data.pid)) {
294
+ // Live orphan from a different instance — kill it to free the port
295
+ try { process.kill(data.pid, 'SIGTERM'); } catch {}
296
+ }
297
+ fs.unlinkSync(filePath);
298
+ }
299
+ } catch {}
300
+ }
301
+ } catch {}
302
+ }
303
+
234
304
  // ─── Command Dispatch ──────────────────────────────────────────
235
305
 
236
306
  // Commands that are safe to retry after a transport failure.
@@ -241,10 +311,10 @@ async function ensureServer(): Promise<ServerState> {
241
311
  export const SAFE_TO_RETRY = new Set([
242
312
  // Read commands — no side effects
243
313
  'text', 'html', 'links', 'forms', 'accessibility',
244
- 'css', 'attrs', 'state', 'dialog',
245
- 'console', 'network', 'cookies', 'perf',
246
- // Meta commands that are read-only
247
- 'tabs', 'status', 'url', 'snapshot', 'snapshot-diff', 'devices', 'sessions',
314
+ 'css', 'attrs', 'element-state', 'dialog',
315
+ 'console', 'network', 'cookies', 'perf', 'value', 'count',
316
+ // Meta commands that are read-only or idempotent
317
+ 'tabs', 'status', 'url', 'snapshot', 'snapshot-diff', 'devices', 'sessions', 'frame',
248
318
  ]);
249
319
 
250
320
  // Commands that return static data independent of page state.
@@ -261,6 +331,15 @@ async function sendCommand(state: ServerState, command: string, args: string[],
261
331
  if (sessionId) {
262
332
  headers['X-Browse-Session'] = sessionId;
263
333
  }
334
+ if (cliFlags.json) {
335
+ headers['X-Browse-Json'] = '1';
336
+ }
337
+ if (cliFlags.contentBoundaries) {
338
+ headers['X-Browse-Boundaries'] = '1';
339
+ }
340
+ if (cliFlags.allowedDomains) {
341
+ headers['X-Browse-Allowed-Domains'] = cliFlags.allowedDomains;
342
+ }
264
343
 
265
344
  try {
266
345
  const resp = await fetch(`http://127.0.0.1:${state.port}/command`, {
@@ -286,22 +365,24 @@ async function sendCommand(state: ServerState, command: string, args: string[],
286
365
  process.stdout.write(text);
287
366
  if (!text.endsWith('\n')) process.stdout.write('\n');
288
367
 
289
- // After restart succeeds, wait for old server to actually die, then start fresh
290
- if (command === 'restart') {
368
+ // After stop/restart, wait for old server to actually die
369
+ if (command === 'stop' || command === 'restart') {
291
370
  const oldPid = state.pid;
292
- // Wait up to 5s for graceful shutdown
293
371
  const deadline = Date.now() + 5000;
294
372
  while (Date.now() < deadline && isProcessAlive(oldPid)) {
295
373
  await Bun.sleep(100);
296
374
  }
297
- // If still alive (e.g. browserManager.close() stalled), force-kill
298
375
  if (isProcessAlive(oldPid)) {
299
376
  try { process.kill(oldPid, 'SIGKILL'); } catch {}
300
- // Brief wait for OS to reclaim the process and release the port
301
377
  await Bun.sleep(300);
302
378
  }
303
- const newState = await startServer();
304
- console.error(`[browse] Server restarted (PID: ${newState.pid})`);
379
+ // Clean up state file
380
+ try { fs.unlinkSync(STATE_FILE); } catch {}
381
+
382
+ if (command === 'restart') {
383
+ const newState = await startServer();
384
+ console.error(`[browse] Server restarted (PID: ${newState.pid})`);
385
+ }
305
386
  }
306
387
  } else {
307
388
  // Try to parse as JSON error
@@ -356,9 +437,12 @@ async function sendCommand(state: ServerState, command: string, args: string[],
356
437
  }
357
438
 
358
439
  // ─── Main ──────────────────────────────────────────────────────
359
- async function main() {
440
+ export async function main() {
360
441
  const args = process.argv.slice(2);
361
442
 
443
+ // Load project config (browse.json) — values serve as defaults
444
+ const config = loadConfig();
445
+
362
446
  // Extract --session flag before command parsing
363
447
  let sessionId: string | undefined;
364
448
  const sessionIdx = args.indexOf('--session');
@@ -370,7 +454,43 @@ async function main() {
370
454
  }
371
455
  args.splice(sessionIdx, 2); // remove --session and its value
372
456
  }
373
- sessionId = sessionId || process.env.BROWSE_SESSION || undefined;
457
+ sessionId = sessionId || process.env.BROWSE_SESSION || config.session || undefined;
458
+
459
+ // Extract --json flag
460
+ let jsonMode = false;
461
+ const jsonIdx = args.indexOf('--json');
462
+ if (jsonIdx !== -1) {
463
+ jsonMode = true;
464
+ args.splice(jsonIdx, 1);
465
+ }
466
+ jsonMode = jsonMode || process.env.BROWSE_JSON === '1' || config.json === true;
467
+
468
+ // Extract --content-boundaries flag
469
+ let contentBoundaries = false;
470
+ const boundIdx = args.indexOf('--content-boundaries');
471
+ if (boundIdx !== -1) {
472
+ contentBoundaries = true;
473
+ args.splice(boundIdx, 1);
474
+ }
475
+ contentBoundaries = contentBoundaries || process.env.BROWSE_CONTENT_BOUNDARIES === '1' || config.contentBoundaries === true;
476
+
477
+ // Extract --allowed-domains flag
478
+ let allowedDomains: string | undefined;
479
+ const domIdx = args.indexOf('--allowed-domains');
480
+ if (domIdx !== -1) {
481
+ allowedDomains = args[domIdx + 1];
482
+ if (!allowedDomains || allowedDomains.startsWith('-')) {
483
+ console.error('Usage: browse --allowed-domains domain1,domain2 <command> [args...]');
484
+ process.exit(1);
485
+ }
486
+ args.splice(domIdx, 2);
487
+ }
488
+ allowedDomains = allowedDomains || process.env.BROWSE_ALLOWED_DOMAINS || (config.allowedDomains ? config.allowedDomains.join(',') : undefined);
489
+
490
+ // Set global flags for sendCommand()
491
+ cliFlags.json = jsonMode;
492
+ cliFlags.contentBoundaries = contentBoundaries;
493
+ cliFlags.allowedDomains = allowedDomains || '';
374
494
 
375
495
  // ─── Local commands (no server needed) ─────────────────────
376
496
  if (args[0] === 'install-skill') {
@@ -382,31 +502,42 @@ async function main() {
382
502
  if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
383
503
  console.log(`browse — Fast headless browser for AI coding agents
384
504
 
385
- Usage: browse [--session <id>] <command> [args...]
505
+ Usage: browse [options] <command> [args...]
386
506
 
387
507
  Navigation: goto <url> | back | forward | reload | url
388
508
  Content: text | html [sel] | links | forms | accessibility
389
509
  Interaction: click <sel> | fill <sel> <val> | select <sel> <val>
390
- hover <sel> | type <text> | press <key>
391
- scroll [sel] | wait <sel> | viewport <WxH>
510
+ hover <sel> | dblclick <sel> | focus <sel>
511
+ check <sel> | uncheck <sel> | drag <src> <tgt>
512
+ type <text> | press <key> | keydown <key> | keyup <key>
513
+ scroll [sel|up|down] | wait <sel|--url|--network-idle>
514
+ viewport <WxH> | highlight <sel> | download <sel> [path]
392
515
  Device: emulate <device> | emulate reset | devices [filter]
393
516
  Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
394
- console [--clear] | network [--clear]
517
+ element-state <sel> | console [--clear] | network [--clear]
395
518
  cookies | storage [set <k> <v>] | perf
519
+ value <sel> | count <sel>
396
520
  Visual: screenshot [path] | pdf [path] | responsive [prefix]
397
521
  Snapshot: snapshot [-i] [-c] [-C] [-d N] [-s sel]
398
522
  Compare: diff <url1> <url2>
399
523
  Multi-step: chain (reads JSON from stdin)
524
+ Network: offline [on|off] | route <pattern> block|fulfill
525
+ Recording: har start | har stop [path]
400
526
  Tabs: tabs | tab <id> | newtab [url] | closetab [id]
527
+ Frames: frame <sel> | frame main
401
528
  Sessions: sessions | session-close <id>
529
+ Auth: auth save <name> <url> <user> <pass|--password-stdin>
530
+ auth login <name> | auth list | auth delete <name>
531
+ State: state save [name] | state load [name]
402
532
  Server: status | cookie <n>=<v> | header <n>:<v>
403
533
  useragent <str> | stop | restart
404
534
  Setup: install-skill [path]
405
535
 
406
536
  Options:
407
- --session <id> Use a named session (isolates tabs, refs, cookies).
408
- Multiple agents can share one server with different sessions.
409
- Also settable via BROWSE_SESSION env var.
537
+ --session <id> Named session (isolates tabs, refs, cookies)
538
+ --json Wrap output as {success, data, command}
539
+ --content-boundaries Wrap page content in nonce-delimited markers
540
+ --allowed-domains <d,d> Block navigation/resources outside allowlist
410
541
 
411
542
  Snapshot flags:
412
543
  -i Interactive elements only (buttons, links, inputs)
@@ -434,7 +565,10 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
434
565
  await sendCommand(state, command, commandArgs, 0, sessionId);
435
566
  }
436
567
 
437
- if (import.meta.main) {
568
+ if (process.env.__BROWSE_SERVER_MODE === '1') {
569
+ import('./server');
570
+ } else if (import.meta.main) {
571
+ // Direct execution: bun run src/cli.ts <command>
438
572
  main().catch((err) => {
439
573
  console.error(`[browse] ${err.message}`);
440
574
  process.exit(1);