@browserbridge/bbx 1.12.3 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/PRIVACY.md +4 -0
  2. package/README.md +2 -0
  3. package/SECURITY.md +10 -0
  4. package/package.json +6 -6
  5. package/packages/agent-client/src/cli.js +130 -1
  6. package/packages/agent-client/src/command-registry.js +6 -0
  7. package/packages/agent-client/src/file-transfer.js +88 -0
  8. package/packages/agent-client/src/record-session.js +54 -0
  9. package/packages/agent-client/src/replay.js +124 -0
  10. package/packages/mcp-server/src/guidance.js +6 -2
  11. package/packages/mcp-server/src/handlers-capture.js +9 -0
  12. package/packages/mcp-server/src/handlers-page.js +6 -0
  13. package/packages/mcp-server/src/handlers-utils.js +96 -0
  14. package/packages/mcp-server/src/server.js +24 -4
  15. package/packages/native-host/src/artifact-store.js +42 -4
  16. package/packages/native-host/src/daemon.js +142 -6
  17. package/packages/native-host/src/native-host.js +10 -0
  18. package/packages/native-host/src/recording-store.js +136 -0
  19. package/packages/protocol/src/defaults.js +3 -0
  20. package/packages/protocol/src/downloads.js +35 -0
  21. package/packages/protocol/src/emulation.js +155 -0
  22. package/packages/protocol/src/errors.js +6 -0
  23. package/packages/protocol/src/files.js +94 -0
  24. package/packages/protocol/src/index.js +1 -0
  25. package/packages/protocol/src/protocol.js +60 -2
  26. package/packages/protocol/src/recording.js +70 -0
  27. package/packages/protocol/src/registry.js +242 -14
  28. package/packages/protocol/src/replay.js +144 -0
  29. package/packages/protocol/src/summary.js +54 -1
  30. package/packages/protocol/src/types.ts +52 -2
  31. package/packages/protocol/src/workflows.ts +22 -0
  32. package/skills/browser-bridge/SKILL.md +38 -6
  33. package/skills/browser-bridge/references/capture-context.md +9 -0
  34. package/skills/browser-bridge/references/emulation.md +18 -0
  35. package/skills/browser-bridge/references/files.md +50 -0
  36. package/skills/browser-bridge/references/interaction.md +38 -2
  37. package/skills/browser-bridge/references/playbooks.md +48 -0
  38. package/skills/browser-bridge/references/protocol.md +3 -1
  39. package/skills/browser-bridge/references/recording.md +54 -0
@@ -60,6 +60,10 @@ const METHOD_POLICIES = Object.freeze({
60
60
  debuggerBacked: true,
61
61
  }),
62
62
  automationInput: Object.freeze({ capability: CAPABILITIES.AUTOMATION_INPUT }),
