@jobshimo/browser-link 0.23.7 → 0.23.9

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.
@@ -6,6 +6,7 @@ import { DEFAULT_IDLE_TTL_MINUTES, IDLE_TTL_STORAGE_KEY, IDLE_TTL_UPDATED_AT_STO
6
6
  import { DEFAULT_FLOW_RECORDING_ENABLED, FLOW_RECORDING_STORAGE_KEY, FLOW_RECORDING_UPDATED_AT_STORAGE_KEY, normalizeFlowRecordingEnabled, parseIncomingFlowRecordingSettings, } from './flow-recording-policy.js';
7
7
  import { buildRecorderJs, buildStopRecorderJs } from './inpage/recorder.js';
8
8
  import { DISCARDED_WHILE_SAVING_MESSAGE, appendRecordingStep, buildAmbiguousNote, buildNavigationWaitForStep, generateRecordingSession, isNavigationForRecording, isSameRecordingSession, parseRecordedPayload, toFlowStep, } from './recording.js';
9
+ import { CONNECT_IN_PROGRESS_ERROR, createReconnectScheduler, isInvoluntaryClose, isReconnectStateFresh, parseStoredReconnectState, reconnectStorageKey, tabIdFromReconnectKey, } from './reconnect-policy.js';
9
10
  const WS_URL = 'ws://127.0.0.1:17529';
10
11
  const CDP_VERSION = '1.3';
11
12
  const CONSOLE_BUFFER_MAX = 200;
@@ -631,8 +632,9 @@ async function waitForLoad(tabId, timeoutMs = 20_000) {
631
632
  }
632
633
  /**
633
634
  * `performFind` / `performClick` / `performType` / `performPress` /
634
- * `performWaitFor` — the extracted bodies of the standalone `find` /
635
- * `click` / `type` / `press` / `wait_for` cases in `handleTool` below.
635
+ * `performWaitFor` / `performDrag` — the extracted bodies of the
636
+ * standalone `find` / `click` / `type` / `press` / `wait_for` / `drag`
637
+ * cases in `handleTool` below.
636
638
  *
637
639
  * Both the standalone `case` handlers AND `browser.flow`'s step executor
638
640
  * (`runFlow` in `./flow.js`) call these exact functions — there is one
@@ -864,6 +866,279 @@ async function performWaitFor(tabId, state, params) {
864
866
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
865
867
  }
866
868
  }
869
+ async function performDrag(tabId, params) {
870
+ try {
871
+ // Same numeric guard the wire boundary used before this body was
872
+ // extracted out of `handleDrag`: anything non-finite or negative is
873
+ // treated as absent, so flow drag steps get identical protection.
874
+ const num = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : undefined;
875
+ const clamp = (v, max) => Math.min(v, max);
876
+ const fromSelector = params.from_selector;
877
+ const toSelector = params.to_selector;
878
+ const fromXRaw = num(params.from_x);
879
+ const fromYRaw = num(params.from_y);
880
+ const toXRaw = num(params.to_x);
881
+ const toYRaw = num(params.to_y);
882
+ // Cap every duration that ends up in a setTimeout to keep CodeQL's
883
+ // "user-controlled timer duration" check happy and to prevent a
884
+ // misbehaving agent from parking the bridge for hours on a typo.
885
+ const durationMs = clamp(num(params.duration_ms) ?? 1500, MAX_DRAG_DURATION_MS);
886
+ const holdBeforeMoveMs = clamp(num(params.hold_before_move_ms) ?? 0, MAX_DRAG_HOLD_MS);
887
+ const holdBeforeReleaseMs = clamp(num(params.hold_before_release_ms) ?? 0, MAX_DRAG_HOLD_MS);
888
+ const hasFromCoords = fromXRaw !== undefined && fromYRaw !== undefined;
889
+ const hasToCoords = toXRaw !== undefined && toYRaw !== undefined;
890
+ if (!fromSelector && !hasFromCoords) {
891
+ return { ok: false, error: 'drag: provide from_selector or both from_x and from_y' };
892
+ }
893
+ if (!toSelector && !hasToCoords) {
894
+ return { ok: false, error: 'drag: provide to_selector or both to_x and to_y' };
895
+ }
896
+ const probeExpr = buildDragProbeJs({
897
+ from_selector: fromSelector,
898
+ to_selector: toSelector,
899
+ from_x: fromXRaw,
900
+ from_y: fromYRaw,
901
+ to_x: toXRaw,
902
+ to_y: toYRaw,
903
+ });
904
+ const probe = await evaluateInTab(tabId, probeExpr);
905
+ if (probe.err) {
906
+ return { ok: false, error: `drag: ${probe.err}` };
907
+ }
908
+ if (!probe.from || !probe.to) {
909
+ return { ok: false, error: 'drag: could not resolve coordinates' };
910
+ }
911
+ if (!probe.from.in_viewport) {
912
+ return {
913
+ ok: false,
914
+ error: 'drag: source point is offscreen — scroll first or pass viewport coords',
915
+ };
916
+ }
917
+ if (!probe.to.in_viewport) {
918
+ return {
919
+ ok: false,
920
+ error: 'drag: destination point is offscreen — scroll first or pass viewport coords',
921
+ };
922
+ }
923
+ const fromX = probe.from.x;
924
+ const fromY = probe.from.y;
925
+ const toX = probe.to.x;
926
+ const toY = probe.to.y;
927
+ const isDraggable = !!probe.draggable;
928
+ // ~30fps interpolation; minimum 2 steps so the path actually has a midpoint.
929
+ const steps = durationMs > 0 ? Math.max(2, Math.round(durationMs / 33)) : 1;
930
+ const stepDelayMs = steps > 0 ? durationMs / steps : 0;
931
+ const eventsFired = [];
932
+ // Every branch below reassigns `dragMode` before the final `return`
933
+ // reads it. (The old `case`-block extraction needed a
934
+ // no-useless-assignment disable here; inside this try/catch scope the
935
+ // linter no longer flags the initializer, so the directive is gone.)
936
+ let dragMode = 'pointer';
937
+ let interceptReceived = false;
938
+ const dragStart = Date.now();
939
+ let interceptionEnabled = false;
940
+ const interpolate = (t) => ({
941
+ x: fromX + (toX - fromX) * t,
942
+ y: fromY + (toY - fromY) * t,
943
+ });
944
+ // Mouse move WITH the left button held down. Chrome's HTML5 drag
945
+ // system only treats movement as a drag when the button state is
946
+ // signalled on every move after mousePressed; omitting it makes
947
+ // Blink ignore the move for drag-detection purposes.
948
+ const moveHeld = (x, y) => cdp(tabId, 'Input.dispatchMouseEvent', {
949
+ type: 'mouseMoved',
950
+ x,
951
+ y,
952
+ button: 'left',
953
+ buttons: 1,
954
+ });
955
+ // Wiggle distance has to comfortably clear `kDragThreshold` (~5px on
956
+ // Mac, 4px on Linux/Windows). 5px landed *right* at the boundary and
957
+ // Chrome did not start the drag — 20px crosses it on every platform.
958
+ const WIGGLE_PX = 20;
959
+ // Time to wait for Input.dragIntercepted after the wiggle. 120ms was
960
+ // enough on local-only synthetic tests but flaky in real Chrome —
961
+ // 250ms is comfortable without dragging out the overall latency.
962
+ const INTERCEPT_TIMEOUT_MS = 250;
963
+ try {
964
+ if (isDraggable) {
965
+ try {
966
+ await cdp(tabId, 'Input.setInterceptDrags', { enabled: true });
967
+ interceptionEnabled = true;
968
+ }
969
+ catch {
970
+ // setInterceptDrags is experimental — fall back to pointer mode silently.
971
+ }
972
+ }
973
+ if (interceptionEnabled) {
974
+ // Arm the listener BEFORE the press+wiggle that may trigger it.
975
+ const interceptPromise = waitForCdpEvent(tabId, 'Input.dragIntercepted', INTERCEPT_TIMEOUT_MS);
976
+ await cdp(tabId, 'Input.dispatchMouseEvent', {
977
+ type: 'mouseMoved',
978
+ x: fromX,
979
+ y: fromY,
980
+ });
981
+ await cdp(tabId, 'Input.dispatchMouseEvent', {
982
+ type: 'mousePressed',
983
+ x: fromX,
984
+ y: fromY,
985
+ button: 'left',
986
+ clickCount: 1,
987
+ });
988
+ if (holdBeforeMoveMs > 0)
989
+ await sleep(holdBeforeMoveMs);
990
+ // Wiggle toward the destination so Chrome's native drag system
991
+ // crosses its activation threshold. Direction matters for libs
992
+ // that have direction-sensitive activation constraints.
993
+ const dx = toX - fromX;
994
+ const dy = toY - fromY;
995
+ const len = Math.hypot(dx, dy) || 1;
996
+ const wx = fromX + (dx / len) * WIGGLE_PX;
997
+ const wy = fromY + (dy / len) * WIGGLE_PX;
998
+ await moveHeld(wx, wy);
999
+ const intercepted = await interceptPromise;
1000
+ if (intercepted) {
1001
+ dragMode = 'html5';
1002
+ interceptReceived = true;
1003
+ eventsFired.push('Input.dragIntercepted');
1004
+ const dragData = intercepted.data;
1005
+ for (let i = 1; i <= steps; i++) {
1006
+ const t = i / steps;
1007
+ const { x, y } = interpolate(t);
1008
+ await moveHeld(x, y);
1009
+ await cdp(tabId, 'Input.dispatchDragEvent', {
1010
+ type: 'dragOver',
1011
+ x,
1012
+ y,
1013
+ data: dragData,
1014
+ });
1015
+ if (i === 1)
1016
+ eventsFired.push('dragOver');
1017
+ if (stepDelayMs > 0)
1018
+ await sleep(stepDelayMs);
1019
+ }
1020
+ await cdp(tabId, 'Input.dispatchDragEvent', {
1021
+ type: 'dragEnter',
1022
+ x: toX,
1023
+ y: toY,
1024
+ data: dragData,
1025
+ });
1026
+ eventsFired.push('dragEnter');
1027
+ await cdp(tabId, 'Input.dispatchDragEvent', {
1028
+ type: 'dragOver',
1029
+ x: toX,
1030
+ y: toY,
1031
+ data: dragData,
1032
+ });
1033
+ if (holdBeforeReleaseMs > 0)
1034
+ await sleep(holdBeforeReleaseMs);
1035
+ await cdp(tabId, 'Input.dispatchDragEvent', {
1036
+ type: 'drop',
1037
+ x: toX,
1038
+ y: toY,
1039
+ data: dragData,
1040
+ });
1041
+ eventsFired.push('drop');
1042
+ await cdp(tabId, 'Input.dispatchMouseEvent', {
1043
+ type: 'mouseReleased',
1044
+ x: toX,
1045
+ y: toY,
1046
+ button: 'left',
1047
+ clickCount: 1,
1048
+ });
1049
+ }
1050
+ else {
1051
+ // Element was tagged draggable but no native drag fired — the page
1052
+ // either preventDefault'd dragstart or wires its own pointer logic.
1053
+ // Continue with pointer-only events from where we already pressed.
1054
+ dragMode = 'pointer';
1055
+ for (let i = 1; i <= steps; i++) {
1056
+ const t = i / steps;
1057
+ const { x, y } = interpolate(t);
1058
+ await moveHeld(x, y);
1059
+ if (stepDelayMs > 0)
1060
+ await sleep(stepDelayMs);
1061
+ }
1062
+ if (holdBeforeReleaseMs > 0)
1063
+ await sleep(holdBeforeReleaseMs);
1064
+ await cdp(tabId, 'Input.dispatchMouseEvent', {
1065
+ type: 'mouseReleased',
1066
+ x: toX,
1067
+ y: toY,
1068
+ button: 'left',
1069
+ clickCount: 1,
1070
+ });
1071
+ }
1072
+ }
1073
+ else {
1074
+ // Pointer-only branch: no native drag involvement at all.
1075
+ dragMode = 'pointer';
1076
+ await cdp(tabId, 'Input.dispatchMouseEvent', {
1077
+ type: 'mouseMoved',
1078
+ x: fromX,
1079
+ y: fromY,
1080
+ });
1081
+ await cdp(tabId, 'Input.dispatchMouseEvent', {
1082
+ type: 'mousePressed',
1083
+ x: fromX,
1084
+ y: fromY,
1085
+ button: 'left',
1086
+ clickCount: 1,
1087
+ });
1088
+ if (holdBeforeMoveMs > 0)
1089
+ await sleep(holdBeforeMoveMs);
1090
+ for (let i = 1; i <= steps; i++) {
1091
+ const t = i / steps;
1092
+ const { x, y } = interpolate(t);
1093
+ await moveHeld(x, y);
1094
+ if (stepDelayMs > 0)
1095
+ await sleep(stepDelayMs);
1096
+ }
1097
+ if (holdBeforeReleaseMs > 0)
1098
+ await sleep(holdBeforeReleaseMs);
1099
+ await cdp(tabId, 'Input.dispatchMouseEvent', {
1100
+ type: 'mouseReleased',
1101
+ x: toX,
1102
+ y: toY,
1103
+ button: 'left',
1104
+ clickCount: 1,
1105
+ });
1106
+ }
1107
+ }
1108
+ finally {
1109
+ // Critical: leaving interception on would block the human user from
1110
+ // dragging anything in this tab. Best-effort cleanup, swallow errors.
1111
+ if (interceptionEnabled) {
1112
+ try {
1113
+ await cdp(tabId, 'Input.setInterceptDrags', { enabled: false });
1114
+ }
1115
+ catch {
1116
+ // ignore
1117
+ }
1118
+ }
1119
+ }
1120
+ return {
1121
+ ok: true,
1122
+ result: {
1123
+ from: { x: fromX, y: fromY, selector: fromSelector ?? null },
1124
+ to: { x: toX, y: toY, selector: toSelector ?? null },
1125
+ duration_ms_actual: Date.now() - dragStart,
1126
+ drag_mode: dragMode,
1127
+ interception_attempted: interceptionEnabled,
1128
+ intercept_received: interceptReceived,
1129
+ events_fired: eventsFired,
1130
+ },
1131
+ };
1132
+ }
1133
+ catch (err) {
1134
+ // The pre-extraction `case 'drag'` let thrown errors bubble to
1135
+ // `handleTool`'s outer catch, which produced this exact same
1136
+ // `err.message` string — converting here instead keeps the standalone
1137
+ // wire behavior identical while giving `runFlow` the `ActionOutcome`
1138
+ // shape it expects from every other perform* function.
1139
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
1140
+ }
1141
+ }
867
1142
  /**
868
1143
  * `handlePing` through `handleSetPermission` — the extracted bodies of the
869
1144
  * remaining standalone tool cases in `handleTool` below that never got the
@@ -960,6 +1235,7 @@ async function handleFlow(tabId, state, msg, p) {
960
1235
  performType: (params) => performType(tabId, params),
961
1236
  performPress: (params) => performPress(tabId, params),
962
1237
  performWaitFor: (params) => performWaitFor(tabId, state, params),
1238
+ performDrag: (params) => performDrag(tabId, params),
963
1239
  buildRecoverySnapshot: () => evaluateInTab(tabId, buildSnapshotJs({ only_interactive: true, max_interactive: 40 })),
964
1240
  });
965
1241
  // The wire-level response is ok:true whenever the flow RAN (even a
@@ -969,285 +1245,29 @@ async function handleFlow(tabId, state, msg, p) {
969
1245
  return { kind: 'tool.response', id: msg.id, ok: true, result: flowResult };
970
1246
  }
971
1247
  async function handleDrag(tabId, msg, p, optStr) {
1248
+ // Thin wire wrapper over performDrag — the same pattern the standalone
1249
+ // click/type/press/wait_for cases use over their perform* functions.
1250
+ // Numbers pass through with a plain typeof check; performDrag applies
1251
+ // the full finite/non-negative guard and the duration/hold clamps.
972
1252
  const optNum = (key) => {
973
1253
  const v = p[key];
974
- return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : undefined;
1254
+ return typeof v === 'number' ? v : undefined;
975
1255
  };
976
- const clamp = (v, max) => Math.min(v, max);
977
- const fromSelector = optStr('from_selector');
978
- const toSelector = optStr('to_selector');
979
- const fromXRaw = optNum('from_x');
980
- const fromYRaw = optNum('from_y');
981
- const toXRaw = optNum('to_x');
982
- const toYRaw = optNum('to_y');
983
- // Cap every duration that ends up in a setTimeout to keep CodeQL's
984
- // "user-controlled timer duration" check happy and to prevent a
985
- // misbehaving agent from parking the bridge for hours on a typo.
986
- const durationMs = clamp(optNum('duration_ms') ?? 1500, MAX_DRAG_DURATION_MS);
987
- const holdBeforeMoveMs = clamp(optNum('hold_before_move_ms') ?? 0, MAX_DRAG_HOLD_MS);
988
- const holdBeforeReleaseMs = clamp(optNum('hold_before_release_ms') ?? 0, MAX_DRAG_HOLD_MS);
989
- const hasFromCoords = fromXRaw !== undefined && fromYRaw !== undefined;
990
- const hasToCoords = toXRaw !== undefined && toYRaw !== undefined;
991
- if (!fromSelector && !hasFromCoords) {
992
- return {
993
- kind: 'tool.response',
994
- id: msg.id,
995
- ok: false,
996
- error: 'drag: provide from_selector or both from_x and from_y',
997
- };
998
- }
999
- if (!toSelector && !hasToCoords) {
1000
- return {
1001
- kind: 'tool.response',
1002
- id: msg.id,
1003
- ok: false,
1004
- error: 'drag: provide to_selector or both to_x and to_y',
1005
- };
1006
- }
1007
- const probeExpr = buildDragProbeJs({
1008
- from_selector: fromSelector,
1009
- to_selector: toSelector,
1010
- from_x: fromXRaw,
1011
- from_y: fromYRaw,
1012
- to_x: toXRaw,
1013
- to_y: toYRaw,
1014
- });
1015
- const probe = await evaluateInTab(tabId, probeExpr);
1016
- if (probe.err) {
1017
- return { kind: 'tool.response', id: msg.id, ok: false, error: `drag: ${probe.err}` };
1018
- }
1019
- if (!probe.from || !probe.to) {
1020
- return {
1021
- kind: 'tool.response',
1022
- id: msg.id,
1023
- ok: false,
1024
- error: 'drag: could not resolve coordinates',
1025
- };
1026
- }
1027
- if (!probe.from.in_viewport) {
1028
- return {
1029
- kind: 'tool.response',
1030
- id: msg.id,
1031
- ok: false,
1032
- error: 'drag: source point is offscreen — scroll first or pass viewport coords',
1033
- };
1034
- }
1035
- if (!probe.to.in_viewport) {
1036
- return {
1037
- kind: 'tool.response',
1038
- id: msg.id,
1039
- ok: false,
1040
- error: 'drag: destination point is offscreen — scroll first or pass viewport coords',
1041
- };
1042
- }
1043
- const fromX = probe.from.x;
1044
- const fromY = probe.from.y;
1045
- const toX = probe.to.x;
1046
- const toY = probe.to.y;
1047
- const isDraggable = !!probe.draggable;
1048
- // ~30fps interpolation; minimum 2 steps so the path actually has a midpoint.
1049
- const steps = durationMs > 0 ? Math.max(2, Math.round(durationMs / 33)) : 1;
1050
- const stepDelayMs = steps > 0 ? durationMs / steps : 0;
1051
- const eventsFired = [];
1052
- // Every branch below reassigns `dragMode` before the final `return` reads it.
1053
- let dragMode = 'pointer'; // eslint-disable-line no-useless-assignment -- see comment above
1054
- let interceptReceived = false;
1055
- const dragStart = Date.now();
1056
- let interceptionEnabled = false;
1057
- const interpolate = (t) => ({
1058
- x: fromX + (toX - fromX) * t,
1059
- y: fromY + (toY - fromY) * t,
1256
+ const outcome = await performDrag(tabId, {
1257
+ from_selector: optStr('from_selector'),
1258
+ from_x: optNum('from_x'),
1259
+ from_y: optNum('from_y'),
1260
+ to_selector: optStr('to_selector'),
1261
+ to_x: optNum('to_x'),
1262
+ to_y: optNum('to_y'),
1263
+ duration_ms: optNum('duration_ms'),
1264
+ hold_before_move_ms: optNum('hold_before_move_ms'),
1265
+ hold_before_release_ms: optNum('hold_before_release_ms'),
1060
1266
  });
1061
- // Mouse move WITH the left button held down. Chrome's HTML5 drag
1062
- // system only treats movement as a drag when the button state is
1063
- // signalled on every move after mousePressed; omitting it makes
1064
- // Blink ignore the move for drag-detection purposes.
1065
- const moveHeld = (x, y) => cdp(tabId, 'Input.dispatchMouseEvent', {
1066
- type: 'mouseMoved',
1067
- x,
1068
- y,
1069
- button: 'left',
1070
- buttons: 1,
1071
- });
1072
- // Wiggle distance has to comfortably clear `kDragThreshold` (~5px on
1073
- // Mac, 4px on Linux/Windows). 5px landed *right* at the boundary and
1074
- // Chrome did not start the drag — 20px crosses it on every platform.
1075
- const WIGGLE_PX = 20;
1076
- // Time to wait for Input.dragIntercepted after the wiggle. 120ms was
1077
- // enough on local-only synthetic tests but flaky in real Chrome —
1078
- // 250ms is comfortable without dragging out the overall latency.
1079
- const INTERCEPT_TIMEOUT_MS = 250;
1080
- try {
1081
- if (isDraggable) {
1082
- try {
1083
- await cdp(tabId, 'Input.setInterceptDrags', { enabled: true });
1084
- interceptionEnabled = true;
1085
- }
1086
- catch {
1087
- // setInterceptDrags is experimental — fall back to pointer mode silently.
1088
- }
1089
- }
1090
- if (interceptionEnabled) {
1091
- // Arm the listener BEFORE the press+wiggle that may trigger it.
1092
- const interceptPromise = waitForCdpEvent(tabId, 'Input.dragIntercepted', INTERCEPT_TIMEOUT_MS);
1093
- await cdp(tabId, 'Input.dispatchMouseEvent', {
1094
- type: 'mouseMoved',
1095
- x: fromX,
1096
- y: fromY,
1097
- });
1098
- await cdp(tabId, 'Input.dispatchMouseEvent', {
1099
- type: 'mousePressed',
1100
- x: fromX,
1101
- y: fromY,
1102
- button: 'left',
1103
- clickCount: 1,
1104
- });
1105
- if (holdBeforeMoveMs > 0)
1106
- await sleep(holdBeforeMoveMs);
1107
- // Wiggle toward the destination so Chrome's native drag system
1108
- // crosses its activation threshold. Direction matters for libs
1109
- // that have direction-sensitive activation constraints.
1110
- const dx = toX - fromX;
1111
- const dy = toY - fromY;
1112
- const len = Math.hypot(dx, dy) || 1;
1113
- const wx = fromX + (dx / len) * WIGGLE_PX;
1114
- const wy = fromY + (dy / len) * WIGGLE_PX;
1115
- await moveHeld(wx, wy);
1116
- const intercepted = await interceptPromise;
1117
- if (intercepted) {
1118
- dragMode = 'html5';
1119
- interceptReceived = true;
1120
- eventsFired.push('Input.dragIntercepted');
1121
- const dragData = intercepted.data;
1122
- for (let i = 1; i <= steps; i++) {
1123
- const t = i / steps;
1124
- const { x, y } = interpolate(t);
1125
- await moveHeld(x, y);
1126
- await cdp(tabId, 'Input.dispatchDragEvent', {
1127
- type: 'dragOver',
1128
- x,
1129
- y,
1130
- data: dragData,
1131
- });
1132
- if (i === 1)
1133
- eventsFired.push('dragOver');
1134
- if (stepDelayMs > 0)
1135
- await sleep(stepDelayMs);
1136
- }
1137
- await cdp(tabId, 'Input.dispatchDragEvent', {
1138
- type: 'dragEnter',
1139
- x: toX,
1140
- y: toY,
1141
- data: dragData,
1142
- });
1143
- eventsFired.push('dragEnter');
1144
- await cdp(tabId, 'Input.dispatchDragEvent', {
1145
- type: 'dragOver',
1146
- x: toX,
1147
- y: toY,
1148
- data: dragData,
1149
- });
1150
- if (holdBeforeReleaseMs > 0)
1151
- await sleep(holdBeforeReleaseMs);
1152
- await cdp(tabId, 'Input.dispatchDragEvent', {
1153
- type: 'drop',
1154
- x: toX,
1155
- y: toY,
1156
- data: dragData,
1157
- });
1158
- eventsFired.push('drop');
1159
- await cdp(tabId, 'Input.dispatchMouseEvent', {
1160
- type: 'mouseReleased',
1161
- x: toX,
1162
- y: toY,
1163
- button: 'left',
1164
- clickCount: 1,
1165
- });
1166
- }
1167
- else {
1168
- // Element was tagged draggable but no native drag fired — the page
1169
- // either preventDefault'd dragstart or wires its own pointer logic.
1170
- // Continue with pointer-only events from where we already pressed.
1171
- dragMode = 'pointer';
1172
- for (let i = 1; i <= steps; i++) {
1173
- const t = i / steps;
1174
- const { x, y } = interpolate(t);
1175
- await moveHeld(x, y);
1176
- if (stepDelayMs > 0)
1177
- await sleep(stepDelayMs);
1178
- }
1179
- if (holdBeforeReleaseMs > 0)
1180
- await sleep(holdBeforeReleaseMs);
1181
- await cdp(tabId, 'Input.dispatchMouseEvent', {
1182
- type: 'mouseReleased',
1183
- x: toX,
1184
- y: toY,
1185
- button: 'left',
1186
- clickCount: 1,
1187
- });
1188
- }
1189
- }
1190
- else {
1191
- // Pointer-only branch: no native drag involvement at all.
1192
- dragMode = 'pointer';
1193
- await cdp(tabId, 'Input.dispatchMouseEvent', {
1194
- type: 'mouseMoved',
1195
- x: fromX,
1196
- y: fromY,
1197
- });
1198
- await cdp(tabId, 'Input.dispatchMouseEvent', {
1199
- type: 'mousePressed',
1200
- x: fromX,
1201
- y: fromY,
1202
- button: 'left',
1203
- clickCount: 1,
1204
- });
1205
- if (holdBeforeMoveMs > 0)
1206
- await sleep(holdBeforeMoveMs);
1207
- for (let i = 1; i <= steps; i++) {
1208
- const t = i / steps;
1209
- const { x, y } = interpolate(t);
1210
- await moveHeld(x, y);
1211
- if (stepDelayMs > 0)
1212
- await sleep(stepDelayMs);
1213
- }
1214
- if (holdBeforeReleaseMs > 0)
1215
- await sleep(holdBeforeReleaseMs);
1216
- await cdp(tabId, 'Input.dispatchMouseEvent', {
1217
- type: 'mouseReleased',
1218
- x: toX,
1219
- y: toY,
1220
- button: 'left',
1221
- clickCount: 1,
1222
- });
1223
- }
1224
- }
1225
- finally {
1226
- // Critical: leaving interception on would block the human user from
1227
- // dragging anything in this tab. Best-effort cleanup, swallow errors.
1228
- if (interceptionEnabled) {
1229
- try {
1230
- await cdp(tabId, 'Input.setInterceptDrags', { enabled: false });
1231
- }
1232
- catch {
1233
- // ignore
1234
- }
1235
- }
1267
+ if (!outcome.ok) {
1268
+ return { kind: 'tool.response', id: msg.id, ok: false, error: outcome.error };
1236
1269
  }
1237
- return {
1238
- kind: 'tool.response',
1239
- id: msg.id,
1240
- ok: true,
1241
- result: {
1242
- from: { x: fromX, y: fromY, selector: fromSelector ?? null },
1243
- to: { x: toX, y: toY, selector: toSelector ?? null },
1244
- duration_ms_actual: Date.now() - dragStart,
1245
- drag_mode: dragMode,
1246
- interception_attempted: interceptionEnabled,
1247
- intercept_received: interceptReceived,
1248
- events_fired: eventsFired,
1249
- },
1250
- };
1270
+ return { kind: 'tool.response', id: msg.id, ok: true, result: outcome.result };
1251
1271
  }
1252
1272
  async function handleDialogRespond(tabId, msg, p, optStr) {
1253
1273
  const accept = p.accept === true;
@@ -1440,6 +1460,16 @@ async function cleanup(tabId) {
1440
1460
  const state = tabStates.get(tabId);
1441
1461
  if (!state)
1442
1462
  return;
1463
+ // Delete the tracking entry SYNCHRONOUSLY, before any await below: the
1464
+ // reconnect policy's CloseContext.tracked contract promises that every
1465
+ // extension-initiated teardown deletes state BEFORE the WS close event
1466
+ // dispatches. ws.close() further down fires that event on a later turn,
1467
+ // so deleting here keeps the promise — the close handler then sees an
1468
+ // untracked socket and never auto-reconnects a teardown the extension
1469
+ // itself initiated. The awaited steps below only need the captured
1470
+ // `state` and raw CDP calls, never the map entry — and deleting first
1471
+ // also makes a re-entrant cleanup a no-op instead of a double detach.
1472
+ tabStates.delete(tabId);
1443
1473
  // Recording never survives a disconnect — there is no server connection
1444
1474
  // left to save to, and leaving captured-but-unsaved steps around after
1445
1475
  // the tab drops its bridge would be a surprising place for them to
@@ -1471,7 +1501,6 @@ async function cleanup(tabId) {
1471
1501
  // Best effort — the resource may already be detached/closed.
1472
1502
  }
1473
1503
  }
1474
- tabStates.delete(tabId);
1475
1504
  }
1476
1505
  chrome.debugger.onEvent.addListener((source, method, params) => {
1477
1506
  if (source.tabId === undefined)
@@ -1517,7 +1546,131 @@ async function forgetTabId(chromeTabId) {
1517
1546
  /* ignore */
1518
1547
  }
