browser-pilot 0.0.14 → 0.0.16

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/README.md +89 -667
  2. package/dist/actions.cjs +1073 -41
  3. package/dist/actions.d.cts +11 -3
  4. package/dist/actions.d.ts +11 -3
  5. package/dist/actions.mjs +1 -1
  6. package/dist/browser-ZCR6AA4D.mjs +11 -0
  7. package/dist/browser.cjs +1431 -62
  8. package/dist/browser.d.cts +4 -4
  9. package/dist/browser.d.ts +4 -4
  10. package/dist/browser.mjs +4 -4
  11. package/dist/cdp.cjs +5 -1
  12. package/dist/cdp.d.cts +1 -1
  13. package/dist/cdp.d.ts +1 -1
  14. package/dist/cdp.mjs +1 -1
  15. package/dist/{chunk-7NDR6V7S.mjs → chunk-6GBYX7C2.mjs} +1405 -528
  16. package/dist/{chunk-KIFB526Y.mjs → chunk-BVZALQT4.mjs} +5 -1
  17. package/dist/chunk-DTVRFXKI.mjs +35 -0
  18. package/dist/chunk-EZNZ72VA.mjs +563 -0
  19. package/dist/{chunk-SPSZZH22.mjs → chunk-LCNFBXB5.mjs} +9 -33
  20. package/dist/{chunk-IN5HPAPB.mjs → chunk-NNEHWWHL.mjs} +28 -10
  21. package/dist/chunk-TJ5B56NV.mjs +804 -0
  22. package/dist/{chunk-XMJABKCF.mjs → chunk-V3VLBQAM.mjs} +1073 -41
  23. package/dist/cli.mjs +2799 -1176
  24. package/dist/{client-Ck2nQksT.d.cts → client-B5QBRgIy.d.cts} +2 -0
  25. package/dist/{client-Ck2nQksT.d.ts → client-B5QBRgIy.d.ts} +2 -0
  26. package/dist/{client-3AFV2IAF.mjs → client-JWWZWO6L.mjs} +4 -2
  27. package/dist/index.cjs +1441 -52
  28. package/dist/index.d.cts +5 -5
  29. package/dist/index.d.ts +5 -5
  30. package/dist/index.mjs +19 -7
  31. package/dist/page-IUUTJ3SW.mjs +7 -0
  32. package/dist/providers.cjs +637 -2
  33. package/dist/providers.d.cts +2 -2
  34. package/dist/providers.d.ts +2 -2
  35. package/dist/providers.mjs +17 -3
  36. package/dist/{types-CjT0vClo.d.ts → types-BflRmiDz.d.cts} +17 -3
  37. package/dist/{types-BSoh5v1Y.d.cts → types-BzM-IfsL.d.ts} +17 -3
  38. package/dist/types-DeVSWhXj.d.cts +142 -0
  39. package/dist/types-DeVSWhXj.d.ts +142 -0
  40. package/package.json +1 -1
  41. package/dist/browser-LZTEHUDI.mjs +0 -9
  42. package/dist/chunk-BRAFQUMG.mjs +0 -229
  43. package/dist/types--wXNHUwt.d.cts +0 -56
  44. package/dist/types--wXNHUwt.d.ts +0 -56
package/dist/cli.mjs CHANGED
@@ -1,18 +1,32 @@
1
1
  #!/usr/bin/env bun
2
+ import {
3
+ BrowserEndpointResolutionError,
4
+ connect,
5
+ resolveBrowserEndpoint
6
+ } from "./chunk-TJ5B56NV.mjs";
7
+ import "./chunk-LCNFBXB5.mjs";
2
8
  import {
3
9
  DEEP_QUERY_SCRIPT,
10
+ LiveTraceCollector,
4
11
  SENSITIVE_AUTOCOMPLETE_TOKENS,
12
+ TRACE_BINDING_NAME,
13
+ TRACE_SCRIPT,
5
14
  addBatchToPage,
6
- connect,
15
+ buildTraceSummaries,
16
+ buildTraceSummary,
17
+ canonicalizeRecordingArtifact,
18
+ createRecordingManifest,
19
+ createTraceId,
7
20
  fuzzyMatchElements,
8
- getBrowserWebSocketUrl,
21
+ grantAudioPermissions,
22
+ normalizeTraceEvent,
9
23
  pcmToWav,
10
24
  redactValueForRecording,
11
25
  validateSteps
12
- } from "./chunk-7NDR6V7S.mjs";
26
+ } from "./chunk-6GBYX7C2.mjs";
13
27
  import {
14
28
  isRecord
15
- } from "./chunk-SPSZZH22.mjs";
29
+ } from "./chunk-DTVRFXKI.mjs";
16
30
  import {
17
31
  DAEMON_MAX_AGE_MS,
18
32
  DAEMON_READY_TIMEOUT_MS
@@ -345,28 +359,99 @@ Content-Type: ${contentType}\r
345
359
  parts.push(data);
346
360
  }
347
361
 
348
- // src/cli/session.ts
362
+ // src/trace/store.ts
363
+ import * as fs from "fs";
349
364
  import { homedir } from "os";
350
- import { join } from "path";
365
+ import { dirname, join, resolve } from "path";
366
+ var TRACE_FILE_NAME = "trace.jsonl";
351
367
  var SESSION_DIR = join(homedir(), ".browser-pilot", "sessions");
368
+ function getSessionTracePath(sessionId) {
369
+ return join(SESSION_DIR, sessionId, TRACE_FILE_NAME);
370
+ }
371
+ function readTraceEvents(path) {
372
+ const resolved = resolve(path);
373
+ if (!fs.existsSync(resolved)) {
374
+ return [];
375
+ }
376
+ const content = fs.readFileSync(resolved, "utf-8").trim();
377
+ if (!content) {
378
+ return [];
379
+ }
380
+ return content.split("\n").map((line) => {
381
+ try {
382
+ return JSON.parse(line);
383
+ } catch {
384
+ return null;
385
+ }
386
+ }).filter((event) => event !== null);
387
+ }
388
+
389
+ // src/cli/browser-endpoint.ts
390
+ async function resolveCLIEndpoint(options = {}) {
391
+ return resolveBrowserEndpoint({
392
+ explicitWsUrl: options.explicitWsUrl,
393
+ channel: options.channel,
394
+ userDataDir: options.userDataDir,
395
+ allowLocalDiscovery: true,
396
+ allowLegacyHostFallback: true
397
+ });
398
+ }
399
+ function formatCandidateLabel(candidate) {
400
+ const channelLabel = candidate.channel ? `${candidate.channel}` : "unknown";
401
+ return `${channelLabel}: ${candidate.userDataDir}`;
402
+ }
403
+ function formatBrowserDiscoveryError(error, options = {}) {
404
+ const explicitFlag = options.explicitFlag ?? "--browser-url";
405
+ if (error instanceof BrowserEndpointResolutionError) {
406
+ if (error.code === "multiple-local-browsers") {
407
+ const candidates = error.details.candidates ?? [];
408
+ const foundLines = candidates.length > 0 ? candidates.map((candidate) => ` - ${formatCandidateLabel(candidate)}`).join("\n") : " - Multiple local Chrome profiles were found";
409
+ return `Multiple running Chrome profiles have remote debugging enabled.
410
+ ${foundLines}
411
+ Pass --channel <stable|beta|dev|canary> or --user-data-dir <path>.`;
412
+ }
413
+ const lines = [
414
+ "Could not auto-discover browser.",
415
+ "Recommended for Chrome 144+:",
416
+ " 1. Open Chrome and enable remote debugging in chrome://inspect/#remote-debugging",
417
+ " 2. Keep Chrome running, then retry",
418
+ "Other options:",
419
+ options.explicitHint ?? ` - Pass ${explicitFlag} with a browser WebSocket URL`,
420
+ " - Launch Chrome with --remote-debugging-port=9222 and a custom --user-data-dir"
421
+ ];
422
+ if (options.reuseSessionHint) {
423
+ lines.push(` - Reuse an existing session: ${options.reuseSessionHint}`);
424
+ }
425
+ if (options.latestSessionHint) {
426
+ lines.push(` - Use latest session: ${options.latestSessionHint}`);
427
+ }
428
+ return lines.join("\n");
429
+ }
430
+ return error instanceof Error ? error.message : String(error);
431
+ }
432
+
433
+ // src/cli/session.ts
434
+ import { homedir as homedir2 } from "os";
435
+ import { join as join2 } from "path";
436
+ var SESSION_DIR2 = join2(homedir2(), ".browser-pilot", "sessions");
352
437
  function getSessionFilePath(id) {
353
- return join(SESSION_DIR, `${id}.json`);
438
+ return join2(SESSION_DIR2, `${id}.json`);
354
439
  }
355
440
  async function ensureSessionDir() {
356
- const fs7 = await import("fs/promises");
357
- await fs7.mkdir(SESSION_DIR, { recursive: true });
441
+ const fs9 = await import("fs/promises");
442
+ await fs9.mkdir(SESSION_DIR2, { recursive: true });
358
443
  }
359
444
  async function saveSession(session) {
360
445
  await ensureSessionDir();
361
- const fs7 = await import("fs/promises");
362
- const filePath = join(SESSION_DIR, `${session.id}.json`);
363
- await fs7.writeFile(filePath, JSON.stringify(session, null, 2));
446
+ const fs9 = await import("fs/promises");
447
+ const filePath = join2(SESSION_DIR2, `${session.id}.json`);
448
+ await fs9.writeFile(filePath, JSON.stringify(session, null, 2));
364
449
  }