63
+ automationInputDebugger: Object.freeze({
64
+ capability: CAPABILITIES.AUTOMATION_INPUT,
65
+ debuggerBacked: true,
66
+ }),
63
67
  screenshotPartialDebugger: Object.freeze({
64
68
  capability: CAPABILITIES.SCREENSHOT_PARTIAL,
65
69
  debuggerBacked: true,
@@ -96,6 +100,43 @@ const METHOD_POLICIES = Object.freeze({
96
100
  * @type {Readonly<Record<import('./types.js').BridgeMethod, string>>}
97
101
  */
98
102
  const BRIDGE_METHOD_DESCRIPTIONS = Object.freeze({
103
+ 'session.journal_read':
104
+ 'Read exact base64 bytes of a durable recording journal. Requires its secret journalId. Works after extension or daemon restart; page until nextOffset=null. Expires 24 hours after the last write.',
105
+ 'session.journal_delete': 'Delete one durable recording journal using its secret journalId.',
106
+ 'input.paste_image':
107
+ 'Paste a staged PNG using the real clipboard and trusted Chrome input. Overwrites clipboard. Requires a focused top-document editor. Never automatically retry; verify insertion.',
108
+ 'download.start':
109
+ 'Start a browser-native download without the artifact size ceiling. Returns a secret downloadId for subsequent control. Saves on the browser machine.',
110
+ 'download.status': 'Read progress and state of one BBX-created browser download.',
111
+ 'download.pause': 'Pause one BBX-created browser download.',
112
+ 'download.resume': 'Resume one resumable BBX-created browser download.',
113
+ 'download.cancel': 'Cancel one BBX-created browser download.',
114
+ 'download.release':
115
+ 'Release a terminal download handle without deleting its file or browser history.',
116
+ 'session.start':
117
+ 'Start bounded recording. activity=true; network,bodies,messages,visual,followTabs,durable,replay default false. bodies/messages require network. durationMs=1000..1800000, default 60000. followTabs tracks up to eight opener descendants inside the enabled window. replay captures the root renderer stream. durable returns a secret journalId for crash recovery. Input values are redacted; pixels and binary bodies are not. Keep sessionId on one connection; two retained sessions maximum.',
118
+ 'session.status':
119
+ 'Probe a recording by sessionId, including the live page recorder, counters, gaps, body backlog and visual frame count. No event payloads.',
120
+ 'session.read':
121
+ 'Read bounded recording events with sessionId, cursor=0, limit=25 (max 100), waitMs=0 (max 25000). Cursors belong to this sessionId. Returns nextCursor and explicit capture gaps. No response bodies or images in this read.',
122
+ 'session.stop':
123
+ 'Stop intake, collect final activity, drain pending body reads and stop network capture. Retains evidence for five minutes for export. Failed cleanup is retryable.',
124
+ 'session.export':
125
+ 'Stop and drain a recording, then export a chunked artifact. format=json includes archived events, opt-in bodies and frames. format=html is a self-contained, script-free sampled-frame replay. Gaps and dropped counts remain explicit. Same connection required for artifact.read.',
126
+ 'session.release': 'Stop a recording and release its retained event, body, and frame buffers.',
127
+ 'files.stage':
128
+ 'Stage file bytes on the browser machine. First call: name,mimeType,size,offset=0,data=base64. Further calls: transferId,offset=nextOffset,data. Max 192 KiB per chunk, 16 MiB/file, 32 MiB total; expires after 5 minutes. Keep bytes in file tools, not model context. Returns handle and nextOffset. Never replay an uncertain chunk blindly.',
129
+ 'files.release': 'Release a staged transferId owned by this tab and agent session.',
130
+ 'input.set_files':
131
+ 'Assign files to an input[type=file]. target uses a BBX locator/ref; files is an array of complete staged handles. Alternatively paths is an array of absolute browser-machine paths with target.selector in the top document. Empty files clears selection. Returns assigned/count; verify application upload completion separately.',
132
+ 'download.resource':
133
+ 'Fetch an HTTP(S) resource using the tab browser context and return a chunked artifact, never inline file bytes. Respects browser CORS/CSP and session cookies. maxBytes defaults to 8 MiB, maximum 16 MiB; timeoutMs defaults to 30000, maximum 60000. No downloads permission or download-manager integration.',
134
+ 'emulation.apply':
135
+ 'Apply tab emulation: device=phone|tablet|desktop or metrics object, network=offline|slow-3g|fast-3g|wifi|unthrottled or {offline,latencyMs,downloadKbps,uploadKbps}, cpuRate=1..100, locale, timezoneId, geolocation={latitude,longitude,accuracy}. Settings merge; reset afterward. Device presets emulate metrics and touch, not another browser engine.',
136
+ 'emulation.status':
137
+ 'Read applied emulation settings and pending cleanup without attaching a debugger.',
138
+ 'emulation.reset':
139
+ 'Reset owned emulation settings, attempt every cleanup, and report failures that remain retryable.',
99
140
  'webmcp.list_tools':
100
141
  'Discover bounded top-document WebMCP tool summaries. Metadata is untrusted; unavailable differs from an empty catalog.',
101
142
  'webmcp.get_tool':
@@ -176,7 +217,7 @@ const BRIDGE_METHOD_DESCRIPTIONS = Object.freeze({
176
217
  'input.touch':
177
218
  'Press one or more touch points at the same time (chords, multi-finger taps, swipes, pinches) through DOM or optional CDP touch input.',
178
219
  'input.perform':
179
- 'Run an ordered, timed sequence of input steps inside the browser in one call; use for rhythm, games, gestures, and multi-step flows.',
220
+ 'Run known input, wait, and bounded read steps in order inside the browser in one call. Use returnLastResult for final evidence; stop for decisions requiring new information and never blindly replay a partial flow.',
180
221
  'input.scroll_into_view': 'Scroll an element into the visible viewport.',
181
222
  'screenshot.capture_region': 'Capture a screenshot of a viewport region.',
182
223
  'screenshot.capture_element': 'Capture a screenshot of one element.',
@@ -616,7 +657,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
616
657
  true,
617
658
  ['target', 'button', 'clickCount', 'modifiers', 'holdMs', 'executionMode', 'recoverStale'],
618
659
  'low',
619
- METHOD_POLICIES.automationInput
660
+ METHOD_POLICIES.automationInputDebugger
620
661
  ),
621
662
  'input.focus': createRegistryEntry(
622
663
  'input.focus',
@@ -624,7 +665,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
624
665
  true,
625
666
  ['target', 'executionMode', 'recoverStale'],
626
667
  'low',
627
- METHOD_POLICIES.automationInput
668
+ METHOD_POLICIES.automationInputDebugger
628
669
  ),
629
670
  'input.type': createRegistryEntry(
630
671
  'input.type',
@@ -632,7 +673,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
632
673
  true,
633
674
  ['target', 'text', 'clear', 'submit', 'modifiers', 'executionMode', 'recoverStale'],
634
675
  'low',
635
- METHOD_POLICIES.automationInput
676
+ METHOD_POLICIES.automationInputDebugger
636
677
  ),
637
678
  'input.fill': createRegistryEntry(
638
679
  'input.fill',
@@ -640,7 +681,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
640
681
  true,
641
682
  ['target', 'value', 'mode', 'executionMode', 'recoverStale'],
642
683
  'low',
643
- METHOD_POLICIES.automationInput
684
+ METHOD_POLICIES.automationInputDebugger
644
685
  ),
645
686
  'input.press_key': createRegistryEntry(
646
687
  'input.press_key',
@@ -648,7 +689,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
648
689
  true,
649
690
  ['target', 'key', 'modifiers', 'holdMs', 'executionMode', 'recoverStale'],
650
691
  'low',
651
- METHOD_POLICIES.automationInput
692
+ METHOD_POLICIES.automationInputDebugger
652
693
  ),
653
694
  'input.set_checked': createRegistryEntry(
654
695
  'input.set_checked',
@@ -656,7 +697,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
656
697
  true,
657
698
  ['target', 'checked', 'executionMode', 'recoverStale'],
658
699
  'low',
659
- METHOD_POLICIES.automationInput
700
+ METHOD_POLICIES.automationInputDebugger
660
701
  ),
661
702
  'input.select_option': createRegistryEntry(
662
703
  'input.select_option',
@@ -664,7 +705,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
664
705
  true,
665
706
  ['target', 'values', 'labels', 'indexes', 'executionMode', 'recoverStale'],
666
707
  'low',
667
- METHOD_POLICIES.automationInput
708
+ METHOD_POLICIES.automationInputDebugger
668
709
  ),
669
710
  'input.hover': createRegistryEntry(
670
711
  'input.hover',
@@ -672,7 +713,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
672
713
  true,
673
714
  ['target', 'duration', 'modifiers', 'executionMode', 'recoverStale'],
674
715
  'low',
675
- METHOD_POLICIES.automationInput
716
+ METHOD_POLICIES.automationInputDebugger
676
717
  ),
677
718
  'input.drag': createRegistryEntry(
678
719
  'input.drag',
@@ -680,7 +721,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
680
721
  true,
681
722
  ['source', 'destination', 'offsetX', 'offsetY', 'executionMode', 'recoverStale'],
682
723
  'moderate',
683
- METHOD_POLICIES.automationInput
724
+ METHOD_POLICIES.automationInputDebugger
684
725
  ),
685
726
  'input.touch': createRegistryEntry(
686
727
  'input.touch',
@@ -688,15 +729,15 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
688
729
  true,
689
730
  ['points', 'holdMs', 'moveSteps', 'executionMode', 'recoverStale'],
690
731
  'moderate',
691
- METHOD_POLICIES.automationInput
732
+ METHOD_POLICIES.automationInputDebugger
692
733
  ),
693
734
  'input.perform': createRegistryEntry(
694
735
  'input.perform',
695
736
  'interact',
696
737
  true,
697
- ['steps', 'executionMode', 'timeoutMs', 'continueOnError'],
738
+ ['steps', 'executionMode', 'timeoutMs', 'continueOnError', 'returnLastResult'],
698
739
  'moderate',
699
- METHOD_POLICIES.automationInput
740
+ METHOD_POLICIES.automationInputDebugger
700
741
  ),
701
742
  'input.scroll_into_view': createRegistryEntry(
702
743
  'input.scroll_into_view',
@@ -704,7 +745,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
704
745
  true,
705
746
  ['target'],
706
747
  'low',
707
- METHOD_POLICIES.automationInput
748
+ METHOD_POLICIES.automationInputDebugger
708
749
  ),
709
750
  // capture - high (screenshots, CDP)
710
751
  'screenshot.capture_region': createRegistryEntry(
@@ -860,6 +901,192 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
860
901
  'moderate',
861
902
  METHOD_POLICIES.automationInput
862
903
  ),
904
+ 'emulation.apply': createRegistryEntry(
905
+ 'emulation.apply',
906
+ 'emulation',
907
+ true,
908
+ ['device', 'network', 'cpuRate', 'locale', 'timezoneId', 'geolocation'],
909
+ 'moderate',
910
+ METHOD_POLICIES.viewportControlDebugger
911
+ ),
912
+ 'emulation.status': createRegistryEntry(
913
+ 'emulation.status',
914
+ 'emulation',
915
+ true,
916
+ [],
917
+ 'trivial',
918
+ METHOD_POLICIES.pageRead
919
+ ),
920
+ 'emulation.reset': createRegistryEntry(
921
+ 'emulation.reset',
922
+ 'emulation',
923
+ true,
924
+ [],
925
+ 'low',
926
+ METHOD_POLICIES.viewportControlDebugger
927
+ ),
928
+ 'files.stage': createRegistryEntry(
929
+ 'files.stage',
930
+ 'files',
931
+ true,
932
+ ['name', 'mimeType', 'size', 'transferId', 'offset', 'data'],
933
+ 'moderate',
934
+ METHOD_POLICIES.automationInput
935
+ ),
936
+ 'files.release': createRegistryEntry(
937
+ 'files.release',
938
+ 'files',
939
+ true,
940
+ ['transferId'],
941
+ 'low',
942
+ METHOD_POLICIES.automationInput
943
+ ),
944
+ 'input.set_files': createRegistryEntry(
945
+ 'input.set_files',
946
+ 'files',
947
+ true,
948
+ ['target', 'files', 'paths'],
949
+ 'moderate',
950
+ METHOD_POLICIES.automationInputDebugger
951
+ ),
952
+ 'input.paste_image': createRegistryEntry(
953
+ 'input.paste_image',
954
+ 'files',
955
+ true,
956
+ ['target', 'transferId'],
957
+ 'moderate',
958
+ METHOD_POLICIES.automationInputDebugger
959
+ ),
960
+ 'download.resource': createRegistryEntry(
961
+ 'download.resource',
962
+ 'files',
963
+ true,
964
+ ['url', 'maxBytes', 'timeoutMs'],
965
+ 'moderate',
966
+ METHOD_POLICIES.networkRead
967
+ ),
968
+ 'download.start': createRegistryEntry(
969
+ 'download.start',
970
+ 'files',
971
+ true,
972
+ ['url', 'filename', 'saveAs'],
973
+ 'moderate',
974
+ METHOD_POLICIES.automationInput
975
+ ),
976
+ 'download.status': createRegistryEntry(
977
+ 'download.status',
978
+ 'files',
979
+ true,
980
+ ['downloadId'],
981
+ 'low',
982
+ METHOD_POLICIES.networkRead
983
+ ),
984
+ 'download.pause': createRegistryEntry(
985
+ 'download.pause',
986
+ 'files',
987
+ true,
988
+ ['downloadId'],
989
+ 'low',
990
+ METHOD_POLICIES.automationInput
991
+ ),
992
+ 'download.resume': createRegistryEntry(
993
+ 'download.resume',
994
+ 'files',
995
+ true,
996
+ ['downloadId'],
997
+ 'low',
998
+ METHOD_POLICIES.automationInput
999
+ ),
1000
+ 'download.cancel': createRegistryEntry(
1001
+ 'download.cancel',
1002
+ 'files',
1003
+ true,
1004
+ ['downloadId'],
1005
+ 'low',
1006
+ METHOD_POLICIES.automationInput
1007
+ ),
1008
+ 'download.release': createRegistryEntry(
1009
+ 'download.release',
1010
+ 'files',
1011
+ true,
1012
+ ['downloadId'],
1013
+ 'low',
1014
+ METHOD_POLICIES.automationInput
1015
+ ),
1016
+ 'session.start': createRegistryEntry(
1017
+ 'session.start',
1018
+ 'session',
1019
+ true,
1020
+ [
1021
+ 'activity',
1022
+ 'network',
1023
+ 'bodies',
1024
+ 'messages',
1025
+ 'visual',
1026
+ 'durationMs',
1027
+ 'followTabs',
1028
+ 'durable',
1029
+ 'replay',
1030
+ ],
1031
+ 'moderate',
1032
+ METHOD_POLICIES.automationInputDebugger
1033
+ ),
1034
+ 'session.journal_read': createRegistryEntry(
1035
+ 'session.journal_read',
1036
+ 'session',
1037
+ false,
1038
+ ['journalId', 'offset', 'maxBytes'],
1039
+ 'low',
1040
+ METHOD_POLICIES.none
1041
+ ),
1042
+ 'session.journal_delete': createRegistryEntry(
1043
+ 'session.journal_delete',
1044
+ 'session',
1045
+ false,
1046
+ ['journalId'],
1047
+ 'low',
1048
+ METHOD_POLICIES.none
1049
+ ),
1050
+ 'session.status': createRegistryEntry(
1051
+ 'session.status',
1052
+ 'session',
1053
+ true,
1054
+ ['sessionId'],
1055
+ 'low',
1056
+ METHOD_POLICIES.pageRead
1057
+ ),
1058
+ 'session.read': createRegistryEntry(
1059
+ 'session.read',
1060
+ 'session',
1061
+ true,
1062
+ ['sessionId', 'cursor', 'limit', 'waitMs'],
1063
+ 'low',
1064
+ METHOD_POLICIES.pageRead
1065
+ ),
1066
+ 'session.stop': createRegistryEntry(
1067
+ 'session.stop',
1068
+ 'session',
1069
+ true,
1070
+ ['sessionId'],
1071
+ 'moderate',
1072
+ METHOD_POLICIES.automationInputDebugger
1073
+ ),
1074
+ 'session.export': createRegistryEntry(
1075
+ 'session.export',
1076
+ 'session',
1077
+ true,
1078
+ ['sessionId', 'format'],
1079
+ 'moderate',
1080
+ METHOD_POLICIES.automationInputDebugger
1081
+ ),
1082
+ 'session.release': createRegistryEntry(
1083
+ 'session.release',
1084
+ 'session',
1085
+ true,
1086
+ ['sessionId'],
1087
+ 'low',
1088
+ METHOD_POLICIES.automationInputDebugger
1089
+ ),
863
1090
  });
864
1091
 
865
1092
  /** @type {ReadonlyArray<import('./types.js').BridgeMethod>} */
@@ -872,6 +1099,7 @@ export const METHOD_SET = new Set(BRIDGE_METHODS);
872
1099
 
873
1100
  /** Methods whose read-only forms may run concurrently without side effects. */
874
1101
  const BATCH_SAFE_METHODS = new Set([
1102
+ 'emulation.status',
875
1103
  'webmcp.list_tools',
876
1104
  'webmcp.get_tool',
877
1105
  'health.ping',
@@ -0,0 +1,144 @@
1
+ // @ts-check
2
+
3
+ /** @typedef {{ frames: Array<{ at: number, data: string, width?: number, height?: number, tabId?: number }>, events: Array<Record<string, unknown>>, bodies?: Array<Record<string, unknown>>, gaps: number, dropped: number, replayMode?: string, memoryWindow?: boolean }} Replay */
4
+
5
+ /** Offline renderer playback. Untrusted data is JSON text; only this fixed viewer script executes.
6
+ * @param {Replay} recording @returns {string} */
7
+ export function renderReplay(recording) {
8
+ const gaps = Number.isSafeInteger(recording.gaps) ? recording.gaps : 0;
9
+ const dropped = Number.isSafeInteger(recording.dropped) ? recording.dropped : 0;
10
+ const nonce = crypto.randomUUID().replaceAll('-', '');
11
+ const frames = recording.frames
12
+ .filter((frame) => Number.isFinite(frame.at) && /^[A-Za-z0-9+/]+={0,2}$/u.test(frame.data))
13
+ .sort((a, b) => a.at - b.at);
14
+ const data = JSON.stringify({
15
+ frames,
16
+ clicks: recording.events.filter((event) => event.type === 'click'),
17
+ })
18
+ .replaceAll('<', '\\u003c')
19
+ .replaceAll('&', '\\u0026');
20
+ return `<!doctype html><html lang="en"><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"><title>BBX renderer replay</title>
21
+ <style>body{font:16px system-ui;background:#17212b;color:#fff;margin:24px}button,input{margin:8px}#stage{position:relative;display:inline-block;max-width:100%}img{max-width:100%;display:block}#click{position:absolute;width:24px;height:24px;border:3px solid #ff574a;border-radius:50%;transform:translate(-50%,-50%);display:none;pointer-events:none}input[type=range]{width:60%}pre{white-space:pre-wrap;overflow-wrap:anywhere}</style>
22
+ <h1>BBX renderer replay</h1><p>${gaps} capture gaps; ${dropped} dropped events.${recording.memoryWindow ? ' Retained memory window only; use the durable journal for the complete replay.' : ''} Frames represent visible pixels. External assets and page scripts are not loaded.</p>
23
+ <div><button id="play">Play</button><select id="speed"><option value="0.5">0.5×</option><option value="1" selected>1×</option><option value="2">2×</option><option value="4">4×</option></select><input id="seek" type="range" min="0" max="1" step="1" value="0" aria-label="Replay time"><output id="time"></output></div>
24
+ <div id="stage"><img id="frame" alt="Recorded renderer output"><span id="click"></span></div><details><summary>Activity and network events</summary><pre id="events"></pre></details>
25
+ <script id="data" type="application/json" nonce="${nonce}">${data}</script><script id="timeline" type="application/json" nonce="${nonce}">${JSON.stringify(recording.events).replaceAll('<', '\\u003c').replaceAll('&', '\\u0026')}</script>
26
+ <script nonce="${nonce}">
27
+ const {frames,clicks}=JSON.parse(document.getElementById('data').textContent);
28
+ const image=document.getElementById('frame'),seek=document.getElementById('seek'),play=document.getElementById('play'),dot=document.getElementById('click');
29
+ document.getElementById('events').textContent=JSON.stringify(JSON.parse(document.getElementById('timeline').textContent),null,2);
30
+ const start=frames[0]?.at||0,end=Math.max(start,frames.at(-1)?.at||0);seek.max=String(end-start);
31
+ let playing=false,position=0,last=0,index=-1,raf=0;
32
+ function show(){let low=0,high=frames.length-1,best=-1;while(low<=high){const mid=(low+high)>>1;if(frames[mid].at<=start+position){best=mid;low=mid+1}else high=mid-1}if(best>=0&&best!==index){index=best;image.src='data:image/jpeg;base64,'+frames[index].data}seek.value=String(position);document.getElementById('time').textContent=(position/1000).toFixed(1)+' / '+((end-start)/1000).toFixed(1)+' s';const f=frames[index];const c=clicks.findLast(c=>c.at<=start+position&&start+position-c.at<600&&(!f?.tabId||f.tabId===c.tabId));dot.style.display=c&&f?.width&&f?.height?'block':'none';if(c&&f?.width&&f?.height){dot.style.left=(100*c.x/f.width)+'%';dot.style.top=(100*c.y/f.height)+'%'}}
33
+ play.onclick=()=>{cancelAnimationFrame(raf);playing=!playing;if(position>=end-start)position=0;play.textContent=playing?'Pause':'Play';last=performance.now();if(playing)raf=requestAnimationFrame(tick)};seek.oninput=()=>{position=Number(seek.value);show()};
34
+ function tick(now){if(playing){position=Math.min(end-start,position+(now-last)*Number(document.getElementById('speed').value));show();if(position>=end-start){playing=false;play.textContent='Play'}}last=now;if(playing)raf=requestAnimationFrame(tick)}show();
35
+ </script></html>`;
36
+ }
37
+
38
+ /** Recover complete records; report a partial tail instead of silently claiming a complete recording.
39
+ * @param {string} text @returns {Replay} */
40
+ export function parseRecordingJournal(text) {
41
+ /** @type {Replay} */
42
+ const result = {
43
+ frames: [],
44
+ events: [],
45
+ bodies: [],
46
+ gaps: 0,
47
+ dropped: 0,
48
+ replayMode: 'renderer-stream',
49
+ };
50
+ const lines = text.split('\n');
51
+ /** @type {Map<number, number>} */
52
+ const eventIndexes = new Map();
53
+ let ended = false;
54
+ if (lines.pop()) result.gaps++;
55
+ for (const line of lines) {
56
+ if (!line) continue;
57
+ const entry = /** @type {Record<string, unknown>} */ (JSON.parse(line));
58
+ if (!entry || typeof entry !== 'object') throw new Error('Invalid journal record.');
59
+ if (
60
+ entry.kind === 'frame' &&
61
+ entry.frame &&
62
+ typeof entry.frame === 'object' &&
63
+ 'data' in entry.frame &&
64
+ typeof entry.frame.data === 'string' &&
65
+ 'at' in entry.frame &&
66
+ typeof entry.frame.at === 'number'
67
+ )
68
+ result.frames.push(/** @type {Replay['frames'][number]} */ (entry.frame));
69
+ else if (
70
+ (entry.kind === 'event' || entry.kind === 'event-update') &&
71
+ entry.event &&
72
+ typeof entry.event === 'object'
73
+ ) {
74
+ const event = /** @type {Record<string, unknown>} */ (entry.event);
75
+ const index =
76
+ entry.kind === 'event-update' && typeof event.seq === 'number'
77
+ ? eventIndexes.get(event.seq)
78
+ : undefined;
79
+ if (index !== undefined) result.events[index] = event;
80
+ else {
81
+ if (typeof event.seq === 'number') eventIndexes.set(event.seq, result.events.length);
82
+ result.events.push(event);
83
+ }
84
+ } else if (entry.kind === 'body' && entry.body && typeof entry.body === 'object')
85
+ result.bodies?.push(/** @type {Record<string, unknown>} */ (entry.body));
86
+ else if (entry.kind === 'end') {
87
+ ended = true;
88
+ result.gaps += Number(entry.gaps) || 0;
89
+ result.dropped = Number(entry.dropped) || 0;
90
+ }
91
+ }
92
+ if (!ended) result.gaps++;
93
+ result.events = correlateReplayEvents(result.events);
94
+ return result;
95
+ }
96
+
97
+ /** @template {Record<string, unknown>} T @param {T[]} events @returns {T[]} */
98
+ export function correlateReplayEvents(events) {
99
+ /** @type {Map<unknown, T[]>} */
100
+ const tabs = new Map();
101
+ for (const event of events) {
102
+ if (
103
+ event.origin !== 'trusted-event' ||
104
+ !['click', 'input', 'change', 'submit'].includes(String(event.type)) ||
105
+ !Number.isFinite(event.at)
106
+ )
107
+ continue;
108
+ const list = tabs.get(event.tabId) ?? [];
109
+ list.push(event);
110
+ tabs.set(event.tabId, list);
111
+ }
112
+ for (const list of tabs.values()) list.sort((a, b) => Number(a.at) - Number(b.at));
113
+ return events.map((event) => {
114
+ if (
115
+ !['request', 'page-error', 'navigation'].includes(String(event.type)) ||
116
+ !Number.isFinite(event.at)
117
+ )
118
+ return event;
119
+ const actions = tabs.get(event.tabId) ?? [];
120
+ let low = 0,
121
+ high = actions.length - 1,
122
+ best = -1;
123
+ while (low <= high) {
124
+ const mid = (low + high) >> 1;
125
+ if (Number(actions[mid].at) <= Number(event.at)) {
126
+ best = mid;
127
+ low = mid + 1;
128
+ } else high = mid - 1;
129
+ }
130
+ const action = actions[best];
131
+ const deltaMs = Number(event.at) - Number(action?.at);
132
+ return action && deltaMs <= 2000
133
+ ? {
134
+ ...event,
135
+ correlation: {
136
+ actionSeq: action.seq,
137
+ deltaMs,
138
+ basis: 'same-tab-time-window',
139
+ causality: false,
140
+ },
141
+ }
142
+ : event;
143
+ });
144
+ }
@@ -228,7 +228,11 @@ const ACTION_SUMMARIES = {
228
228
  performed: {
229
229
  text: (r) => {
230
230
  const failures = Array.isArray(r.failures) ? r.failures.length : 0;
231
- return `Performed ${r.completed ?? 0}/${r.total ?? 0} step(s) in ${r.elapsedMs ?? 0}ms${failures ? ` with ${failures} failed step(s)` : ''}.`;
231
+ const lastResult = toRecord(r.lastResult);
232
+ const finalEvidence = lastResult.truncated
233
+ ? ' Final result omitted: size limit exceeded; inspect with a narrower read, do not replay actions.'
234
+ : describeEffects(toRecord(lastResult.result).effects);
235
+ return `Performed ${r.completed ?? 0}/${r.total ?? 0} step(s) in ${r.elapsedMs ?? 0}ms${failures ? ` with ${failures} failed step(s)` : ''}.${finalEvidence}`;
232
236
  },
233
237
  evidence: (r) => r,
234
238
  },
@@ -480,6 +484,55 @@ function summarizeBridgeResponseBody(response, method) {
480
484
  }),
481
485
  };
482
486
  }
487
+ if (method?.startsWith('emulation.')) {
488
+ return {
489
+ ok: true,
490
+ summary: `Emulation ${result.active ? 'active' : 'inactive'}; ${Array.isArray(result.pendingReset) ? result.pendingReset.length : 0} setting(s) retained.`,
491
+ evidence: result,
492
+ };
493
+ }
494
+ if (method === 'input.set_files') {
495
+ return {
496
+ ok: true,
497
+ summary: `File input ${result.assigned ? 'assigned' : 'not verified'}: ${result.count ?? 0} file(s).`,
498
+ evidence: result,
499
+ };
500
+ }
501
+ if (method?.startsWith('files.')) {
502
+ return {
503
+ ok: true,
504
+ summary: result.released
505
+ ? 'Staged file released.'
506
+ : `File staged: ${result.nextOffset ?? 0}/${result.size ?? 0} bytes.`,
507
+ evidence: result,
508
+ };
509
+ }
510
+ if (method === 'download.resource') {
511
+ return { ok: true, summary: 'Resource downloaded to a chunked artifact.', evidence: result };
512
+ }
513
+ if (method?.startsWith('download.')) {
514
+ return {
515
+ ok: true,
516
+ summary: result.released
517
+ ? 'Browser download handle released.'
518
+ : `Browser download ${result.state ?? 'updated'}: ${result.bytesReceived ?? 0}/${result.totalBytes ?? -1} bytes.`,
519
+ evidence: result,
520
+ };
521
+ }
522
+ if (method === 'input.paste_image') {
523
+ return {
524
+ ok: true,
525
+ summary: 'Native image paste dispatched. Verify insertion in the editor.',
526
+ evidence: result,
527
+ };
528
+ }
529
+ if (method?.startsWith('session.')) {
530
+ return {
531
+ ok: true,
532
+ summary: `Recording ${result.sessionId ?? ''}: ${result.released ? 'released' : result.delivery === 'artifact' ? 'exported' : result.active ? 'active' : 'stopped'}; ${result.gaps ?? 0} gap(s).`,
533
+ evidence: result,
534
+ };
535
+ }
483
536
  if (typeof result.daemon === 'string') {
484
537
  const access =
485
538
  result.access && typeof result.access === 'object'