1519
1548
  }
1549
+ /* Auto-reconnect after an involuntary WS close.
1550
+ *
1551
+ * When a REGISTERED tab's WebSocket dies under us (unclean close — the
1552
+ * primary MCP server crashed or restarted), the scheduler below retries
1553
+ * `connectTab` with per-tab backoff (see RECONNECT_DELAYS_MS in
1554
+ * reconnect-policy.ts), reusing the preserved `prevTabId:*` id so the new
1555
+ * primary can keep the same tab_id (or emit `tab-renamed` if it can't —
1556
+ * existing contract, nothing new on the wire). Every EXTENSION-initiated
1557
+ * teardown (explicit disconnect, tab removal, idle park, debugger detach)
1558
+ * runs `cleanup` BEFORE the close event dispatches, so the close handler
1559
+ * sees an untracked socket and never schedules; a CLEAN close from the
1560
+ * server (browser.reset) is a deliberate goodbye and never schedules
1561
+ * either — the popup Connect button stays the fallback for both, as today.
1562
+ *
1563
+ * MV3 durability: each scheduled attempt is mirrored into
1564
+ * `chrome.storage.session` (`pendingReconnect:*`, right next to the
1565
+ * `prevTabId:*` keys). Plain timers cover the common case — the backoff
1566
+ * span sits inside the service worker's idle grace, and every attempt
1567
+ * touches chrome.* APIs which resets the idle clock — but if the worker is
1568
+ * killed anyway, `resumePendingReconnects` below picks the budget back up
1569
+ * on the next wake, and entries older than RECONNECT_STATE_TTL_MS are
1570
+ * discarded rather than surprising the user minutes later. */
1571
+ async function storeReconnectState(tabId, attempt) {
1572
+ try {
1573
+ await chrome.storage.session.set({
1574
+ [reconnectStorageKey(tabId)]: { attempt, updatedAt: Date.now() },
1575
+ });
1576
+ }
1577
+ catch {
1578
+ /* non-fatal — worst case a killed service worker cannot resume this retry */
1579
+ }
1580
+ }
1581
+ async function clearReconnectState(tabId) {
1582
+ try {
1583
+ await chrome.storage.session.remove(reconnectStorageKey(tabId));
1584
+ }
1585
+ catch {
1586
+ /* ignore */
1587
+ }
1588
+ }
1589
+ const reconnectScheduler = createReconnectScheduler({
1590
+ attempt: async (tabId) => {
1591
+ try {
1592
+ await chrome.tabs.get(tabId);
1593
+ }
1594
+ catch {
1595
+ // The Chrome tab itself is gone — nothing left to reconnect.
1596
+ return 'stop';
1597
+ }
1598
+ const existing = tabStates.get(tabId);
1599
+ if (existing) {
1600
+ // A live bridge already exists (e.g. the user pressed Connect while
1601
+ // a retry was pending) — done. A state whose socket is not OPEN yet
1602
+ // belongs to a connect attempt still mid-flight (cleanup deletes
1603
+ // state synchronously, so it never lingers here); keep the backoff
1604
+ // going instead of declaring victory early.
1605
+ return existing.ws?.readyState === WebSocket.OPEN ? 'connected' : 'retry';
1606
+ }
1607
+ try {
1608
+ const result = await connectTab(tabId);
1609
+ return result.ok ? 'connected' : 'retry';
1610
+ }
1611
+ catch {
1612
+ return 'retry';
1613
+ }
1614
+ },
1615
+ onStateChange: (tabId, state) => {
1616
+ if (state)
1617
+ void storeReconnectState(tabId, state.attempt);
1618
+ else
1619
+ void clearReconnectState(tabId);
1620
+ },
1621
+ });
1622
+ /** Resume reconnect cycles a killed service worker left behind. Runs once
1623
+ * per worker start (top-level call below) — `chrome.storage.session`
1624
+ * survives worker restarts but not browser restarts, which is exactly the
1625
+ * lifetime the retry budget should have. */
1626
+ async function resumePendingReconnects() {
1627
+ let stored;
1628
+ try {
1629
+ stored = await chrome.storage.session.get(null);
1630
+ }
1631
+ catch {
1632
+ return;
1633
+ }
1634
+ const now = Date.now();
1635
+ for (const [key, value] of Object.entries(stored)) {
1636
+ const tabId = tabIdFromReconnectKey(key);
1637
+ if (tabId === null)
1638
+ continue;
1639
+ const state = parseStoredReconnectState(value);
1640
+ if (state && isReconnectStateFresh(state.updatedAt, now) && !tabStates.has(tabId)) {
1641
+ reconnectScheduler.schedule(tabId, state.attempt);
1642
+ }
1643
+ else {
1644
+ void clearReconnectState(tabId);
1645
+ }
1646
+ }
1647
+ }
1648
+ void resumePendingReconnects();
1649
+ /** Tabs with a `connectTab` currently in flight. The `tabStates.has` check
1650
+ * inside `doConnectTab` alone cannot prevent two concurrent connects — the
1651
+ * state lands only after three awaited chrome.* calls — so this SYNCHRONOUS
1652
+ * check-and-set is the real per-tab reentrancy guard. */
1653
+ const connectsInFlight = new Set();
1520
1654
  async function connectTab(tabId) {
1655
+ if (connectsInFlight.has(tabId)) {
1656
+ // A concurrent connect (popup Connect racing a scheduled reconnect
1657
+ // attempt, or vice versa) — the in-flight attempt owns the tab.
1658
+ // Answer benignly instead of letting a second attempt fail its
1659
+ // debugger attach with a confusing error; the popup renders this
1660
+ // exact message as the "Reconnecting" card, not an error.
1661
+ return { ok: false, error: CONNECT_IN_PROGRESS_ERROR };
1662
+ }
1663
+ connectsInFlight.add(tabId);
1664
+ try {
1665
+ return await doConnectTab(tabId);
1666
+ }
1667
+ finally {
1668
+ connectsInFlight.delete(tabId);
1669
+ }
1670
+ }
1671
+ /** Body of `connectTab` — call only through the wrapper above, which holds
1672
+ * the per-tab in-flight lock for the full duration of the attempt. */
1673
+ async function doConnectTab(tabId) {
1521
1674
  if (tabStates.has(tabId)) {
1522
1675
  return { ok: false, error: 'This tab is already connected' };
1523
1676
  }
@@ -1591,6 +1744,13 @@ async function connectTab(tabId) {
1591
1744
  // new kind into the tool.request handler.
1592
1745
  switch (msg.kind) {
1593
1746
  case 'tab.registered': {
1747
+ // A successful registration ends any reconnect cycle for this
1748
+ // tab. Covers the resume path re-arming a tab the user just
1749
+ // connected manually (resumePendingReconnects's tabStates
1750
+ // check races connectTab); when the SCHEDULER drove this very
1751
+ // connect, its in-flight guard makes this cancel a clean,
1752
+ // notify-once end of the cycle.
1753
+ reconnectScheduler.cancel(tabId);
1594
1754
  state.serverTabId = msg.payload.tabId;
1595
1755
  // serverVersion is optional on the wire so older servers that
1596
1756
  // predate the field still parse — narrow on `typeof string`.
@@ -1652,21 +1812,47 @@ async function connectTab(tabId) {
1652
1812
  }
1653
1813
  })();
1654
1814
  });