365
450
  async function loadSession(id) {
366
- const fs7 = await import("fs/promises");
367
- const filePath = join(SESSION_DIR, `${id}.json`);
451
+ const fs9 = await import("fs/promises");
452
+ const filePath = join2(SESSION_DIR2, `${id}.json`);
368
453
  try {
369
- const content = await fs7.readFile(filePath, "utf-8");
454
+ const content = await fs9.readFile(filePath, "utf-8");
370
455
  return JSON.parse(content);
371
456
  } catch (error) {
372
457
  if (error.code === "ENOENT") {
@@ -388,10 +473,10 @@ async function updateSession(id, updates) {
388
473
  return updated;
389
474
  }
390
475
  async function deleteSession(id) {
391
- const fs7 = await import("fs/promises");
392
- const filePath = join(SESSION_DIR, `${id}.json`);
476
+ const fs9 = await import("fs/promises");
477
+ const filePath = join2(SESSION_DIR2, `${id}.json`);
393
478
  try {
394
- await fs7.unlink(filePath);
479
+ await fs9.unlink(filePath);
395
480
  } catch (error) {
396
481
  if (error.code !== "ENOENT") {
397
482
  throw error;
@@ -399,18 +484,18 @@ async function deleteSession(id) {
399
484
  }
400
485
  }
401
486
  async function deleteSessionFull(id) {
402
- const fs7 = await import("fs/promises");
403
- const filePath = join(SESSION_DIR, `${id}.json`);
487
+ const fs9 = await import("fs/promises");
488
+ const filePath = join2(SESSION_DIR2, `${id}.json`);
404
489
  try {
405
- await fs7.unlink(filePath);
490
+ await fs9.unlink(filePath);
406
491
  } catch (error) {
407
492
  if (error.code !== "ENOENT") {
408
493
  throw error;
409
494
  }
410
495
  }
411
- const dirPath = join(SESSION_DIR, id);
496
+ const dirPath = join2(SESSION_DIR2, id);
412
497
  try {
413
- await fs7.rm(dirPath, { recursive: true });
498
+ await fs9.rm(dirPath, { recursive: true });
414
499
  } catch (error) {
415
500
  if (error.code !== "ENOENT") {
416
501
  throw error;
@@ -419,14 +504,14 @@ async function deleteSessionFull(id) {
419
504
  }
420
505
  async function listSessions() {
421
506
  await ensureSessionDir();
422
- const fs7 = await import("fs/promises");
507
+ const fs9 = await import("fs/promises");
423
508
  try {
424
- const files = await fs7.readdir(SESSION_DIR);
509
+ const files = await fs9.readdir(SESSION_DIR2);
425
510
  const sessions = [];
426
511
  for (const file of files) {
427
512
  if (file.endsWith(".json")) {
428
513
  try {
429
- const content = await fs7.readFile(join(SESSION_DIR, file), "utf-8");
514
+ const content = await fs9.readFile(join2(SESSION_DIR2, file), "utf-8");
430
515
  sessions.push(JSON.parse(content));
431
516
  } catch {
432
517
  }
@@ -449,9 +534,271 @@ async function getDefaultSession() {
449
534
  return sessions[0] ?? null;
450
535
  }
451
536
 
537
+ // src/cli/session-logger.ts
538
+ import * as fs2 from "fs";
539
+ import { homedir as homedir3 } from "os";
540
+ import { dirname as dirname2, join as join3, resolve as resolve2 } from "path";
541
+ var SESSION_DIR3 = join3(homedir3(), ".browser-pilot", "sessions");
542
+ var SessionLogger = class {
543
+ logPath;
544
+ exportLogPath = null;
545
+ seq = 0;
546
+ sessionId;
547
+ constructor(sessionId, exportLogPath) {
548
+ this.sessionId = sessionId;
549
+ const sessionDir = join3(SESSION_DIR3, sessionId);
550
+ this.logPath = join3(sessionDir, TRACE_FILE_NAME);
551
+ if (!fs2.existsSync(sessionDir)) {
552
+ fs2.mkdirSync(sessionDir, { recursive: true });
553
+ }
554
+ if (exportLogPath) {
555
+ this.exportLogPath = resolve2(exportLogPath);
556
+ const exportDir = dirname2(this.exportLogPath);
557
+ if (!fs2.existsSync(exportDir)) {
558
+ fs2.mkdirSync(exportDir, { recursive: true });
559
+ }
560
+ }
561
+ if (fs2.existsSync(this.logPath)) {
562
+ this.seq = this.countEntries();
563
+ }
564
+ }
565
+ /**
566
+ * Log a raw compatibility entry through the canonical trace substrate.
567
+ */
568
+ log(entry) {
569
+ const fullEntry = {
570
+ seq: ++this.seq,
571
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
572
+ ...entry
573
+ };
574
+ this.logTrace(this.compatibilityEntryToTrace(fullEntry));
575
+ }
576
+ logTrace(event) {
577
+ const normalized = normalizeTraceEvent({
578
+ traceId: event.traceId ?? createTraceId(event.channel),
579
+ sessionId: this.sessionId,
580
+ ...event
581
+ });
582
+ const line = `${JSON.stringify(normalized)}
583
+ `;
584
+ fs2.appendFileSync(this.logPath, line, "utf-8");
585
+ if (this.exportLogPath) {
586
+ try {
587
+ fs2.appendFileSync(this.exportLogPath, line, "utf-8");
588
+ } catch (err) {
589
+ console.warn(`[browser-pilot] Failed to write to export log: ${err}`);
590
+ }
591
+ }
592
+ }
593
+ /**
594
+ * Get the export log path (if configured)
595
+ */
596
+ getExportLogPath() {
597
+ return this.exportLogPath;
598
+ }
599
+ /**
600
+ * Log a command execution
601
+ */
602
+ logCommand(cmd, args, result, durationMs, screenshotFile) {
603
+ this.log({
604
+ type: "command",
605
+ cmd,
606
+ args,
607
+ status: result.success ? "success" : "failed",
608
+ durationMs,
609
+ error: result.error,
610
+ hints: result.hints,
611
+ screenshotFile
612
+ });
613
+ }
614
+ /**
615
+ * Log an error
616
+ */
617
+ logError(error, context) {
618
+ this.log({
619
+ type: "error",
620
+ error: error.message,
621
+ args: context
622
+ });
623
+ }
624
+ /**
625
+ * Get the log file path
626
+ */
627
+ getLogPath() {
628
+ return this.logPath;
629
+ }
630
+ /**
631
+ * Get log statistics
632
+ */
633
+ getLogStats() {
634
+ if (!fs2.existsSync(this.logPath)) {
635
+ return { entries: 0, size: 0 };
636
+ }
637
+ const stat = fs2.statSync(this.logPath);
638
+ const entries = this.countEntries();
639
+ let first;
640
+ let last;
641
+ if (entries > 0) {
642
+ const lines = fs2.readFileSync(this.logPath, "utf-8").trim().split("\n");
643
+ const firstEntry = this.parseLine(lines[0]);
644
+ const lastEntry = this.parseLine(lines[lines.length - 1]);
645
+ first = firstEntry?.ts;
646
+ last = lastEntry?.ts;
647
+ }
648
+ return {
649
+ entries,
650
+ size: stat.size,
651
+ first,
652
+ last
653
+ };
654
+ }
655
+ /**
656
+ * Get the last n log entries
657
+ */
658
+ tailLog(n) {
659
+ if (!fs2.existsSync(this.logPath)) {
660
+ return [];
661
+ }
662
+ const content = fs2.readFileSync(this.logPath, "utf-8").trim();
663
+ if (!content) {
664
+ return [];
665
+ }
666
+ const lines = content.split("\n");
667
+ const startIndex = Math.max(0, lines.length - n);
668
+ const result = [];
669
+ for (let i = startIndex; i < lines.length; i++) {
670
+ const entry = this.parseLine(lines[i]);
671
+ if (entry) {
672
+ result.push(entry);
673
+ }
674
+ }
675
+ return result;
676
+ }
677
+ /**
678
+ * Count entries in the log file
679
+ */
680
+ countEntries() {
681
+ if (!fs2.existsSync(this.logPath)) {
682
+ return 0;
683
+ }
684
+ const content = fs2.readFileSync(this.logPath, "utf-8").trim();
685
+ if (!content) {
686
+ return 0;
687
+ }
688
+ return content.split("\n").length;
689
+ }
690
+ /**
691
+ * Parse a single log line
692
+ */
693
+ parseLine(line) {
694
+ if (!line) {
695
+ return null;
696
+ }
697
+ try {
698
+ const event = JSON.parse(line);
699
+ return this.traceToCompatibilityEntry(event);
700
+ } catch {
701
+ return null;
702
+ }
703
+ }
704
+ compatibilityEntryToTrace(entry) {
705
+ if (entry.type === "command") {
706
+ return normalizeTraceEvent({
707
+ traceId: `cmd-${entry.seq}`,
708
+ sessionId: this.sessionId,
709
+ ts: entry.ts,
710
+ channel: "action",
711
+ event: entry.status === "failed" ? "action.failed" : "action.succeeded",
712
+ severity: entry.status === "failed" ? "error" : "info",
713
+ summary: `${entry.cmd ?? "command"}${entry.selectorUsed ? ` ${entry.selectorUsed}` : ""}`,
714
+ data: {
715
+ cmd: entry.cmd ?? null,
716
+ args: entry.args ?? {},
717
+ durationMs: entry.durationMs ?? null,
718
+ error: entry.error ?? null,
719
+ hints: entry.hints ?? [],
720
+ screenshotFile: entry.screenshotFile ?? null,
721
+ legacy: entry
722
+ },
723
+ selectorUsed: entry.selectorUsed,
724
+ url: entry.urlAfter ?? entry.urlBefore
725
+ });
726
+ }
727
+ if (entry.type === "error") {
728
+ return normalizeTraceEvent({
729
+ traceId: `err-${entry.seq}`,
730
+ sessionId: this.sessionId,
731
+ ts: entry.ts,
732
+ channel: "runtime",
733
+ event: "runtime.exception",
734
+ severity: "error",
735
+ summary: entry.error ?? "Session error",
736
+ data: {
737
+ args: entry.args ?? {},
738
+ legacy: entry
739
+ }
740
+ });
741
+ }
742
+ return normalizeTraceEvent({
743
+ traceId: `evt-${entry.seq}`,
744
+ sessionId: this.sessionId,
745
+ ts: entry.ts,
746
+ channel: "session",
747
+ event: entry.cmd ?? "session.event",
748
+ severity: entry.status === "failed" ? "error" : "info",
749
+ summary: entry.cmd ?? "Session event",
750
+ data: {
751
+ args: entry.args ?? {},
752
+ status: entry.status ?? null,
753
+ legacy: entry
754
+ },
755
+ url: entry.urlAfter ?? entry.urlBefore
756
+ });
757
+ }
758
+ traceToCompatibilityEntry(event) {
759
+ const legacy = event.data["legacy"];
760
+ if (legacy && typeof legacy === "object") {
761
+ return legacy;
762
+ }
763
+ return {
764
+ seq: this.seq,
765
+ ts: event.ts,
766
+ type: event.severity === "error" ? "error" : event.channel === "action" ? "command" : "event",
767
+ cmd: event.event,
768
+ args: event.data,
769
+ status: event.event === "action.failed" ? "failed" : event.event === "action.succeeded" ? "success" : void 0,
770
+ selectorUsed: event.selectorUsed,
771
+ urlAfter: event.url,
772
+ error: event.severity === "error" ? event.summary : void 0
773
+ };
774
+ }
775
+ };
776
+ var loggerCache = /* @__PURE__ */ new Map();
777
+ function getSessionLogger(sessionId, exportLogPath) {
778
+ const cacheKey = exportLogPath ? `${sessionId}:${exportLogPath}` : sessionId;
779
+ let logger = loggerCache.get(cacheKey);
780
+ if (!logger) {
781
+ logger = new SessionLogger(sessionId, exportLogPath);
782
+ loggerCache.set(cacheKey, logger);
783
+ }
784
+ return logger;
785
+ }
786
+
452
787
  // src/cli/commands/audio.ts
453
788
  var AUDIO_HELP = `
454
- bp audio - Test voice/audio AI agents in the browser
789
+ bp audio - Actively exercise voice and audio pipelines
790
+
791
+ When to use:
792
+ You need to inject microphone input, capture spoken output, or quickly validate a voice stack.
793
+
794
+ When not to use:
795
+ You are investigating cross-cutting failure causes over time. Use \`bp trace summary --view voice\` after capture.
796
+
797
+ Default flow:
798
+ setup -> goto or activate the app -> check -> roundtrip or capture -> trace summary
799
+
800
+ Common mistake:
801
+ Injecting overrides after the app already created its audio pipeline.
455
802
 
456
803
  Feed audio as microphone input, capture the agent's spoken response,
457
804
  and optionally transcribe it. Designed for AI apps that respond with
@@ -461,11 +808,11 @@ Usage:
461
808
  bp audio <subcommand> [options]
462
809
 
463
810
  Subcommands:
811
+ setup Inject audio hooks into the session
812
+ check Validate the current audio pipeline
464
813
  roundtrip Play input + capture response (full voice round-trip)
465
814
  play Feed audio file into the page's fake microphone
466
815
  capture Capture audio output from the page
467
- setup Set up audio I/O on the session (auto-runs if needed)
468
- check Validate audio pipeline and report status
469
816
 
470
817
  Common Options:
471
818
  -s, --session [id] Session to use (omit: auto-connect, -s: latest, -s <id>: specific)
@@ -494,51 +841,32 @@ Roundtrip Options:
494
841
  --timeout <ms> Max total round-trip time (default: 120000)
495
842
  --send-selector <sel> Click this selector after input finishes (push-to-talk)
496
843
 
497
- Voice Agent Testing (typical workflow):
498
- # 1. Connect to browser
499
- bp connect --provider generic --name my-test
500
-
501
- # 2. Navigate to voice agent
502
- bp exec -s my-test '{"action":"goto","url":"https://my-voice-app.com"}'
503
-
504
- # 3. Validate audio pipeline
505
- bp audio check -s my-test
506
-
507
- # 4. Run voice roundtrip with transcription
508
- bp audio roundtrip -s my-test -i prompt.wav --transcribe -o response.wav
509
-
510
- Setup Order (CRITICAL):
511
- Audio overrides must be injected BEFORE the voice agent initializes.
512
- If the agent auto-starts on page load:
513
- 1. bp audio setup -s my-test
514
- 2. bp exec -s my-test '{"action":"goto","url":"..."}' (reload)
515
- 3. sleep 3
516
- 4. bp audio check -s my-test (expect READY)
517
- If capture returns empty, reload the page so the agent re-initializes
518
- after overrides are in place.
519
-
520
- Diagnosing Issues:
521
- bp audio check -s SESSION Validate the full audio pipeline
522
- bp audio check -s SESSION --json Machine-readable pipeline status
523
-
524
- 0 AudioContexts = agent not initialized (wait or reload)
525
- NOT READY = overrides missing (run setup, then reload)
526
- latencyMs: -1 = agent didn't respond (check bp audio check)
527
- Garbage transcript = sample rate issue (use --verbose)
528
-
529
- Quick Validation:
530
- 1. bp audio check -> shows "READY" with agent AudioContext detected
531
- 2. bp audio roundtrip -> shows non-zero latencyMs and response duration
532
- 3. If latencyMs is -1, agent didn't respond -- check audio check output
844
+ Typical workflows:
845
+ Click-to-start app:
846
+ bp audio setup -s vt
847
+ bp exec -s vt '{"action":"goto","url":"https://my-voice-app.com"}'
848
+ bp snapshot -i -s vt
849
+ bp exec -s vt '{"action":"click","selector":"ref:e4"}'
850
+ bp audio check -s vt
851
+ bp audio roundtrip -s vt -i prompt.wav --transcribe -o response.wav
852
+
853
+ Auto-start app:
854
+ bp audio setup -s vt
855
+ bp exec -s vt '{"action":"goto","url":"https://my-voice-app.com"}'
856
+ bp audio check -s vt
857
+ bp audio roundtrip -s vt -i prompt.wav --transcribe
858
+
859
+ Likely next commands:
860
+ bp trace summary -s vt --view voice
861
+ bp record -s vt --profile voice
862
+ bp env permissions grant -s vt microphone
533
863
 
534
864
  Tips:
535
- - Default --silence-timeout is 1500ms. Agents rarely pause >1.5s mid-reply,
536
- so this works well. Increase only if your agent has long thinking pauses.
537
- - Use --pre-delay if the page needs time after audio injection.
538
- - --send-selector for push-to-talk UIs (click after speaking).
539
- - --transcribe adds ~1-2s (Whisper is fast). Safe in hot loop.
540
- - Use --verbose to see per-chunk RMS and silence detection.
541
- - Use --json for structured output in CI/scripting.
865
+ - \`bp audio check\` is the first diagnostic command.
866
+ - If you see 0 AudioContexts, the app has not initialized yet.
867
+ - If you see NOT READY, run \`bp audio setup\` and reload or re-open the app flow.
868
+ - Use \`--send-selector\` for push-to-talk UIs.
869
+ - Use \`bp trace summary --view voice\` when the question is causal, not just operational.
542
870
 
543
871
  Environment:
544
872
  OPENAI_API_KEY Required for --transcribe. Validated immediately on use.
@@ -611,10 +939,14 @@ async function resolveConnection(sessionId, useLatestSession, trace) {
611
939
  }
612
940
  let wsUrl;
613
941
  try {
614
- wsUrl = await getBrowserWebSocketUrl("localhost:9222");
615
- } catch {
942
+ wsUrl = (await resolveCLIEndpoint()).wsUrl;
943
+ } catch (error) {
616
944
  throw new Error(
617
- "Could not auto-discover browser.\nEither:\n 1. Start Chrome with: --remote-debugging-port=9222\n 2. Use an existing session: bp audio -s <session-id>\n 3. Use latest session: bp audio -s"
945
+ formatBrowserDiscoveryError(error, {
946
+ explicitHint: " - Create a session first: bp connect --browser-url <ws-url>",
947
+ reuseSessionHint: "bp audio -s <session-id>",
948
+ latestSessionHint: "bp audio -s"
949
+ })
618
950
  );
619
951
  }
620
952
  const browser = await connect({ provider: "generic", wsUrl, debug: trace });
@@ -633,13 +965,13 @@ async function resolveConnection(sessionId, useLatestSession, trace) {
633
965
  return { browser, session, isNewSession: true };
634
966
  }
635
967
  async function readInputFile(filePath) {
636
- const fs7 = await import("fs/promises");
637
- const buffer = await fs7.readFile(filePath);
968
+ const fs9 = await import("fs/promises");
969
+ const buffer = await fs9.readFile(filePath);
638
970
  return new Uint8Array(buffer);
639
971
  }
640
972
  async function getFileSize(filePath) {
641
- const fs7 = await import("fs/promises");
642
- const stat = await fs7.stat(filePath);
973
+ const fs9 = await import("fs/promises");
974
+ const stat = await fs9.stat(filePath);
643
975
  return stat.size;
644
976
  }
645
977
  function formatMs(ms) {
@@ -654,8 +986,8 @@ function basename(filePath) {
654
986
  return filePath.split("/").pop()?.split("\\").pop() ?? filePath;
655
987
  }
656
988
  async function writeWavFile(filePath, data) {
657
- const fs7 = await import("fs/promises");
658
- await fs7.writeFile(filePath, new Uint8Array(data));
989
+ const fs9 = await import("fs/promises");
990
+ await fs9.writeFile(filePath, new Uint8Array(data));
659
991
  }
660
992
  var CHECK_DIAGNOSTICS_EXPRESSION = `
661
993
  (function() {
@@ -854,6 +1186,7 @@ async function audioCommand(args, globalOptions) {
854
1186
  options.useLatestSession ?? false,
855
1187
  globalOptions.trace ?? false
856
1188
  );
1189
+ const logger = getSessionLogger(session.id, session.exportLog);
857
1190
  if (isNewSession) {
858
1191
  console.log(`Created new session: ${session.id}`);
859
1192
  }
@@ -863,6 +1196,12 @@ async function audioCommand(args, globalOptions) {
863
1196
  case "setup": {
864
1197
  await page.setupAudio();
865
1198
  const msg = "Audio I/O set up (microphone override + output capture ready)";
1199
+ logger.logTrace({
1200
+ channel: "voice",
1201
+ event: "voice.pipeline.ready",
1202
+ summary: msg,
1203
+ data: { subcommand: "setup" }
1204
+ });
866
1205
  output(
867
1206
  globalOptions.format === "json" ? { success: true, message: msg } : msg,
868
1207
  globalOptions.format
@@ -875,8 +1214,24 @@ async function audioCommand(args, globalOptions) {
875
1214
  const parsedDiag = JSON.parse(rawDiag);
876
1215
  if (!isRecord(parsedDiag)) throw new Error("Invalid audio diagnostics payload");
877
1216
  const diag = parsedDiag;
1217
+ const checkJson = buildCheckJson(diag);
1218
+ logger.logTrace({
1219
+ channel: "voice",
1220
+ event: checkJson.ready ? "voice.pipeline.ready" : "voice.pipeline.notReady",
1221
+ severity: checkJson.ready ? "info" : "error",
1222
+ summary: checkJson.ready ? "Audio pipeline ready" : "Audio pipeline not ready",
1223
+ data: checkJson
1224
+ });
1225
+ if (checkJson.agentDetected) {
1226
+ logger.logTrace({
1227
+ channel: "media",
1228
+ event: "media.track.started",
1229
+ summary: "Audio track detected during audio check",
1230
+ data: { kind: "audio", sampleRate: checkJson.agentSampleRate }
1231
+ });
1232
+ }
878
1233
  if (globalOptions.format === "json") {
879
- output(buildCheckJson(diag), "json");
1234
+ output(checkJson, "json");
880
1235
  } else {
881
1236
  console.log(formatCheckPretty(diag));
882
1237
  }
@@ -889,8 +1244,20 @@ async function audioCommand(args, globalOptions) {
889
1244
  const audioData = await readInputFile(options.input);
890
1245
  console.log(`Playing ${options.input} (${audioData.length} bytes)...`);
891
1246
  const start = Date.now();
1247
+ logger.logTrace({
1248
+ channel: "voice",
1249
+ event: "voice.capture.started",
1250
+ summary: "Audio playback started",
1251
+ data: { file: options.input }
1252
+ });
892
1253
  await page.audioInput.play(audioData, { waitForEnd: !options.noWait });
893
1254
  const durationMs = Date.now() - start;
1255
+ logger.logTrace({
1256
+ channel: "voice",
1257
+ event: "voice.capture.stopped",
1258
+ summary: "Audio playback finished",
1259
+ data: { file: options.input, durationMs }
1260
+ });
894
1261
  const result = { success: true, file: options.input, durationMs };
895
1262
  output(
896
1263
  globalOptions.format === "json" ? result : `Playback complete (${durationMs}ms)`,
@@ -908,6 +1275,12 @@ async function audioCommand(args, globalOptions) {
908
1275
  });
909
1276
  }
910
1277
  let capture;
1278
+ logger.logTrace({
1279
+ channel: "media",
1280
+ event: "media.playback.started",
1281
+ summary: "Audio capture started",
1282
+ data: { subcommand: "capture" }
1283
+ });
911
1284
  if (options.duration && options.duration > 0) {
912
1285
  await page.audioOutput.start();
913
1286
  await sleep(options.duration);
@@ -919,6 +1292,15 @@ async function audioCommand(args, globalOptions) {
919
1292
  maxDuration: options.maxDuration ?? 3e5
920
1293
  });
921
1294
  }
1295
+ logger.logTrace({
1296
+ channel: "media",
1297
+ event: "media.playback.stopped",
1298
+ summary: "Audio capture stopped",
1299
+ data: {
1300
+ durationMs: Math.round(capture.durationMs),
1301
+ samples: capture.left.length
1302
+ }
1303
+ });
922
1304
  if (options.out) {
923
1305
  const wav = pcmToWav({
924
1306
  left: capture.left,
@@ -987,6 +1369,12 @@ async function audioCommand(args, globalOptions) {
987
1369
  preDelay: options.preDelay,
988
1370
  sendSelector: options.sendSelector
989
1371
  });
1372
+ logger.logTrace({
1373
+ channel: "voice",
1374
+ event: "voice.capture.started",
1375
+ summary: "Voice roundtrip started",
1376
+ data: { file: options.input }
1377
+ });
990
1378
  let savedFile;
991
1379
  if (options.out) {
992
1380
  if (result.audio.left.length === 0) {
@@ -1007,6 +1395,17 @@ async function audioCommand(args, globalOptions) {
1007
1395
  transcript = tr.text;
1008
1396
  }
1009
1397
  const hasResponse = result.latencyMs !== -1 && result.audio.left.length > 0;
1398
+ logger.logTrace({
1399
+ channel: hasResponse ? "voice" : "media",
1400
+ event: hasResponse ? "voice.capture.detectedAudio" : "voice.pipeline.notReady",
1401
+ severity: hasResponse ? "info" : "error",
1402
+ summary: hasResponse ? "Voice response captured" : "Voice response missing",
1403
+ data: {
1404
+ latencyMs: result.latencyMs,
1405
+ durationMs: Math.round(result.audio.durationMs),
1406
+ samples: result.audio.left.length
1407
+ }
1408
+ });
1010
1409
  if (globalOptions.format === "json") {
1011
1410
  const jsonResult = {
1012
1411
  success: true,
@@ -1061,17 +1460,17 @@ Available: setup, play, capture, roundtrip, check`
1061
1460
  }
1062
1461
  }
1063
1462
  function sleep(ms) {
1064
- return new Promise((resolve5) => setTimeout(resolve5, ms));
1463
+ return new Promise((resolve8) => setTimeout(resolve8, ms));
1065
1464
  }
1066
1465
 
1067
1466
  // src/cli/commands/clean.ts
1068
- import * as fs2 from "fs";
1069
- import { homedir as homedir2 } from "os";
1070
- import { join as join3 } from "path";
1467
+ import * as fs4 from "fs";
1468
+ import { homedir as homedir4 } from "os";
1469
+ import { join as join5 } from "path";
1071
1470
 
1072
1471
  // src/daemon/lifecycle.ts
1073
- import * as fs from "fs";
1074
- import { dirname, join as join2 } from "path";
1472
+ import * as fs3 from "fs";
1473
+ import { dirname as dirname3, join as join4 } from "path";
1075
1474
  function parseSessionRecord(raw) {
1076
1475
  const parsed = JSON.parse(raw);
1077
1476
  return isRecord(parsed) ? parsed : null;
@@ -1115,7 +1514,7 @@ async function clearDaemonFromSession(sessionFilePath) {
1115
1514
  }
1116
1515
 
1117
1516
  // src/cli/commands/clean.ts
1118
- var SESSION_DIR2 = join3(homedir2(), ".browser-pilot", "sessions");
1517
+ var SESSION_DIR4 = join5(homedir4(), ".browser-pilot", "sessions");
1119
1518
  var CLEAN_HELP = `
1120
1519
  bp clean - Remove stale browser sessions
1121
1520
 
@@ -1161,12 +1560,12 @@ function formatBytes2(bytes) {
1161
1560
  }
1162
1561
  function getSessionSize(sessionId) {
1163
1562
  let total = 0;
1164
- const jsonPath = join3(SESSION_DIR2, `${sessionId}.json`);
1563
+ const jsonPath = join5(SESSION_DIR4, `${sessionId}.json`);
1165
1564
  try {
1166
- total += fs2.statSync(jsonPath).size;
1565
+ total += fs4.statSync(jsonPath).size;
1167
1566
  } catch {
1168
1567
  }
1169
- const dirPath = join3(SESSION_DIR2, sessionId);
1568
+ const dirPath = join5(SESSION_DIR4, sessionId);
1170
1569
  try {
1171
1570
  total += getDirSize(dirPath);
1172
1571
  } catch {
@@ -1176,14 +1575,14 @@ function getSessionSize(sessionId) {
1176
1575
  function getDirSize(dirPath) {
1177
1576
  let total = 0;
1178
1577
  try {
1179
- const entries = fs2.readdirSync(dirPath, { withFileTypes: true });
1578
+ const entries = fs4.readdirSync(dirPath, { withFileTypes: true });
1180
1579
  for (const entry of entries) {
1181
- const fullPath = join3(dirPath, entry.name);
1580
+ const fullPath = join5(dirPath, entry.name);
1182
1581
  if (entry.isDirectory()) {
1183
1582
  total += getDirSize(fullPath);
1184
1583
  } else if (entry.isFile()) {
1185
1584
  try {
1186
- total += fs2.statSync(fullPath).size;
1585
+ total += fs4.statSync(fullPath).size;
1187
1586
  } catch {
1188
1587
  }
1189
1588
  }
@@ -1398,12 +1797,12 @@ async function closeCommand(args, globalOptions) {
1398
1797
 
1399
1798
  // src/cli/daemon-spawn.ts
1400
1799
  import { spawn } from "child_process";
1401
- import * as fs3 from "fs";
1402
- import { dirname as dirname2, join as join4, resolve } from "path";
1800
+ import * as fs5 from "fs";
1801
+ import { dirname as dirname4, join as join6, resolve as resolve3 } from "path";
1403
1802
  import { fileURLToPath } from "url";
1404
1803
  function spawnDaemon(sessionId, idleTimeoutMs) {
1405
- const thisDir = dirname2(fileURLToPath(import.meta.url));
1406
- const daemonScript = resolve(join4(thisDir, "..", "daemon", "index.ts"));
1804
+ const thisDir = dirname4(fileURLToPath(import.meta.url));
1805
+ const daemonScript = resolve3(join6(thisDir, "..", "daemon", "index.ts"));
1407
1806
  const args = [daemonScript, sessionId];
1408
1807
  if (idleTimeoutMs) {
1409
1808
  args.push("--idle-timeout", String(idleTimeoutMs));
@@ -1423,7 +1822,7 @@ async function waitForDaemonReady(sessionFilePath, expectedPid, timeoutMs = DAEM
1423
1822
  const pollInterval = 100;
1424
1823
  while (Date.now() < deadline) {
1425
1824
  try {
1426
- const raw = fs3.readFileSync(sessionFilePath, "utf-8");
1825
+ const raw = fs5.readFileSync(sessionFilePath, "utf-8");
1427
1826
  const parsed = JSON.parse(raw);
1428
1827
  if (!isRecord(parsed)) continue;
1429
1828
  const daemon = isRecord(parsed["daemon"]) ? parsed["daemon"] : null;
@@ -1448,6 +1847,8 @@ Options:
1448
1847
  -p, --provider <type> Provider: generic | browserbase | browserless (default: generic)
1449
1848
  --url <value> Browser WebSocket URL, or page URL when used with --new-tab
1450
1849
  --browser-url <ws-url> Explicit browser WebSocket URL
1850
+ --channel <name> Local Chrome channel: stable | beta | dev | canary
1851
+ --user-data-dir <path> Explicit local Chrome user data dir for auto-discovery
1451
1852
  --page-url <url> URL to open in the attached page/new tab
1452
1853
  -n, --name <id> Custom session name (default: auto-generated)
1453
1854
  -r, --resume <id> Resume an existing session by ID
@@ -1467,10 +1868,12 @@ Options:
1467
1868
  -h, --help Show this help
1468
1869
 
1469
1870
  Examples:
1470
- bp connect # Auto-connect to local Chrome (port 9222)
1871
+ bp connect # Auto-connect to local Chrome
1872
+ bp connect --channel beta # Narrow auto-discovery to Chrome Beta
1873
+ bp connect --user-data-dir ~/tmp/chrome-dev # Use a specific Chrome profile
1471
1874
  bp connect --record # Connect with session-level recording
1472
- bp connect --provider generic --name dev # Connect with custom session name
1473
- bp connect --url ws://localhost:9222/devtools # Explicit WebSocket URL
1875
+ bp connect --name dev # Auto-connect with a custom session name
1876
+ bp connect --url ws://localhost:9222/devtools/browser/abc123 # Explicit WebSocket URL
1474
1877
  bp connect --resume dev # Resume a previous session
1475
1878
  bp connect --target-url localhost:3000 # Attach to tab matching URL
1476
1879
  bp connect --new-tab --url https://example.com # Create and attach to a fresh tab
@@ -1486,6 +1889,14 @@ function parseConnectArgs(args) {
1486
1889
  options.url = args[++i];
1487
1890
  } else if (arg === "--browser-url") {
1488
1891
  options.browserUrl = args[++i];
1892
+ } else if (arg === "--channel") {
1893
+ const channel = args[++i];
1894
+ if (channel !== "stable" && channel !== "beta" && channel !== "dev" && channel !== "canary") {
1895
+ throw new Error("--channel must be one of: stable, beta, dev, canary");
1896
+ }
1897
+ options.channel = channel;
1898
+ } else if (arg === "--user-data-dir") {
1899
+ options.userDataDir = args[++i];
1489
1900
  } else if (arg === "--page-url") {
1490
1901
  options.pageUrl = args[++i];
1491
1902
  } else if (arg === "--name" || arg === "-n") {
@@ -1561,6 +1972,9 @@ async function connectCommand(args, globalOptions) {
1561
1972
  const provider = options.provider ?? "generic";
1562
1973
  let wsUrl = options.browserUrl ?? options.url;
1563
1974
  let pageUrl = options.pageUrl;
1975
+ let connectionSource;
1976
+ let resolvedChannel;
1977
+ let resolvedUserDataDir;
1564
1978
  if (options.newTab && options.url && !options.url.startsWith("ws://") && !options.url.startsWith("wss://")) {
1565
1979
  pageUrl = options.url;
1566
1980
  if (!options.browserUrl) {
@@ -1569,17 +1983,31 @@ async function connectCommand(args, globalOptions) {
1569
1983
  }
1570
1984
  if (provider === "generic" && !wsUrl) {
1571
1985
  try {
1572
- wsUrl = await getBrowserWebSocketUrl("localhost:9222");
1573
- } catch {
1986
+ const resolved = await resolveCLIEndpoint({
1987
+ explicitWsUrl: wsUrl,
1988
+ channel: options.channel,
1989
+ userDataDir: options.userDataDir
1990
+ });
1991
+ wsUrl = resolved.wsUrl;
1992
+ connectionSource = resolved.source;
1993
+ resolvedChannel = resolved.channel;
1994
+ resolvedUserDataDir = resolved.userDataDir;
1995
+ } catch (error) {
1574
1996
  throw new Error(
1575
- "Could not auto-discover browser. Specify --url or start Chrome with --remote-debugging-port=9222"
1997
+ formatBrowserDiscoveryError(error, {
1998
+ explicitFlag: "--browser-url"
1999
+ })
1576
2000
  );
1577
2001
  }
2002
+ } else if (wsUrl) {
2003
+ connectionSource = "explicit-ws";
1578
2004
  }
1579
2005
  const connectOptions = {
1580
2006
  provider,
1581
2007
  debug: globalOptions.trace,
1582
2008
  wsUrl,
2009
+ channel: options.channel,
2010
+ userDataDir: options.userDataDir,
1583
2011
  apiKey: options.apiKey,
1584
2012
  projectId: options.projectId
1585
2013
  };
@@ -1609,9 +2037,13 @@ async function connectCommand(args, globalOptions) {
1609
2037
  currentUrl,
1610
2038
  metadata: {
1611
2039
  ...browser.metadata,
2040
+ ...connectionSource ? { connectionSource } : {},
2041
+ ...resolvedChannel ? { resolvedChannel } : {},
2042
+ ...resolvedUserDataDir ? { resolvedUserDataDir } : {},
1612
2043
  ...recordSettings ? { record: recordSettings } : {}
1613
2044
  }
1614
2045
  };
2046
+ const outputMetadata = session.metadata;
1615
2047
  await saveSession(session);
1616
2048
  await browser.disconnect();
1617
2049
  let daemonResult;
@@ -1639,7 +2071,10 @@ async function connectCommand(args, globalOptions) {
1639
2071
  provider,
1640
2072
  currentUrl,
1641
2073
  recording: !!recordSettings,
1642
- metadata: browser.metadata,
2074
+ connectionSource,
2075
+ resolvedChannel,
2076
+ resolvedUserDataDir,
2077
+ metadata: outputMetadata,
1643
2078
  daemon: daemonResult
1644
2079
  },
1645
2080
  globalOptions.format
@@ -1647,10 +2082,10 @@ async function connectCommand(args, globalOptions) {
1647
2082
  }
1648
2083
 
1649
2084
  // src/cli/commands/daemon.ts
1650
- import * as fs4 from "fs";
1651
- import { homedir as homedir3 } from "os";
1652
- import { join as join5 } from "path";
1653
- var SESSION_DIR3 = join5(homedir3(), ".browser-pilot", "sessions");
2085
+ import * as fs6 from "fs";
2086
+ import { homedir as homedir5 } from "os";
2087
+ import { join as join7 } from "path";
2088
+ var SESSION_DIR5 = join7(homedir5(), ".browser-pilot", "sessions");
1654
2089
  var DAEMON_HELP = `
1655
2090
  bp daemon - Manage daemon processes for browser sessions
1656
2091
 
@@ -1766,12 +2201,12 @@ async function daemonCommand(args, globalOptions) {
1766
2201
  }
1767
2202
  case "logs": {
1768
2203
  const session = await getSession(globalOptions);
1769
- const logPath = join5(SESSION_DIR3, session.id, "daemon.log");
1770
- if (!fs4.existsSync(logPath)) {
2204
+ const logPath = join7(SESSION_DIR5, session.id, "daemon.log");
2205
+ if (!fs6.existsSync(logPath)) {
1771
2206
  output({ sessionId: session.id, message: "No daemon log found" }, globalOptions.format);
1772
2207
  return;
1773
2208
  }
1774
- const content = fs4.readFileSync(logPath, "utf-8");
2209
+ const content = fs6.readFileSync(logPath, "utf-8");
1775
2210
  const lines = content.trim().split("\n");
1776
2211
  const n = daemonOptions.lines ?? 50;
1777
2212
  const tail = lines.slice(-n);
@@ -2278,6 +2713,15 @@ async function diagnoseElement(page, selector, options = {}) {
2278
2713
  var DIAGNOSE_HELP = `
2279
2714
  bp diagnose - Debug element selection and find alternatives
2280
2715
 
2716
+ When to use:
2717
+ A selector or ref failed and you need to understand why.
2718
+
2719
+ When not to use:
2720
+ You have not inspected the page yet. Start with \`bp snapshot -i\`.
2721
+
2722
+ Common mistake:
2723
+ Jumping to raw JavaScript before checking browser-pilot's selector diagnostics and suggested alternatives.
2724
+
2281
2725
  Usage:
2282
2726
  bp diagnose <selector> Diagnose specific selector
2283
2727
  bp diagnose "<fuzzy query>" Fuzzy search for elements
@@ -2288,19 +2732,14 @@ Examples:
2288
2732
  bp diagnose "ref:e4" Diagnose by element ref
2289
2733
 
2290
2734
  Options:
2291
- --json Output as JSON
2292
- --max <n> Max candidates for fuzzy match (default: 5)
2293
- -s, --session <id> Use specific session
2294
- --help Show this help
2295
-
2296
- Output (exact match):
2297
- - Visibility: display, opacity, in viewport
2298
- - Interactivity: disabled, covered by overlay
2299
- - Alternative selectors
2300
-
2301
- Output (fuzzy match):
2302
- - Top N candidates ranked by similarity
2303
- - Role, name, visibility for each
2735
+ --json Output as JSON
2736
+ --max <n> Max candidates for fuzzy match (default: 5)
2737
+ -s, --session <id> Use specific session
2738
+ --help Show this help
2739
+
2740
+ Likely next commands:
2741
+ bp exec '[{"action":"click","selector":"<suggested-selector>"}]'
2742
+ bp snapshot -i
2304
2743
  `;
2305
2744
  function parseDiagnoseArgs(args) {
2306
2745
  const options = {};
@@ -2430,367 +2869,1069 @@ async function diagnoseCommand(args, globalOptions) {
2430
2869
  }
2431
2870
  }
2432
2871
 
2433
- // src/cli/attach.ts
2434
- async function resolveSession(sessionId) {
2435
- if (sessionId) {
2436
- return loadSession(sessionId);
2872
+ // src/cli/commands/env.ts
2873
+ import { dirname as dirname5 } from "path";
2874
+
2875
+ // src/cli/env-state.ts
2876
+ function normalizeStoredPermission(name) {
2877
+ const value = String(name).trim().toLowerCase();
2878
+ if (value === "microphone" || value === "audio" || value === "audiocapture" || value === "audio-capture") {
2879
+ return "microphone";
2437
2880
  }
2438
- const session = await getDefaultSession();
2439
- if (!session) {
2440
- throw new Error('No session found. Run "bp connect" first.');
2881
+ if (value === "camera" || value === "videocapture" || value === "video-capture") {
2882
+ return "camera";
2441
2883
  }
2442
- return session;
2884
+ if (value === "notifications") {
2885
+ return "notifications";
2886
+ }
2887
+ if (value === "geolocation") {
2888
+ return "geolocation";
2889
+ }
2890
+ return null;
2443
2891
  }
2444
- function isDaemonHealthy(session) {
2445
- if (!session.daemon) return false;
2446
- const daemonAge = Date.now() - new Date(session.daemon.startedAt).getTime();
2447
- if (daemonAge > DAEMON_MAX_AGE_MS) {
2448
- return false;
2449
- }
2450
- if (session.daemon.lastHeartbeat) {
2451
- const heartbeatAge = Date.now() - new Date(session.daemon.lastHeartbeat).getTime();
2452
- if (heartbeatAge > 9e4) {
2453
- return false;
2892
+ function toProtocolPermission(name) {
2893
+ const normalized = normalizeStoredPermission(name);
2894
+ if (normalized === "microphone") return "audioCapture";
2895
+ if (normalized === "camera") return "videoCapture";
2896
+ if (normalized === "notifications") return "notifications";
2897
+ if (normalized === "geolocation") return "geolocation";
2898
+ return null;
2899
+ }
2900
+ function originFromUrl(url) {
2901
+ try {
2902
+ const parsed = new URL(url ?? "");
2903
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") {
2904
+ return parsed.origin;
2454
2905
  }
2906
+ return void 0;
2907
+ } catch {
2908
+ return void 0;
2455
2909
  }
2456
- return isDaemonAlive(session.daemon.pid);
2457
2910
  }
2458
- async function cleanupStaleDaemon(session, reason) {
2459
- console.warn(`[browser-pilot] Daemon unavailable (${reason}), falling back to direct WebSocket`);
2460
- const sessionFilePath = getSessionFilePath(session.id);
2461
- await clearDaemonFromSession(sessionFilePath);
2462
- if (session.daemon?.socketPath) {
2463
- try {
2464
- const fsPromises = await import("fs/promises");
2465
- await fsPromises.unlink(session.daemon.socketPath).catch(() => {
2911
+ function buildPermissionOverrideScript(granted) {
2912
+ const normalized = [
2913
+ ...new Set(granted.map((value) => normalizeStoredPermission(value)).filter(Boolean))
2914
+ ];
2915
+ return `
2916
+ (() => {
2917
+ const granted = ${JSON.stringify(normalized)};
2918
+ globalThis.__bpGrantedPermissions = granted;
2919
+
2920
+ if (!navigator.permissions || !navigator.permissions.query) {
2921
+ return;
2922
+ }
2923
+
2924
+ if (!globalThis.__bpPermissionOverrideInstalled) {
2925
+ globalThis.__bpPermissionOverrideInstalled = true;
2926
+ const originalQuery = navigator.permissions.query.bind(navigator.permissions);
2927
+ navigator.permissions.query = function(desc) {
2928
+ const rawName = desc && desc.name ? String(desc.name) : '';
2929
+ const normalizedName =
2930
+ rawName === 'audio-capture' || rawName === 'audioCapture'
2931
+ ? 'microphone'
2932
+ : rawName === 'video-capture' || rawName === 'videoCapture'
2933
+ ? 'camera'
2934
+ : rawName;
2935
+ if (Array.isArray(globalThis.__bpGrantedPermissions) && globalThis.__bpGrantedPermissions.includes(normalizedName)) {
2936
+ return Promise.resolve({
2937
+ state: 'granted',
2938
+ onchange: null,
2939
+ addEventListener() {},
2940
+ removeEventListener() {},
2941
+ dispatchEvent() { return true; },
2942
+ });
2943
+ }
2944
+ return originalQuery(desc);
2945
+ };
2946
+ }
2947
+ })();
2948
+ `.trim();
2949
+ }
2950
+ function buildVisibilityOverrideScript(state) {
2951
+ return `
2952
+ (() => {
2953
+ const nextState = ${JSON.stringify(state)};
2954
+
2955
+ if (!globalThis.__bpApplyVisibilityState) {
2956
+ globalThis.__bpApplyVisibilityState = function(value) {
2957
+ Object.defineProperty(document, 'visibilityState', {
2958
+ configurable: true,
2959
+ get() { return value; },
2466
2960
  });
2467
- } catch {
2468
- }
2961
+ Object.defineProperty(document, 'hidden', {
2962
+ configurable: true,
2963
+ get() { return value === 'hidden'; },
2964
+ });
2965
+ document.dispatchEvent(new Event('visibilitychange'));
2966
+ };
2469
2967
  }
2968
+
2969
+ globalThis.__bpForcedVisibilityState = nextState;
2970
+ globalThis.__bpApplyVisibilityState(nextState);
2971
+ })();
2972
+ `.trim();
2470
2973
  }
2471
- async function attachSession(session, options = {}) {
2472
- if (session.daemon) {
2473
- if (!isDaemonHealthy(session)) {
2474
- const reason = !isDaemonAlive(session.daemon.pid) ? "PID not alive" : "daemon expired (>60min)";
2475
- await cleanupStaleDaemon(session, reason);
2476
- } else {
2477
- try {
2478
- const { createDaemonTransport } = await import("./transport-WHEBAZUP.mjs");
2479
- const { createCDPClientFromTransport } = await import("./client-3AFV2IAF.mjs");
2480
- const transport = await createDaemonTransport(session.daemon.socketPath);
2481
- const cdp = createCDPClientFromTransport(transport, {
2482
- debug: options.trace
2974
+ function buildNetworkOverrideScript(state) {
2975
+ const normalized = {
2976
+ offline: Boolean(state?.offline),
2977
+ latency: Math.max(0, Math.round(state?.latency ?? 0))
2978
+ };
2979
+ return `
2980
+ (() => {
2981
+ const config = ${JSON.stringify(normalized)};
2982
+ globalThis.__bpNetworkOverrideState = config;
2983
+ globalThis.__bpTrackedWebSockets = globalThis.__bpTrackedWebSockets || new Set();
2984
+
2985
+ if (!globalThis.__bpNetworkOverrideInstalled) {
2986
+ globalThis.__bpNetworkOverrideInstalled = true;
2987
+
2988
+ const originalFetch = globalThis.fetch ? globalThis.fetch.bind(globalThis) : null;
2989
+ if (originalFetch) {
2990
+ globalThis.fetch = function(...args) {
2991
+ const current = globalThis.__bpNetworkOverrideState || { offline: false, latency: 0 };
2992
+ if (current.offline) {
2993
+ return Promise.reject(new TypeError('Failed to fetch'));
2994
+ }
2995
+ return new Promise((resolve, reject) => {
2996
+ setTimeout(() => {
2997
+ originalFetch(...args).then(resolve, reject);
2998
+ }, current.latency || 0);
2483
2999
  });
2484
- const { Browser: BrowserClass } = await import("./browser-LZTEHUDI.mjs");
2485
- const browser2 = BrowserClass.fromCDP(cdp, session);
2486
- const page2 = addBatchToPage(await browser2.page(void 0, { targetId: session.targetId }));
2487
- const currentUrl2 = await page2.url();
2488
- const refCache2 = session.metadata?.refCache;
2489
- if (refCache2 && refCache2.url === currentUrl2) {
2490
- page2.importRefMap(refCache2.refMap);
3000
+ };
3001
+ }
3002
+
3003
+ if (globalThis.XMLHttpRequest && !globalThis.__bpNetworkXhrPatched) {
3004
+ globalThis.__bpNetworkXhrPatched = true;
3005
+ const originalSend = XMLHttpRequest.prototype.send;
3006
+ XMLHttpRequest.prototype.send = function(body) {
3007
+ const current = globalThis.__bpNetworkOverrideState || { offline: false, latency: 0 };
3008
+ if (current.offline) {
3009
+ setTimeout(() => {
3010
+ this.dispatchEvent(new ProgressEvent('error'));
3011
+ if (typeof this.onerror === 'function') {
3012
+ this.onerror(new ProgressEvent('error'));
3013
+ }
3014
+ }, 0);
3015
+ return;
2491
3016
  }
2492
- return { session, browser: browser2, page: page2, viaDaemon: true };
2493
- } catch (err) {
2494
- const reason = err instanceof Error ? err.message : String(err);
2495
- await cleanupStaleDaemon(session, reason);
3017
+ const invoke = () => originalSend.call(this, body);
3018
+ if (current.latency > 0) {
3019
+ setTimeout(invoke, current.latency);
3020
+ } else {
3021
+ invoke();
3022
+ }
3023
+ };
3024
+ }
3025
+
3026
+ if (globalThis.WebSocket && !globalThis.__bpNetworkWebSocketPatched) {
3027
+ globalThis.__bpNetworkWebSocketPatched = true;
3028
+ const NativeWebSocket = globalThis.WebSocket;
3029
+ const notifyOfflineSocket = (socket, code = 1012, reason = 'offline') => {
3030
+ if (!socket || socket.__bpOfflineNotified) {
3031
+ return;
3032
+ }
3033
+ try {
3034
+ socket.__bpOfflineNotified = true;
3035
+ } catch {}
3036
+ try {
3037
+ socket.readyState = 3;
3038
+ } catch {}
3039
+ const errorEvent = new Event('error');
3040
+ const closeEvent =
3041
+ typeof CloseEvent === 'function'
3042
+ ? new CloseEvent('close', { code, reason, wasClean: false })
3043
+ : new Event('close');
3044
+ try {
3045
+ socket.dispatchEvent(errorEvent);
3046
+ } catch {}
3047
+ try {
3048
+ if (typeof socket.onerror === 'function') {
3049
+ socket.onerror(errorEvent);
3050
+ }
3051
+ } catch {}
3052
+ try {
3053
+ socket.dispatchEvent(closeEvent);
3054
+ } catch {}
3055
+ try {
3056
+ if (typeof socket.onclose === 'function') {
3057
+ socket.onclose(closeEvent);
3058
+ }
3059
+ } catch {}
3060
+ };
3061
+ const createOfflineSocket = (url) => {
3062
+ const target = new EventTarget();
3063
+ const socket = {
3064
+ url: String(url),
3065
+ readyState: 0,
3066
+ bufferedAmount: 0,
3067
+ extensions: '',
3068
+ protocol: '',
3069
+ binaryType: 'blob',
3070
+ onopen: null,
3071
+ onerror: null,
3072
+ onclose: null,
3073
+ onmessage: null,
3074
+ addEventListener: target.addEventListener.bind(target),
3075
+ removeEventListener: target.removeEventListener.bind(target),
3076
+ dispatchEvent: target.dispatchEvent.bind(target),
3077
+ send() {
3078
+ throw new DOMException('WebSocket is offline', 'InvalidStateError');
3079
+ },
3080
+ close(code = 1012, reason = 'offline') {
3081
+ socket.readyState = 3;
3082
+ notifyOfflineSocket(socket, code, reason);
3083
+ },
3084
+ };
3085
+ setTimeout(() => {
3086
+ notifyOfflineSocket(socket, 1012, 'offline');
3087
+ }, 0);
3088
+ return socket;
3089
+ };
3090
+ const WrappedWebSocket = function(url, protocols) {
3091
+ const current = globalThis.__bpNetworkOverrideState || { offline: false };
3092
+ if (current.offline) {
3093
+ return createOfflineSocket(url);
3094
+ }
3095
+ const socket =
3096
+ arguments.length > 1 ? new NativeWebSocket(url, protocols) : new NativeWebSocket(url);
3097
+ globalThis.__bpTrackedWebSockets.add(socket);
3098
+ const nativeClose = socket.close.bind(socket);
3099
+ socket.close = function(code = 1000, reason = '') {
3100
+ const current = globalThis.__bpNetworkOverrideState || { offline: false };
3101
+ if (current.offline) {
3102
+ notifyOfflineSocket(socket, code || 1012, reason || 'offline');
3103
+ }
3104
+ return nativeClose(code, reason);
3105
+ };
3106
+ const failOffline = () => {
3107
+ const current = globalThis.__bpNetworkOverrideState || { offline: false };
3108
+ if (current.offline) {
3109
+ try {
3110
+ socket.close(1012, 'offline');
3111
+ } catch {}
3112
+ }
3113
+ };
3114
+ socket.addEventListener('open', failOffline);
3115
+ socket.addEventListener('close', () => {
3116
+ try {
3117
+ globalThis.__bpTrackedWebSockets.delete(socket);
3118
+ } catch {}
3119
+ });
3120
+ setTimeout(failOffline, 25);
3121
+ return socket;
3122
+ };
3123
+ WrappedWebSocket.prototype = NativeWebSocket.prototype;
3124
+ Object.setPrototypeOf(WrappedWebSocket, NativeWebSocket);
3125
+ globalThis.WebSocket = WrappedWebSocket;
3126
+ }
3127
+ }
3128
+
3129
+ if (globalThis.__bpTrackedWebSockets) {
3130
+ for (const socket of globalThis.__bpTrackedWebSockets) {
3131
+ if (socket && socket.readyState === 1 && config.offline) {
3132
+ try {
3133
+ socket.close(1012, 'offline');
3134
+ } catch {}
2496
3135
  }
2497
3136
  }
2498
3137
  }
2499
- let browser;
2500
- try {
2501
- browser = await connect({
2502
- provider: session.provider,
2503
- wsUrl: session.wsUrl,
2504
- debug: options.trace
2505
- });
2506
- } catch {
2507
- await deleteSession(session.id);
2508
- throw new Error(
2509
- `Session "${session.id}" is no longer valid (browser may have closed).
2510
- Session file has been cleaned up. Run "bp connect" to create a new session.`
2511
- );
3138
+
3139
+ if (globalThis.navigator && typeof globalThis.navigator === 'object') {
3140
+ try {
3141
+ Object.defineProperty(globalThis.navigator, 'onLine', {
3142
+ configurable: true,
3143
+ get() { return !config.offline; },
3144
+ });
3145
+ } catch {}
2512
3146
  }
2513
- const page = addBatchToPage(await browser.page(void 0, { targetId: session.targetId }));
2514
- const currentUrl = await page.url();
2515
- const refCache = session.metadata?.refCache;
2516
- if (refCache && refCache.url === currentUrl) {
2517
- page.importRefMap(refCache.refMap);
3147
+ })();
3148
+ `.trim();
3149
+ }
3150
+ async function applyPermissionState(cdp, origin, granted) {
3151
+ const protocolPermissions = [
3152
+ ...new Set(granted.map((value) => toProtocolPermission(value)).filter(Boolean))
3153
+ ];
3154
+ if (protocolPermissions.length > 0) {
3155
+ await cdp.send("Browser.grantPermissions", {
3156
+ permissions: protocolPermissions,
3157
+ origin: origin ?? ""
3158
+ });
2518
3159
  }
2519
- return { session, browser, page, viaDaemon: false };
3160
+ const script = buildPermissionOverrideScript(granted);
3161
+ await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: script });
3162
+ await cdp.send("Runtime.evaluate", { expression: script, awaitPromise: false });
3163
+ }
3164
+ async function applyVisibilityState(cdp, state) {
3165
+ const script = buildVisibilityOverrideScript(state);
3166
+ await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: script });
3167
+ await cdp.send("Runtime.evaluate", { expression: script, awaitPromise: false });
3168
+ }
3169
+ async function applyNetworkOverride(cdp, state) {
3170
+ const script = buildNetworkOverrideScript(state);
3171
+ await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: script });
3172
+ await cdp.send("Runtime.evaluate", { expression: script, awaitPromise: false });
2520
3173
  }
2521
3174
 
2522
- // src/cli/commands/eval.ts
2523
- var EVAL_HELP = `
2524
- bp eval - Evaluate JavaScript in the browser
3175
+ // src/cli/commands/env.ts
3176
+ var ENV_HELP = `
3177
+ bp env - Browser/session environment controls
2525
3178
 
2526
- Convenience wrapper around exec's evaluate action.
2527
- No JSON escaping needed -- just pass a JS expression directly.
3179
+ When to use:
3180
+ You need deterministic permission, network, visibility, or geolocation changes without dropping to raw CDP or eval.
3181
+
3182
+ When not to use:
3183
+ You are inspecting or automating DOM interactions. Use \`bp snapshot\`, \`bp exec\`, \`bp record\`, or \`bp trace\`.
3184
+
3185
+ Default flow:
3186
+ change environment -> run exec or audio flow -> inspect with trace summary or watch
3187
+
3188
+ Common mistake:
3189
+ Treating \`env\` as a generic utilities bucket. It is only for browser and session state controls.
3190
+
3191
+ Use this namespace when you need deterministic controls over browser permissions,
3192
+ network, visibility, or geolocation during investigation and automation.
2528
3193
 
2529
3194
  Usage:
2530
- bp eval '<expression>' Evaluate inline JavaScript
2531
- bp eval -f <file> Evaluate JavaScript from a file
2532
- echo '<expr>' | bp eval Evaluate from stdin
3195
+ bp env permissions <action> [permission] [options]
3196
+ bp env network <action> [options]
3197
+ bp env visibility <state> [options]
3198
+ bp env geolocation <action> [options]
2533
3199
 
2534
- Options:
2535
- -f, --file <path> Read JavaScript from a file
2536
- --wrap Wrap the expression in an async IIFE
2537
- -s, --session <id> Session to use (default: most recent)
2538
- -f, --format <fmt> Output format: json | pretty (default: pretty)
2539
- --json Alias for -f json
2540
- --trace Enable debug tracing
2541
- -h, --help Show this help
3200
+ Subcommands:
3201
+ permissions grant, revoke, reset, get
3202
+ network offline, online, throttle
3203
+ visibility hidden, visible
3204
+ geolocation set, clear
3205
+
3206
+ Common options:
3207
+ -s, --session <id> Session to use (omit: auto-connect, -s: latest, -s <id>: specific)
3208
+ -h, --help Show help
2542
3209
 
2543
3210
  Examples:
2544
- bp eval 'document.title'
2545
- bp eval 'document.querySelectorAll("a").length'
2546
- bp eval -f scrape.js
2547
- `.trimEnd();
2548
- function parseEvalArgs(args) {
3211
+ # Browser permissions
3212
+ bp env permissions get -s my-session microphone
3213
+ bp env permissions grant -s my-session microphone
3214
+ bp env permissions reset -s my-session
3215
+
3216
+ # Network control
3217
+ bp env network offline -s my-session
3218
+ bp env network online -s my-session
3219
+ bp env network throttle -s my-session --latency 200 --down 128kbps --up 64kbps
3220
+
3221
+ # Visibility
3222
+ bp env visibility hidden -s my-session
3223
+ bp env visibility visible -s my-session
3224
+
3225
+ # Geolocation
3226
+ bp env geolocation set -s my-session --lat 37.7749 --lon -122.4194
3227
+ bp env geolocation clear -s my-session
3228
+
3229
+ Likely next commands:
3230
+ bp trace watch -s my-session --view ws --assert profile:reconnect
3231
+ bp exec -s my-session '[{"action":"assertPermission","name":"microphone","state":"granted"}]'
3232
+ bp trace summary -s my-session --view permissions
3233
+ `;
3234
+ var PermissionNames;
3235
+ ((PermissionNames2) => {
3236
+ PermissionNames2.NAVIGATION = {
3237
+ microphone: "microphone",
3238
+ camera: "camera",
3239
+ notifications: "notifications",
3240
+ geolocation: "geolocation",
3241
+ audio: "audio",
3242
+ audioCapture: "audio-capture",
3243
+ all: "all"
3244
+ };
3245
+ PermissionNames2.PROTOCOL = {
3246
+ microphone: "audioCapture",
3247
+ camera: "videoCapture",
3248
+ notifications: "notifications",
3249
+ geolocation: "geolocation",
3250
+ audio: "audioCapture",
3251
+ audioCapture: "audioCapture",
3252
+ all: "all"
3253
+ };
3254
+ })(PermissionNames || (PermissionNames = {}));
3255
+ function parseEnvArgs(args) {
2549
3256
  const options = {};
2550
- let expression;
2551
- for (let i = 0; i < args.length; i++) {
3257
+ let i = 0;
3258
+ for (; i < args.length; i++) {
2552
3259
  const arg = args[i];
2553
- if (arg === "-f" || arg === "--file") {
2554
- options.file = args[++i];
2555
- } else if (arg === "--wrap") {
2556
- options.wrap = true;
2557
- } else if (!expression && !arg.startsWith("-")) {
2558
- expression = arg;
3260
+ if (!options.topCommand && (arg === "permissions" || arg === "network" || arg === "visibility" || arg === "geolocation")) {
3261
+ options.topCommand = arg;
3262
+ continue;
3263
+ }
3264
+ if (arg === "-h" || arg === "--help") {
3265
+ options.help = true;
3266
+ continue;
3267
+ }
3268
+ if (arg === "-s" || arg === "--session") {
3269
+ const next = args[i + 1];
3270
+ if (next && !next.startsWith("-")) {
3271
+ }
3272
+ const after = args[i + 1];
3273
+ if (!after || after.startsWith("-")) {
3274
+ options.useLatestSession = true;
3275
+ }
3276
+ continue;
3277
+ }
3278
+ if (arg === "--lat") {
3279
+ options.lat = Number.parseFloat(args[++i] ?? "0");
3280
+ continue;
3281
+ }
3282
+ if (arg === "--lon") {
3283
+ options.lon = Number.parseFloat(args[++i] ?? "0");
3284
+ continue;
3285
+ }
3286
+ if (arg === "--accuracy" || arg === "--acc") {
3287
+ options.accuracy = Number.parseFloat(args[++i] ?? "1");
3288
+ continue;
3289
+ }
3290
+ if (arg === "--duration") {
3291
+ const value = Number.parseInt(args[++i] ?? "0", 10);
3292
+ if (Number.isFinite(value) && value > 0) options.duration = value;
3293
+ continue;
3294
+ }
3295
+ if (arg === "--latency") {
3296
+ const value = Number.parseInt(args[++i] ?? "0", 10);
3297
+ if (Number.isFinite(value) && value >= 0) options.latency = value;
3298
+ continue;
3299
+ }
3300
+ if (arg === "--down") {
3301
+ options.down = args[++i];
3302
+ continue;
3303
+ }
3304
+ if (arg === "--up") {
3305
+ options.up = args[++i];
3306
+ continue;
3307
+ }
3308
+ if (!arg.startsWith("-") && options.topCommand) {
3309
+ if (options.topCommand === "permissions") {
3310
+ if (!options.permissionMode) {
3311
+ options.permissionMode = arg;
3312
+ continue;
3313
+ }
3314
+ if (!options.permissionName && options.permissionMode !== "get" && options.permissionMode !== "reset") {
3315
+ options.permissionName = arg;
3316
+ continue;
3317
+ }
3318
+ if (!options.permissionName && options.permissionMode === "get") {
3319
+ options.permissionName = arg;
3320
+ continue;
3321
+ }
3322
+ }
3323
+ if (options.topCommand === "network") {
3324
+ options.networkAction = arg;
3325
+ continue;
3326
+ }
3327
+ if (options.topCommand === "visibility") {
3328
+ options.visibility = arg;
3329
+ continue;
3330
+ }
3331
+ if (options.topCommand === "geolocation") {
3332
+ options.geoAction = arg;
3333
+ }
2559
3334
  }
2560
3335
  }
2561
- return { expression, options };
2562
- }
2563
- function normalizeEvalExpression(expression, wrap = false) {
2564
- const trimmed = expression.trim();
2565
- const needsWrap = wrap || trimmed.includes("=>") || /\bawait\b/.test(trimmed);
2566
- if (!needsWrap) {
2567
- return trimmed;
2568
- }
2569
- if (wrap || /\bawait\b/.test(trimmed)) {
2570
- return `(async () => (${trimmed}))()`;
2571
- }
2572
- return `(() => (${trimmed}))()`;
3336
+ return options;
2573
3337
  }
2574
- async function evalCommand(args, globalOptions) {
2575
- if (globalOptions.help) {
2576
- console.log(EVAL_HELP);
2577
- return;
2578
- }
2579
- const { expression: argExpression, options: evalOptions } = parseEvalArgs(args);
2580
- let expression = argExpression;
2581
- if (evalOptions.file) {
2582
- const fs7 = await import("fs/promises");
2583
- expression = await fs7.readFile(evalOptions.file, "utf-8");
3338
+ function coercePermissionArg(value) {
3339
+ return value;
3340
+ }
3341
+ function toBytesPerSecond(raw) {
3342
+ if (!raw) return void 0;
3343
+ const text = raw.trim().toLowerCase();
3344
+ const match = text.match(/^([0-9]*\.?[0-9]+)\s*(kbps|mbps|k|m)?$/);
3345
+ if (!match || !match[1]) return void 0;
3346
+ const value = Number.parseFloat(match[1]);
3347
+ if (!Number.isFinite(value) || value <= 0) return void 0;
3348
+ const unit = match[2] ?? "kbps";
3349
+ if (unit === "mbps" || unit === "m") return Math.round(value * 1e6 / 8);
3350
+ return Math.round(value * 1e3 / 8);
3351
+ }
3352
+ async function resolveConnection2(sessionId, useLatestSession = false) {
3353
+ if (sessionId) {
3354
+ const session2 = await loadSession(sessionId);
3355
+ const browser2 = await connect({ provider: session2.provider, wsUrl: session2.wsUrl });
3356
+ return { browser: browser2, session: session2 };
2584
3357
  }
2585
- if (!expression && !process.stdin.isTTY) {
2586
- const chunks = [];
2587
- for await (const chunk of process.stdin) {
2588
- chunks.push(chunk);
3358
+ if (useLatestSession) {
3359
+ const defaultSession = await getDefaultSession();
3360
+ if (!defaultSession) {
3361
+ throw new Error('No sessions found. Run "bp connect" first or use "-s" for latest session.');
2589
3362
  }
2590
- expression = Buffer.concat(chunks).toString("utf-8").trim();
2591
- }
2592
- if (!expression) {
2593
- throw new Error(
2594
- "No expression provided.\n\nUsage:\n bp eval 'document.title'\n bp eval -f script.js\n echo 'document.title' | bp eval"
2595
- );
3363
+ const browser2 = await connect({
3364
+ provider: defaultSession.provider,
3365
+ wsUrl: defaultSession.wsUrl
3366
+ });
3367
+ return { browser: browser2, session: defaultSession };
2596
3368
  }
2597
- const session = await resolveSession(globalOptions.session);
2598
- const { browser, page } = await attachSession(session, { trace: globalOptions.trace });
3369
+ let wsUrl;
2599
3370
  try {
2600
- const step = {
2601
- action: "evaluate",
2602
- value: normalizeEvalExpression(expression, evalOptions.wrap)
2603
- };
2604
- const result = await page.batch([step]);
2605
- const stepResult = result.steps[0];
2606
- if (!stepResult.success) {
2607
- throw new Error(stepResult.error ?? "Evaluation failed");
2608
- }
2609
- output(
2610
- globalOptions.format === "json" ? { success: true, result: stepResult.result } : stepResult.result,
2611
- globalOptions.format
3371
+ wsUrl = (await resolveCLIEndpoint()).wsUrl;
3372
+ } catch (error) {
3373
+ throw new Error(
3374
+ formatBrowserDiscoveryError(error, {
3375
+ explicitHint: " - Create a session first: bp connect --browser-url <ws-url>",
3376
+ reuseSessionHint: "bp env -s <id> ...",
3377
+ latestSessionHint: "bp env -s"
3378
+ })
2612
3379
  );
2613
- await updateSession(session.id, { currentUrl: await page.url() });
2614
- } finally {
2615
- await browser.disconnect();
2616
3380
  }
2617
- }
2618
-
2619
- // src/cli/commands/exec.ts
2620
- import * as nodeFs from "fs";
2621
- import { basename as basename2, dirname as dirname4, join as join7, resolve as resolve3 } from "path";
2622
-
2623
- // src/cli/session-logger.ts
2624
- import * as fs5 from "fs";
2625
- import { homedir as homedir4 } from "os";
2626
- import { dirname as dirname3, join as join6, resolve as resolve2 } from "path";
2627
- var SESSION_DIR4 = join6(homedir4(), ".browser-pilot", "sessions");
2628
- var SessionLogger = class {
2629
- logPath;
2630
- exportLogPath = null;
2631
- seq = 0;
2632
- constructor(sessionId, exportLogPath) {
2633
- const sessionDir = join6(SESSION_DIR4, sessionId);
2634
- this.logPath = join6(sessionDir, "log.jsonl");
2635
- if (!fs5.existsSync(sessionDir)) {
2636
- fs5.mkdirSync(sessionDir, { recursive: true });
2637
- }
2638
- if (exportLogPath) {
2639
- this.exportLogPath = resolve2(exportLogPath);
2640
- const exportDir = dirname3(this.exportLogPath);
2641
- if (!fs5.existsSync(exportDir)) {
2642
- fs5.mkdirSync(exportDir, { recursive: true });
2643
- }
2644
- }
2645
- if (fs5.existsSync(this.logPath)) {
2646
- this.seq = this.countEntries();
3381
+ const browser = await connect({ provider: "generic", wsUrl });
3382
+ const page = await browser.page();
3383
+ const currentUrl = await page.url();
3384
+ const newSessionId = generateSessionId();
3385
+ const session = {
3386
+ id: newSessionId,
3387
+ provider: "generic",
3388
+ wsUrl: browser.wsUrl,
3389
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3390
+ lastActivity: (/* @__PURE__ */ new Date()).toISOString(),
3391
+ currentUrl
3392
+ };
3393
+ await saveSession(session);
3394
+ const sessionFile = getSessionFilePath(newSessionId);
3395
+ await import("fs/promises").then(
3396
+ (fs9) => fs9.mkdir(dirname5(sessionFile), { recursive: true })
3397
+ );
3398
+ return { browser, session };
3399
+ }
3400
+ function clampRate(value) {
3401
+ if (value === void 0) return void 0;
3402
+ if (!Number.isFinite(value) || value < 0) return void 0;
3403
+ return Math.round(value);
3404
+ }
3405
+ function isStoredPermissionName(value) {
3406
+ return value !== null;
3407
+ }
3408
+ async function getPermissionStates(page) {
3409
+ const expr = `
3410
+ (() => {
3411
+ const names = ['geolocation', 'microphone', 'audio-capture', 'camera', 'notifications', 'clipboard-read', 'clipboard-write'];
3412
+ return Promise.all(names.map(async (name) => {
3413
+ if (!navigator.permissions || !navigator.permissions.query) {
3414
+ return { name, state: 'unsupported' };
3415
+ }
3416
+ try {
3417
+ const result = await navigator.permissions.query({ name });
3418
+ return { name, state: result.state };
3419
+ } catch {
3420
+ return { name, state: 'unsupported' };
3421
+ }
3422
+ }));
3423
+ })()
3424
+ `;
3425
+ return page.evaluate(expr);
3426
+ }
3427
+ async function permissionCommand(action, nameInput, page) {
3428
+ const requested = nameInput && nameInput !== "all" ? coercePermissionArg(nameInput) : "all";
3429
+ if (action === "get") {
3430
+ const states = await getPermissionStates(page);
3431
+ if (requested !== "all") {
3432
+ const lower = String(requested).toLowerCase();
3433
+ return states.filter(
3434
+ (item) => item.name === lower || item.name === PermissionNames.NAVIGATION[requested]
3435
+ ).map((item) => ({ action, name: item.name, state: item.state }));
3436
+ }
3437
+ return states.map((item) => ({ action, name: item.name, state: item.state }));
3438
+ }
3439
+ const permissionNames = requested === "all" ? Object.values(PermissionNames.NAVIGATION).filter((v) => v !== "all") : [PermissionNames.NAVIGATION[requested] ?? String(requested)];
3440
+ const protocolNames = requested === "all" ? ["geolocation", "audioCapture", "videoCapture", "notifications"] : permissionNames.map(
3441
+ (item) => PermissionNames.PROTOCOL[item] ?? String(item)
3442
+ );
3443
+ if (action === "grant") {
3444
+ const origin2 = await page.evaluate("window.location.origin");
3445
+ await page.cdpClient.send("Browser.grantPermissions", {
3446
+ permissions: protocolNames.filter((value) => value !== "all" && value !== "audio"),
3447
+ origin: origin2
3448
+ });
3449
+ if (permissionNames.includes("microphone")) {
3450
+ await grantAudioPermissions(page.cdpClient, origin2);
2647
3451
  }
3452
+ const result = await getPermissionStates(page);
3453
+ return result.map((item) => ({ action, name: item.name, state: item.state }));
2648
3454
  }
2649
- /**
2650
- * Log a raw entry (writes to both core and export logs)
2651
- */
2652
- log(entry) {
2653
- const fullEntry = {
2654
- seq: ++this.seq,
2655
- ts: (/* @__PURE__ */ new Date()).toISOString(),
2656
- ...entry
2657
- };
2658
- const line = `${JSON.stringify(fullEntry)}
2659
- `;
2660
- fs5.appendFileSync(this.logPath, line, "utf-8");
2661
- if (this.exportLogPath) {
3455
+ const origin = await page.evaluate("window.location.origin");
3456
+ if (action === "revoke" || action === "reset") {
3457
+ for (const permission of protocolNames) {
3458
+ if (permission === "all") continue;
2662
3459
  try {
2663
- fs5.appendFileSync(this.exportLogPath, line, "utf-8");
2664
- } catch (err) {
2665
- console.warn(`[browser-pilot] Failed to write to export log: ${err}`);
3460
+ await page.cdpClient.send("Browser.resetPermissions", {
3461
+ permissions: [permission],
3462
+ origin
3463
+ });
3464
+ } catch {
3465
+ await page.cdpClient.send("Browser.revokePermissions", {
3466
+ permissions: [permission],
3467
+ origin
3468
+ });
2666
3469
  }
2667
3470
  }
3471
+ const result = await getPermissionStates(page);
3472
+ return result.map((item) => ({ action, name: item.name, state: item.state }));
2668
3473
  }
2669
- /**
2670
- * Get the export log path (if configured)
2671
- */
2672
- getExportLogPath() {
2673
- return this.exportLogPath;
3474
+ throw new Error(`Unsupported permission action: ${action}`);
3475
+ }
3476
+ function formatPermissionOutput(session, data) {
3477
+ const lines = [`Session: ${session.id}`, ""];
3478
+ for (const row of data) {
3479
+ const state = typeof row.state === "string" ? row.state : row.state === void 0 || row.state === null ? "unknown" : JSON.stringify(row.state);
3480
+ lines.push(`${row.name}: ${state} (${row.action})`);
2674
3481
  }
2675
- /**
2676
- * Log a command execution
2677
- */
2678
- logCommand(cmd, args, result, durationMs, screenshotFile) {
2679
- this.log({
2680
- type: "command",
2681
- cmd,
2682
- args,
2683
- status: result.success ? "success" : "failed",
2684
- durationMs,
2685
- error: result.error,
2686
- hints: result.hints,
2687
- screenshotFile
3482
+ return lines.join("\n");
3483
+ }
3484
+ async function runNetworkCommand(action, options, page, session) {
3485
+ await page.cdpClient.send("Network.enable");
3486
+ const applyNetworkState = async (offline, latency2, downloadThroughput, uploadThroughput, connectionType) => {
3487
+ const state = {
3488
+ offline,
3489
+ latency: latency2,
3490
+ downloadThroughput,
3491
+ uploadThroughput,
3492
+ connectionType
3493
+ };
3494
+ try {
3495
+ await page.cdpClient.send("Network.emulateNetworkConditionsByRule", {
3496
+ offline,
3497
+ matchedNetworkConditions: [
3498
+ {
3499
+ urlPattern: "",
3500
+ latency: latency2,
3501
+ downloadThroughput,
3502
+ uploadThroughput
3503
+ }
3504
+ ]
3505
+ });
3506
+ await page.cdpClient.send("Network.overrideNetworkState", state);
3507
+ } catch {
3508
+ await page.cdpClient.send("Network.emulateNetworkConditions", state);
3509
+ }
3510
+ };
3511
+ if (action === "offline") {
3512
+ await applyNetworkState(true, options.latency ?? 0, 0, 0, "none");
3513
+ await applyNetworkOverride(page.cdpClient, {
3514
+ offline: true,
3515
+ latency: options.latency ?? 0
2688
3516
  });
3517
+ console.log(`Session ${session.id}: network set to offline`);
3518
+ return;
2689
3519
  }
2690
- /**
2691
- * Log an error
2692
- */
2693
- logError(error, context) {
2694
- this.log({
2695
- type: "error",
2696
- error: error.message,
2697
- args: context
3520
+ if (action === "online") {
3521
+ await applyNetworkState(false, 0, -1, -1, "wifi");
3522
+ await applyNetworkOverride(page.cdpClient, {
3523
+ offline: false,
3524
+ latency: 0
2698
3525
  });
3526
+ console.log(`Session ${session.id}: network set to online`);
3527
+ return;
2699
3528
  }
2700
- /**
2701
- * Get the log file path
2702
- */
2703
- getLogPath() {
2704
- return this.logPath;
3529
+ const latency = clampRate(options.latency) ?? 0;
3530
+ const down = clampRate(toBytesPerSecond(options.down)) ?? 1e6;
3531
+ const up = clampRate(toBytesPerSecond(options.up)) ?? 5e5;
3532
+ await applyNetworkState(false, latency, down, up, "wifi");
3533
+ await applyNetworkOverride(page.cdpClient, {
3534
+ offline: false,
3535
+ latency
3536
+ });
3537
+ console.log(
3538
+ `Session ${session.id}: network throttled | latency=${latency}ms down=${down}B/s up=${up}B/s`
3539
+ );
3540
+ }
3541
+ async function runVisibilityCommand(state, page, session) {
3542
+ await applyVisibilityState(page.cdpClient, state);
3543
+ console.log(`Session ${session.id}: visibility set to ${state}`);
3544
+ }
3545
+ async function runGeolocationCommand(action, options, page, session) {
3546
+ if (action === "clear") {
3547
+ await page.clearGeolocation();
3548
+ console.log(`Session ${session.id}: geolocation override cleared`);
3549
+ return;
2705
3550
  }
2706
- /**
2707
- * Get log statistics
2708
- */
2709
- getLogStats() {
2710
- if (!fs5.existsSync(this.logPath)) {
2711
- return { entries: 0, size: 0 };
2712
- }
2713
- const stat = fs5.statSync(this.logPath);
2714
- const entries = this.countEntries();
2715
- let first;
2716
- let last;
2717
- if (entries > 0) {
2718
- const lines = fs5.readFileSync(this.logPath, "utf-8").trim().split("\n");
2719
- const firstEntry = this.parseLine(lines[0]);
2720
- const lastEntry = this.parseLine(lines[lines.length - 1]);
2721
- first = firstEntry?.ts;
2722
- last = lastEntry?.ts;
2723
- }
2724
- return {
2725
- entries,
2726
- size: stat.size,
2727
- first,
2728
- last
2729
- };
3551
+ if (options.lat === void 0 || options.lon === void 0) {
3552
+ throw new Error("geolocation set requires --lat and --lon");
2730
3553
  }
2731
- /**
2732
- * Get the last n log entries
2733
- */
2734
- tailLog(n) {
2735
- if (!fs5.existsSync(this.logPath)) {
2736
- return [];
2737
- }
2738
- const content = fs5.readFileSync(this.logPath, "utf-8").trim();
2739
- if (!content) {
2740
- return [];
3554
+ await page.setGeolocation({
3555
+ latitude: options.lat,
3556
+ longitude: options.lon,
3557
+ accuracy: options.accuracy ?? 1
3558
+ });
3559
+ console.log(
3560
+ `Session ${session.id}: geolocation set to ${options.lat}, ${options.lon} (accuracy ${options.accuracy ?? 1})`
3561
+ );
3562
+ }
3563
+ async function envCommand(args, globalOptions) {
3564
+ const options = parseEnvArgs(args);
3565
+ if (options.help || globalOptions.help || !options.topCommand) {
3566
+ console.log(ENV_HELP);
3567
+ return;
3568
+ }
3569
+ const { browser, session } = await resolveConnection2(
3570
+ globalOptions.session,
3571
+ options.useLatestSession ?? false
3572
+ );
3573
+ const page = await browser.page(void 0, { targetId: session.targetId });
3574
+ const outputAsJson = globalOptions.format === "json";
3575
+ const existingEnv = session.metadata?.env ?? {};
3576
+ try {
3577
+ if (options.topCommand === "permissions") {
3578
+ const permissionMode = options.permissionMode ?? "get";
3579
+ if (permissionMode === "get" && !options.permissionName) {
3580
+ const result2 = await permissionCommand(permissionMode, "all", page);
3581
+ if (outputAsJson) {
3582
+ console.log(JSON.stringify({ session: session.id, permissions: result2 }, null, 2));
3583
+ } else {
3584
+ console.log(formatPermissionOutput(session, result2));
3585
+ }
3586
+ return;
3587
+ }
3588
+ const result = await permissionCommand(permissionMode, options.permissionName, page);
3589
+ if (permissionMode !== "get") {
3590
+ const nextPermissions = permissionMode === "reset" ? [] : (() => {
3591
+ const current = new Set(
3592
+ (existingEnv.permissions ?? []).map((value) => normalizeStoredPermission(value)).filter(isStoredPermissionName)
3593
+ );
3594
+ const requested = options.permissionName === "all" || !options.permissionName ? ["microphone", "camera", "notifications", "geolocation"] : [normalizeStoredPermission(options.permissionName)].filter(
3595
+ isStoredPermissionName
3596
+ );
3597
+ if (permissionMode === "grant") {
3598
+ for (const name of requested) current.add(name);
3599
+ } else {
3600
+ for (const name of requested) current.delete(name);
3601
+ }
3602
+ return [...current];
3603
+ })();
3604
+ const nextEnv = {
3605
+ ...existingEnv,
3606
+ permissions: nextPermissions
3607
+ };
3608
+ await updateSession(session.id, { metadata: { env: nextEnv } });
3609
+ const currentUrl = await page.evaluate("window.location.href");
3610
+ await applyPermissionState(page.cdpClient, originFromUrl(currentUrl), nextPermissions);
3611
+ }
3612
+ if (outputAsJson) {
3613
+ console.log(
3614
+ JSON.stringify(
3615
+ { session: session.id, action: permissionMode, permissions: result },
3616
+ null,
3617
+ 2
3618
+ )
3619
+ );
3620
+ } else {
3621
+ console.log(formatPermissionOutput(session, result));
3622
+ }
3623
+ return;
2741
3624
  }
2742
- const lines = content.split("\n");
2743
- const startIndex = Math.max(0, lines.length - n);
2744
- const result = [];
2745
- for (let i = startIndex; i < lines.length; i++) {
2746
- const entry = this.parseLine(lines[i]);
2747
- if (entry) {
2748
- result.push(entry);
3625
+ if (options.topCommand === "network") {
3626
+ const action = options.networkAction;
3627
+ if (!action) {
3628
+ throw new Error("network command requires action: offline, online, or throttle");
3629
+ }
3630
+ await runNetworkCommand(action, options, page, session);
3631
+ await updateSession(session.id, {
3632
+ metadata: {
3633
+ env: {
3634
+ ...existingEnv,
3635
+ network: action === "online" ? {
3636
+ offline: false,
3637
+ latency: 0
3638
+ } : {
3639
+ offline: action === "offline",
3640
+ latency: action === "throttle" ? options.latency ?? 0 : options.latency ?? 0
3641
+ }
3642
+ }
3643
+ }
3644
+ });
3645
+ if (options.duration && options.duration > 0) {
3646
+ await new Promise((resolve8) => setTimeout(resolve8, options.duration));
3647
+ if (action === "offline" || action === "throttle") {
3648
+ await runNetworkCommand("online", {}, page, session);
3649
+ await updateSession(session.id, {
3650
+ metadata: {
3651
+ env: {
3652
+ ...existingEnv,
3653
+ network: {
3654
+ offline: false,
3655
+ latency: 0
3656
+ }
3657
+ }
3658
+ }
3659
+ });
3660
+ }
2749
3661
  }
3662
+ return;
2750
3663
  }
2751
- return result;
2752
- }
2753
- /**
2754
- * Count entries in the log file
2755
- */
2756
- countEntries() {
2757
- if (!fs5.existsSync(this.logPath)) {
2758
- return 0;
3664
+ if (options.topCommand === "visibility") {
3665
+ if (!options.visibility) {
3666
+ throw new Error("visibility command requires: hidden or visible");
3667
+ }
3668
+ await runVisibilityCommand(options.visibility, page, session);
3669
+ await updateSession(session.id, {
3670
+ metadata: {
3671
+ env: {
3672
+ ...existingEnv,
3673
+ visibility: options.visibility
3674
+ }
3675
+ }
3676
+ });
3677
+ return;
2759
3678
  }
2760
- const content = fs5.readFileSync(this.logPath, "utf-8").trim();
2761
- if (!content) {
2762
- return 0;
3679
+ if (options.topCommand === "geolocation") {
3680
+ if (!options.geoAction) {
3681
+ throw new Error("geolocation command requires: set or clear");
3682
+ }
3683
+ await runGeolocationCommand(options.geoAction, options, page, session);
3684
+ await updateSession(session.id, {
3685
+ metadata: {
3686
+ env: {
3687
+ ...existingEnv,
3688
+ geolocation: options.geoAction === "clear" ? void 0 : {
3689
+ latitude: options.lat,
3690
+ longitude: options.lon,
3691
+ accuracy: options.accuracy ?? 1
3692
+ }
3693
+ }
3694
+ }
3695
+ });
3696
+ return;
2763
3697
  }
2764
- return content.split("\n").length;
3698
+ throw new Error("Unknown env command. Run bp env --help for usage.");
3699
+ } finally {
3700
+ await browser.disconnect();
2765
3701
  }
2766
- /**
2767
- * Parse a single log line
2768
- */
2769
- parseLine(line) {
2770
- if (!line) {
2771
- return null;
3702
+ }
3703
+
3704
+ // src/cli/attach.ts
3705
+ async function applySessionEnvironment(page, currentUrl, settings) {
3706
+ if (!settings) {
3707
+ return;
3708
+ }
3709
+ const origin = originFromUrl(currentUrl);
3710
+ if (Array.isArray(settings.permissions)) {
3711
+ await applyPermissionState(page.cdpClient, origin, settings.permissions);
3712
+ }
3713
+ if (settings.geolocation) {
3714
+ await page.setGeolocation(settings.geolocation);
3715
+ }
3716
+ if (settings.visibility) {
3717
+ await applyVisibilityState(page.cdpClient, settings.visibility);
3718
+ }
3719
+ if (settings.network) {
3720
+ await applyNetworkOverride(page.cdpClient, settings.network);
3721
+ }
3722
+ }
3723
+ async function resolveSession(sessionId) {
3724
+ if (sessionId) {
3725
+ return loadSession(sessionId);
3726
+ }
3727
+ const session = await getDefaultSession();
3728
+ if (!session) {
3729
+ throw new Error('No session found. Run "bp connect" first.');
3730
+ }
3731
+ return session;
3732
+ }
3733
+ function isDaemonHealthy(session) {
3734
+ if (!session.daemon) return false;
3735
+ const daemonAge = Date.now() - new Date(session.daemon.startedAt).getTime();
3736
+ if (daemonAge > DAEMON_MAX_AGE_MS) {
3737
+ return false;
3738
+ }
3739
+ if (session.daemon.lastHeartbeat) {
3740
+ const heartbeatAge = Date.now() - new Date(session.daemon.lastHeartbeat).getTime();
3741
+ if (heartbeatAge > 9e4) {
3742
+ return false;
2772
3743
  }
3744
+ }
3745
+ return isDaemonAlive(session.daemon.pid);
3746
+ }
3747
+ async function cleanupStaleDaemon(session, reason) {
3748
+ console.warn(`[browser-pilot] Daemon unavailable (${reason}), falling back to direct WebSocket`);
3749
+ const sessionFilePath = getSessionFilePath(session.id);
3750
+ await clearDaemonFromSession(sessionFilePath);
3751
+ if (session.daemon?.socketPath) {
2773
3752
  try {
2774
- return JSON.parse(line);
3753
+ const fsPromises = await import("fs/promises");
3754
+ await fsPromises.unlink(session.daemon.socketPath).catch(() => {
3755
+ });
2775
3756
  } catch {
2776
- return null;
2777
3757
  }
2778
3758
  }
2779
- };
2780
- var loggerCache = /* @__PURE__ */ new Map();
2781
- function getSessionLogger(sessionId, exportLogPath) {
2782
- const cacheKey = exportLogPath ? `${sessionId}:${exportLogPath}` : sessionId;
2783
- let logger = loggerCache.get(cacheKey);
2784
- if (!logger) {
2785
- logger = new SessionLogger(sessionId, exportLogPath);
2786
- loggerCache.set(cacheKey, logger);
3759
+ }
3760
+ async function attachSession(session, options = {}) {
3761
+ if (session.daemon) {
3762
+ if (!isDaemonHealthy(session)) {
3763
+ const reason = !isDaemonAlive(session.daemon.pid) ? "PID not alive" : "daemon expired (>60min)";
3764
+ await cleanupStaleDaemon(session, reason);
3765
+ } else {
3766
+ try {
3767
+ const { createDaemonTransport } = await import("./transport-WHEBAZUP.mjs");
3768
+ const { createCDPClientFromTransport } = await import("./client-JWWZWO6L.mjs");
3769
+ const transport = await createDaemonTransport(session.daemon.socketPath);
3770
+ const cdp = createCDPClientFromTransport(transport, {
3771
+ debug: options.trace
3772
+ });
3773
+ const { Browser: BrowserClass } = await import("./browser-ZCR6AA4D.mjs");
3774
+ const { Page: PageClass } = await import("./page-IUUTJ3SW.mjs");
3775
+ const browser2 = BrowserClass.fromCDP(cdp, session);
3776
+ const page2 = session.daemon.cdpSessionId && session.targetId ? addBatchToPage(
3777
+ await (async () => {
3778
+ cdp.setSessionId(session.daemon?.cdpSessionId);
3779
+ const attachedPage = new PageClass(cdp, session.targetId);
3780
+ await attachedPage.init();
3781
+ return attachedPage;
3782
+ })()
3783
+ ) : addBatchToPage(await browser2.page(void 0, { targetId: session.targetId }));
3784
+ const currentUrl2 = await page2.url();
3785
+ await applySessionEnvironment(page2, currentUrl2, session.metadata?.env);
3786
+ const refCache2 = session.metadata?.refCache;
3787
+ if (refCache2 && refCache2.url === currentUrl2) {
3788
+ page2.importRefMap(refCache2.refMap);
3789
+ }
3790
+ return { session, browser: browser2, page: page2, viaDaemon: true };
3791
+ } catch (err) {
3792
+ const reason = err instanceof Error ? err.message : String(err);
3793
+ await cleanupStaleDaemon(session, reason);
3794
+ }
3795
+ }
3796
+ }
3797
+ let browser;
3798
+ try {
3799
+ browser = await connect({
3800
+ provider: session.provider,
3801
+ wsUrl: session.wsUrl,
3802
+ debug: options.trace
3803
+ });
3804
+ } catch {
3805
+ await deleteSession(session.id);
3806
+ throw new Error(
3807
+ `Session "${session.id}" is no longer valid (browser may have closed).
3808
+ Session file has been cleaned up. Run "bp connect" to create a new session.`
3809
+ );
3810
+ }
3811
+ const page = addBatchToPage(await browser.page(void 0, { targetId: session.targetId }));
3812
+ const currentUrl = await page.url();
3813
+ await applySessionEnvironment(page, currentUrl, session.metadata?.env);
3814
+ const refCache = session.metadata?.refCache;
3815
+ if (refCache && refCache.url === currentUrl) {
3816
+ page.importRefMap(refCache.refMap);
3817
+ }
3818
+ return { session, browser, page, viaDaemon: false };
3819
+ }
3820
+
3821
+ // src/cli/commands/eval.ts
3822
+ var EVAL_HELP = `
3823
+ bp eval - Evaluate JavaScript in the browser
3824
+
3825
+ Convenience wrapper around exec's evaluate action.
3826
+ No JSON escaping needed -- just pass a JS expression directly.
3827
+
3828
+ Usage:
3829
+ bp eval '<expression>' Evaluate inline JavaScript
3830
+ bp eval -f <file> Evaluate JavaScript from a file
3831
+ echo '<expr>' | bp eval Evaluate from stdin
3832
+
3833
+ Options:
3834
+ -f, --file <path> Read JavaScript from a file
3835
+ --wrap Wrap the expression in an async IIFE
3836
+ -s, --session <id> Session to use (default: most recent)
3837
+ -f, --format <fmt> Output format: json | pretty (default: pretty)
3838
+ --json Alias for -f json
3839
+ --trace Enable debug tracing
3840
+ -h, --help Show this help
3841
+
3842
+ Examples:
3843
+ bp eval 'document.title'
3844
+ bp eval 'document.querySelectorAll("a").length'
3845
+ bp eval -f scrape.js
3846
+ `.trimEnd();
3847
+ function parseEvalArgs(args) {
3848
+ const options = {};
3849
+ let expression;
3850
+ for (let i = 0; i < args.length; i++) {
3851
+ const arg = args[i];
3852
+ if (arg === "-f" || arg === "--file") {
3853
+ options.file = args[++i];
3854
+ } else if (arg === "--wrap") {
3855
+ options.wrap = true;
3856
+ } else if (!expression && !arg.startsWith("-")) {
3857
+ expression = arg;
3858
+ }
3859
+ }
3860
+ return { expression, options };
3861
+ }
3862
+ function normalizeEvalExpression(expression, wrap = false) {
3863
+ const trimmed = expression.trim();
3864
+ const needsWrap = wrap || trimmed.includes("=>") || /\bawait\b/.test(trimmed);
3865
+ if (!needsWrap) {
3866
+ return trimmed;
3867
+ }
3868
+ if (wrap || /\bawait\b/.test(trimmed)) {
3869
+ return `(async () => (${trimmed}))()`;
3870
+ }
3871
+ return `(() => (${trimmed}))()`;
3872
+ }
3873
+ async function evalCommand(args, globalOptions) {
3874
+ if (globalOptions.help) {
3875
+ console.log(EVAL_HELP);
3876
+ return;
3877
+ }
3878
+ const { expression: argExpression, options: evalOptions } = parseEvalArgs(args);
3879
+ let expression = argExpression;
3880
+ if (evalOptions.file) {
3881
+ const fs9 = await import("fs/promises");
3882
+ expression = await fs9.readFile(evalOptions.file, "utf-8");
3883
+ }
3884
+ if (!expression && !process.stdin.isTTY) {
3885
+ const chunks = [];
3886
+ for await (const chunk of process.stdin) {
3887
+ chunks.push(chunk);
3888
+ }
3889
+ expression = Buffer.concat(chunks).toString("utf-8").trim();
3890
+ }
3891
+ if (!expression) {
3892
+ throw new Error(
3893
+ "No expression provided.\n\nUsage:\n bp eval 'document.title'\n bp eval -f script.js\n echo 'document.title' | bp eval"
3894
+ );
3895
+ }
3896
+ const session = await resolveSession(globalOptions.session);
3897
+ const { browser, page } = await attachSession(session, { trace: globalOptions.trace });
3898
+ try {
3899
+ const step = {
3900
+ action: "evaluate",
3901
+ value: normalizeEvalExpression(expression, evalOptions.wrap)
3902
+ };
3903
+ const result = await page.batch([step]);
3904
+ const stepResult = result.steps[0];
3905
+ if (!stepResult.success) {
3906
+ throw new Error(stepResult.error ?? "Evaluation failed");
3907
+ }
3908
+ output(
3909
+ globalOptions.format === "json" ? { success: true, result: stepResult.result } : stepResult.result,
3910
+ globalOptions.format
3911
+ );
3912
+ await updateSession(session.id, { currentUrl: await page.url() });
3913
+ } finally {
3914
+ await browser.disconnect();
2787
3915
  }
2788
- return logger;
2789
3916
  }
2790
3917
 
2791
3918
  // src/cli/commands/exec.ts
3919
+ import * as nodeFs from "fs";
3920
+ import { basename as basename2, dirname as dirname6, join as join8, resolve as resolve4 } from "path";
2792
3921
  var EXEC_HELP = `
2793
- bp exec - Execute browser actions on current session
3922
+ bp exec - Act on a page with high-level browser steps
3923
+
3924
+ When to use:
3925
+ You know what you want to do in the browser and want to click, fill, wait, or assert in one batch.
3926
+
3927
+ When not to use:
3928
+ A human is demonstrating the workflow from scratch. Use \`bp record\`.
3929
+
3930
+ Default flow:
3931
+ snapshot -> exec -> snapshot or trace summary
3932
+
3933
+ Common mistake:
3934
+ Guessing brittle selectors instead of taking \`bp snapshot -i\` and using refs.
2794
3935
 
2795
3936
  Usage:
2796
3937
  bp exec '<json>' Execute action(s) from inline JSON
@@ -2804,7 +3945,7 @@ Options:
2804
3945
  -s, --session <id> Session to use (default: most recent)
2805
3946
  -f, --format <fmt> Output format: json | pretty (default: pretty)
2806
3947
  --json Alias for -f json
2807
- --trace Enable debug tracing
3948
+ --debug Enable CDP transport debugging (global option)
2808
3949
 
2809
3950
  Recording:
2810
3951
  --record Enable screenshot recording
@@ -2818,12 +3959,16 @@ Recording:
2818
3959
 
2819
3960
  Examples:
2820
3961
  bp exec '{"action":"goto","url":"https://example.com"}'
3962
+ bp exec '[{"action":"click","selector":"ref:e4"},{"action":"assertText","expect":"Saved"}]'
3963
+ bp exec '[{"action":"waitForWsMessage","match":"*realtime*","where":{"type":"ready"}}]'
2821
3964
  bp exec --record '[{"action":"fill","selector":"#email","value":"me@test.com"},{"action":"submit","selector":"form"}]'
2822
3965
  bp exec --dialog accept '{"action":"click","selector":"#delete-btn"}'
2823
3966
  bp exec -f login-steps.json
2824
3967
 
2825
- Run 'bp actions' for the complete action reference.
2826
- Run 'bp quickstart' for getting started guide.
3968
+ Likely next commands:
3969
+ bp snapshot -i
3970
+ bp diagnose "<selector>"
3971
+ bp trace summary -s <session> --view console
2827
3972
  `.trimEnd();
2828
3973
  function parseExecArgs(args) {
2829
3974
  const options = {};
@@ -2884,24 +4029,24 @@ async function captureFinalUrl(page, steps, fallback) {
2884
4029
  if (!mightNavigate) {
2885
4030
  return currentUrl;
2886
4031
  }
2887
- await new Promise((resolve5) => setTimeout(resolve5, 200));
4032
+ await new Promise((resolve8) => setTimeout(resolve8, 200));
2888
4033
  return getCurrentUrlSafe(page, currentUrl);
2889
4034
  }
2890
4035
  function mirrorRecordingToExport(recordingManifest, exportLogPath) {
2891
4036
  try {
2892
- const sourceDir = dirname4(recordingManifest);
2893
- const exportDir = dirname4(exportLogPath);
4037
+ const sourceDir = dirname6(recordingManifest);
4038
+ const exportDir = dirname6(exportLogPath);
2894
4039
  const manifestName = basename2(recordingManifest);
2895
- const exportManifestPath = join7(exportDir, manifestName);
4040
+ const exportManifestPath = join8(exportDir, manifestName);
2896
4041
  nodeFs.copyFileSync(recordingManifest, exportManifestPath);
2897
- const sourceScreenshotsDir = join7(sourceDir, "screenshots");
2898
- const exportScreenshotsDir = join7(exportDir, "screenshots");
4042
+ const sourceScreenshotsDir = join8(sourceDir, "screenshots");
4043
+ const exportScreenshotsDir = join8(exportDir, "screenshots");
2899
4044
  if (nodeFs.existsSync(sourceScreenshotsDir)) {
2900
4045
  nodeFs.rmSync(exportScreenshotsDir, { force: true, recursive: true });
2901
4046
  nodeFs.mkdirSync(exportScreenshotsDir, { recursive: true });
2902
4047
  const files = nodeFs.readdirSync(sourceScreenshotsDir);
2903
4048
  for (const file of files) {
2904
- nodeFs.copyFileSync(join7(sourceScreenshotsDir, file), join7(exportScreenshotsDir, file));
4049
+ nodeFs.copyFileSync(join8(sourceScreenshotsDir, file), join8(exportScreenshotsDir, file));
2905
4050
  }
2906
4051
  }
2907
4052
  } catch (err) {
@@ -2915,8 +4060,8 @@ async function execCommand(args, globalOptions) {
2915
4060
  }
2916
4061
  let { actionsJson, options: execOptions } = parseExecArgs(args);
2917
4062
  if (execOptions.file) {
2918
- const fs7 = await import("fs/promises");
2919
- actionsJson = await fs7.readFile(execOptions.file, "utf-8");
4063
+ const fs9 = await import("fs/promises");
4064
+ actionsJson = await fs9.readFile(execOptions.file, "utf-8");
2920
4065
  }
2921
4066
  if (!actionsJson && !process.stdin.isTTY) {
2922
4067
  const chunks = [];
@@ -2965,10 +4110,10 @@ Run 'bp actions' for complete action reference.${evalTip}`
2965
4110
  highlights: execOptions.noHighlights ? false : sessionRecord?.highlights ?? true
2966
4111
  };
2967
4112
  if (execOptions.recordDir) {
2968
- recordOptions.outputDir = resolve3(execOptions.recordDir);
4113
+ recordOptions.outputDir = resolve4(execOptions.recordDir);
2969
4114
  } else {
2970
- const { homedir: homedir6 } = await import("os");
2971
- recordOptions.outputDir = join7(homedir6(), ".browser-pilot", "sessions", session.id);
4115
+ const { homedir: homedir7 } = await import("os");
4116
+ recordOptions.outputDir = join8(homedir7(), ".browser-pilot", "sessions", session.id);
2972
4117
  }
2973
4118
  }
2974
4119
  const { browser, page } = await attachSession(session, { trace: globalOptions.trace });
@@ -3004,6 +4149,38 @@ Run 'bp actions' for complete action reference.${evalTip}`
3004
4149
  stepResult.durationMs,
3005
4150
  stepResult.screenshotPath ? basename2(stepResult.screenshotPath) : void 0
3006
4151
  );
4152
+ if (stepResult.success && stepResult.action === "waitForWsMessage" && stepResult.result) {
4153
+ logger.logTrace({
4154
+ channel: "ws",
4155
+ event: "ws.frame.received",
4156
+ summary: "waitForWsMessage matched",
4157
+ data: stepResult.result
4158
+ });
4159
+ }
4160
+ if (stepResult.success && stepResult.action === "assertTextChanged" && stepResult.text) {
4161
+ logger.logTrace({
4162
+ channel: "dom",
4163
+ event: "dom.text.changed",
4164
+ summary: "Text changed",
4165
+ data: { text: stepResult.text }
4166
+ });
4167
+ }
4168
+ if (stepResult.success && stepResult.action === "assertPermission" && stepResult.result) {
4169
+ logger.logTrace({
4170
+ channel: "permission",
4171
+ event: "permission.state",
4172
+ summary: "Permission assertion passed",
4173
+ data: stepResult.result
4174
+ });
4175
+ }
4176
+ if (stepResult.success && stepResult.action === "assertMediaTrackLive" && stepResult.result) {
4177
+ logger.logTrace({
4178
+ channel: "media",
4179
+ event: "media.track.started",
4180
+ summary: "Live media track detected",
4181
+ data: stepResult.result
4182
+ });
4183
+ }
3007
4184
  }
3008
4185
  if (result.recordingManifest && session.exportLog) {
3009
4186
  mirrorRecordingToExport(result.recordingManifest, session.exportLog);
@@ -3056,8 +4233,8 @@ Run 'bp actions' for complete action reference.${evalTip}`
3056
4233
  ...result.recordingManifest ? { recordingManifest: result.recordingManifest } : {}
3057
4234
  };
3058
4235
  if (execOptions.outputFile) {
3059
- const fs7 = await import("fs/promises");
3060
- await fs7.writeFile(execOptions.outputFile, renderOutput(payload, globalOptions.format));
4236
+ const fs9 = await import("fs/promises");
4237
+ await fs9.writeFile(execOptions.outputFile, renderOutput(payload, globalOptions.format));
3061
4238
  process.stderr.write(`Wrote output to ${execOptions.outputFile}
3062
4239
  `);
3063
4240
  } else {
@@ -3067,7 +4244,7 @@ Run 'bp actions' for complete action reference.${evalTip}`
3067
4244
  const frameCount = result.steps.filter((s) => s.screenshotPath).length;
3068
4245
  process.stderr.write(
3069
4246
  `
3070
- Recording: ${frameCount} screenshots saved to ${dirname4(result.recordingManifest)}
4247
+ Recording: ${frameCount} screenshots saved to ${dirname6(result.recordingManifest)}
3071
4248
  `
3072
4249
  );
3073
4250
  }
@@ -3242,501 +4419,161 @@ function parseListArgs(args) {
3242
4419
  }
3243
4420
  }
3244
4421
  return options;
3245
- }
3246
- function formatLogEntry(entry) {
3247
- const time = new Date(entry.ts).toLocaleTimeString();
3248
- const status = entry.status === "failed" ? "\u2717" : entry.status === "success" ? "\u2713" : "\u25CB";
3249
- if (entry.type === "command") {
3250
- const dur = entry.durationMs ? `(${entry.durationMs}ms)` : "";
3251
- return `${time} ${status} ${entry.cmd} ${dur}`;
3252
- }
3253
- if (entry.type === "error") {
3254
- return `${time} \u2717 ERROR: ${entry.error}`;
3255
- }
3256
- return `${time} ${entry.type}: ${entry.cmd || ""}`;
3257
- }
3258
- async function listCommand(args, globalOptions) {
3259
- const listOptions = parseListArgs(args);
3260
- if (listOptions.help) {
3261
- console.log(HELP);
3262
- return;
3263
- }
3264
- if (listOptions.logPath || listOptions.logTail !== void 0 || listOptions.info) {
3265
- let session;
3266
- if (globalOptions.session) {
3267
- session = await loadSession(globalOptions.session);
3268
- } else {
3269
- session = await getDefaultSession();
3270
- }
3271
- if (!session) {
3272
- throw new Error('No session found. Run "bp connect" first or specify with -s.');
3273
- }
3274
- const logger = getSessionLogger(session.id);
3275
- if (listOptions.logPath) {
3276
- console.log(logger.getLogPath());
3277
- return;
3278
- }
3279
- if (listOptions.logTail !== void 0) {
3280
- const entries = logger.tailLog(listOptions.logTail);
3281
- if (globalOptions.format === "json") {
3282
- output(entries, "json");
3283
- return;
3284
- }
3285
- if (entries.length === 0) {
3286
- console.log("No log entries.");
3287
- return;
3288
- }
3289
- console.log(`Last ${entries.length} log entries for session ${session.id}:
3290
- `);
3291
- for (const entry of entries) {
3292
- console.log(` ${formatLogEntry(entry)}`);
3293
- }
3294
- return;
3295
- }
3296
- if (listOptions.info) {
3297
- const stats = logger.getLogStats();
3298
- if (globalOptions.format === "json") {
3299
- output({ session, logStats: stats }, "json");
3300
- return;
3301
- }
3302
- console.log(`Session: ${session.id}
3303
- `);
3304
- console.log(` Provider: ${session.provider}`);
3305
- console.log(` Created: ${new Date(session.createdAt).toLocaleString()}`);
3306
- console.log(` Last activity: ${new Date(session.lastActivity).toLocaleString()}`);
3307
- console.log(` URL: ${session.currentUrl}`);
3308
- if (session.exportLog) {
3309
- console.log(` Export log: ${session.exportLog}`);
3310
- }
3311
- console.log("");
3312
- console.log("Log Stats:");
3313
- console.log(` Path: ${logger.getLogPath()}`);
3314
- console.log(` Entries: ${stats.entries}`);
3315
- console.log(` Size: ${formatBytes3(stats.size)}`);
3316
- if (stats.first) {
3317
- console.log(` First: ${new Date(stats.first).toLocaleString()}`);
3318
- }
3319
- if (stats.last) {
3320
- console.log(` Last: ${new Date(stats.last).toLocaleString()}`);
3321
- }
3322
- return;
3323
- }
3324
- }
3325
- const sessions = await listSessions();
3326
- const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1e3;
3327
- const now = Date.now();
3328
- const stale = sessions.filter((s) => now - new Date(s.lastActivity).getTime() > TWO_DAYS_MS);
3329
- const fresh = sessions.filter((s) => now - new Date(s.lastActivity).getTime() <= TWO_DAYS_MS);
3330
- if (stale.length > 0) {
3331
- for (const s of stale) {
3332
- await deleteSessionFull(s.id);
3333
- }
3334
- }
3335
- if (globalOptions.format === "json") {
3336
- output(fresh, "json");
3337
- return;
3338
- }
3339
- if (stale.length > 0) {
3340
- console.log(`Cleaned ${stale.length} stale session(s) (>2 days old).
3341
- `);
3342
- }
3343
- if (fresh.length === 0) {
3344
- console.log("No active sessions.");
3345
- console.log('Run "bp connect" to create a new session.');
3346
- return;
3347
- }
3348
- console.log("Active Sessions:\n");
3349
- console.log(" Tip: bp list -s <name> --log-tail View action log");
3350
- console.log(" bp list -s <name> --log-path Get log file path\n");
3351
- const displaySessions = fresh.slice(0, 20);
3352
- for (const session of displaySessions) {
3353
- const age = getAge(new Date(session.lastActivity));
3354
- const daemonStatus = session.daemon ? isDaemonAlive(session.daemon.pid) ? "running" : "dead" : "none";
3355
- console.log(` ${session.id}`);
3356
- console.log(` Provider: ${session.provider}`);
3357
- console.log(` URL: ${session.currentUrl}`);
3358
- console.log(` Last activity: ${age}`);
3359
- console.log(` Daemon: ${daemonStatus}`);
3360
- console.log("");
3361
- }
3362
- if (fresh.length > 20) {
3363
- console.log(` (showing 20 of ${fresh.length} sessions)
3364
- `);
3365
- }
3366
- }
3367
- function formatBytes3(bytes) {
3368
- if (bytes < 1024) return `${bytes} B`;
3369
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
3370
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
3371
- }
3372
- function getAge(date) {
3373
- const now = Date.now();
3374
- const diff = now - date.getTime();
3375
- const seconds = Math.floor(diff / 1e3);
3376
- if (seconds < 60) return "just now";
3377
- const minutes = Math.floor(seconds / 60);
3378
- if (minutes < 60) return `${minutes}m ago`;
3379
- const hours = Math.floor(minutes / 60);
3380
- if (hours < 24) return `${hours}h ago`;
3381
- const days = Math.floor(hours / 24);
3382
- return `${days}d ago`;
3383
- }
3384
-
3385
- // src/cli/commands/listen.ts
3386
- var LISTEN_HELP = `
3387
- bp listen - Monitor network traffic (WebSocket/HTTP)
3388
-
3389
- Attach to a browser session and stream network events as JSONL.
3390
- Status messages go to stderr; stdout is clean JSONL (pipeable to jq).
3391
-
3392
- Usage:
3393
- bp listen <mode> [options]
3394
-
3395
- Modes:
3396
- ws WebSocket traffic only
3397
- http HTTP requests/responses only
3398
- all Both WebSocket and HTTP
3399
-
3400
- Options:
3401
- -s, --session [id] Session to use (omit: auto-connect, -s: latest, -s <id>: specific)
3402
- -m, --match <glob> Filter by URL glob pattern (e.g. "*realtime*")
3403
- -o, --output <file> Write JSONL to file instead of stdout
3404
- --max-payload <n> Max text payload preview length (default: 256)
3405
- --timeout <ms> Auto-stop after N milliseconds
3406
- -q, --quiet Suppress stderr status messages
3407
- -h, --help Show this help
3408
-
3409
- Output Format (JSONL):
3410
- {"ts":"...","type":"ws:created","requestId":"1.2","url":"wss://..."}
3411
- {"ts":"...","type":"ws:frame:sent","requestId":"1.2","opcode":1,"length":142,"payload":"..."}
3412
- {"ts":"...","type":"ws:frame:recv","requestId":"1.2","opcode":2,"length":24000,"payload":"[binary: 18000 bytes]"}
3413
- {"ts":"...","type":"ws:closed","requestId":"1.2"}
3414
- {"ts":"...","type":"http:request","requestId":"3.1","method":"POST","url":"https://..."}
3415
- {"ts":"...","type":"http:response","requestId":"3.1","status":200,"mimeType":"application/json"}
3416
-
3417
- Examples:
3418
- # Debug a voice agent's WebSocket protocol
3419
- bp listen ws -m "*voice*" -o voice-traffic.jsonl
3420
-
3421
- # Watch all API calls during a session
3422
- bp listen http -m "*/api/*" --max-payload 1024
3423
-
3424
- # Capture everything for 60 seconds
3425
- bp listen all -o full-trace.jsonl --timeout 60000
3426
-
3427
- # Pipe to jq for live filtering
3428
- bp listen ws | jq 'select(.type == "ws:frame:recv")'
3429
- `;
3430
- function parseListenArgs(args) {
3431
- const options = {};
3432
- for (let i = 0; i < args.length; i++) {
3433
- const arg = args[i];
3434
- if (arg === "-m" || arg === "--match") {
3435
- options.match = args[++i];
3436
- } else if (arg === "-o" || arg === "--output") {
3437
- options.output = args[++i];
3438
- } else if (arg === "--max-payload") {
3439
- options.maxPayload = Number.parseInt(args[++i] ?? "", 10);
3440
- } else if (arg === "--timeout") {
3441
- options.timeout = Number.parseInt(args[++i] ?? "", 10);
3442
- } else if (arg === "-q" || arg === "--quiet") {
3443
- options.quiet = true;
3444
- } else if (arg === "-h" || arg === "--help") {
3445
- options.help = true;
3446
- } else if (arg === "-s" || arg === "--session") {
3447
- const nextArg = args[i + 1];
3448
- if (!nextArg || nextArg.startsWith("-")) {
3449
- options.useLatestSession = true;
3450
- }
3451
- } else if (!arg.startsWith("-") && !options.mode) {
3452
- if (arg === "ws" || arg === "http" || arg === "all") {
3453
- options.mode = arg;
3454
- }
3455
- }
3456
- }
3457
- return options;
3458
- }
3459
- function globToRegex(pattern) {
3460
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
3461
- const withWildcards = escaped.replace(/\*/g, ".*");
3462
- return new RegExp(`^${withWildcards}$`);
3463
- }
3464
- var TrafficMonitor = class {
3465
- cdp;
3466
- opts;
3467
- matchRegex;
3468
- wsUrls = /* @__PURE__ */ new Map();
3469
- httpUrls = /* @__PURE__ */ new Map();
3470
- handlers = [];
3471
- lineCount = 0;
3472
- constructor(cdp, opts) {
3473
- this.cdp = cdp;
3474
- this.opts = opts;
3475
- this.matchRegex = opts.match ? globToRegex(opts.match) : null;
3476
- }
3477
- emit(record) {
3478
- this.opts.write(JSON.stringify(record));
3479
- this.lineCount++;
3480
- }
3481
- matchesUrl(url) {
3482
- if (!this.matchRegex) return true;
3483
- return this.matchRegex.test(url);
3484
- }
3485
- formatPayload(payloadData, opcode) {
3486
- const data = payloadData ?? "";
3487
- if (opcode === 2) {
3488
- const byteLength = Math.floor(data.length * 3 / 4);
3489
- return { payload: `[binary: ${byteLength} bytes]`, length: data.length };
3490
- }
3491
- const length = data.length;
3492
- if (length > this.opts.maxPayload) {
3493
- return {
3494
- payload: `${data.slice(0, this.opts.maxPayload)}... [truncated, ${length} total]`,
3495
- length
3496
- };
3497
- }
3498
- return { payload: data, length };
3499
- }
3500
- subscribe(event, handler) {
3501
- this.cdp.on(event, handler);
3502
- this.handlers.push({ event, handler });
3503
- }
3504
- start() {
3505
- const mode = this.opts.mode;
3506
- if (mode === "ws" || mode === "all") {
3507
- this.subscribe("Network.webSocketCreated", (params) => {
3508
- const url = params["url"];
3509
- const requestId = params["requestId"];
3510
- if (!this.matchesUrl(url)) return;
3511
- this.wsUrls.set(requestId, url);
3512
- this.emit({
3513
- ts: (/* @__PURE__ */ new Date()).toISOString(),
3514
- type: "ws:created",
3515
- requestId,
3516
- url
3517
- });
3518
- });
3519
- this.subscribe("Network.webSocketFrameSent", (params) => {
3520
- const requestId = params["requestId"];
3521
- if (!this.wsUrls.has(requestId)) return;
3522
- const response = params["response"];
3523
- const opcode = response?.opcode ?? 1;
3524
- const { payload, length } = this.formatPayload(response?.payloadData, opcode);
3525
- this.emit({
3526
- ts: (/* @__PURE__ */ new Date()).toISOString(),
3527
- type: "ws:frame:sent",
3528
- requestId,
3529
- opcode,
3530
- length,
3531
- payload
3532
- });
3533
- });
3534
- this.subscribe("Network.webSocketFrameReceived", (params) => {
3535
- const requestId = params["requestId"];
3536
- if (!this.wsUrls.has(requestId)) return;
3537
- const response = params["response"];
3538
- const opcode = response?.opcode ?? 1;
3539
- const { payload, length } = this.formatPayload(response?.payloadData, opcode);
3540
- this.emit({
3541
- ts: (/* @__PURE__ */ new Date()).toISOString(),
3542
- type: "ws:frame:recv",
3543
- requestId,
3544
- opcode,
3545
- length,
3546
- payload
3547
- });
3548
- });
3549
- this.subscribe("Network.webSocketClosed", (params) => {
3550
- const requestId = params["requestId"];
3551
- if (!this.wsUrls.has(requestId)) return;
3552
- this.wsUrls.delete(requestId);
3553
- this.emit({
3554
- ts: (/* @__PURE__ */ new Date()).toISOString(),
3555
- type: "ws:closed",
3556
- requestId
3557
- });
3558
- });
3559
- }
3560
- if (mode === "http" || mode === "all") {
3561
- this.subscribe("Network.requestWillBeSent", (params) => {
3562
- const request = params["request"];
3563
- const url = request?.url ?? "";
3564
- const requestId = params["requestId"];
3565
- if (!this.matchesUrl(url)) return;
3566
- this.httpUrls.set(requestId, url);
3567
- this.emit({
3568
- ts: params["wallTime"] ? new Date(params["wallTime"] * 1e3).toISOString() : (/* @__PURE__ */ new Date()).toISOString(),
3569
- type: "http:request",
3570
- requestId,
3571
- method: request?.method ?? "GET",
3572
- url
3573
- });
3574
- });
3575
- this.subscribe("Network.responseReceived", (params) => {
3576
- const requestId = params["requestId"];
3577
- if (!this.httpUrls.has(requestId)) return;
3578
- const response = params["response"];
3579
- this.emit({
3580
- ts: (/* @__PURE__ */ new Date()).toISOString(),
3581
- type: "http:response",
3582
- requestId,
3583
- status: response?.status ?? 0,
3584
- mimeType: response?.mimeType ?? ""
3585
- });
3586
- });
3587
- this.subscribe("Network.loadingFailed", (params) => {
3588
- const requestId = params["requestId"];
3589
- if (!this.httpUrls.has(requestId)) return;
3590
- this.emit({
3591
- ts: (/* @__PURE__ */ new Date()).toISOString(),
3592
- type: "http:failed",
3593
- requestId,
3594
- errorText: params["errorText"] ?? ""
3595
- });
3596
- });
3597
- }
3598
- }
3599
- stop() {
3600
- for (const { event, handler } of this.handlers) {
3601
- this.cdp.off(event, handler);
3602
- }
3603
- this.handlers = [];
3604
- }
3605
- };
3606
- async function resolveConnection2(sessionId, useLatestSession, trace) {
3607
- if (sessionId) {
3608
- const session2 = await loadSession(sessionId);
3609
- const browser2 = await connect({
3610
- provider: session2.provider,
3611
- wsUrl: session2.wsUrl,
3612
- debug: trace
3613
- });
3614
- return { browser: browser2, session: session2 };
3615
- }
3616
- if (useLatestSession) {
3617
- const session2 = await getDefaultSession();
3618
- if (!session2) {
3619
- throw new Error('No sessions found. Run "bp connect" first or omit -s to auto-connect.');
3620
- }
3621
- const browser2 = await connect({
3622
- provider: session2.provider,
3623
- wsUrl: session2.wsUrl,
3624
- debug: trace
3625
- });
3626
- return { browser: browser2, session: session2 };
4422
+ }
4423
+ function formatLogEntry(entry) {
4424
+ const time = new Date(entry.ts).toLocaleTimeString();
4425
+ const status = entry.status === "failed" ? "\u2717" : entry.status === "success" ? "\u2713" : "\u25CB";
4426
+ if (entry.type === "command") {
4427
+ const dur = entry.durationMs ? `(${entry.durationMs}ms)` : "";
4428
+ return `${time} ${status} ${entry.cmd} ${dur}`;
3627
4429
  }
3628
- let wsUrl;
3629
- try {
3630
- wsUrl = await getBrowserWebSocketUrl("localhost:9222");
3631
- } catch {
3632
- throw new Error(
3633
- "Could not auto-discover browser.\nEither:\n 1. Start Chrome with: --remote-debugging-port=9222\n 2. Use an existing session: bp listen -s <session-id>\n 3. Use latest session: bp listen -s"
3634
- );
4430
+ if (entry.type === "error") {
4431
+ return `${time} \u2717 ERROR: ${entry.error}`;
3635
4432
  }
3636
- const browser = await connect({ provider: "generic", wsUrl, debug: trace });
3637
- const page = await browser.page();
3638
- const currentUrl = await page.url();
3639
- const newSessionId = generateSessionId();
3640
- const session = {
3641
- id: newSessionId,
3642
- provider: "generic",
3643
- wsUrl: browser.wsUrl,
3644
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3645
- lastActivity: (/* @__PURE__ */ new Date()).toISOString(),
3646
- currentUrl
3647
- };
3648
- await saveSession(session);
3649
- return { browser, session };
4433
+ return `${time} ${entry.type}: ${entry.cmd || ""}`;
3650
4434
  }
3651
- async function listenCommand(args, globalOptions) {
3652
- const options = parseListenArgs(args);
3653
- if (options.help || globalOptions.help || !options.mode) {
3654
- console.log(LISTEN_HELP);
4435
+ async function listCommand(args, globalOptions) {
4436
+ const listOptions = parseListArgs(args);
4437
+ if (listOptions.help) {
4438
+ console.log(HELP);
3655
4439
  return;
3656
4440
  }
3657
- const log = options.quiet ? () => {
3658
- } : (msg) => process.stderr.write(`${msg}
4441
+ if (listOptions.logPath || listOptions.logTail !== void 0 || listOptions.info) {
4442
+ let session;
4443
+ if (globalOptions.session) {
4444
+ session = await loadSession(globalOptions.session);
4445
+ } else {
4446
+ session = await getDefaultSession();
4447
+ }
4448
+ if (!session) {
4449
+ throw new Error('No session found. Run "bp connect" first or specify with -s.');
4450
+ }
4451
+ const logger = getSessionLogger(session.id);
4452
+ if (listOptions.logPath) {
4453
+ console.log(logger.getLogPath());
4454
+ return;
4455
+ }
4456
+ if (listOptions.logTail !== void 0) {
4457
+ const entries = logger.tailLog(listOptions.logTail);
4458
+ if (globalOptions.format === "json") {
4459
+ output(entries, "json");
4460
+ return;
4461
+ }
4462
+ if (entries.length === 0) {
4463
+ console.log("No log entries.");
4464
+ return;
4465
+ }
4466
+ console.log(`Last ${entries.length} log entries for session ${session.id}:
3659
4467
  `);
3660
- const { browser, session } = await resolveConnection2(
3661
- globalOptions.session,
3662
- options.useLatestSession ?? false,
3663
- globalOptions.trace ?? false
3664
- );
3665
- let outputStream;
3666
- if (options.output) {
3667
- const fs7 = await import("fs");
3668
- const fileStream = fs7.createWriteStream(options.output, { flags: "w" });
3669
- outputStream = {
3670
- write: (line) => fileStream.write(`${line}
3671
- `),
3672
- close: () => fileStream.end()
3673
- };
3674
- log(`Writing to ${options.output}`);
3675
- } else {
3676
- outputStream = {
3677
- write: (line) => {
3678
- try {
3679
- process.stdout.write(`${line}
4468
+ for (const entry of entries) {
4469
+ console.log(` ${formatLogEntry(entry)}`);
4470
+ }
4471
+ return;
4472
+ }
4473
+ if (listOptions.info) {
4474
+ const stats = logger.getLogStats();
4475
+ if (globalOptions.format === "json") {
4476
+ output({ session, logStats: stats }, "json");
4477
+ return;
4478
+ }
4479
+ console.log(`Session: ${session.id}
3680
4480
  `);
3681
- } catch {
3682
- process.exit(0);
3683
- }
4481
+ console.log(` Provider: ${session.provider}`);
4482
+ console.log(` Created: ${new Date(session.createdAt).toLocaleString()}`);
4483
+ console.log(` Last activity: ${new Date(session.lastActivity).toLocaleString()}`);
4484
+ console.log(` URL: ${session.currentUrl}`);
4485
+ if (session.exportLog) {
4486
+ console.log(` Export log: ${session.exportLog}`);
3684
4487
  }
3685
- };
3686
- process.stdout.on("error", (err) => {
3687
- if (err.code === "EPIPE") {
3688
- process.exit(0);
4488
+ console.log("");
4489
+ console.log("Log Stats:");
4490
+ console.log(` Path: ${logger.getLogPath()}`);
4491
+ console.log(` Entries: ${stats.entries}`);
4492
+ console.log(` Size: ${formatBytes3(stats.size)}`);
4493
+ if (stats.first) {
4494
+ console.log(` First: ${new Date(stats.first).toLocaleString()}`);
3689
4495
  }
3690
- });
4496
+ if (stats.last) {
4497
+ console.log(` Last: ${new Date(stats.last).toLocaleString()}`);
4498
+ }
4499
+ return;
4500
+ }
3691
4501
  }
3692
- try {
3693
- const page = await browser.page(void 0, { targetId: session.targetId });
3694
- const cdp = page.cdpClient;
3695
- await cdp.send("Network.enable");
3696
- const monitor = new TrafficMonitor(cdp, {
3697
- mode: options.mode,
3698
- match: options.match,
3699
- maxPayload: options.maxPayload ?? 256,
3700
- write: (line) => outputStream.write(line)
3701
- });
3702
- monitor.start();
3703
- const matchLabel = options.match ? ` matching "${options.match}"` : "";
3704
- log(`Listening for ${options.mode} traffic${matchLabel} (session: ${session.id})`);
3705
- log("Press Ctrl+C to stop.");
3706
- let cleaned = false;
3707
- const cleanup = () => {
3708
- if (cleaned) return;
3709
- cleaned = true;
3710
- monitor.stop();
3711
- log(`
3712
- Stopped. ${monitor.lineCount} events captured.`);
3713
- outputStream.close?.();
3714
- browser.disconnect().catch(() => {
3715
- });
3716
- process.exit(0);
3717
- };
3718
- process.on("SIGINT", cleanup);
3719
- process.on("SIGTERM", cleanup);
3720
- if (options.timeout && options.timeout > 0) {
3721
- setTimeout(() => {
3722
- log(`
3723
- Timeout reached (${options.timeout}ms).`);
3724
- cleanup();
3725
- }, options.timeout);
4502
+ const sessions = await listSessions();
4503
+ const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1e3;
4504
+ const now = Date.now();
4505
+ const stale = sessions.filter((s) => now - new Date(s.lastActivity).getTime() > TWO_DAYS_MS);
4506
+ const fresh = sessions.filter((s) => now - new Date(s.lastActivity).getTime() <= TWO_DAYS_MS);
4507
+ if (stale.length > 0) {
4508
+ for (const s of stale) {
4509
+ await deleteSessionFull(s.id);
3726
4510
  }
3727
- await new Promise(() => {
3728
- });
3729
- } catch (error) {
3730
- outputStream.close?.();
3731
- await browser.disconnect();
3732
- throw error;
3733
4511
  }
4512
+ if (globalOptions.format === "json") {
4513
+ output(fresh, "json");
4514
+ return;
4515
+ }
4516
+ if (stale.length > 0) {
4517
+ console.log(`Cleaned ${stale.length} stale session(s) (>2 days old).
4518
+ `);
4519
+ }
4520
+ if (fresh.length === 0) {
4521
+ console.log("No active sessions.");
4522
+ console.log('Run "bp connect" to create a new session.');
4523
+ return;
4524
+ }
4525
+ console.log("Active Sessions:\n");
4526
+ console.log(" Tip: bp list -s <name> --log-tail View action log");
4527
+ console.log(" bp list -s <name> --log-path Get log file path\n");
4528
+ const displaySessions = fresh.slice(0, 20);
4529
+ for (const session of displaySessions) {
4530
+ const age = getAge(new Date(session.lastActivity));
4531
+ const daemonStatus = session.daemon ? isDaemonAlive(session.daemon.pid) ? "running" : "dead" : "none";
4532
+ console.log(` ${session.id}`);
4533
+ console.log(` Provider: ${session.provider}`);
4534
+ console.log(` URL: ${session.currentUrl}`);
4535
+ console.log(` Last activity: ${age}`);
4536
+ console.log(` Daemon: ${daemonStatus}`);
4537
+ console.log("");
4538
+ }
4539
+ if (fresh.length > 20) {
4540
+ console.log(` (showing 20 of ${fresh.length} sessions)
4541
+ `);
4542
+ }
4543
+ }
4544
+ function formatBytes3(bytes) {
4545
+ if (bytes < 1024) return `${bytes} B`;
4546
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
4547
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
4548
+ }
4549
+ function getAge(date) {
4550
+ const now = Date.now();
4551
+ const diff = now - date.getTime();
4552
+ const seconds = Math.floor(diff / 1e3);
4553
+ if (seconds < 60) return "just now";
4554
+ const minutes = Math.floor(seconds / 60);
4555
+ if (minutes < 60) return `${minutes}m ago`;
4556
+ const hours = Math.floor(minutes / 60);
4557
+ if (hours < 24) return `${hours}h ago`;
4558
+ const days = Math.floor(hours / 24);
4559
+ return `${days}d ago`;
3734
4560
  }
3735
4561
 
3736
4562
  // src/cli/commands/page.ts
3737
4563
  var PAGE_HELP = `
3738
4564
  bp page - Show a compact overview of the current page
3739
4565
 
4566
+ When to use:
4567
+ You want a quick summary of URL, title, headings, forms, and interactive controls.
4568
+
4569
+ When not to use:
4570
+ You need the full accessibility tree or reusable refs for precise automation. Use \`bp snapshot\`.
4571
+
4572
+ Likely next commands:
4573
+ bp snapshot -i
4574
+ bp forms
4575
+ bp exec '[{"action":"click","selector":"ref:e4"}]'
4576
+
3740
4577
  Usage:
3741
4578
  bp page [options]
3742
4579
 
@@ -3938,8 +4775,9 @@ async function quickstartCommand() {
3938
4775
 
3939
4776
  // src/cli/commands/record.ts
3940
4777
  import * as nodeFs2 from "fs";
3941
- import { homedir as homedir5 } from "os";
3942
- import { join as join8 } from "path";
4778
+ import { existsSync as existsSync6 } from "fs";
4779
+ import { homedir as homedir6 } from "os";
4780
+ import { dirname as dirname7, join as join9, resolve as resolve5 } from "path";
3943
4781
 
3944
4782
  // src/recording/aggregator.ts
3945
4783
  var INPUT_DEBOUNCE_MS = 300;
@@ -4686,6 +5524,15 @@ var RECORDER_SCRIPT = `(function() {
4686
5524
  })();`;
4687
5525
 
4688
5526
  // src/recording/recorder.ts
5527
+ function readString(value) {
5528
+ return typeof value === "string" ? value : void 0;
5529
+ }
5530
+ function readStringOr(value, fallback = "") {
5531
+ return readString(value) ?? fallback;
5532
+ }
5533
+ function formatConsoleArg(entry) {
5534
+ return readString(entry["value"]) ?? readString(entry["description"]) ?? "";
5535
+ }
4689
5536
  var Recorder = class {
4690
5537
  cdp;
4691
5538
  options;
@@ -4705,6 +5552,8 @@ var Recorder = class {
4705
5552
  pendingBodies = [];
4706
5553
  wsUrls = /* @__PURE__ */ new Map();
4707
5554
  httpUrls = /* @__PURE__ */ new Map();
5555
+ traceEvents = [];
5556
+ traceHandlers = [];
4708
5557
  constructor(cdp, options) {
4709
5558
  this.cdp = cdp;
4710
5559
  this.options = options ?? {};
@@ -4726,6 +5575,7 @@ var Recorder = class {
4726
5575
  throw new Error("Recording already in progress");
4727
5576
  }
4728
5577
  this.events = [];
5578
+ this.traceEvents = [];
4729
5579
  this.startTime = Date.now();
4730
5580
  this.recording = true;
4731
5581
  await this.cdp.send("Runtime.enable");
@@ -4740,23 +5590,75 @@ var Recorder = class {
4740
5590
  this.startUrl = "";
4741
5591
  }
4742
5592
  await this.cdp.send("Runtime.addBinding", { name: RECORDER_BINDING_NAME });
5593
+ await this.cdp.send("Runtime.addBinding", { name: TRACE_BINDING_NAME });
4743
5594
  await this.cdp.send("Page.addScriptToEvaluateOnNewDocument", {
4744
5595
  source: RECORDER_SCRIPT
4745
5596
  });
5597
+ await this.cdp.send("Page.addScriptToEvaluateOnNewDocument", {
5598
+ source: TRACE_SCRIPT
5599
+ });
4746
5600
  await this.cdp.send("Runtime.evaluate", {
4747
5601
  expression: RECORDER_SCRIPT,
4748
5602
  awaitPromise: false
4749
5603
  });
5604
+ await this.cdp.send("Runtime.evaluate", {
5605
+ expression: TRACE_SCRIPT,
5606
+ awaitPromise: false
5607
+ });
4750
5608
  this.bindingHandler = (params) => {
5609
+ const payload = readString(params["payload"]);
5610
+ if (!payload) {
5611
+ return;
5612
+ }
4751
5613
  if (params["name"] === RECORDER_BINDING_NAME) {
4752
- this.handleBindingCall(params["payload"]);
5614
+ this.handleBindingCall(payload);
5615
+ } else if (params["name"] === TRACE_BINDING_NAME) {
5616
+ this.handleTraceBindingCall(payload);
4753
5617
  }
4754
5618
  };
4755
5619
  this.cdp.on("Runtime.bindingCalled", this.bindingHandler);
5620
+ this.subscribeTrace("Runtime.consoleAPICalled", (params) => {
5621
+ const type = readStringOr(params["type"], "log");
5622
+ if (type !== "log" && type !== "warn" && type !== "error") {
5623
+ return;
5624
+ }
5625
+ const args = Array.isArray(params["args"]) ? params["args"] : [];
5626
+ const text = args.map(formatConsoleArg).filter(Boolean).join(" ");
5627
+ this.traceEvents.push(
5628
+ normalizeTraceEvent({
5629
+ traceId: createTraceId("console"),
5630
+ sessionId: "",
5631
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
5632
+ elapsedMs: this.elapsed(),
5633
+ channel: "console",
5634
+ event: `console.${type}`,
5635
+ severity: type === "error" ? "error" : type === "warn" ? "warn" : "info",
5636
+ summary: text || `console.${type}`,
5637
+ data: { args },
5638
+ url: this.startUrl
5639
+ })
5640
+ );
5641
+ });
5642
+ this.subscribeTrace("Runtime.exceptionThrown", (params) => {
5643
+ const details = params["exceptionDetails"] ?? {};
5644
+ this.traceEvents.push(
5645
+ normalizeTraceEvent({
5646
+ traceId: createTraceId("runtime"),
5647
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
5648
+ elapsedMs: this.elapsed(),
5649
+ channel: "runtime",
5650
+ event: "runtime.exception",
5651
+ severity: "error",
5652
+ summary: readString(details["text"]) ?? "Runtime exception",
5653
+ data: details,
5654
+ url: this.startUrl
5655
+ })
5656
+ );
5657
+ });
4756
5658
  if (this.options.listen) {
4757
5659
  const listenOpts = typeof this.options.listen === "boolean" ? { mode: "all" } : this.options.listen;
4758
5660
  this.listenOpts = listenOpts;
4759
- this.matchRegex = listenOpts.match ? globToRegex2(listenOpts.match) : null;
5661
+ this.matchRegex = listenOpts.match ? globToRegex(listenOpts.match) : null;
4760
5662
  await this.cdp.send("Network.enable");
4761
5663
  this.setupNetworkListeners(listenOpts);
4762
5664
  }
@@ -4780,6 +5682,10 @@ var Recorder = class {
4780
5682
  this.cdp.off(event, handler);
4781
5683
  }
4782
5684
  this.networkHandlers = [];
5685
+ for (const { event, handler } of this.traceHandlers) {
5686
+ this.cdp.off(event, handler);
5687
+ }
5688
+ this.traceHandlers = [];
4783
5689
  if (this.listenOpts) {
4784
5690
  await this.cdp.send("Network.disable");
4785
5691
  }
@@ -4790,7 +5696,8 @@ var Recorder = class {
4790
5696
  recordedAt: new Date(this.startTime).toISOString(),
4791
5697
  startUrl: this.startUrl,
4792
5698
  duration,
4793
- steps
5699
+ steps,
5700
+ traceEvents: this.traceEvents
4794
5701
  };
4795
5702
  if (this.listenOpts) {
4796
5703
  const mode = this.listenOpts.mode ?? "all";
@@ -4831,11 +5738,35 @@ var Recorder = class {
4831
5738
  } catch {
4832
5739
  }
4833
5740
  }
5741
+ handleTraceBindingCall(payload) {
5742
+ if (!this.recording) return;
5743
+ try {
5744
+ const data = JSON.parse(payload);
5745
+ this.traceEvents.push(
5746
+ normalizeTraceEvent({
5747
+ traceId: createTraceId("trace"),
5748
+ ts: data.ts ? new Date(data.ts).toISOString() : (/* @__PURE__ */ new Date()).toISOString(),
5749
+ elapsedMs: this.elapsed(),
5750
+ channel: this.channelForTraceEvent(data.event),
5751
+ event: data.event,
5752
+ severity: data.severity,
5753
+ summary: data.summary ?? data.event,
5754
+ data: data.data ?? {},
5755
+ url: typeof data.data?.["url"] === "string" ? data.data["url"] : this.startUrl
5756
+ })
5757
+ );
5758
+ } catch {
5759
+ }
5760
+ }
4834
5761
  /** Subscribe to a CDP event, tracking for cleanup. */
4835
5762
  subscribeNetwork(event, handler) {
4836
5763
  this.cdp.on(event, handler);
4837
5764
  this.networkHandlers.push({ event, handler });
4838
5765
  }
5766
+ subscribeTrace(event, handler) {
5767
+ this.cdp.on(event, handler);
5768
+ this.traceHandlers.push({ event, handler });
5769
+ }
4839
5770
  /** Check if a URL matches the configured filter. */
4840
5771
  matchesUrl(url) {
4841
5772
  if (!this.matchRegex) return true;
@@ -4879,6 +5810,20 @@ var Recorder = class {
4879
5810
  type: "created",
4880
5811
  url
4881
5812
  });
5813
+ this.traceEvents.push(
5814
+ normalizeTraceEvent({
5815
+ traceId: createTraceId("ws"),
5816
+ ts: new Date(now).toISOString(),
5817
+ elapsedMs: this.elapsed(),
5818
+ channel: "ws",
5819
+ event: "ws.connection.created",
5820
+ summary: `WebSocket opened ${url}`,
5821
+ data: { url },
5822
+ connectionId: requestId,
5823
+ requestId,
5824
+ url
5825
+ })
5826
+ );
4882
5827
  });
4883
5828
  this.subscribeNetwork("Network.webSocketFrameSent", (params) => {
4884
5829
  const requestId = params["requestId"];
@@ -4896,6 +5841,20 @@ var Recorder = class {
4896
5841
  payload,
4897
5842
  length
4898
5843
  });
5844
+ this.traceEvents.push(
5845
+ normalizeTraceEvent({
5846
+ traceId: createTraceId("ws"),
5847
+ ts: new Date(now).toISOString(),
5848
+ elapsedMs: this.elapsed(),
5849
+ channel: "ws",
5850
+ event: "ws.frame.sent",
5851
+ summary: `WebSocket frame sent ${requestId}`,
5852
+ data: { opcode, payload, length },
5853
+ connectionId: requestId,
5854
+ requestId,
5855
+ url: this.wsUrls.get(requestId)
5856
+ })
5857
+ );
4899
5858
  });
4900
5859
  this.subscribeNetwork("Network.webSocketFrameReceived", (params) => {
4901
5860
  const requestId = params["requestId"];
@@ -4913,10 +5872,25 @@ var Recorder = class {
4913
5872
  payload,
4914
5873
  length
4915
5874
  });
5875
+ this.traceEvents.push(
5876
+ normalizeTraceEvent({
5877
+ traceId: createTraceId("ws"),
5878
+ ts: new Date(now).toISOString(),
5879
+ elapsedMs: this.elapsed(),
5880
+ channel: "ws",
5881
+ event: "ws.frame.received",
5882
+ summary: `WebSocket frame received ${requestId}`,
5883
+ data: { opcode, payload, length },
5884
+ connectionId: requestId,
5885
+ requestId,
5886
+ url: this.wsUrls.get(requestId)
5887
+ })
5888
+ );
4916
5889
  });
4917
5890
  this.subscribeNetwork("Network.webSocketClosed", (params) => {
4918
5891
  const requestId = params["requestId"];
4919
5892
  if (!this.wsUrls.has(requestId)) return;
5893
+ const url = this.wsUrls.get(requestId);
4920
5894
  this.wsUrls.delete(requestId);
4921
5895
  const now = Date.now();
4922
5896
  this.wsEvents.push({
@@ -4925,6 +5899,21 @@ var Recorder = class {
4925
5899
  elapsedMs: this.elapsed(),
4926
5900
  type: "closed"
4927
5901
  });
5902
+ this.traceEvents.push(
5903
+ normalizeTraceEvent({
5904
+ traceId: createTraceId("ws"),
5905
+ ts: new Date(now).toISOString(),
5906
+ elapsedMs: this.elapsed(),
5907
+ channel: "ws",
5908
+ event: "ws.connection.closed",
5909
+ severity: "warn",
5910
+ summary: `WebSocket closed ${requestId}`,
5911
+ data: { url: url ?? null },
5912
+ connectionId: requestId,
5913
+ requestId,
5914
+ url
5915
+ })
5916
+ );
4928
5917
  });
4929
5918
  }
4930
5919
  if (mode === "http" || mode === "all") {
@@ -4944,6 +5933,23 @@ var Recorder = class {
4944
5933
  headers: request?.headers,
4945
5934
  body: request?.postData
4946
5935
  });
5936
+ this.traceEvents.push(
5937
+ normalizeTraceEvent({
5938
+ traceId: createTraceId("http"),
5939
+ ts: new Date(now).toISOString(),
5940
+ elapsedMs: this.elapsed(),
5941
+ channel: "http",
5942
+ event: "http.request.sent",
5943
+ summary: `${request?.method ?? "GET"} ${url}`,
5944
+ data: {
5945
+ method: request?.method ?? "GET",
5946
+ headers: request?.headers ?? {},
5947
+ body: request?.postData ?? null
5948
+ },
5949
+ requestId,
5950
+ url
5951
+ })
5952
+ );
4947
5953
  });
4948
5954
  this.subscribeNetwork("Network.responseReceived", (params) => {
4949
5955
  const requestId = params["requestId"];
@@ -4958,6 +5964,23 @@ var Recorder = class {
4958
5964
  headers: response?.headers,
4959
5965
  mimeType: response?.mimeType
4960
5966
  });
5967
+ this.traceEvents.push(
5968
+ normalizeTraceEvent({
5969
+ traceId: createTraceId("http"),
5970
+ ts: new Date(now).toISOString(),
5971
+ elapsedMs: this.elapsed(),
5972
+ channel: "http",
5973
+ event: "http.response.received",
5974
+ summary: `${response?.status ?? 0} ${this.httpUrls.get(requestId) ?? ""}`,
5975
+ data: {
5976
+ status: response?.status ?? 0,
5977
+ headers: response?.headers ?? {},
5978
+ mimeType: response?.mimeType ?? null
5979
+ },
5980
+ requestId,
5981
+ url: this.httpUrls.get(requestId)
5982
+ })
5983
+ );
4961
5984
  if (this.listenOpts?.captureResponseBodies) {
4962
5985
  const bodyPromise = this.cdp.send("Network.getResponseBody", {
4963
5986
  requestId
@@ -4972,8 +5995,37 @@ var Recorder = class {
4972
5995
  this.pendingBodies.push(bodyPromise);
4973
5996
  }
4974
5997
  });
5998
+ this.subscribeNetwork("Network.loadingFailed", (params) => {
5999
+ const requestId = params["requestId"];
6000
+ this.traceEvents.push(
6001
+ normalizeTraceEvent({
6002
+ traceId: createTraceId("http"),
6003
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
6004
+ elapsedMs: this.elapsed(),
6005
+ channel: "http",
6006
+ event: "http.response.failed",
6007
+ severity: "error",
6008
+ summary: `HTTP request failed ${requestId}`,
6009
+ data: {
6010
+ errorText: params["errorText"] ?? null,
6011
+ blockedReason: params["blockedReason"] ?? null,
6012
+ canceled: params["canceled"] ?? false
6013
+ },
6014
+ requestId,
6015
+ url: this.httpUrls.get(requestId)
6016
+ })
6017
+ );
6018
+ });
4975
6019
  }
4976
6020
  }
6021
+ channelForTraceEvent(eventName) {
6022
+ if (eventName.startsWith("permission.")) return "permission";
6023
+ if (eventName.startsWith("media.")) return "media";
6024
+ if (eventName.startsWith("voice.")) return "voice";
6025
+ if (eventName.startsWith("dom.")) return "dom";
6026
+ if (eventName.startsWith("runtime.")) return "runtime";
6027
+ return "session";
6028
+ }
4977
6029
  /** Build a merged timeline from action events and network events. */
4978
6030
  buildTimeline() {
4979
6031
  const entries = [];
@@ -5021,7 +6073,7 @@ var Recorder = class {
5021
6073
  return entries;
5022
6074
  }
5023
6075
  };
5024
- function globToRegex2(pattern) {
6076
+ function globToRegex(pattern) {
5025
6077
  const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
5026
6078
  const withWildcards = escaped.replace(/\*/g, ".*");
5027
6079
  return new RegExp(`^${withWildcards}$`);
@@ -5029,48 +6081,57 @@ function globToRegex2(pattern) {
5029
6081
 
5030
6082
  // src/cli/commands/record.ts
5031
6083
  var RECORD_HELP = `
5032
- bp record - Record browser actions to JSON
6084
+ bp record - Capture a human demo into one canonical artifact
6085
+
6086
+ When to use:
6087
+ A human is demonstrating the workflow and you want replayable automation later.
6088
+
6089
+ When not to use:
6090
+ You already have steps and just want to run or validate them. Use \`bp exec\` or \`bp run\`.
6091
+
6092
+ Default flow:
6093
+ capture -> summary -> inspect or trace -> derive -> run
6094
+
6095
+ Common mistake:
6096
+ Opening \`recording.json\` first. Start with \`bp record summary\`.
5033
6097
 
5034
6098
  Usage:
5035
6099
  bp record [options]
5036
-
5037
- Options:
5038
- -s, --session [id] Session to use:
5039
- - omit -s: auto-connect to local browser
5040
- - -s alone: use most recent session
5041
- - -s <id>: use specific session
5042
- -f, --file <path> Output file (default: recording.json)
5043
- --timeout <ms> Auto-stop after timeout (optional)
5044
- --listen [mode] Capture network traffic: ws, http, or all (default: all)
5045
- --bodies Capture HTTP response bodies (requires --listen)
5046
- -m, --match <glob> Filter network URLs by glob pattern (requires --listen)
5047
- --max-payload <n> Max WebSocket payload preview length (default: 256)
5048
- -h, --help Show this help
6100
+ bp record <inspect|summary|derive|export> [artifact] [options]
6101
+
6102
+ Capture options:
6103
+ -s, --session [id] Session to use (omit: auto-connect, -s: latest, -s <id>: specific)
6104
+ -f, --file <path> Artifact output path (default: recording.json)
6105
+ --timeout <ms> Auto-stop after timeout
6106
+ --profile <name> automation | realtime | voice | auth (default: automation)
6107
+ --listen [mode] ws | http | all (default: all)
6108
+ --bodies Capture HTTP response bodies
6109
+ -m, --match <glob> Filter HTTP/WS URLs
6110
+ --max-payload <n> Max WebSocket payload preview length (default: 256)
6111
+
6112
+ Artifact subcommands:
6113
+ inspect [artifact] Show artifact metadata and next commands
6114
+ summary [artifact] Show workflow summary plus trace views
6115
+ derive <artifact> -o <output> Write replayable steps for bp run
6116
+ export <artifact> -o <output> Write canonical triage bundle
5049
6117
 
5050
6118
  Examples:
5051
- bp record # Auto-connect to local Chrome
5052
- bp record -s # Use most recent session
5053
- bp record -s mysession # Use specific session
5054
- bp record -f login.json # Save to specific file
5055
- bp record --timeout 60000 # Auto-stop after 60s
5056
- bp record --listen # Record actions + all network traffic
5057
- bp record --listen ws -m "*voice*" # Record actions + matching WS traffic
5058
- bp record --listen http --bodies # Record actions + HTTP with bodies
5059
-
5060
- Recording captures: clicks, inputs, form submissions, navigation.
5061
- Screenshots are captured automatically after each interaction and saved
5062
- to the session directory (~/.browser-pilot/sessions/<id>/screenshots/).
5063
- When --listen is enabled, network traffic is captured alongside actions
5064
- and merged into a unified timeline in the output file.
5065
- Sensitive fields are automatically redacted as [REDACTED] based on the
5066
- field settings (password, hidden, one-time-code, card autofill hints).
5067
- Recording also enables session-level auto-recording for subsequent
5068
- bp exec calls (equivalent to bp connect --record).
5069
-
5070
- Press Ctrl+C to stop recording and save.
5071
- `;
6119
+ bp record -s demo --profile automation
6120
+ bp record --profile voice --bodies
6121
+ bp record summary recording.json
6122
+ bp record inspect recording.json
6123
+ bp record derive recording.json -o workflow.json
6124
+ bp record export recording.json -o bundle.json
6125
+
6126
+ Likely next commands:
6127
+ bp record summary recording.json
6128
+ bp trace summary recording.json --view ws
6129
+ bp record derive recording.json -o workflow.json
6130
+ `.trim();
6131
+ var DEFAULT_ARTIFACT = "recording.json";
5072
6132
  function parseRecordArgs(args) {
5073
6133
  const options = {};
6134
+ let nextIsArtifact = false;
5074
6135
  for (let i = 0; i < args.length; i++) {
5075
6136
  const arg = args[i];
5076
6137
  if (arg === "-f" || arg === "--file") {
@@ -5086,7 +6147,7 @@ function parseRecordArgs(args) {
5086
6147
  }
5087
6148
  } else if (arg === "--listen") {
5088
6149
  const nextArg = args[i + 1];
5089
- if (nextArg && (nextArg === "ws" || nextArg === "http" || nextArg === "all")) {
6150
+ if (nextArg === "ws" || nextArg === "http" || nextArg === "all") {
5090
6151
  options.listen = nextArg;
5091
6152
  i++;
5092
6153
  } else {
@@ -5098,176 +6159,265 @@ function parseRecordArgs(args) {
5098
6159
  options.match = args[++i];
5099
6160
  } else if (arg === "--max-payload") {
5100
6161
  options.maxPayload = Number.parseInt(args[++i] ?? "", 10);
6162
+ } else if (arg === "--profile") {
6163
+ const profile = args[++i];
6164
+ if (profile === "automation" || profile === "realtime" || profile === "voice" || profile === "auth") {
6165
+ options.profile = profile;
6166
+ }
6167
+ } else if (arg === "-o" || arg === "--output") {
6168
+ options.output = args[++i];
6169
+ } else if (!arg.startsWith("-") && !options.subcommand && !nextIsArtifact) {
6170
+ if (isSubcommand(arg)) {
6171
+ options.subcommand = arg;
6172
+ nextIsArtifact = arg !== "capture";
6173
+ } else if (!options.artifactPath) {
6174
+ options.artifactPath = arg;
6175
+ }
6176
+ } else if (!arg.startsWith("-") && nextIsArtifact && !options.artifactPath) {
6177
+ options.artifactPath = arg;
6178
+ nextIsArtifact = false;
5101
6179
  }
5102
6180
  }
5103
6181
  return options;
5104
6182
  }
5105
- async function resolveConnection3(sessionId, useLatestSession, trace) {
6183
+ function isSubcommand(value) {
6184
+ return value === "capture" || value === "inspect" || value === "summary" || value === "derive" || value === "export";
6185
+ }
6186
+ async function resolveConnection3(sessionId, useLatestSession, debug) {
5106
6187
  if (sessionId) {
5107
6188
  const session2 = await loadSession(sessionId);
5108
6189
  const browser2 = await connect({
5109
6190
  provider: session2.provider,
5110
6191
  wsUrl: session2.wsUrl,
5111
- debug: trace
6192
+ debug
5112
6193
  });
5113
6194
  return { browser: browser2, session: session2, isNewSession: false };
5114
6195
  }
5115
6196
  if (useLatestSession) {
5116
6197
  const session2 = await getDefaultSession();
5117
6198
  if (!session2) {
5118
- throw new Error(
5119
- 'No sessions found. Run "bp connect" first or use "bp record" to auto-connect.'
5120
- );
6199
+ throw new Error('No sessions found. Run "bp connect" first or omit -s to auto-connect.');
5121
6200
  }
5122
6201
  const browser2 = await connect({
5123
6202
  provider: session2.provider,
5124
6203
  wsUrl: session2.wsUrl,
5125
- debug: trace
6204
+ debug
5126
6205
  });
5127
6206
  return { browser: browser2, session: session2, isNewSession: false };
5128
6207
  }
5129
6208
  let wsUrl;
5130
6209
  try {
5131
- wsUrl = await getBrowserWebSocketUrl("localhost:9222");
5132
- } catch {
6210
+ wsUrl = (await resolveCLIEndpoint()).wsUrl;
6211
+ } catch (error) {
5133
6212
  throw new Error(
5134
- "Could not auto-discover browser.\nEither:\n 1. Start Chrome with: --remote-debugging-port=9222\n 2. Use an existing session: bp record -s <session-id>\n 3. Use latest session: bp record -s"
6213
+ formatBrowserDiscoveryError(error, {
6214
+ explicitHint: " - Create a session first: bp connect --browser-url <ws-url>",
6215
+ reuseSessionHint: "bp record -s <session-id>",
6216
+ latestSessionHint: "bp record -s"
6217
+ })
5135
6218
  );
5136
6219
  }
5137
6220
  const browser = await connect({
5138
6221
  provider: "generic",
5139
6222
  wsUrl,
5140
- debug: trace
6223
+ debug
5141
6224
  });
5142
6225
  const page = await browser.page();
5143
6226
  const currentUrl = await page.url();
5144
- const newSessionId = generateSessionId();
5145
6227
  const session = {
5146
- id: newSessionId,
6228
+ id: generateSessionId(),
5147
6229
  provider: "generic",
5148
6230
  wsUrl: browser.wsUrl,
5149
6231
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
5150
6232
  lastActivity: (/* @__PURE__ */ new Date()).toISOString(),
5151
6233
  currentUrl
5152
6234
  };
5153
- await saveSession(session);
5154
- return { browser, session, isNewSession: true };
6235
+ await saveSession(session);
6236
+ return { browser, session, isNewSession: true };
6237
+ }
6238
+ function artifactSessionDir(sessionId) {
6239
+ return join9(homedir6(), ".browser-pilot", "sessions", sessionId);
6240
+ }
6241
+ function resolveArtifactPath(explicit, session) {
6242
+ if (explicit) {
6243
+ return resolve5(explicit);
6244
+ }
6245
+ if (session) {
6246
+ return join9(artifactSessionDir(session.id), DEFAULT_ARTIFACT);
6247
+ }
6248
+ return resolve5(DEFAULT_ARTIFACT);
6249
+ }
6250
+ function normalizeProfile(profile) {
6251
+ return profile ?? "automation";
6252
+ }
6253
+ function tipsForArtifact(path) {
6254
+ return {
6255
+ tip: {
6256
+ reason: "summary_first",
6257
+ command: `bp record summary ${path}`
6258
+ },
6259
+ alternateTips: [
6260
+ {
6261
+ reason: "derive_replayable_steps",
6262
+ command: `bp record derive ${path} -o workflow.json`
6263
+ },
6264
+ {
6265
+ reason: "inspect_trace_views",
6266
+ command: `bp trace summary ${path} --view ws`
6267
+ }
6268
+ ]
6269
+ };
5155
6270
  }
5156
- async function recordCommand(args, globalOptions) {
5157
- const options = parseRecordArgs(args);
5158
- if (options.help || globalOptions.help) {
5159
- console.log(RECORD_HELP);
5160
- return;
6271
+ function buildSummary(artifact, source) {
6272
+ return {
6273
+ source,
6274
+ version: artifact.version,
6275
+ session: artifact.session,
6276
+ counts: {
6277
+ steps: artifact.recipe.steps.length,
6278
+ actions: artifact.actions.length,
6279
+ screenshots: artifact.screenshots.length,
6280
+ traceEvents: artifact.trace.events.length,
6281
+ assertions: artifact.assertions.length
6282
+ },
6283
+ trace: artifact.trace.summaries,
6284
+ tips: tipsForArtifact(source)
6285
+ };
6286
+ }
6287
+ function deriveAssertions(artifact) {
6288
+ const assertions = [];
6289
+ if (artifact.session.endUrl) {
6290
+ assertions.push({ action: "assertUrl", expect: artifact.session.endUrl });
6291
+ }
6292
+ for (const action of artifact.actions) {
6293
+ if (action.action === "fill" && action.selector && typeof action.value === "string") {
6294
+ assertions.push({
6295
+ action: "assertValue",
6296
+ selector: action.selector,
6297
+ expect: action.value
6298
+ });
6299
+ }
6300
+ }
6301
+ return assertions;
6302
+ }
6303
+ async function loadArtifact(pathOrFallback) {
6304
+ if (!existsSync6(pathOrFallback)) {
6305
+ throw new Error(`Artifact not found: ${pathOrFallback}`);
5161
6306
  }
5162
- const outputFile = options.file ?? "recording.json";
6307
+ const raw = JSON.parse(nodeFs2.readFileSync(pathOrFallback, "utf-8"));
6308
+ return { path: pathOrFallback, artifact: canonicalizeRecordingArtifact(raw) };
6309
+ }
6310
+ function artifactToFrames(artifact) {
6311
+ const screenshotsByAction = new Map(artifact.screenshots.map((shot) => [shot.actionId, shot]));
6312
+ return artifact.actions.map((action, index) => {
6313
+ const screenshot = screenshotsByAction.get(action.id);
6314
+ return {
6315
+ seq: index + 1,
6316
+ timestamp: Date.parse(action.ts),
6317
+ action: action.action,
6318
+ selector: action.selector,
6319
+ selectorUsed: action.selectorUsed,
6320
+ value: action.value,
6321
+ url: action.url,
6322
+ coordinates: action.coordinates,
6323
+ boundingBox: action.boundingBox,
6324
+ success: action.success,
6325
+ durationMs: action.durationMs,
6326
+ error: action.error,
6327
+ screenshot: screenshot?.file ?? "",
6328
+ pageUrl: action.pageUrl,
6329
+ pageTitle: action.pageTitle,
6330
+ stepIndex: action.stepIndex,
6331
+ actionId: action.id
6332
+ };
6333
+ });
6334
+ }
6335
+ async function runRecordCapture(args, globalOptions) {
6336
+ const profile = normalizeProfile(args.profile);
5163
6337
  const { browser, session, isNewSession } = await resolveConnection3(
5164
6338
  globalOptions.session,
5165
- options.useLatestSession ?? false,
6339
+ args.useLatestSession ?? false,
5166
6340
  globalOptions.trace ?? false
5167
6341
  );
5168
6342
  if (isNewSession) {
5169
6343
  console.log(`Created new session: ${session.id}`);
5170
6344
  }
5171
6345
  if (!session.metadata?.record) {
5172
- const recordSettings = {};
5173
- await updateSession(session.id, { metadata: { record: recordSettings } });
6346
+ const recordSettings2 = {};
6347
+ await updateSession(session.id, { metadata: { record: recordSettings2 } });
5174
6348
  }
5175
- const page = await browser.page();
6349
+ const page = await browser.page(void 0, { targetId: session.targetId });
5176
6350
  const cdp = page.cdpClient;
5177
- let listenConfig;
5178
- if (options.listen) {
5179
- const listenOpts = {
5180
- mode: typeof options.listen === "string" ? options.listen : "all",
5181
- match: options.match,
5182
- captureResponseBodies: options.bodies,
5183
- maxPayload: options.maxPayload
5184
- };
5185
- listenConfig = listenOpts;
5186
- }
5187
- const sessionDir = join8(homedir5(), ".browser-pilot", "sessions", session.id);
5188
- const screenshotDir = join8(sessionDir, "screenshots");
5189
- const manifestPath = join8(sessionDir, "recording.json");
6351
+ const sessionDir = artifactSessionDir(session.id);
6352
+ const screenshotDir = join9(sessionDir, "screenshots");
6353
+ const canonicalPath = join9(sessionDir, DEFAULT_ARTIFACT);
6354
+ const outputPath = resolve5(args.file ?? DEFAULT_ARTIFACT);
5190
6355
  nodeFs2.mkdirSync(screenshotDir, { recursive: true });
5191
- const recordingFrames = [];
5192
- let manifestRecordedAt = (/* @__PURE__ */ new Date()).toISOString();
5193
- let manifestStartUrl = "";
5194
- try {
5195
- const existing = JSON.parse(nodeFs2.readFileSync(manifestPath, "utf-8"));
5196
- if (existing.frames && Array.isArray(existing.frames)) {
5197
- recordingFrames.push(...existing.frames);
5198
- }
5199
- if (existing.recordedAt) manifestRecordedAt = existing.recordedAt;
5200
- if (existing.startUrl) manifestStartUrl = existing.startUrl;
5201
- } catch {
6356
+ const existingArtifact = existsSync6(canonicalPath) ? canonicalizeRecordingArtifact(
6357
+ JSON.parse(nodeFs2.readFileSync(canonicalPath, "utf-8"))
6358
+ ) : null;
6359
+ const recordingFrames = existingArtifact ? artifactToFrames(existingArtifact) : [];
6360
+ let listenConfig = {
6361
+ mode: typeof args.listen === "string" ? args.listen : "all",
6362
+ match: args.match,
6363
+ captureResponseBodies: Boolean(args.bodies),
6364
+ maxPayload: args.maxPayload
6365
+ };
6366
+ if (args.listen === false) {
6367
+ listenConfig = void 0;
6368
+ }
6369
+ if (!args.listen && profile === "voice") {
6370
+ listenConfig = {
6371
+ mode: "all",
6372
+ match: args.match,
6373
+ captureResponseBodies: Boolean(args.bodies),
6374
+ maxPayload: args.maxPayload
6375
+ };
5202
6376
  }
5203
- const recordFormat = session.metadata?.record?.format ?? "webp";
5204
- const recordQuality = session.metadata?.record?.quality ?? 40;
6377
+ const recordSettings = session.metadata?.record;
6378
+ const recordFormat = recordSettings?.format ?? "webp";
6379
+ const recordQuality = recordSettings?.quality ?? 40;
5205
6380
  let screenshotCount = 0;
5206
- function eventKindLabel(kind) {
5207
- switch (kind) {
5208
- case "click":
5209
- case "dblclick":
5210
- return "click";
5211
- case "input":
5212
- case "change":
5213
- return "fill";
5214
- case "submit":
5215
- return "submit";
5216
- case "keydown":
5217
- return "press";
5218
- case "navigation":
5219
- return "goto";
5220
- default:
5221
- return kind;
5222
- }
5223
- }
5224
6381
  async function captureScreenshotForEvent(event) {
5225
6382
  try {
5226
6383
  const ts = Date.now();
5227
6384
  const seq = String(recordingFrames.length + 1).padStart(4, "0");
5228
6385
  const label = eventKindLabel(event.kind);
5229
6386
  const filename = `${seq}-${ts}-${label}.${recordFormat}`;
5230
- const filepath = join8(screenshotDir, filename);
6387
+ const filepath = join9(screenshotDir, filename);
5231
6388
  const result = await cdp.send("Page.captureScreenshot", {
5232
6389
  format: recordFormat === "png" ? "png" : recordFormat === "jpeg" ? "jpeg" : "webp",
5233
6390
  quality: recordFormat === "png" ? void 0 : recordQuality
5234
6391
  });
5235
- const buffer = Buffer.from(result.data, "base64");
5236
- nodeFs2.writeFileSync(filepath, buffer);
6392
+ nodeFs2.writeFileSync(filepath, Buffer.from(result.data, "base64"));
5237
6393
  let pageUrl;
5238
6394
  let pageTitle;
5239
6395
  try {
5240
- const urlResult = await cdp.send("Runtime.evaluate", {
5241
- expression: "location.href",
5242
- returnByValue: true
5243
- });
5244
- pageUrl = urlResult.result.value;
5245
- const titleResult = await cdp.send("Runtime.evaluate", {
5246
- expression: "document.title",
5247
- returnByValue: true
5248
- });
5249
- pageTitle = titleResult.result.value;
6396
+ pageUrl = await page.url();
6397
+ pageTitle = await page.title();
5250
6398
  } catch {
5251
6399
  }
5252
6400
  const targetMetadata = event.element ? {
5253
- tagName: event.element.tag,
5254
- inputType: event.element.type ?? void 0
6401
+ tagName: event.element["tag"],
6402
+ inputType: event.element["type"] ?? void 0
5255
6403
  } : void 0;
5256
- const frame = {
6404
+ recordingFrames.push({
5257
6405
  seq: recordingFrames.length + 1,
5258
6406
  timestamp: ts,
5259
6407
  action: label,
5260
6408
  selector: event.selectors?.[0]?.selector,
6409
+ selectorUsed: event.selectors?.[0]?.selector,
5261
6410
  value: redactValueForRecording(event.value, targetMetadata),
5262
6411
  coordinates: event.client,
5263
6412
  success: true,
5264
6413
  durationMs: 0,
5265
6414
  screenshot: `screenshots/${filename}`,
5266
6415
  pageUrl,
5267
- pageTitle
5268
- };
5269
- recordingFrames.push(frame);
5270
- screenshotCount++;
6416
+ pageTitle,
6417
+ stepIndex: recordingFrames.length,
6418
+ actionId: `action-${recordingFrames.length + 1}`
6419
+ });
6420
+ screenshotCount += 1;
5271
6421
  } catch {
5272
6422
  }
5273
6423
  }
@@ -5277,108 +6427,194 @@ async function recordCommand(args, globalOptions) {
5277
6427
  };
5278
6428
  const recorder = new Recorder(cdp, recorderOptions);
5279
6429
  let stopping = false;
5280
- async function stopAndSave() {
5281
- if (stopping) return;
6430
+ const stopAndSave = async () => {
6431
+ if (stopping) {
6432
+ return;
6433
+ }
5282
6434
  stopping = true;
5283
6435
  try {
5284
6436
  const recording = await recorder.stop();
5285
- const fs7 = await import("fs/promises");
5286
- await fs7.writeFile(outputFile, JSON.stringify(recording, null, 2));
5287
- let currentUrl = "";
5288
- let viewport = { width: 1280, height: 720 };
5289
- try {
5290
- const urlResult = await cdp.send("Runtime.evaluate", {
5291
- expression: "location.href",
5292
- returnByValue: true
5293
- });
5294
- currentUrl = urlResult.result.value;
5295
- } catch {
5296
- }
5297
- try {
5298
- const metrics = await cdp.send("Page.getLayoutMetrics");
5299
- viewport = {
5300
- width: metrics.cssVisualViewport.clientWidth,
5301
- height: metrics.cssVisualViewport.clientHeight
5302
- };
5303
- } catch {
5304
- }
5305
- const manifest = {
5306
- version: 1,
5307
- recordedAt: manifestRecordedAt,
6437
+ const currentUrl = await page.url().catch(() => recording.startUrl);
6438
+ const manifest = createRecordingManifest({
6439
+ recordedAt: existingArtifact?.recordedAt ?? recording.recordedAt,
5308
6440
  sessionId: session.id,
5309
- startUrl: manifestStartUrl || recording.startUrl,
6441
+ startUrl: existingArtifact?.session.startUrl ?? recording.startUrl,
5310
6442
  endUrl: currentUrl,
5311
- viewport,
5312
- format: recordFormat,
5313
- quality: recordQuality,
5314
- totalDurationMs: recording.duration,
5315
- success: true,
5316
- frames: recordingFrames
5317
- };
5318
- nodeFs2.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
5319
- await updateSession(session.id, { currentUrl: currentUrl || recording.startUrl });
5320
- await browser.disconnect();
5321
- const networkInfo = recording.network ? `, ${recording.network.requests.length} HTTP requests` : "";
5322
- const wsInfo = recording.websockets ? `, ${recording.websockets.frames.length} WS frames` : "";
5323
- const timelineInfo = recording.timeline ? ` (${recording.timeline.length} timeline entries)` : "";
5324
- const screenshotInfo = screenshotCount > 0 ? `, ${screenshotCount} screenshots` : "";
5325
- console.log(
5326
- `
5327
- Saved ${recording.steps.length} steps${screenshotInfo}${networkInfo}${wsInfo}${timelineInfo} to ${outputFile}`
5328
- );
5329
- if (screenshotCount > 0) {
5330
- console.log(`Screenshots: ${sessionDir}`);
6443
+ targetId: page.targetId,
6444
+ profile,
6445
+ steps: recording.steps,
6446
+ frames: recordingFrames,
6447
+ traceEvents: recording.traceEvents ?? [],
6448
+ assertions: deriveAssertions(
6449
+ createRecordingManifest({
6450
+ recordedAt: recording.recordedAt,
6451
+ sessionId: session.id,
6452
+ startUrl: recording.startUrl,
6453
+ endUrl: currentUrl,
6454
+ targetId: page.targetId,
6455
+ profile,
6456
+ steps: recording.steps,
6457
+ frames: recordingFrames,
6458
+ traceEvents: recording.traceEvents ?? []
6459
+ })
6460
+ ),
6461
+ notes: profile === "voice" ? ["Voice profile capture"] : [],
6462
+ recordingManifest: DEFAULT_ARTIFACT,
6463
+ screenshotDir: "screenshots/"
6464
+ });
6465
+ nodeFs2.writeFileSync(canonicalPath, JSON.stringify(manifest, null, 2));
6466
+ if (outputPath !== canonicalPath) {
6467
+ nodeFs2.mkdirSync(dirname7(outputPath), { recursive: true });
6468
+ nodeFs2.writeFileSync(outputPath, JSON.stringify(manifest, null, 2));
5331
6469
  }
6470
+ await updateSession(session.id, { currentUrl });
6471
+ await browser.disconnect();
6472
+ const summary = buildSummary(manifest, outputPath);
5332
6473
  if (globalOptions.format === "json") {
5333
- output(
5334
- {
5335
- success: true,
5336
- file: outputFile,
5337
- steps: recording.steps.length,
5338
- screenshots: screenshotCount,
5339
- duration: recording.duration,
5340
- networkRequests: recording.network?.requests.length ?? 0,
5341
- wsFrames: recording.websockets?.frames.length ?? 0,
5342
- timelineEntries: recording.timeline?.length ?? 0
5343
- },
5344
- "json"
6474
+ output({ success: true, ...summary }, "json");
6475
+ } else {
6476
+ console.log(
6477
+ `Saved ${manifest.recipe.steps.length} steps, ${screenshotCount} screenshots, ${manifest.trace.events.length} trace events to ${outputPath}`
5345
6478
  );
6479
+ console.log(`Use: bp record summary ${outputPath}`);
5346
6480
  }
5347
- process.exit(0);
5348
6481
  } catch (error) {
5349
- console.error("Error saving recording:", error);
6482
+ console.error(
6483
+ `Error saving recording: ${error instanceof Error ? error.message : String(error)}`
6484
+ );
5350
6485
  process.exit(1);
5351
6486
  }
5352
- }
6487
+ };
5353
6488
  const handleSignal = () => {
5354
- stopAndSave().catch((err) => {
5355
- console.error("Error during shutdown:", err);
5356
- process.exit(1);
5357
- });
6489
+ void stopAndSave();
5358
6490
  };
5359
6491
  process.on("SIGINT", handleSignal);
5360
6492
  process.on("SIGTERM", handleSignal);
5361
- if (options.timeout && options.timeout > 0) {
5362
- setTimeout(() => {
5363
- void stopAndSave();
5364
- }, options.timeout);
6493
+ if (args.timeout && args.timeout > 0) {
6494
+ setTimeout(() => void stopAndSave(), args.timeout);
5365
6495
  }
5366
6496
  await recorder.start();
5367
- console.log(`Recording... Press Ctrl+C to stop and save to ${outputFile}`);
5368
- if (options.listen) {
5369
- const listenMode = typeof options.listen === "string" ? options.listen : "all";
5370
- const matchLabel = options.match ? ` matching "${options.match}"` : "";
5371
- console.log(`Network capture: ${listenMode} traffic${matchLabel}`);
5372
- }
6497
+ console.log(`Recording... Press Ctrl+C to stop. Artifact: ${outputPath}`);
5373
6498
  console.log(`Session: ${session.id}`);
6499
+ console.log(`Profile: ${profile}`);
5374
6500
  console.log(`URL: ${await page.url()}`);
5375
6501
  }
6502
+ async function runRecordInspect(pathHint, globalOptions) {
6503
+ const session = globalOptions.session ? await loadSession(globalOptions.session) : await getDefaultSession();
6504
+ const artifactPath = resolveArtifactPath(pathHint, session ?? void 0);
6505
+ const { path, artifact } = await loadArtifact(artifactPath);
6506
+ output(buildSummary(artifact, path), globalOptions.format ?? "pretty");
6507
+ }
6508
+ async function runRecordSummary(pathHint, globalOptions) {
6509
+ const session = globalOptions.session ? await loadSession(globalOptions.session) : await getDefaultSession();
6510
+ const artifactPath = resolveArtifactPath(pathHint, session ?? void 0);
6511
+ const { path, artifact } = await loadArtifact(artifactPath);
6512
+ const summary = buildSummary(artifact, path);
6513
+ summary.trace = buildTraceSummaries(artifact.trace.events);
6514
+ output(summary, globalOptions.format ?? "pretty");
6515
+ }
6516
+ async function runRecordDerive(pathHint, outputPath, globalOptions) {
6517
+ if (!outputPath) {
6518
+ throw new Error("record derive requires -o <workflow.json>");
6519
+ }
6520
+ const session = globalOptions.session ? await loadSession(globalOptions.session) : await getDefaultSession();
6521
+ const artifactPath = resolveArtifactPath(pathHint, session ?? void 0);
6522
+ const { artifact } = await loadArtifact(artifactPath);
6523
+ const steps = artifact.recipe.steps;
6524
+ nodeFs2.mkdirSync(dirname7(resolve5(outputPath)), { recursive: true });
6525
+ nodeFs2.writeFileSync(outputPath, JSON.stringify(steps, null, 2));
6526
+ output(
6527
+ {
6528
+ success: true,
6529
+ output: outputPath,
6530
+ steps: steps.length,
6531
+ suggestedAssertions: deriveAssertions(artifact)
6532
+ },
6533
+ globalOptions.format ?? "pretty"
6534
+ );
6535
+ }
6536
+ async function runRecordExport(pathHint, outputPath, globalOptions) {
6537
+ if (!outputPath) {
6538
+ throw new Error("record export requires -o <bundle.json>");
6539
+ }
6540
+ const session = globalOptions.session ? await loadSession(globalOptions.session) : await getDefaultSession();
6541
+ const artifactPath = resolveArtifactPath(pathHint, session ?? void 0);
6542
+ const { artifact, path } = await loadArtifact(artifactPath);
6543
+ const bundle = {
6544
+ source: path,
6545
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
6546
+ artifact,
6547
+ summary: buildSummary(artifact, path)
6548
+ };
6549
+ nodeFs2.mkdirSync(dirname7(resolve5(outputPath)), { recursive: true });
6550
+ nodeFs2.writeFileSync(outputPath, JSON.stringify(bundle, null, 2));
6551
+ output({ success: true, output: outputPath }, globalOptions.format ?? "pretty");
6552
+ }
6553
+ function eventKindLabel(kind) {
6554
+ switch (kind) {
6555
+ case "click":
6556
+ case "dblclick":
6557
+ return "click";
6558
+ case "input":
6559
+ case "change":
6560
+ return "fill";
6561
+ case "submit":
6562
+ return "submit";
6563
+ case "keydown":
6564
+ return "press";
6565
+ case "navigation":
6566
+ return "goto";
6567
+ default:
6568
+ return kind;
6569
+ }
6570
+ }
6571
+ async function recordCommand(args, globalOptions) {
6572
+ const options = parseRecordArgs(args);
6573
+ const command = options.subcommand ?? "capture";
6574
+ if (options.help || globalOptions.help) {
6575
+ console.log(RECORD_HELP);
6576
+ return;
6577
+ }
6578
+ if (command === "capture") {
6579
+ await runRecordCapture(options, globalOptions);
6580
+ return;
6581
+ }
6582
+ const pathHint = options.artifactPath ?? DEFAULT_ARTIFACT;
6583
+ switch (command) {
6584
+ case "inspect":
6585
+ await runRecordInspect(pathHint, globalOptions);
6586
+ break;
6587
+ case "summary":
6588
+ await runRecordSummary(pathHint, globalOptions);
6589
+ break;
6590
+ case "derive":
6591
+ await runRecordDerive(pathHint, options.output, globalOptions);
6592
+ break;
6593
+ case "export":
6594
+ await runRecordExport(pathHint, options.output, globalOptions);
6595
+ break;
6596
+ default:
6597
+ throw new Error(`Unknown record subcommand: ${command}`);
6598
+ }
6599
+ }
5376
6600
 
5377
6601
  // src/cli/commands/run.ts
5378
6602
  import { readFile } from "fs/promises";
5379
- import { resolve as resolve4 } from "path";
6603
+ import { resolve as resolve6 } from "path";
5380
6604
  var RUN_HELP = `
5381
- bp run - Execute a workflow file (JSON steps)
6605
+ bp run - Execute a saved workflow file
6606
+
6607
+ When to use:
6608
+ You already have reusable steps from \`bp record derive\` or a hand-authored workflow.
6609
+
6610
+ When not to use:
6611
+ You are exploring a page inline or debugging one interaction. Use \`bp exec\`.
6612
+
6613
+ Default flow:
6614
+ derive or author workflow -> run -> inspect failures -> harden with assertions
6615
+
6616
+ Common mistake:
6617
+ Treating \`run\` as discovery. It is for repeatable execution, not first-pass exploration.
5382
6618
 
5383
6619
  Usage:
5384
6620
  bp run <workflow.json> [options]
@@ -5392,7 +6628,7 @@ Options:
5392
6628
  --timeout <ms> Default timeout for all steps (ms)
5393
6629
  -s, --session <id> Session to use (default: most recent)
5394
6630
  --json Output results as JSON
5395
- --trace Enable debug tracing
6631
+ --debug Enable CDP transport debugging (global option)
5396
6632
  -h, --help Show this help
5397
6633
 
5398
6634
  Examples:
@@ -5400,7 +6636,9 @@ Examples:
5400
6636
  bp run checkout.json --on-fail continue --json
5401
6637
  bp run smoke-test.json --timeout 10000
5402
6638
 
5403
- Run 'bp actions' for complete action reference.
6639
+ Likely next commands:
6640
+ bp trace summary -s <session> --view console
6641
+ bp exec --record -f workflow.json
5404
6642
  `.trimEnd();
5405
6643
  function parseRunArgs(args) {
5406
6644
  const options = {};
@@ -5438,7 +6676,7 @@ async function runCommand(args, globalOptions) {
5438
6676
  "No workflow file provided. Usage: bp run <workflow.json>\n\nRun 'bp run --help' for details."
5439
6677
  );
5440
6678
  }
5441
- const absPath = resolve4(workflowPath);
6679
+ const absPath = resolve6(workflowPath);
5442
6680
  let raw;
5443
6681
  try {
5444
6682
  raw = await readFile(absPath, "utf-8");
@@ -5590,7 +6828,7 @@ async function screenshotCommand(args, globalOptions) {
5590
6828
  }
5591
6829
 
5592
6830
  // src/cli/commands/snapshot.ts
5593
- import * as fs6 from "fs";
6831
+ import * as fs7 from "fs";
5594
6832
 
5595
6833
  // src/browser/overlay.ts
5596
6834
  var OVERLAY_SCRIPT = `(function() {
@@ -5841,7 +7079,19 @@ function formatDiffPretty(diff) {
5841
7079
 
5842
7080
  // src/cli/commands/snapshot.ts
5843
7081
  var SNAPSHOT_HELP = `
5844
- bp snapshot - Get page accessibility snapshot with element refs
7082
+ bp snapshot - Inspect the page and collect refs
7083
+
7084
+ When to use:
7085
+ You need to understand the current page or choose reliable targets for \`bp exec\`.
7086
+
7087
+ When not to use:
7088
+ You need long-running behavior, network, or voice causality. Use \`bp trace\`.
7089
+
7090
+ Default flow:
7091
+ snapshot -i -> use ref:eN selectors in exec -> take a fresh snapshot after navigation
7092
+
7093
+ Common mistake:
7094
+ Reusing refs after the page navigated or materially changed.
5845
7095
 
5846
7096
  Usage:
5847
7097
  bp snapshot [options]
@@ -5857,16 +7107,21 @@ Options:
5857
7107
  -s, --session <id> Session to use (default: most recent)
5858
7108
  -f, --format <fmt> Output format: json | pretty (default: pretty)
5859
7109
  --json Alias for -f json
5860
- --trace Enable debug tracing
7110
+ --debug Enable CDP transport debugging (global option)
5861
7111
  -h, --help Show this help
5862
7112
 
5863
7113
  Examples:
5864
7114
  bp snapshot # Full accessibility tree as readable text
5865
- bp snapshot -i # Interactive elements only (best for AI agents)
7115
+ bp snapshot -i # Interactive elements only; best default for automation
5866
7116
  bp snapshot --role radio,checkbox # Focus on specific control roles
5867
7117
  bp snapshot --json > page.json # Save full snapshot to file
5868
7118
  bp snapshot --diff before.json # Show what changed since before.json
5869
7119
  bp snapshot --inspect # Visual ref labels on the page
7120
+
7121
+ Likely next commands:
7122
+ bp exec '[{"action":"click","selector":"ref:e4"}]'
7123
+ bp page
7124
+ bp diagnose "ref:e4"
5870
7125
  `.trimEnd();
5871
7126
  function parseSnapshotArgs(args) {
5872
7127
  const options = {
@@ -5902,7 +7157,7 @@ function writeInfo(message, asStderr = false) {
5902
7157
  `);
5903
7158
  }
5904
7159
  function sleep2(ms) {
5905
- return new Promise((resolve5) => setTimeout(resolve5, ms));
7160
+ return new Promise((resolve8) => setTimeout(resolve8, ms));
5906
7161
  }
5907
7162
  async function snapshotCommand(args, globalOptions) {
5908
7163
  const options = parseSnapshotArgs(args);
@@ -5939,10 +7194,10 @@ async function snapshotCommand(args, globalOptions) {
5939
7194
  }
5940
7195
  });
5941
7196
  if (options.diffFile) {
5942
- if (!fs6.existsSync(options.diffFile)) {
7197
+ if (!fs7.existsSync(options.diffFile)) {
5943
7198
  throw new Error(`Diff file not found: ${options.diffFile}`);
5944
7199
  }
5945
- const beforeContent = fs6.readFileSync(options.diffFile, "utf-8");
7200
+ const beforeContent = fs7.readFileSync(options.diffFile, "utf-8");
5946
7201
  const parsedBefore = JSON.parse(beforeContent);
5947
7202
  if (!isRecord(parsedBefore) || !Array.isArray(parsedBefore["accessibilityTree"])) {
5948
7203
  throw new Error("Diff file is not a valid PageSnapshot JSON payload");
@@ -5950,7 +7205,7 @@ async function snapshotCommand(args, globalOptions) {
5950
7205
  const beforeSnapshot = parsedBefore;
5951
7206
  const diff = diffSnapshots(beforeSnapshot, snapshot);
5952
7207
  if (options.outputFile) {
5953
- fs6.writeFileSync(options.outputFile, renderOutput(diff, globalOptions.format));
7208
+ fs7.writeFileSync(options.outputFile, renderOutput(diff, globalOptions.format));
5954
7209
  writeInfo(`Wrote output to ${options.outputFile}`, true);
5955
7210
  } else if (globalOptions.format === "json") {
5956
7211
  output(diff, "json");
@@ -5990,7 +7245,7 @@ async function snapshotCommand(args, globalOptions) {
5990
7245
  }
5991
7246
  }
5992
7247
  if (options.outputFile) {
5993
- fs6.writeFileSync(options.outputFile, renderOutput(payload, globalOptions.format));
7248
+ fs7.writeFileSync(options.outputFile, renderOutput(payload, globalOptions.format));
5994
7249
  writeInfo(`Wrote output to ${options.outputFile}`, true);
5995
7250
  } else {
5996
7251
  output(payload, globalOptions.format);
@@ -6064,6 +7319,15 @@ async function targetsCommand(_args, globalOptions) {
6064
7319
  var TEXT_HELP = `
6065
7320
  bp text - Extract text content from the current page
6066
7321
 
7322
+ When to use:
7323
+ You need readable content for summarization, comparison, or assertions outside the action batch.
7324
+
7325
+ When not to use:
7326
+ You are choosing clickable or fillable targets. Use \`bp snapshot -i\` or \`bp page\`.
7327
+
7328
+ Common mistake:
7329
+ Expecting \`bp text\` to tell you what to click next. It is content-focused, not action-focused.
7330
+
6067
7331
  Usage:
6068
7332
  bp text [options]
6069
7333
 
@@ -6072,13 +7336,17 @@ Options:
6072
7336
  -s, --session <id> Session to use (default: most recent)
6073
7337
  -f, --format <fmt> Output format: json | pretty (default: pretty)
6074
7338
  --json Alias for -f json
6075
- --trace Enable debug tracing
7339
+ --debug Enable CDP transport debugging (global option)
6076
7340
  -h, --help Show this help
6077
7341
 
6078
7342
  Examples:
6079
7343
  bp text # Extract all text from the page
6080
7344
  bp text --selector '#main' # Extract text from #main element only
6081
7345
  bp text --json # Output as JSON with URL and selector info
7346
+
7347
+ Likely next commands:
7348
+ bp snapshot -i
7349
+ bp exec '[{"action":"assertText","expect":"..."}]'
6082
7350
  `.trimEnd();
6083
7351
  function parseTextArgs(args) {
6084
7352
  const options = {};
@@ -6127,56 +7395,404 @@ async function textCommand(args, globalOptions) {
6127
7395
  }
6128
7396
  }
6129
7397
 
7398
+ // src/cli/commands/trace.ts
7399
+ import * as fs8 from "fs";
7400
+ import { dirname as dirname8, resolve as resolve7 } from "path";
7401
+ var TRACE_HELP = `
7402
+ bp trace - Inspect, summarize, and watch behavior over time
7403
+
7404
+ When to use:
7405
+ The question spans time, causality, network, console, permissions, media, or voice state.
7406
+
7407
+ When not to use:
7408
+ You already know the target element and just need to click, fill, or assert DOM state once. Use \`bp exec\`.
7409
+
7410
+ Default flow:
7411
+ start or tail live capture -> summary by view -> watch if you need a durable assertion -> export evidence
7412
+
7413
+ Common mistake:
7414
+ Using \`trace\` as the primary action surface. It analyzes behavior; it does not replace \`exec\` or \`run\`.
7415
+
7416
+ Usage:
7417
+ bp trace <start|tail|summary|watch|export|merge> [artifact|trace.jsonl] [options]
7418
+
7419
+ Commands:
7420
+ start Capture live trace events into the session trace store
7421
+ tail Stream live canonical events (listen compatibility surface)
7422
+ summary Summarize a session trace or saved artifact
7423
+ watch Run a durable trace assertion over a live capture window
7424
+ export Export events + summary to JSON
7425
+ merge Merge multiple artifacts or trace files
7426
+
7427
+ Options:
7428
+ -s, --session [id] Session to use (omit: default session, -s: latest, -s <id>: specific)
7429
+ --view <name> ws | voice | console | permissions | media | ui | session
7430
+ -m, --match <glob> Filter HTTP/WS URLs for live capture
7431
+ -o, --output <path> Output file for start/export/merge
7432
+ --timeout <ms> Stop live capture or watch after timeout
7433
+ --max-payload <n> Max WebSocket payload preview length (default: 256)
7434
+ --assert <name> Watch assertion (profile:reconnect, no-console-errors)
7435
+ -q, --quiet Suppress status lines for live capture
7436
+ -h, --help Show help
7437
+
7438
+ Examples:
7439
+ bp trace start -s dev --timeout 30000
7440
+ bp trace tail ws -m "*realtime*"
7441
+ bp trace summary -s dev --view session
7442
+ bp trace summary recording.json --view ws
7443
+ bp trace watch -s dev --view ws --assert profile:reconnect --timeout 15000
7444
+ bp trace watch -s dev --view console --assert no-console-errors --timeout 5000
7445
+ bp trace export -s dev -o trace-bundle.json
7446
+ bp trace merge trace-a.jsonl trace-b.jsonl -o merged-trace.json
7447
+
7448
+ Likely next commands:
7449
+ bp trace summary -s dev --view ws
7450
+ bp trace summary -s dev --view voice
7451
+ bp trace export -s dev -o trace-bundle.json
7452
+ `.trim();
7453
+ function parseTraceArgs(args) {
7454
+ const options = { sources: [], view: "session" };
7455
+ for (let i = 0; i < args.length; i++) {
7456
+ const arg = args[i];
7457
+ if (!options.subcommand && (arg === "start" || arg === "tail" || arg === "summary" || arg === "watch" || arg === "export" || arg === "merge")) {
7458
+ options.subcommand = arg;
7459
+ continue;
7460
+ }
7461
+ if (!options.subcommand && (arg === "ws" || arg === "http" || arg === "all")) {
7462
+ options.subcommand = "tail";
7463
+ options.mode = arg;
7464
+ continue;
7465
+ }
7466
+ if (arg === "-o" || arg === "--output") {
7467
+ options.output = args[++i];
7468
+ } else if (arg === "--timeout") {
7469
+ options.timeout = Number.parseInt(args[++i] ?? "", 10);
7470
+ } else if (arg === "--view") {
7471
+ const view = args[++i];
7472
+ if (view === "ws" || view === "voice" || view === "console" || view === "permissions" || view === "media" || view === "ui" || view === "session") {
7473
+ options.view = view;
7474
+ }
7475
+ } else if (arg === "--assert") {
7476
+ options.assert = args[++i];
7477
+ } else if (arg === "-m" || arg === "--match") {
7478
+ options.match = args[++i];
7479
+ } else if (arg === "--max-payload") {
7480
+ options.maxPayload = Number.parseInt(args[++i] ?? "", 10);
7481
+ } else if (arg === "-q" || arg === "--quiet") {
7482
+ options.quiet = true;
7483
+ } else if (arg === "-h" || arg === "--help") {
7484
+ options.help = true;
7485
+ } else if (arg === "-s" || arg === "--session") {
7486
+ const nextArg = args[i + 1];
7487
+ if (!nextArg || nextArg.startsWith("-")) {
7488
+ options.useLatestSession = true;
7489
+ }
7490
+ } else if (arg === "ws" || arg === "http" || arg === "all") {
7491
+ options.mode = arg;
7492
+ } else if (!arg.startsWith("-")) {
7493
+ if (!options.source) {
7494
+ options.source = arg;
7495
+ }
7496
+ options.sources.push(arg);
7497
+ }
7498
+ }
7499
+ options.subcommand ??= "summary";
7500
+ return options;
7501
+ }
7502
+ async function resolveTraceSource(sourceHint, sessionId) {
7503
+ if (sourceHint && fs8.existsSync(sourceHint)) {
7504
+ if (sourceHint.endsWith(".jsonl")) {
7505
+ return { path: resolve7(sourceHint), events: readTraceEvents(sourceHint) };
7506
+ }
7507
+ const raw = JSON.parse(fs8.readFileSync(sourceHint, "utf-8"));
7508
+ const artifact = canonicalizeRecordingArtifact(raw);
7509
+ return { path: resolve7(sourceHint), events: artifact.trace.events };
7510
+ }
7511
+ let session = null;
7512
+ if (sessionId) {
7513
+ session = await loadSession(sessionId);
7514
+ } else {
7515
+ session = await getDefaultSession();
7516
+ }
7517
+ if (!session) {
7518
+ throw new Error('No session found. Run "bp connect" first or pass an artifact path.');
7519
+ }
7520
+ const tracePath = getSessionTracePath(session.id);
7521
+ if (fs8.existsSync(tracePath)) {
7522
+ return { path: tracePath, events: readTraceEvents(tracePath) };
7523
+ }
7524
+ const artifactPath = resolve7(getSessionTracePath(session.id), "..", "recording.json");
7525
+ if (fs8.existsSync(artifactPath)) {
7526
+ const raw = JSON.parse(fs8.readFileSync(artifactPath, "utf-8"));
7527
+ const artifact = canonicalizeRecordingArtifact(raw);
7528
+ return { path: artifactPath, events: artifact.trace.events };
7529
+ }
7530
+ return { path: tracePath, events: [] };
7531
+ }
7532
+ async function runLiveTrace(sessionId, options, debug, mode) {
7533
+ const session = await resolveSession(sessionId);
7534
+ const { browser, page } = await attachSession(session, { trace: debug });
7535
+ const tracePath = getSessionTracePath(session.id);
7536
+ fs8.mkdirSync(dirname8(tracePath), { recursive: true });
7537
+ const sinkPath = options.output ? resolve7(options.output) : tracePath;
7538
+ fs8.mkdirSync(dirname8(sinkPath), { recursive: true });
7539
+ const liveEvents = [];
7540
+ const collector = new LiveTraceCollector(page.cdpClient, {
7541
+ sessionId: session.id,
7542
+ targetId: page.targetId,
7543
+ mode: options.mode ?? "all",
7544
+ match: options.match,
7545
+ maxPayload: options.maxPayload ?? 256,
7546
+ onEvent: (event) => {
7547
+ liveEvents.push(event);
7548
+ if (mode !== "tail") {
7549
+ fs8.appendFileSync(tracePath, `${JSON.stringify(event)}
7550
+ `, "utf-8");
7551
+ }
7552
+ if (options.output) {
7553
+ fs8.appendFileSync(sinkPath, `${JSON.stringify(event)}
7554
+ `, "utf-8");
7555
+ }
7556
+ if (mode === "tail") {
7557
+ process.stdout.write(`${JSON.stringify(event)}
7558
+ `);
7559
+ }
7560
+ }
7561
+ });
7562
+ const stopPromise = new Promise((resolveStop) => {
7563
+ const stop = () => resolveStop();
7564
+ process.once("SIGINT", stop);
7565
+ process.once("SIGTERM", stop);
7566
+ if (options.timeout && options.timeout > 0) {
7567
+ setTimeout(stop, options.timeout);
7568
+ }
7569
+ });
7570
+ if (!options.quiet && mode !== "tail") {
7571
+ process.stderr.write(
7572
+ `Tracing session ${session.id} -> ${mode === "start" ? tracePath : "stdout"}
7573
+ `
7574
+ );
7575
+ }
7576
+ try {
7577
+ await collector.start();
7578
+ await stopPromise;
7579
+ return await collector.stop();
7580
+ } finally {
7581
+ await browser.disconnect();
7582
+ }
7583
+ }
7584
+ function evaluateWatchAssertion(events, assertion, view) {
7585
+ if (assertion === "no-console-errors") {
7586
+ const summary = buildTraceSummary(events, "console");
7587
+ return {
7588
+ ok: summary.errors === 0,
7589
+ reason: summary.errors === 0 ? "No console errors detected" : `${summary.errors} console/runtime errors detected`
7590
+ };
7591
+ }
7592
+ if (assertion === "profile:reconnect" && view === "ws") {
7593
+ const wsEvents = events.filter((event) => event.event.startsWith("ws."));
7594
+ const created = wsEvents.filter((event) => event.event === "ws.connection.created");
7595
+ const closed = wsEvents.filter((event) => event.event === "ws.connection.closed");
7596
+ const reopened = created.length > 1 || closed.length === 0;
7597
+ return {
7598
+ ok: created.length > 0 && reopened,
7599
+ reason: created.length === 0 ? "No WebSocket connections were observed" : reopened ? "WebSocket stayed up or reconnected" : "WebSocket closed without a reconnect"
7600
+ };
7601
+ }
7602
+ return {
7603
+ ok: false,
7604
+ reason: `Unsupported assertion: ${assertion}`
7605
+ };
7606
+ }
7607
+ async function traceStart(options, globalOptions) {
7608
+ const events = await runLiveTrace(
7609
+ globalOptions.session,
7610
+ options,
7611
+ globalOptions.trace ?? false,
7612
+ "start"
7613
+ );
7614
+ output(
7615
+ {
7616
+ success: true,
7617
+ events: events.length,
7618
+ output: getSessionTracePath((await resolveSession(globalOptions.session)).id)
7619
+ },
7620
+ "pretty"
7621
+ );
7622
+ }
7623
+ async function traceTail(options, globalOptions) {
7624
+ await runLiveTrace(globalOptions.session, options, globalOptions.trace ?? false, "tail");
7625
+ }
7626
+ async function traceSummary(options, globalOptions) {
7627
+ const source = await resolveTraceSource(options.source, globalOptions.session);
7628
+ const filtered = options.mode === "ws" ? source.events.filter((event) => event.channel === "ws") : options.mode === "http" ? source.events.filter((event) => event.channel === "http") : source.events;
7629
+ const summary = buildTraceSummary(filtered, options.view ?? "session");
7630
+ output(
7631
+ {
7632
+ source: source.path,
7633
+ view: options.view ?? "session",
7634
+ summary
7635
+ },
7636
+ globalOptions.format ?? "pretty"
7637
+ );
7638
+ }
7639
+ async function traceWatch(options, globalOptions) {
7640
+ const view = options.view ?? "session";
7641
+ const events = options.source ? (await resolveTraceSource(options.source, globalOptions.session)).events : await runLiveTrace(globalOptions.session, options, globalOptions.trace ?? false, "watch");
7642
+ const assertion = options.assert ?? (view === "ws" ? "profile:reconnect" : "no-console-errors");
7643
+ const result = evaluateWatchAssertion(events, assertion, view);
7644
+ if (!result.ok) {
7645
+ throw new Error(result.reason);
7646
+ }
7647
+ output(
7648
+ {
7649
+ success: true,
7650
+ assertion,
7651
+ reason: result.reason,
7652
+ summary: buildTraceSummary(events, view)
7653
+ },
7654
+ globalOptions.format ?? "pretty"
7655
+ );
7656
+ }
7657
+ async function traceExport(options, globalOptions) {
7658
+ if (!options.output) {
7659
+ throw new Error("trace export requires -o <output.json>");
7660
+ }
7661
+ const source = await resolveTraceSource(options.source, globalOptions.session);
7662
+ const payload = {
7663
+ source: source.path,
7664
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
7665
+ summary: buildTraceSummary(source.events, options.view ?? "session"),
7666
+ views: buildTraceSummaries(source.events),
7667
+ events: source.events
7668
+ };
7669
+ fs8.mkdirSync(dirname8(resolve7(options.output)), { recursive: true });
7670
+ fs8.writeFileSync(resolve7(options.output), JSON.stringify(payload, null, 2));
7671
+ output({ success: true, output: resolve7(options.output) }, globalOptions.format ?? "pretty");
7672
+ }
7673
+ async function traceMerge(options, globalOptions) {
7674
+ if (options.sources.length === 0) {
7675
+ throw new Error("trace merge requires at least one artifact or trace file");
7676
+ }
7677
+ if (!options.output) {
7678
+ throw new Error("trace merge requires -o <output.json>");
7679
+ }
7680
+ const merged = [];
7681
+ for (const sourcePath of options.sources) {
7682
+ const source = await resolveTraceSource(sourcePath, globalOptions.session);
7683
+ merged.push(...source.events);
7684
+ }
7685
+ merged.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
7686
+ const payload = {
7687
+ mergedAt: (/* @__PURE__ */ new Date()).toISOString(),
7688
+ summary: buildTraceSummary(merged, options.view ?? "session"),
7689
+ views: buildTraceSummaries(merged),
7690
+ events: merged
7691
+ };
7692
+ fs8.mkdirSync(dirname8(resolve7(options.output)), { recursive: true });
7693
+ fs8.writeFileSync(resolve7(options.output), JSON.stringify(payload, null, 2));
7694
+ output(
7695
+ { success: true, output: resolve7(options.output), events: merged.length },
7696
+ globalOptions.format ?? "pretty"
7697
+ );
7698
+ }
7699
+ async function traceCommand(args, globalOptions) {
7700
+ const options = parseTraceArgs(args);
7701
+ if (options.help || globalOptions.help) {
7702
+ console.log(TRACE_HELP);
7703
+ return;
7704
+ }
7705
+ switch (options.subcommand) {
7706
+ case "start":
7707
+ await traceStart(options, globalOptions);
7708
+ break;
7709
+ case "tail":
7710
+ await traceTail(options, globalOptions);
7711
+ break;
7712
+ case "summary":
7713
+ await traceSummary(options, globalOptions);
7714
+ break;
7715
+ case "watch":
7716
+ await traceWatch(options, globalOptions);
7717
+ break;
7718
+ case "export":
7719
+ await traceExport(options, globalOptions);
7720
+ break;
7721
+ case "merge":
7722
+ await traceMerge(options, globalOptions);
7723
+ break;
7724
+ default:
7725
+ console.log(TRACE_HELP);
7726
+ }
7727
+ }
7728
+
6130
7729
  // src/cli/index.ts
6131
7730
  var HELP2 = `
6132
- bp - Browser automation CLI for AI agents
7731
+ bp - automation-first browser CLI for agents
7732
+
7733
+ Route the job first:
7734
+ Inspect page state snapshot, page, forms, text, targets, diagnose
7735
+ Act in the browser exec, run
7736
+ Capture a human demo record
7737
+ Analyze behavior over time trace (listen is a compatibility alias)
7738
+ Exercise voice/media audio
7739
+ Change browser conditions env
6133
7740
 
6134
7741
  Usage:
6135
7742
  bp <command> [options]
6136
7743
 
6137
7744
  Commands:
6138
- quickstart Getting started guide (start here!)
6139
- connect Create browser session
6140
- exec Execute actions
6141
- eval Evaluate JavaScript expression
6142
- page Show a compact page overview
6143
- forms List form controls on the page
7745
+ quickstart Getting started guide
7746
+ connect Create a browser session
7747
+ exec Execute high-level actions
7748
+ snapshot Inspect current page with refs
7749
+ record Record a human workflow and derive replayable output
7750
+ trace Inspect and analyze behavior over time (listen alias for live stream)
7751
+ audio Set up/validate/inject/capture voice pipelines
7752
+ env Session and browser-environment controls
7753
+ run Run a workflow file
7754
+ page Compact page overview
7755
+ forms List form controls
6144
7756
  targets List available browser tabs
6145
- run Run a workflow file (JSON steps)
6146
- record Record browser actions to JSON
6147
- audio Audio I/O for voice agent testing
6148
- listen Monitor network traffic (WebSocket/HTTP)
6149
- snapshot Get page with element refs
6150
- diagnose Debug element selection issues
6151
- text Extract text content
6152
- screenshot Take screenshot
6153
- daemon Manage daemon processes (status, stop, logs)
7757
+ daemon Manage session daemon
7758
+ list List sessions
6154
7759
  close Close session
6155
- list List sessions (--log-path, --log-tail, --info)
6156
- clean Clean up old sessions
7760
+ clean Clean old sessions and artifacts
6157
7761
  actions Complete action reference
6158
7762
 
7763
+ Golden paths:
7764
+ 1. Automate a page
7765
+ bp connect --provider generic --name dev
7766
+ bp snapshot -i -s dev
7767
+ bp exec -s dev '[{"action":"click","selector":"ref:e4"}]'
7768
+
7769
+ 2. Capture a manual workflow and derive automation
7770
+ bp record -s demo --profile automation
7771
+ bp record summary demo/recording.json
7772
+ bp record derive demo/recording.json -o workflow.json
7773
+ bp run workflow.json
7774
+
7775
+ 3. Debug a realtime or voice session
7776
+ bp trace start -s dev
7777
+ bp trace summary -s dev --view ws
7778
+ bp audio check -s dev
7779
+ bp trace summary -s dev --view voice
7780
+
7781
+ 4. Exercise failure modes
7782
+ bp env network offline -s dev --duration 10000
7783
+ bp trace watch -s dev --view ws --assert profile:reconnect --timeout 15000
7784
+
6159
7785
  Options:
6160
7786
  -s, --session <id> Session ID
6161
7787
  -f, --format <fmt> json | pretty (default: pretty)
6162
7788
  --json Alias for -f json
6163
- --trace Enable debug tracing
7789
+ --debug Enable debug logs for CDP transport
7790
+ --trace Legacy alias for --debug
6164
7791
  --dialog <mode> Handle dialogs: accept | dismiss
6165
7792
  -h, --help Show help
6166
7793
 
6167
- Examples:
6168
- bp connect --provider generic --name dev
6169
- bp exec '{"action":"goto","url":"https://example.com"}'
6170
- bp exec --record '[{"action":"click","selector":"#checkout"}]'
6171
- bp snapshot -i
6172
- bp exec '{"action":"click","selector":"ref:e3"}'
6173
- bp clean --max-size 500MB
6174
- bp eval 'document.title'
6175
- bp audio roundtrip -i prompt.wav --transcribe --silence-timeout 5000
6176
-
6177
- Run 'bp quickstart' for CLI workflow guide.
6178
- Run 'bp actions' for complete action reference.
6179
- Run 'bp audio --help' for voice agent testing guide.
7794
+ Notes:
7795
+ Start with "record summary" or "trace summary" before opening raw artifacts.
6180
7796
  `;
6181
7797
  function parseGlobalOptions(args) {
6182
7798
  const options = {
@@ -6198,7 +7814,8 @@ function parseGlobalOptions(args) {
6198
7814
  options.format = "json";
6199
7815
  } else if (arg === "--pretty") {
6200
7816
  options.format = "pretty";
6201
- } else if (arg === "--trace") {
7817
+ } else if (arg === "--debug" || arg === "--trace") {
7818
+ options.debug = true;
6202
7819
  options.trace = true;
6203
7820
  } else if (arg === "-h" || arg === "--help") {
6204
7821
  options.help = true;
@@ -6297,9 +7914,6 @@ async function main() {
6297
7914
  case "screenshot":
6298
7915
  await screenshotCommand(remaining, options);
6299
7916
  break;
6300
- case "daemon":
6301
- await daemonCommand(remaining, options);
6302
- break;
6303
7917
  case "close":
6304
7918
  await closeCommand(remaining, options);
6305
7919
  break;
@@ -6318,11 +7932,20 @@ async function main() {
6318
7932
  case "record":
6319
7933
  await recordCommand(remaining, options);
6320
7934
  break;
7935
+ case "trace":
7936
+ await traceCommand(remaining, options);
7937
+ break;
6321
7938
  case "audio":
6322
7939
  await audioCommand(remaining, options);
6323
7940
  break;
7941
+ case "env":
7942
+ await envCommand(remaining, options);
7943
+ break;
6324
7944
  case "listen":
6325
- await listenCommand(remaining, options);
7945
+ await traceCommand(["tail", ...remaining], options);
7946
+ break;
7947
+ case "daemon":
7948
+ await daemonCommand(remaining, options);
6326
7949
  break;
6327
7950
  case "help":
6328
7951
  case "--help":