1815
+ // A WebSocket 'error' is always followed by a 'close', so teardown
1816
+ // lives in the close handler alone — this one only settles a
1817
+ // pre-registration connect failure with the friendlier diagnosis (for
1818
+ // a post-registration drop `settle` is already spent and this no-ops).
1655
1819
  ws.addEventListener('error', () => {
1656
- void cleanup(tabId).catch(() => {
1657
- // Best effort — already-detached state is fine.
1658
- });
1659
1820
  settle({ ok: false, error: 'WebSocket connection failed. Is the MCP server running?' });
1660
1821
  });
1661
- ws.addEventListener('close', () => {
1822
+ ws.addEventListener('close', (ev) => {
1823
+ const current = tabStates.get(tabId);
1824
+ if (current !== undefined && current.ws !== ws) {
1825
+ // A newer connection already owns this tab — this close belongs to
1826
+ // a superseded socket, and tearing state down here would kill the
1827
+ // NEW bridge rather than the one that just died.
1828
+ settle({ ok: false, error: 'WebSocket closed before registration' });
1829
+ return;
1830
+ }
1831
+ // Decide BEFORE cleanup wipes the evidence: an unclean close of a
1832
+ // still-tracked, registered connection means the server died under
1833
+ // us — the one case the auto-reconnect scheduler exists for. See
1834
+ // the reconnect section doc above storeReconnectState for why every
1835
+ // other close (explicit disconnect, tab removal, idle park,
1836
+ // browser.reset's clean goodbye) falls out of this predicate.
1837
+ const involuntary = isInvoluntaryClose({
1838
+ tracked: current?.ws === ws,
1839
+ registered: current?.serverTabId !== undefined,
1840
+ wasClean: ev.wasClean,
1841
+ });
1662
1842
  void cleanup(tabId).catch(() => {
1663
1843
  // Best effort — already-detached state is fine.
1664
1844
  });
1665
1845
  settle({ ok: false, error: 'WebSocket closed before registration' });
1846
+ if (involuntary)
1847
+ reconnectScheduler.schedule(tabId);
1666
1848
  });
1667
1849
  });
1668
1850
  }
1669
1851
  async function disconnectTab(tabId) {
1852
+ // Explicit user intent beats any pending auto-reconnect — cancel it
1853
+ // first so a scheduled retry cannot resurrect the bridge right after
1854
+ // the user asked for it to go away.
1855
+ reconnectScheduler.cancel(tabId);
1670
1856
  await cleanup(tabId);
1671
1857
  // Explicit user-driven disconnect → forget the previous tab id so the
1672
1858
  // next "Connect" starts from a clean slate (vs. an involuntary
@@ -1676,8 +1862,17 @@ async function disconnectTab(tabId) {
1676
1862
  }
1677
1863
  function getTabStatus(tabId) {
1678
1864
  const state = tabStates.get(tabId);
1679
- if (!state)
1865
+ if (!state) {
1866
+ // Keep the popup truthful while auto-reconnect is working: "not
1867
+ // connected, but actively retrying" rather than a flat idle state.
1868
+ // `connectsInFlight` covers the short gap inside a live attempt
1869
+ // before its TabState lands — without it the popup's poll could
1870
+ // misread that gap as idle and freeze the card there.
1871
+ if (reconnectScheduler.isPending(tabId) || connectsInFlight.has(tabId)) {
1872
+ return { connected: false, reconnecting: true };
1873
+ }
1680
1874
  return { connected: false };
1875
+ }
1681
1876
  return { connected: true, serverTabId: state.serverTabId };
1682
1877
  }
1683
1878
  /**
@@ -2041,6 +2236,12 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
2041
2236
  if (!isRuntimeMessage(msg))
2042
2237
  return false;
2043
2238
  if (msg.action === 'connect') {
2239
+ // An explicit Connect cancels any FUTURE auto-reconnect retries for
2240
+ // this tab. If a scheduled attempt is already mid-flight (cancel
2241
+ // cannot abort it), connectTab's per-tab in-flight lock turns this
2242
+ // call into a benign "already in progress" result instead of a
2243
+ // second concurrent connect.
2244
+ reconnectScheduler.cancel(msg.tabId);
2044
2245
  void connectTab(msg.tabId).then(sendResponse);
2045
2246
  return true;
2046
2247
  }
@@ -2086,6 +2287,8 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
2086
2287
  return false;
2087
2288
  });
2088
2289
  chrome.tabs.onRemoved.addListener((tabId) => {
2290
+ // No tab, no reconnect — abort any pending retry cycle for it.
2291
+ reconnectScheduler.cancel(tabId);
2089
2292
  void cleanup(tabId).catch(() => {
2090
2293
  // Best effort.
2091
2294
  });