iframer-toolkit 0.10.5 → 0.10.8

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.
@@ -35,6 +35,23 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
35
35
  mod
36
36
  ));
37
37
 
38
+ // src/lib/paths.ts
39
+ var paths_exports = {};
40
+ __export(paths_exports, {
41
+ getDataDir: () => getDataDir
42
+ });
43
+ function getDataDir() {
44
+ return process.env.IFRAMER_DATA_DIR || import_path.default.join(import_os.default.homedir(), ".iframer");
45
+ }
46
+ var import_path, import_os;
47
+ var init_paths = __esm({
48
+ "src/lib/paths.ts"() {
49
+ "use strict";
50
+ import_path = __toESM(require("path"));
51
+ import_os = __toESM(require("os"));
52
+ }
53
+ });
54
+
38
55
  // src/lib/session/persistence.ts
39
56
  var persistence_exports = {};
40
57
  __export(persistence_exports, {
@@ -96,23 +113,6 @@ var init_persistence = __esm({
96
113
  }
97
114
  });
98
115
 
99
- // src/lib/paths.ts
100
- var paths_exports = {};
101
- __export(paths_exports, {
102
- getDataDir: () => getDataDir
103
- });
104
- function getDataDir() {
105
- return process.env.IFRAMER_DATA_DIR || import_path.default.join(import_os.default.homedir(), ".iframer");
106
- }
107
- var import_path, import_os;
108
- var init_paths = __esm({
109
- "src/lib/paths.ts"() {
110
- "use strict";
111
- import_path = __toESM(require("path"));
112
- import_os = __toESM(require("os"));
113
- }
114
- });
115
-
116
116
  // index.ts
117
117
  var import_express = __toESM(require("express"));
118
118
  var import_path12 = __toESM(require("path"));
@@ -127,11 +127,11 @@ var import_path11 = __toESM(require("path"));
127
127
  var import_url = require("url");
128
128
 
129
129
  // src/lib/browser/session-manager.ts
130
- var import_child_process = require("child_process");
131
- var import_fs2 = __toESM(require("fs"));
130
+ var import_child_process2 = require("child_process");
131
+ var import_fs3 = __toESM(require("fs"));
132
132
 
133
133
  // src/lib/browser/launcher.ts
134
- var import_fs = __toESM(require("fs"));
134
+ var import_fs2 = __toESM(require("fs"));
135
135
  var import_patchright = require("patchright");
136
136
 
137
137
  // src/lib/browser/stealth.ts
@@ -542,22 +542,213 @@ function createLogger(tag) {
542
542
  };
543
543
  }
544
544
 
545
+ // src/lib/browser/registry.ts
546
+ var import_fs = __toESM(require("fs"));
547
+ var import_path2 = __toESM(require("path"));
548
+ var import_child_process = require("child_process");
549
+ init_paths();
550
+ var log = createLogger("registry");
551
+ function browsersDir() {
552
+ const dir = import_path2.default.join(getDataDir(), "browsers");
553
+ import_fs.default.mkdirSync(dir, { recursive: true });
554
+ return dir;
555
+ }
556
+ function serverInfoPath() {
557
+ return import_path2.default.join(getDataDir(), "server.json");
558
+ }
559
+ function isPidAlive(pid) {
560
+ if (!Number.isInteger(pid) || pid <= 1) return false;
561
+ try {
562
+ process.kill(pid, 0);
563
+ return true;
564
+ } catch {
565
+ return false;
566
+ }
567
+ }
568
+ function pidMatchesMarker(pid, marker) {
569
+ if (!isPidAlive(pid)) return false;
570
+ try {
571
+ const cmd = (0, import_child_process.execSync)(`ps -o command= -p ${pid}`, { encoding: "utf8" });
572
+ return cmd.includes(marker);
573
+ } catch {
574
+ return false;
575
+ }
576
+ }
577
+ function findChromePidByMarker(marker) {
578
+ try {
579
+ const out = (0, import_child_process.execSync)(`pgrep -f -- "${marker}"`, { encoding: "utf8" }).trim();
580
+ const pids = out.split("\n").map((s) => parseInt(s, 10)).filter((n) => Number.isInteger(n) && n !== process.pid);
581
+ if (pids.length === 0) return null;
582
+ return Math.min(...pids);
583
+ } catch {
584
+ return null;
585
+ }
586
+ }
587
+ function registerBrowser(rec) {
588
+ try {
589
+ import_fs.default.writeFileSync(import_path2.default.join(browsersDir(), `${rec.chromePid}.json`), JSON.stringify(rec, null, 2));
590
+ } catch (err) {
591
+ log.warn(`failed to write browser record for pid ${rec.chromePid}: ${err}`);
592
+ }
593
+ }
594
+ function unregisterBrowser(chromePid) {
595
+ try {
596
+ import_fs.default.unlinkSync(import_path2.default.join(browsersDir(), `${chromePid}.json`));
597
+ } catch {
598
+ }
599
+ }
600
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
601
+ async function forceKillBrowser(rec) {
602
+ if (!isPidAlive(rec.chromePid)) return true;
603
+ if (!pidMatchesMarker(rec.chromePid, rec.marker)) {
604
+ return true;
605
+ }
606
+ try {
607
+ process.kill(rec.chromePid, "SIGKILL");
608
+ } catch {
609
+ }
610
+ const deadline = Date.now() + 2e3;
611
+ while (Date.now() < deadline) {
612
+ if (!isPidAlive(rec.chromePid)) return true;
613
+ await sleep(100);
614
+ }
615
+ return !isPidAlive(rec.chromePid);
616
+ }
617
+ async function reapOrphanBrowsers() {
618
+ let reaped = 0;
619
+ let skipped = 0;
620
+ let files = [];
621
+ try {
622
+ files = import_fs.default.readdirSync(browsersDir()).filter((f) => f.endsWith(".json"));
623
+ } catch {
624
+ return { reaped, skipped };
625
+ }
626
+ for (const file of files) {
627
+ const full = import_path2.default.join(browsersDir(), file);
628
+ let rec;
629
+ try {
630
+ rec = JSON.parse(import_fs.default.readFileSync(full, "utf8"));
631
+ } catch {
632
+ try {
633
+ import_fs.default.unlinkSync(full);
634
+ } catch {
635
+ }
636
+ continue;
637
+ }
638
+ if (!isPidAlive(rec.chromePid) || !pidMatchesMarker(rec.chromePid, rec.marker)) {
639
+ try {
640
+ import_fs.default.unlinkSync(full);
641
+ } catch {
642
+ }
643
+ continue;
644
+ }
645
+ if (isPidAlive(rec.ownerPid)) {
646
+ skipped++;
647
+ continue;
648
+ }
649
+ log.info(`reaping orphan Chrome pid=${rec.chromePid} (${rec.key}), owner ${rec.ownerPid} is dead`);
650
+ if (await forceKillBrowser(rec)) {
651
+ try {
652
+ import_fs.default.unlinkSync(full);
653
+ } catch {
654
+ }
655
+ reaped++;
656
+ } else {
657
+ log.warn(`failed to kill orphan Chrome pid=${rec.chromePid} \u2014 leaving record for next sweep`);
658
+ }
659
+ }
660
+ return { reaped, skipped };
661
+ }
662
+ function writeServerInfo(info) {
663
+ import_fs.default.writeFileSync(serverInfoPath(), JSON.stringify(info, null, 2));
664
+ }
665
+ function readServerInfo() {
666
+ try {
667
+ const info = JSON.parse(import_fs.default.readFileSync(serverInfoPath(), "utf8"));
668
+ if (!Number.isInteger(info.pid) || !Number.isInteger(info.port)) return null;
669
+ return info;
670
+ } catch {
671
+ return null;
672
+ }
673
+ }
674
+ function clearServerInfo(pid) {
675
+ const info = readServerInfo();
676
+ if (info && info.pid === pid) {
677
+ try {
678
+ import_fs.default.unlinkSync(serverInfoPath());
679
+ } catch {
680
+ }
681
+ }
682
+ }
683
+
545
684
  // src/lib/browser/launcher.ts
546
- var log = createLogger("launcher");
685
+ var log2 = createLogger("launcher");
547
686
  var UBLOCK_PATH = "/extensions/uBlock0.chromium";
548
687
  function findChromeExecutable() {
549
688
  if (process.env.CHROME_EXECUTABLE) return process.env.CHROME_EXECUTABLE;
550
- if (import_fs.default.existsSync("/usr/bin/google-chrome-stable")) return "/usr/bin/google-chrome-stable";
689
+ if (import_fs2.default.existsSync("/usr/bin/google-chrome-stable")) return "/usr/bin/google-chrome-stable";
551
690
  return void 0;
552
691
  }
553
692
  var cachedBrowser = null;
693
+ var headedBrowser = null;
694
+ var headedPid = null;
695
+ var headedIdleTimer = null;
696
+ var HEADED_IDLE_MS = 5 * 60 * 1e3;
697
+ function armHeadedIdleTimer() {
698
+ if (headedIdleTimer) clearTimeout(headedIdleTimer);
699
+ headedIdleTimer = setTimeout(() => {
700
+ if (!headedBrowser) return;
701
+ const pages = headedBrowser.contexts().reduce((n, c) => n + c.pages().length, 0);
702
+ if (pages > 0) {
703
+ armHeadedIdleTimer();
704
+ return;
705
+ }
706
+ log2.info("idle timeout for the pivot headed browser, closing");
707
+ void closeHeadedBrowser();
708
+ }, HEADED_IDLE_MS);
709
+ }
710
+ async function getHeadedBrowser() {
711
+ if (!headedBrowser?.isConnected()) {
712
+ const marker = `--iframer-key=pivot-headed-${process.pid}-${Date.now()}`;
713
+ headedBrowser = await import_patchright.chromium.launch({ headless: false, args: [...STEALTH_ARGS, marker] });
714
+ headedPid = findChromePidByMarker(marker);
715
+ if (headedPid) {
716
+ registerBrowser({
717
+ key: "pivot::headed",
718
+ chromePid: headedPid,
719
+ ownerPid: process.pid,
720
+ marker,
721
+ launchedAt: (/* @__PURE__ */ new Date()).toISOString()
722
+ });
723
+ } else {
724
+ log2.warn("could not resolve Chrome PID for the pivot headed browser \u2014 force-kill unavailable");
725
+ }
726
+ }
727
+ armHeadedIdleTimer();
728
+ return headedBrowser;
729
+ }
730
+ async function closeHeadedBrowser() {
731
+ if (headedIdleTimer) {
732
+ clearTimeout(headedIdleTimer);
733
+ headedIdleTimer = null;
734
+ }
735
+ if (headedBrowser) {
736
+ await headedBrowser.close().catch(() => {
737
+ });
738
+ headedBrowser = null;
739
+ }
740
+ if (headedPid !== null) {
741
+ unregisterBrowser(headedPid);
742
+ headedPid = null;
743
+ }
744
+ }
554
745
  async function getBrowser(_name = "chromium") {
555
746
  if (cachedBrowser) {
556
747
  if (cachedBrowser.isConnected()) return cachedBrowser;
557
748
  try {
558
749
  await cachedBrowser.close();
559
750
  } catch (e) {
560
- log.warn(`stale browser close failed: ${e}`);
751
+ log2.warn(`stale browser close failed: ${e}`);
561
752
  }
562
753
  cachedBrowser = null;
563
754
  }
@@ -568,11 +759,12 @@ async function getBrowser(_name = "chromium") {
568
759
  return cachedBrowser;
569
760
  }
570
761
  async function closeBrowser() {
762
+ await closeHeadedBrowser();
571
763
  if (!cachedBrowser) return;
572
764
  try {
573
765
  await cachedBrowser.close();
574
766
  } catch (e) {
575
- log.warn(`closeBrowser failed: ${e}`);
767
+ log2.warn(`closeBrowser failed: ${e}`);
576
768
  }
577
769
  cachedBrowser = null;
578
770
  }
@@ -581,7 +773,7 @@ async function getBrowserWithFallback(_preferred) {
581
773
  }
582
774
  async function launchHeadful(displayNum) {
583
775
  const executablePath = findChromeExecutable();
584
- const hasExtensions = import_fs.default.existsSync(UBLOCK_PATH);
776
+ const hasExtensions = import_fs2.default.existsSync(UBLOCK_PATH);
585
777
  const args = [
586
778
  "--no-sandbox",
587
779
  "--disable-setuid-sandbox",
@@ -601,7 +793,7 @@ async function launchHeadful(displayNum) {
601
793
  env: { ...process.env, DISPLAY: `:${displayNum}` }
602
794
  };
603
795
  if (executablePath) launchOpts.executablePath = executablePath;
604
- log.debug(`headful: ${executablePath || "patchright chromium"}, extensions: ${hasExtensions}`);
796
+ log2.debug(`headful: ${executablePath || "patchright chromium"}, extensions: ${hasExtensions}`);
605
797
  return import_patchright.chromium.launch(launchOpts);
606
798
  }
607
799
 
@@ -658,7 +850,13 @@ var TIMING = {
658
850
  /** Scroll step delay */
659
851
  SCROLL_DELAY: 150,
660
852
  /** Stale state check interval */
661
- STALE_CHECK_INTERVAL: 2e3
853
+ STALE_CHECK_INTERVAL: 2e3,
854
+ /** captureApi: grace period after the last step for a submit's XHR to fire */
855
+ CAPTURE_SETTLE: 300,
856
+ /** captureApi: max wait for in-flight requests to answer before stopping */
857
+ CAPTURE_PENDING_MAX: 3e3,
858
+ /** Post-failure probe ("does the selector exist right now?") budget */
859
+ FAILURE_PROBE: 3e3
662
860
  };
663
861
  var CAPTCHA_GRID = {
664
862
  /** Pixels from bframe top to grid start (reCAPTCHA) */
@@ -696,6 +894,10 @@ var TIMEOUTS = {
696
894
  DEFAULT_STALE: 2e4,
697
895
  NAVIGATION: 6e4,
698
896
  SELECTOR_WAIT: 1e4,
897
+ // Actionability wait for click. Under the 20s stale timer on purpose, so
898
+ // Playwright's own reason (not visible, covered by another element…) reaches
899
+ // the agent instead of a generic "no state change".
900
+ CLICK: 8e3,
699
901
  TOTP_INPUT: 5e3,
700
902
  API_REQUEST: 18e4,
701
903
  HEALTH_CHECK: 3e3,
@@ -742,7 +944,7 @@ function generateWindowsFingerprint() {
742
944
  }
743
945
 
744
946
  // src/lib/browser/session-manager.ts
745
- var log2 = createLogger("session");
947
+ var log3 = createLogger("session");
746
948
  var BASE_DISPLAY = parseInt(process.env.VNC_BASE_DISPLAY || "99", 10);
747
949
  var MAX_SESSIONS = parseInt(process.env.VNC_MAX_SESSIONS || "20", 10);
748
950
  var SESSION_TIMEOUT = parseInt(process.env.VNC_SESSION_TIMEOUT_MS || "300000", 10);
@@ -766,7 +968,7 @@ function waitForSocket(displayNum, timeoutMs = 5e3) {
766
968
  return new Promise((resolve, reject) => {
767
969
  const start = Date.now();
768
970
  const check = () => {
769
- if (import_fs2.default.existsSync(socketPath)) return resolve();
971
+ if (import_fs3.default.existsSync(socketPath)) return resolve();
770
972
  if (Date.now() - start > timeoutMs) return reject(new Error(`Xvfb socket not ready after ${timeoutMs}ms`));
771
973
  setTimeout(check, 100);
772
974
  };
@@ -788,17 +990,17 @@ async function startSession(userId) {
788
990
  const displayNum = allocateDisplay();
789
991
  const vncPort = 5900 + displayNum;
790
992
  const wsPort = 6080 + (displayNum - BASE_DISPLAY);
791
- const xvfb = (0, import_child_process.spawn)("Xvfb", [`:${displayNum}`, "-screen", "0", "1920x1080x24", "-ac"], {
993
+ const xvfb = (0, import_child_process2.spawn)("Xvfb", [`:${displayNum}`, "-screen", "0", "1920x1080x24", "-ac"], {
792
994
  stdio: "ignore"
793
995
  });
794
996
  await waitForSocket(displayNum);
795
- const x11vnc = (0, import_child_process.spawn)(
997
+ const x11vnc = (0, import_child_process2.spawn)(
796
998
  "x11vnc",
797
999
  ["-display", `:${displayNum}`, "-nopw", "-listen", "localhost", "-rfbport", String(vncPort), "-shared", "-forever"],
798
1000
  { stdio: "ignore" }
799
1001
  );
800
- const noVncPath = import_fs2.default.existsSync("/usr/share/novnc") ? "/usr/share/novnc" : "/usr/share/noVNC";
801
- const websockify = (0, import_child_process.spawn)("websockify", ["--web", noVncPath, String(wsPort), `localhost:${vncPort}`], {
1002
+ const noVncPath = import_fs3.default.existsSync("/usr/share/novnc") ? "/usr/share/novnc" : "/usr/share/noVNC";
1003
+ const websockify = (0, import_child_process2.spawn)("websockify", ["--web", noVncPath, String(wsPort), `localhost:${vncPort}`], {
802
1004
  stdio: "ignore"
803
1005
  });
804
1006
  await new Promise((r) => setTimeout(r, 500));
@@ -809,7 +1011,7 @@ async function startSession(userId) {
809
1011
  const stealthScript = buildStealthScript(fingerprint);
810
1012
  contextStealthScripts.set(context, stealthScript);
811
1013
  const page = await context.newPage();
812
- log2.debug(`fingerprint: ${fingerprint.userAgent.slice(0, 60)}... DPR=${fingerprint.deviceScaleFactor} screen=${fingerprint.screenWidth}x${fingerprint.screenHeight}`);
1014
+ log3.debug(`fingerprint: ${fingerprint.userAgent.slice(0, 60)}... DPR=${fingerprint.deviceScaleFactor} screen=${fingerprint.screenWidth}x${fingerprint.screenHeight}`);
813
1015
  const session = {
814
1016
  displayNum,
815
1017
  vncPort,
@@ -861,7 +1063,7 @@ async function stopSession(userId) {
861
1063
  killProcess(session.xvfb);
862
1064
  await new Promise((r) => setTimeout(r, 1e3));
863
1065
  try {
864
- import_fs2.default.unlinkSync(`/tmp/.X11-unix/X${session.displayNum}`);
1066
+ import_fs3.default.unlinkSync(`/tmp/.X11-unix/X${session.displayNum}`);
865
1067
  } catch {
866
1068
  }
867
1069
  freeDisplay(session.displayNum);
@@ -875,9 +1077,9 @@ async function cleanupAllSessions() {
875
1077
 
876
1078
  // src/lib/auth/crypto.ts
877
1079
  var import_crypto = __toESM(require("crypto"));
878
- var import_fs3 = __toESM(require("fs"));
1080
+ var import_fs4 = __toESM(require("fs"));
879
1081
  var import_os2 = __toESM(require("os"));
880
- var import_path2 = __toESM(require("path"));
1082
+ var import_path3 = __toESM(require("path"));
881
1083
  init_paths();
882
1084
  var SALT = "iframer-session";
883
1085
  var INFO = "encryption";
@@ -887,21 +1089,21 @@ var TAG_LENGTH = 16;
887
1089
  function getLocalToken() {
888
1090
  if (process.env.IFRAMER_SECRET) return process.env.IFRAMER_SECRET;
889
1091
  const candidates = [
890
- import_path2.default.join(getDataDir(), "secret"),
891
- import_path2.default.join(process.env.XDG_RUNTIME_DIR || import_os2.default.tmpdir(), "iframer-secret")
1092
+ import_path3.default.join(getDataDir(), "secret"),
1093
+ import_path3.default.join(process.env.XDG_RUNTIME_DIR || import_os2.default.tmpdir(), "iframer-secret")
892
1094
  ];
893
1095
  for (const file of candidates) {
894
1096
  try {
895
- const existing = import_fs3.default.readFileSync(file, "utf8").trim();
1097
+ const existing = import_fs4.default.readFileSync(file, "utf8").trim();
896
1098
  if (existing) return existing;
897
1099
  } catch {
898
1100
  }
899
1101
  }
900
1102
  for (const file of candidates) {
901
1103
  try {
902
- import_fs3.default.mkdirSync(import_path2.default.dirname(file), { recursive: true });
1104
+ import_fs4.default.mkdirSync(import_path3.default.dirname(file), { recursive: true });
903
1105
  const secret = import_crypto.default.randomBytes(32).toString("hex");
904
- import_fs3.default.writeFileSync(file, secret, { mode: 384 });
1106
+ import_fs4.default.writeFileSync(file, secret, { mode: 384 });
905
1107
  return secret;
906
1108
  } catch {
907
1109
  }
@@ -961,17 +1163,17 @@ function generateTOTP(secret, period = 30, digits = 6) {
961
1163
  init_persistence();
962
1164
 
963
1165
  // src/lib/screenshot.ts
964
- var import_fs4 = __toESM(require("fs"));
965
- var import_path3 = __toESM(require("path"));
966
- var log3 = createLogger("screenshot");
1166
+ var import_fs5 = __toESM(require("fs"));
1167
+ var import_path4 = __toESM(require("path"));
1168
+ var log4 = createLogger("screenshot");
967
1169
  var MAX_AGE_MS = parseInt(process.env.IFRAMER_SCREENSHOT_MAX_AGE_MS || String(24 * 60 * 60 * 1e3), 10);
968
1170
  var MAX_FILES = parseInt(process.env.IFRAMER_SCREENSHOT_MAX_FILES || "500", 10);
969
1171
  var PRUNE_THROTTLE_MS = 5 * 60 * 1e3;
970
1172
  var lastPruneAt = 0;
971
1173
  function saveScreenshot(buffer, filename, screenshotDir, publicUrl) {
972
- import_fs4.default.mkdirSync(screenshotDir, { recursive: true });
973
- const filePath = import_path3.default.join(screenshotDir, filename);
974
- import_fs4.default.writeFileSync(filePath, buffer);
1174
+ import_fs5.default.mkdirSync(screenshotDir, { recursive: true });
1175
+ const filePath = import_path4.default.join(screenshotDir, filename);
1176
+ import_fs5.default.writeFileSync(filePath, buffer);
975
1177
  maybePrune(screenshotDir);
976
1178
  return `${publicUrl}/screenshots/${filename}`;
977
1179
  }
@@ -986,10 +1188,10 @@ function pruneScreenshots(dir, opts = {}) {
986
1188
  const maxFiles = opts.maxFiles ?? MAX_FILES;
987
1189
  const now = opts.now ?? Date.now();
988
1190
  try {
989
- const entries = import_fs4.default.readdirSync(dir).filter((f) => f.endsWith(".jpg") || f.endsWith(".jpeg") || f.endsWith(".png")).map((f) => {
990
- const full = import_path3.default.join(dir, f);
1191
+ const entries = import_fs5.default.readdirSync(dir).filter((f) => f.endsWith(".jpg") || f.endsWith(".jpeg") || f.endsWith(".png")).map((f) => {
1192
+ const full = import_path4.default.join(dir, f);
991
1193
  try {
992
- return { full, mtimeMs: import_fs4.default.statSync(full).mtimeMs };
1194
+ return { full, mtimeMs: import_fs5.default.statSync(full).mtimeMs };
993
1195
  } catch {
994
1196
  return null;
995
1197
  }
@@ -999,7 +1201,7 @@ function pruneScreenshots(dir, opts = {}) {
999
1201
  for (const e of entries) {
1000
1202
  if (now - e.mtimeMs > maxAgeMs) {
1001
1203
  try {
1002
- import_fs4.default.unlinkSync(e.full);
1204
+ import_fs5.default.unlinkSync(e.full);
1003
1205
  removed++;
1004
1206
  } catch {
1005
1207
  }
@@ -1011,23 +1213,23 @@ function pruneScreenshots(dir, opts = {}) {
1011
1213
  survivors.sort((a, b) => a.mtimeMs - b.mtimeMs);
1012
1214
  for (const e of survivors.slice(0, survivors.length - maxFiles)) {
1013
1215
  try {
1014
- import_fs4.default.unlinkSync(e.full);
1216
+ import_fs5.default.unlinkSync(e.full);
1015
1217
  removed++;
1016
1218
  } catch {
1017
1219
  }
1018
1220
  }
1019
1221
  }
1020
- if (removed > 0) log3.debug(`pruned ${removed} old screenshot(s) from ${dir}`);
1222
+ if (removed > 0) log4.debug(`pruned ${removed} old screenshot(s) from ${dir}`);
1021
1223
  return removed;
1022
1224
  } catch (err) {
1023
- log3.warn(`screenshot prune failed: ${err instanceof Error ? err.message : String(err)}`);
1225
+ log4.warn(`screenshot prune failed: ${err instanceof Error ? err.message : String(err)}`);
1024
1226
  return 0;
1025
1227
  }
1026
1228
  }
1027
1229
 
1028
1230
  // src/lib/session/sqlite-store.ts
1029
- var import_path4 = __toESM(require("path"));
1030
- var import_fs5 = __toESM(require("fs"));
1231
+ var import_path5 = __toESM(require("path"));
1232
+ var import_fs6 = __toESM(require("fs"));
1031
1233
  var IS_BUN = typeof globalThis.Bun !== "undefined";
1032
1234
  function createBunDb(dbPath) {
1033
1235
  const { Database } = require("bun:sqlite");
@@ -1066,8 +1268,8 @@ function createNodeDb(dbPath) {
1066
1268
  var SqliteStore = class {
1067
1269
  db;
1068
1270
  constructor(dataDir) {
1069
- import_fs5.default.mkdirSync(dataDir, { recursive: true });
1070
- const dbPath = import_path4.default.join(dataDir, "iframer.db");
1271
+ import_fs6.default.mkdirSync(dataDir, { recursive: true });
1272
+ const dbPath = import_path5.default.join(dataDir, "iframer.db");
1071
1273
  this.db = IS_BUN ? createBunDb(dbPath) : createNodeDb(dbPath);
1072
1274
  this.db.run(`
1073
1275
  CREATE TABLE IF NOT EXISTS sessions (
@@ -1147,13 +1349,13 @@ var import_patchright2 = require("patchright");
1147
1349
  var import_crypto2 = require("crypto");
1148
1350
 
1149
1351
  // src/lib/browser/chrome-downloader.ts
1150
- var import_fs6 = __toESM(require("fs"));
1151
- var import_path5 = __toESM(require("path"));
1352
+ var import_fs7 = __toESM(require("fs"));
1353
+ var import_path6 = __toESM(require("path"));
1152
1354
  var import_os3 = __toESM(require("os"));
1153
- var import_child_process2 = require("child_process");
1154
- var log4 = createLogger("chrome");
1355
+ var import_child_process3 = require("child_process");
1356
+ var log5 = createLogger("chrome");
1155
1357
  var CHROME_VERSIONS_URL = "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json";
1156
- var DEFAULT_INSTALL_DIR = import_path5.default.join(import_os3.default.homedir(), ".iframer", "chrome");
1358
+ var DEFAULT_INSTALL_DIR = import_path6.default.join(import_os3.default.homedir(), ".iframer", "chrome");
1157
1359
  function getPlatform() {
1158
1360
  const arch = process.arch;
1159
1361
  const platform = process.platform;
@@ -1165,24 +1367,24 @@ function getPlatform() {
1165
1367
  function getChromeExecutablePath(installDir) {
1166
1368
  const platform = process.platform;
1167
1369
  if (platform === "darwin") {
1168
- const entries = import_fs6.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
1370
+ const entries = import_fs7.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
1169
1371
  const dir = entries[0] || "chrome-mac-arm64";
1170
- return import_path5.default.join(installDir, dir, "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing");
1372
+ return import_path6.default.join(installDir, dir, "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing");
1171
1373
  }
1172
1374
  if (platform === "linux") {
1173
- const entries = import_fs6.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
1375
+ const entries = import_fs7.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
1174
1376
  const dir = entries[0] || "chrome-linux64";
1175
- return import_path5.default.join(installDir, dir, "chrome");
1377
+ return import_path6.default.join(installDir, dir, "chrome");
1176
1378
  }
1177
1379
  if (platform === "win32") {
1178
- const entries = import_fs6.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
1380
+ const entries = import_fs7.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
1179
1381
  const dir = entries[0] || "chrome-win64";
1180
- return import_path5.default.join(installDir, dir, "chrome.exe");
1382
+ return import_path6.default.join(installDir, dir, "chrome.exe");
1181
1383
  }
1182
1384
  throw new Error(`Unsupported platform: ${platform}`);
1183
1385
  }
1184
1386
  async function downloadChrome(installDir = DEFAULT_INSTALL_DIR) {
1185
- log4.info("Downloading Chrome for Testing (first time only)...");
1387
+ log5.info("Downloading Chrome for Testing (first time only)...");
1186
1388
  const res = await fetch(CHROME_VERSIONS_URL);
1187
1389
  if (!res.ok) throw new Error(`Failed to fetch Chrome versions: ${res.status}`);
1188
1390
  const data = await res.json();
@@ -1193,35 +1395,35 @@ async function downloadChrome(installDir = DEFAULT_INSTALL_DIR) {
1193
1395
  if (!download2) throw new Error(`No Chrome for Testing download for platform: ${platform}`);
1194
1396
  const url = download2.url;
1195
1397
  const version = channel.version;
1196
- log4.debug(`Version ${version} for ${platform}`);
1197
- log4.debug(`URL: ${url}`);
1198
- import_fs6.default.mkdirSync(installDir, { recursive: true });
1199
- const zipPath = import_path5.default.join(installDir, "chrome.zip");
1398
+ log5.debug(`Version ${version} for ${platform}`);
1399
+ log5.debug(`URL: ${url}`);
1400
+ import_fs7.default.mkdirSync(installDir, { recursive: true });
1401
+ const zipPath = import_path6.default.join(installDir, "chrome.zip");
1200
1402
  const dlRes = await fetch(url);
1201
1403
  if (!dlRes.ok) throw new Error(`Download failed: ${dlRes.status}`);
1202
1404
  const buf = Buffer.from(await dlRes.arrayBuffer());
1203
- import_fs6.default.writeFileSync(zipPath, buf);
1204
- log4.info(`Downloaded ${(buf.length / 1024 / 1024).toFixed(1)}MB`);
1205
- (0, import_child_process2.execSync)(`unzip -o -q "${zipPath}" -d "${installDir}"`, { stdio: "inherit" });
1206
- import_fs6.default.unlinkSync(zipPath);
1405
+ import_fs7.default.writeFileSync(zipPath, buf);
1406
+ log5.info(`Downloaded ${(buf.length / 1024 / 1024).toFixed(1)}MB`);
1407
+ (0, import_child_process3.execSync)(`unzip -o -q "${zipPath}" -d "${installDir}"`, { stdio: "inherit" });
1408
+ import_fs7.default.unlinkSync(zipPath);
1207
1409
  const execPath = getChromeExecutablePath(installDir);
1208
- if (!import_fs6.default.existsSync(execPath)) {
1410
+ if (!import_fs7.default.existsSync(execPath)) {
1209
1411
  throw new Error(`Chrome executable not found after extraction: ${execPath}`);
1210
1412
  }
1211
1413
  if (process.platform !== "win32") {
1212
- import_fs6.default.chmodSync(execPath, 493);
1414
+ import_fs7.default.chmodSync(execPath, 493);
1213
1415
  }
1214
- import_fs6.default.writeFileSync(import_path5.default.join(installDir, "version.json"), JSON.stringify({ version, platform, downloadedAt: (/* @__PURE__ */ new Date()).toISOString() }));
1215
- log4.info(`Installed at: ${execPath}`);
1416
+ import_fs7.default.writeFileSync(import_path6.default.join(installDir, "version.json"), JSON.stringify({ version, platform, downloadedAt: (/* @__PURE__ */ new Date()).toISOString() }));
1417
+ log5.info(`Installed at: ${execPath}`);
1216
1418
  return execPath;
1217
1419
  }
1218
1420
  function findChromeForTesting() {
1219
1421
  if (process.env.CHROME_EXECUTABLE) {
1220
- if (import_fs6.default.existsSync(process.env.CHROME_EXECUTABLE)) return process.env.CHROME_EXECUTABLE;
1422
+ if (import_fs7.default.existsSync(process.env.CHROME_EXECUTABLE)) return process.env.CHROME_EXECUTABLE;
1221
1423
  }
1222
1424
  try {
1223
1425
  const execPath = getChromeExecutablePath(DEFAULT_INSTALL_DIR);
1224
- if (import_fs6.default.existsSync(execPath)) return execPath;
1426
+ if (import_fs7.default.existsSync(execPath)) return execPath;
1225
1427
  } catch {
1226
1428
  }
1227
1429
  return null;
@@ -1241,8 +1443,8 @@ function findChrome() {
1241
1443
  // Playwright/Patchright installed browsers (Docker containers)
1242
1444
  ...(() => {
1243
1445
  try {
1244
- const dirs = import_fs6.default.readdirSync("/ms-playwright").filter((d) => d.startsWith("chromium-")).sort().reverse();
1245
- return dirs.map((d) => import_path5.default.join("/ms-playwright", d, "chrome-linux", "chrome"));
1446
+ const dirs = import_fs7.default.readdirSync("/ms-playwright").filter((d) => d.startsWith("chromium-")).sort().reverse();
1447
+ return dirs.map((d) => import_path6.default.join("/ms-playwright", d, "chrome-linux", "chrome"));
1246
1448
  } catch {
1247
1449
  return [];
1248
1450
  }
@@ -1252,7 +1454,7 @@ function findChrome() {
1252
1454
  "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
1253
1455
  ];
1254
1456
  for (const p of systemPaths) {
1255
- if (import_fs6.default.existsSync(p)) return p;
1457
+ if (import_fs7.default.existsSync(p)) return p;
1256
1458
  }
1257
1459
  return null;
1258
1460
  }
@@ -1262,10 +1464,10 @@ async function ensureChrome() {
1262
1464
  try {
1263
1465
  return await downloadChrome();
1264
1466
  } catch (err) {
1265
- log4.error(`Failed to download Chrome for Testing: ${err instanceof Error ? err.message : String(err)}`);
1467
+ log5.error(`Failed to download Chrome for Testing: ${err instanceof Error ? err.message : String(err)}`);
1266
1468
  const system = findChrome();
1267
1469
  if (system) {
1268
- log4.warn(`Falling back to system Chrome: ${system}`);
1470
+ log5.warn(`Falling back to system Chrome: ${system}`);
1269
1471
  return system;
1270
1472
  }
1271
1473
  throw new Error("No Chrome found. Download failed and no system Chrome available.");
@@ -1273,7 +1475,7 @@ async function ensureChrome() {
1273
1475
  }
1274
1476
 
1275
1477
  // src/lib/browser/cloak-browser.ts
1276
- var log5 = createLogger("cloak");
1478
+ var log6 = createLogger("cloak");
1277
1479
  var _available = null;
1278
1480
  function cloakEnabled() {
1279
1481
  return process.env.IFRAMER_USE_CLOAKBROWSER === "1" || process.env.IFRAMER_USE_CLOAKBROWSER === "true";
@@ -1292,14 +1494,14 @@ async function ensureBinary() {
1292
1494
  try {
1293
1495
  const info = cloak.binaryInfo();
1294
1496
  if (!info.installed) {
1295
- log5.info("Downloading CloakBrowser binary...");
1497
+ log6.info("Downloading CloakBrowser binary...");
1296
1498
  await cloak.ensureBinary();
1297
- log5.info("CloakBrowser ready");
1499
+ log6.info("CloakBrowser ready");
1298
1500
  }
1299
1501
  _available = true;
1300
1502
  return true;
1301
1503
  } catch (err) {
1302
- log5.warn(`CloakBrowser setup failed: ${err instanceof Error ? err.message : String(err)}`);
1504
+ log6.warn(`CloakBrowser setup failed: ${err instanceof Error ? err.message : String(err)}`);
1303
1505
  _available = false;
1304
1506
  return false;
1305
1507
  }
@@ -1314,157 +1516,20 @@ async function launchCloakBrowser(options) {
1314
1516
  headless: options.headless,
1315
1517
  args: options.args
1316
1518
  });
1317
- log5.info(`CloakBrowser launched (headless=${options.headless})`);
1519
+ log6.info(`CloakBrowser launched (headless=${options.headless})`);
1318
1520
  return browser;
1319
1521
  } catch (err) {
1320
- log5.warn(`CloakBrowser launch failed: ${err instanceof Error ? err.message : String(err)}`);
1522
+ log6.warn(`CloakBrowser launch failed: ${err instanceof Error ? err.message : String(err)}`);
1321
1523
  return null;
1322
1524
  }
1323
1525
  }
1324
1526
 
1325
- // src/lib/browser/registry.ts
1326
- var import_fs7 = __toESM(require("fs"));
1327
- var import_path6 = __toESM(require("path"));
1328
- var import_child_process3 = require("child_process");
1329
- init_paths();
1330
- var log6 = createLogger("registry");
1331
- function browsersDir() {
1332
- const dir = import_path6.default.join(getDataDir(), "browsers");
1333
- import_fs7.default.mkdirSync(dir, { recursive: true });
1334
- return dir;
1335
- }
1336
- function serverInfoPath() {
1337
- return import_path6.default.join(getDataDir(), "server.json");
1338
- }
1339
- function isPidAlive(pid) {
1340
- if (!Number.isInteger(pid) || pid <= 1) return false;
1341
- try {
1342
- process.kill(pid, 0);
1343
- return true;
1344
- } catch {
1345
- return false;
1346
- }
1347
- }
1348
- function pidMatchesMarker(pid, marker) {
1349
- if (!isPidAlive(pid)) return false;
1350
- try {
1351
- const cmd = (0, import_child_process3.execSync)(`ps -o command= -p ${pid}`, { encoding: "utf8" });
1352
- return cmd.includes(marker);
1353
- } catch {
1354
- return false;
1355
- }
1356
- }
1357
- function findChromePidByMarker(marker) {
1358
- try {
1359
- const out = (0, import_child_process3.execSync)(`pgrep -f -- "${marker}"`, { encoding: "utf8" }).trim();
1360
- const pids = out.split("\n").map((s) => parseInt(s, 10)).filter((n) => Number.isInteger(n) && n !== process.pid);
1361
- if (pids.length === 0) return null;
1362
- return Math.min(...pids);
1363
- } catch {
1364
- return null;
1365
- }
1366
- }
1367
- function registerBrowser(rec) {
1368
- try {
1369
- import_fs7.default.writeFileSync(import_path6.default.join(browsersDir(), `${rec.chromePid}.json`), JSON.stringify(rec, null, 2));
1370
- } catch (err) {
1371
- log6.warn(`failed to write browser record for pid ${rec.chromePid}: ${err}`);
1372
- }
1373
- }
1374
- function unregisterBrowser(chromePid) {
1375
- try {
1376
- import_fs7.default.unlinkSync(import_path6.default.join(browsersDir(), `${chromePid}.json`));
1377
- } catch {
1378
- }
1379
- }
1380
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1381
- async function forceKillBrowser(rec) {
1382
- if (!isPidAlive(rec.chromePid)) return true;
1383
- if (!pidMatchesMarker(rec.chromePid, rec.marker)) {
1384
- return true;
1385
- }
1386
- try {
1387
- process.kill(rec.chromePid, "SIGKILL");
1388
- } catch {
1389
- }
1390
- const deadline = Date.now() + 2e3;
1391
- while (Date.now() < deadline) {
1392
- if (!isPidAlive(rec.chromePid)) return true;
1393
- await sleep(100);
1394
- }
1395
- return !isPidAlive(rec.chromePid);
1396
- }
1397
- async function reapOrphanBrowsers() {
1398
- let reaped = 0;
1399
- let skipped = 0;
1400
- let files = [];
1401
- try {
1402
- files = import_fs7.default.readdirSync(browsersDir()).filter((f) => f.endsWith(".json"));
1403
- } catch {
1404
- return { reaped, skipped };
1405
- }
1406
- for (const file of files) {
1407
- const full = import_path6.default.join(browsersDir(), file);
1408
- let rec;
1409
- try {
1410
- rec = JSON.parse(import_fs7.default.readFileSync(full, "utf8"));
1411
- } catch {
1412
- try {
1413
- import_fs7.default.unlinkSync(full);
1414
- } catch {
1415
- }
1416
- continue;
1417
- }
1418
- if (!isPidAlive(rec.chromePid) || !pidMatchesMarker(rec.chromePid, rec.marker)) {
1419
- try {
1420
- import_fs7.default.unlinkSync(full);
1421
- } catch {
1422
- }
1423
- continue;
1424
- }
1425
- if (isPidAlive(rec.ownerPid)) {
1426
- skipped++;
1427
- continue;
1428
- }
1429
- log6.info(`reaping orphan Chrome pid=${rec.chromePid} (${rec.key}), owner ${rec.ownerPid} is dead`);
1430
- if (await forceKillBrowser(rec)) {
1431
- try {
1432
- import_fs7.default.unlinkSync(full);
1433
- } catch {
1434
- }
1435
- reaped++;
1436
- } else {
1437
- log6.warn(`failed to kill orphan Chrome pid=${rec.chromePid} \u2014 leaving record for next sweep`);
1438
- }
1439
- }
1440
- return { reaped, skipped };
1441
- }
1442
- function writeServerInfo(info) {
1443
- import_fs7.default.writeFileSync(serverInfoPath(), JSON.stringify(info, null, 2));
1444
- }
1445
- function readServerInfo() {
1446
- try {
1447
- const info = JSON.parse(import_fs7.default.readFileSync(serverInfoPath(), "utf8"));
1448
- if (!Number.isInteger(info.pid) || !Number.isInteger(info.port)) return null;
1449
- return info;
1450
- } catch {
1451
- return null;
1452
- }
1453
- }
1454
- function clearServerInfo(pid) {
1455
- const info = readServerInfo();
1456
- if (info && info.pid === pid) {
1457
- try {
1458
- import_fs7.default.unlinkSync(serverInfoPath());
1459
- } catch {
1460
- }
1461
- }
1462
- }
1463
-
1464
1527
  // src/lib/browser/daemon.ts
1465
1528
  var log7 = createLogger("daemon");
1466
1529
  var DEFAULT_IDLE_TIMEOUT = 5 * 60 * 1e3;
1467
1530
  var CLOSE_GRACE_MS = 5e3;
1531
+ var BUSY_MAX_MS = 30 * 60 * 1e3;
1532
+ var BLANK_IDLE_MS = 2 * 60 * 1e3;
1468
1533
  var DEFAULT_INSTANCE = "default";
1469
1534
  function keyOf(mode, instanceId) {
1470
1535
  return `${mode}::${instanceId}`;
@@ -1556,7 +1621,8 @@ var BrowserDaemon = class {
1556
1621
  createdAt: /* @__PURE__ */ new Date(),
1557
1622
  chromePid,
1558
1623
  marker,
1559
- active: 0
1624
+ active: 0,
1625
+ busySince: null
1560
1626
  };
1561
1627
  this.instances.set(key, instance);
1562
1628
  this.resetIdleTimer(key);
@@ -1568,14 +1634,19 @@ var BrowserDaemon = class {
1568
1634
  * Always pair with release() in a finally block. */
1569
1635
  acquire(mode, instanceId = DEFAULT_INSTANCE) {
1570
1636
  const instance = this.instances.get(keyOf(mode, instanceId));
1571
- if (instance) instance.active++;
1637
+ if (!instance) return;
1638
+ if (instance.active === 0) instance.busySince = Date.now();
1639
+ instance.active++;
1572
1640
  }
1573
1641
  release(mode, instanceId = DEFAULT_INSTANCE) {
1574
1642
  const key = keyOf(mode, instanceId);
1575
1643
  const instance = this.instances.get(key);
1576
1644
  if (!instance) return;
1577
1645
  instance.active = Math.max(0, instance.active - 1);
1578
- if (instance.active === 0) this.resetIdleTimer(key);
1646
+ if (instance.active === 0) {
1647
+ instance.busySince = null;
1648
+ this.resetIdleTimer(key);
1649
+ }
1579
1650
  }
1580
1651
  isRunning(mode, instanceId = DEFAULT_INSTANCE) {
1581
1652
  const instance = this.instances.get(keyOf(mode, instanceId));
@@ -1586,6 +1657,35 @@ var BrowserDaemon = class {
1586
1657
  return false;
1587
1658
  }
1588
1659
  }
1660
+ /** Close instances that never left about:blank and have been idle a while.
1661
+ * Those are accidents — a pipeline that failed before its first navigate, or
1662
+ * a mode picked for a call that never needed a browser — and in headful mode
1663
+ * each one is an empty window sitting on the user's desktop. Called on the
1664
+ * server's reap tick, so it also catches instances whose idle timer was lost
1665
+ * (e.g. the daemon was restarted under them). */
1666
+ sweepBlankInstances() {
1667
+ const stopped = [];
1668
+ const now = Date.now();
1669
+ for (const [key, inst] of this.instances.entries()) {
1670
+ if (inst.active > 0) continue;
1671
+ if (now - inst.createdAt.getTime() < BLANK_IDLE_MS) continue;
1672
+ let blank = false;
1673
+ try {
1674
+ const pages = inst.context.pages();
1675
+ blank = pages.length === 0 || pages.every((p) => {
1676
+ const u = p.url();
1677
+ return u === "about:blank" || u === "" || u === "chrome://newtab/";
1678
+ });
1679
+ } catch {
1680
+ continue;
1681
+ }
1682
+ if (!blank) continue;
1683
+ log7.info(`blank-orphan sweep: ${key} never left about:blank, stopping`);
1684
+ stopped.push(key);
1685
+ this.stopKey(key).catch((err) => log7.warn(`blank sweep stop failed for ${key}: ${err}`));
1686
+ }
1687
+ return stopped;
1688
+ }
1589
1689
  /** Distinct modes that currently have at least one live instance. */
1590
1690
  runningModes() {
1591
1691
  return [...new Set(this.liveInstances().map((i) => i.mode))];
@@ -1704,8 +1804,14 @@ var BrowserDaemon = class {
1704
1804
  setTimeout(() => {
1705
1805
  const instance = this.instances.get(key);
1706
1806
  if (instance && instance.active > 0) {
1707
- this.resetIdleTimer(key);
1708
- return;
1807
+ const busyMs = instance.busySince ? Date.now() - instance.busySince : 0;
1808
+ if (busyMs < BUSY_MAX_MS) {
1809
+ this.resetIdleTimer(key);
1810
+ return;
1811
+ }
1812
+ log7.warn(`${key} reported busy for ${Math.round(busyMs / 6e4)}m \u2014 treating active=${instance.active} as leaked, stopping`);
1813
+ instance.active = 0;
1814
+ instance.busySince = null;
1709
1815
  }
1710
1816
  log7.info(`Idle timeout for ${key}, stopping...`);
1711
1817
  this.stopKey(key).catch((err) => log7.warn(`idle stop failed for ${key}: ${err}`));
@@ -1880,6 +1986,53 @@ var RefStore = class {
1880
1986
  }
1881
1987
  };
1882
1988
 
1989
+ // src/lib/site-protection.ts
1990
+ var PROTECTED_INTERVAL_MS = 3e3;
1991
+ var nextRequest = /* @__PURE__ */ new Map();
1992
+ async function paceProtectedDomain(domain) {
1993
+ const now = Date.now();
1994
+ const slot = Math.max(now, nextRequest.get(domain) || 0);
1995
+ nextRequest.set(domain, slot + PROTECTED_INTERVAL_MS);
1996
+ if (slot > now) await new Promise((resolve) => setTimeout(resolve, slot - now));
1997
+ }
1998
+ async function detectSiteProtection(page) {
1999
+ const cookies = await page.context().cookies(page.url()).catch(() => []);
2000
+ if (cookies.some((c) => /^(incap_ses_|visid_incap_|nlbi_)/i.test(c.name))) return "imperva";
2001
+ if (cookies.some((c) => /^(?:_abck|bm_sz|ak_bmsc|bm_sv)$/.test(c.name))) return "akamai";
2002
+ const html = await page.content().catch(() => "");
2003
+ if (/\/[_]Incapsula_Resource|incapsula incident id|powered by imperva/i.test(html)) return "imperva";
2004
+ if (/errors\.edgesuite\.net|akamai bot manager/i.test(html)) return "akamai";
2005
+ return null;
2006
+ }
2007
+ var HeadedRequiredError = class extends Error {
2008
+ constructor(url) {
2009
+ super("This site requires a headed browser.");
2010
+ this.url = url;
2011
+ }
2012
+ url;
2013
+ stepIndex = 0;
2014
+ results = [];
2015
+ obstacles = [];
2016
+ };
2017
+ function protectionHooks(headless, respectful = false) {
2018
+ return {
2019
+ before: async (page, step) => {
2020
+ const protection = await detectSiteProtection(page);
2021
+ if (protection) {
2022
+ respectful = true;
2023
+ if (headless) throw new HeadedRequiredError(page.url());
2024
+ }
2025
+ if (respectful) await paceProtectedDomain(new URL(step.url || page.url()).hostname);
2026
+ },
2027
+ after: async (page) => {
2028
+ const protection = await detectSiteProtection(page);
2029
+ if (!protection) return;
2030
+ respectful = true;
2031
+ if (headless) throw new HeadedRequiredError(page.url());
2032
+ }
2033
+ };
2034
+ }
2035
+
1883
2036
  // src/lib/execution/pipeline-executor.ts
1884
2037
  var import_playwright_core = require("playwright-core");
1885
2038
 
@@ -2017,6 +2170,24 @@ async function humanType(page, selector, text, opts = {}) {
2017
2170
  await sleep3(randRange(TIMING.POST_CLICK));
2018
2171
  }
2019
2172
  await assertFocused(page, selector, !opts.skipClick);
2173
+ if (opts.clear) {
2174
+ const hadText = await page.evaluate(() => {
2175
+ const el = document.activeElement;
2176
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
2177
+ el.select();
2178
+ return el.value.length > 0;
2179
+ }
2180
+ if (el?.isContentEditable) {
2181
+ window.getSelection()?.selectAllChildren(el);
2182
+ return (el.textContent || "").length > 0;
2183
+ }
2184
+ return false;
2185
+ });
2186
+ if (hadText) {
2187
+ await page.keyboard.press("Backspace");
2188
+ await sleep3(randRange(TIMING.CHAR_DELAY));
2189
+ }
2190
+ }
2020
2191
  const factor = SPEED_FACTOR[opts.speed || "normal"] ?? 1;
2021
2192
  for (const char of text) {
2022
2193
  await page.keyboard.type(char);
@@ -2172,7 +2343,36 @@ function sleep3(ms) {
2172
2343
  // src/lib/actions/handlers/navigation.ts
2173
2344
  init_persistence();
2174
2345
 
2346
+ // src/lib/budget.ts
2347
+ var BudgetExceededError = class extends Error {
2348
+ constructor(label, ms) {
2349
+ super(`${label} did not answer within ${ms}ms`);
2350
+ this.name = "BudgetExceededError";
2351
+ }
2352
+ };
2353
+ function withBudget(p, ms, label = "page call") {
2354
+ p.catch(() => void 0);
2355
+ let timer;
2356
+ return Promise.race([
2357
+ p,
2358
+ new Promise((_, reject) => {
2359
+ timer = setTimeout(() => reject(new BudgetExceededError(label, ms)), ms);
2360
+ timer.unref?.();
2361
+ })
2362
+ ]).finally(() => {
2363
+ if (timer) clearTimeout(timer);
2364
+ });
2365
+ }
2366
+
2175
2367
  // src/lib/actions/resolve-selector.ts
2368
+ var PLAYWRIGHT_ONLY = /^\s*(text|xpath|role|id|data-testid|internal:[\w-]+)\s*=|:(has-text|text|text-is|text-matches|visible|nth-match|right-of|left-of|above|below|near)\b|\s>>\s/;
2369
+ function assertCssSelector(selector) {
2370
+ if (PLAYWRIGHT_ONLY.test(selector)) {
2371
+ throw new Error(
2372
+ `Unsupported selector "${selector}": Playwright text/role selectors (text=, :has-text(), :text-is(), >>) aren't supported. Locate the element by its text with a \`find\` step (e.g. { type: "find", text: "Texas" } or { role: "option", name: "Texas" }) and act on the @e ref it returns, in the same execute call.`
2373
+ );
2374
+ }
2375
+ }
2176
2376
  function resolveSelector(selector, ctx) {
2177
2377
  if (selector.startsWith("@a:")) {
2178
2378
  const name = selector.slice(3);
@@ -2193,19 +2393,149 @@ function resolveSelector(selector, ctx) {
2193
2393
  }
2194
2394
  return ref.selector;
2195
2395
  }
2396
+ assertCssSelector(selector);
2196
2397
  return selector;
2197
2398
  }
2198
2399
  function anchorNameOf(selector) {
2199
2400
  return typeof selector === "string" && selector.startsWith("@a:") ? selector.slice(3) : null;
2200
2401
  }
2201
2402
 
2403
+ // src/lib/reachability.ts
2404
+ var HONEYPOT_REASONS = /* @__PURE__ */ new Set(["zero-size", "hidden", "transparent", "offscreen", "clipped"]);
2405
+ var HONEYPOT_NAME_RE = /^(website|url|homepage|fax|honeypot|honey|hp|hp_[\w-]*|bot|bot_[\w-]*|trap|_gotcha|leave_?blank|do_?not_?fill)$/i;
2406
+ function isHoneypotReason(reason) {
2407
+ return !!reason && HONEYPOT_REASONS.has(reason);
2408
+ }
2409
+ function looksLikeBaitField(attrs) {
2410
+ const name = attrs.name || "";
2411
+ const id = attrs.id || "";
2412
+ if (HONEYPOT_NAME_RE.test(name) || HONEYPOT_NAME_RE.test(id)) return true;
2413
+ const textual = !attrs.type || /^(text|email|url|tel|search|number)$/i.test(attrs.type);
2414
+ if (textual && attrs.tabindex === "-1") return true;
2415
+ return false;
2416
+ }
2417
+ function honeypotRefusalMessage(selector, reason, name) {
2418
+ const what = name ? `"${name}" (${selector})` : selector;
2419
+ return `fill: refusing to fill ${what} \u2014 no human can reach this field (${reason}). It is almost certainly a honeypot: filling it marks the submission as a bot and the form fails with a generic error. Skip it. If you are certain it is a real field, retry with force: true.`;
2420
+ }
2421
+ async function probeField(page, selector) {
2422
+ const r = await page.evaluate((sel) => {
2423
+ const el = document.querySelector(sel);
2424
+ if (!el) return { found: false };
2425
+ const attrs = {
2426
+ name: el.getAttribute("name"),
2427
+ id: el.id || null,
2428
+ tabindex: el.getAttribute("tabindex"),
2429
+ type: el.getAttribute("type")
2430
+ };
2431
+ const tag = el.tagName.toLowerCase();
2432
+ {
2433
+ const r0 = el.getBoundingClientRect();
2434
+ const cx0 = r0.left + r0.width / 2, cy0 = r0.top + r0.height / 2;
2435
+ const dx = cx0 < 0 || cx0 >= window.innerWidth ? cx0 - window.innerWidth / 2 : 0;
2436
+ const dy = cy0 < 0 || cy0 >= window.innerHeight ? cy0 - window.innerHeight / 2 : 0;
2437
+ if (dx || dy) try {
2438
+ window.scrollBy(dx, dy);
2439
+ } catch {
2440
+ }
2441
+ }
2442
+ const rect = el.getBoundingClientRect();
2443
+ let reason = null;
2444
+ if (rect.width === 0 || rect.height === 0) reason = "zero-size";
2445
+ const style = getComputedStyle(el);
2446
+ if (!reason && (style.display === "none" || style.visibility === "hidden")) reason = "hidden";
2447
+ if (!reason) {
2448
+ let opacity = 1;
2449
+ for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
2450
+ const o = parseFloat(getComputedStyle(n).opacity);
2451
+ const animating = o < 1 && typeof n.getAnimations === "function" && n.getAnimations().length > 0;
2452
+ if (!isNaN(o) && !animating) opacity *= o;
2453
+ }
2454
+ if (opacity < 0.05) reason = "transparent";
2455
+ }
2456
+ if (!reason) {
2457
+ const docW = Math.max(document.documentElement.scrollWidth, window.innerWidth);
2458
+ const docH = Math.max(document.documentElement.scrollHeight, window.innerHeight);
2459
+ const left = rect.left + window.scrollX, right = rect.right + window.scrollX;
2460
+ const top = rect.top + window.scrollY, bottom = rect.bottom + window.scrollY;
2461
+ if (right <= 0 || left >= docW || bottom <= 0 || top >= docH) reason = "offscreen";
2462
+ }
2463
+ if (!reason) {
2464
+ const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
2465
+ const inViewport = cx >= 0 && cy >= 0 && cx < window.innerWidth && cy < window.innerHeight;
2466
+ let clipped = false;
2467
+ for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
2468
+ const s = getComputedStyle(a);
2469
+ const clipsX = /hidden|clip/.test(s.overflowX), clipsY = /hidden|clip/.test(s.overflowY);
2470
+ if (!clipsX && !clipsY) continue;
2471
+ const ar = a.getBoundingClientRect();
2472
+ const outside = clipsX && (cx < ar.left || cx > ar.right) || clipsY && (cy < ar.top || cy > ar.bottom);
2473
+ const tiny = clipsY && ar.height <= 2 || clipsX && ar.width <= 2;
2474
+ if (outside || tiny) {
2475
+ clipped = true;
2476
+ break;
2477
+ }
2478
+ }
2479
+ if (inViewport) {
2480
+ const hit = document.elementFromPoint(cx, cy);
2481
+ if (!(hit && (hit === el || el.contains(hit)))) reason = clipped ? "clipped" : "covered";
2482
+ } else if (clipped) {
2483
+ reason = "clipped";
2484
+ }
2485
+ }
2486
+ return { found: true, reason, attrs, tag };
2487
+ }, selector);
2488
+ if (!r.found) return { found: false, hiddenReason: null, suspiciousName: false };
2489
+ return {
2490
+ found: true,
2491
+ hiddenReason: r.reason ?? null,
2492
+ suspiciousName: looksLikeBaitField(r.attrs),
2493
+ tag: r.tag,
2494
+ name: r.attrs.name ?? void 0
2495
+ };
2496
+ }
2497
+
2202
2498
  // src/lib/actions/handlers/navigation.ts
2203
2499
  var log9 = createLogger("actions");
2204
- async function navigate(page, step, ctx) {
2205
- await page.goto(step.url, {
2206
- waitUntil: step.waitUntil || "domcontentloaded",
2207
- timeout: TIMEOUTS.NAVIGATION
2208
- });
2500
+ var NAV_COMMIT_SUSPECT_MS = 12e3;
2501
+ async function navigate(page, step, ctx, monitor) {
2502
+ const waitUntil = step.waitUntil || "domcontentloaded";
2503
+ const urlBefore = page.url();
2504
+ const onDialog = (d) => {
2505
+ (d.type() === "beforeunload" ? d.accept() : d.dismiss()).catch(() => void 0);
2506
+ };
2507
+ page.on("dialog", onDialog);
2508
+ try {
2509
+ const nav = page.goto(step.url, { waitUntil, timeout: TIMEOUTS.NAVIGATION });
2510
+ nav.catch(() => void 0);
2511
+ let suspectTimer;
2512
+ const outcome = await Promise.race([
2513
+ nav.then(() => "done"),
2514
+ new Promise((r) => {
2515
+ suspectTimer = setTimeout(() => r("slow"), NAV_COMMIT_SUSPECT_MS);
2516
+ })
2517
+ ]).finally(() => clearTimeout(suspectTimer));
2518
+ if (outcome === "slow") {
2519
+ if (page.url() !== urlBefore) {
2520
+ await nav;
2521
+ } else {
2522
+ log9.warn(`navigate: no commit after ${NAV_COMMIT_SUSPECT_MS}ms, falling back to location.assign`);
2523
+ monitor?.reportActivity();
2524
+ await withBudget(page.evaluate((u) => {
2525
+ location.assign(u);
2526
+ }, step.url), 3e3, "location.assign").catch(() => void 0);
2527
+ try {
2528
+ await page.waitForURL((u) => u.toString() !== urlBefore, { timeout: 15e3, waitUntil });
2529
+ } catch {
2530
+ throw new Error(
2531
+ `navigate: the page never left ${urlBefore} for ${step.url} (tried a normal navigation, then location.assign). The site is blocking the route change. Use a click on the site's own link to get there.`
2532
+ );
2533
+ }
2534
+ }
2535
+ }
2536
+ } finally {
2537
+ page.off("dialog", onDialog);
2538
+ }
2209
2539
  const stealthScript = contextStealthScripts.get(page.context()) ?? STEALTH_SCRIPT;
2210
2540
  try {
2211
2541
  await page.evaluate(stealthScript);
@@ -2221,14 +2551,37 @@ async function navigate(page, step, ctx) {
2221
2551
  }
2222
2552
  }
2223
2553
  async function click(page, step, ctx) {
2224
- await page.click(resolveSelector(step.selector, ctx));
2554
+ await page.click(resolveSelector(step.selector, ctx), { timeout: TIMEOUTS.CLICK });
2555
+ }
2556
+ async function isCombobox(page, selector) {
2557
+ return withBudget(
2558
+ page.evaluate((sel) => {
2559
+ const el = document.querySelector(sel);
2560
+ if (!el) return false;
2561
+ const role = el.getAttribute("role");
2562
+ return role === "combobox" || el.hasAttribute("aria-autocomplete") || el.getAttribute("aria-haspopup") === "listbox" || el.hasAttribute("list") || !!el.closest('[role="combobox"]');
2563
+ }, selector),
2564
+ 3e3
2565
+ ).catch(() => false);
2225
2566
  }
2226
2567
  async function fill(page, step, ctx) {
2227
2568
  const selector = resolveSelector(step.selector, ctx);
2228
2569
  const value = step.value;
2570
+ if (!step.force) {
2571
+ let probe = null;
2572
+ try {
2573
+ probe = await probeField(page, selector);
2574
+ } catch {
2575
+ probe = null;
2576
+ }
2577
+ if (probe?.found && isHoneypotReason(probe.hiddenReason)) {
2578
+ throw new Error(honeypotRefusalMessage(step.selector, probe.hiddenReason, probe.name));
2579
+ }
2580
+ }
2581
+ const combobox = await isCombobox(page, selector);
2229
2582
  await page.fill(selector, value);
2230
2583
  const stuck = await page.evaluate(
2231
- ({ sel, val }) => {
2584
+ ({ sel, val, keepFocus }) => {
2232
2585
  const el = document.querySelector(sel);
2233
2586
  if (!el) return false;
2234
2587
  const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
@@ -2244,13 +2597,15 @@ async function fill(page, step, ctx) {
2244
2597
  }
2245
2598
  el.dispatchEvent(new Event("input", { bubbles: true }));
2246
2599
  el.dispatchEvent(new Event("change", { bubbles: true }));
2247
- try {
2248
- el.blur();
2249
- } catch {
2600
+ if (!keepFocus) {
2601
+ try {
2602
+ el.blur();
2603
+ } catch {
2604
+ }
2250
2605
  }
2251
2606
  return el.value === val;
2252
2607
  },
2253
- { sel: selector, val: value }
2608
+ { sel: selector, val: value, keepFocus: combobox }
2254
2609
  );
2255
2610
  if (!stuck) {
2256
2611
  const loc = page.locator(selector);
@@ -2258,10 +2613,12 @@ async function fill(page, step, ctx) {
2258
2613
  await loc.click();
2259
2614
  await loc.fill("");
2260
2615
  await loc.pressSequentially(value, { delay: 15 });
2261
- await page.evaluate((sel) => {
2262
- const el = document.querySelector(sel);
2263
- el?.blur?.();
2264
- }, selector);
2616
+ if (!combobox) {
2617
+ await page.evaluate((sel) => {
2618
+ const el = document.querySelector(sel);
2619
+ el?.blur?.();
2620
+ }, selector);
2621
+ }
2265
2622
  } catch {
2266
2623
  }
2267
2624
  }
@@ -2285,7 +2642,7 @@ async function rightClick(page, step, ctx) {
2285
2642
  }
2286
2643
  }
2287
2644
  async function humanTypeStep(page, step, ctx) {
2288
- await humanType(page, resolveSelector(step.selector, ctx), step.value, { skipClick: step.skipClick, speed: step.speed });
2645
+ await humanType(page, resolveSelector(step.selector, ctx), step.value, { skipClick: step.skipClick, speed: step.speed, clear: step.clear });
2289
2646
  }
2290
2647
  async function selectOption(page, step, ctx) {
2291
2648
  const selector = resolveSelector(step.selector, ctx);
@@ -2328,7 +2685,29 @@ async function scroll(page, step, ctx) {
2328
2685
  { dy: step.deltaY ?? 0, sel: selector }
2329
2686
  );
2330
2687
  }
2331
- async function keyboard(page, step) {
2688
+ async function selectAllInFocused(page) {
2689
+ await page.evaluate(() => {
2690
+ const el = document.activeElement;
2691
+ if (el && (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement)) {
2692
+ el.select();
2693
+ } else if (el && el.isContentEditable) {
2694
+ const sel = window.getSelection();
2695
+ sel?.selectAllChildren(el);
2696
+ } else {
2697
+ window.getSelection()?.selectAllChildren(document.body);
2698
+ }
2699
+ });
2700
+ }
2701
+ async function keyboard(page, step, ctx) {
2702
+ if ((step.meta || step.ctrl) && step.key.toLowerCase() === "a" && !step.shift && !step.alt) {
2703
+ await selectAllInFocused(page);
2704
+ return;
2705
+ }
2706
+ if ((step.meta || step.ctrl) && ctx.driver === "extension") {
2707
+ throw new Error(
2708
+ `keyboard: ${step.meta ? "Cmd" : "Ctrl"}+${step.key} can't be sent in extension mode (the relay drops Cmd/Ctrl, so it would arrive as a plain "${step.key}"). Nothing was pressed. Instead: select-all \u2192 keyboard {key:"a", meta:true} (handled in-page); replace a field's text \u2192 fill, or human-type with clear:true; paste \u2192 the paste step.`
2709
+ );
2710
+ }
2332
2711
  const mods = [
2333
2712
  step.meta ? "Meta" : null,
2334
2713
  step.ctrl ? "Control" : null,
@@ -2513,6 +2892,56 @@ async function takeSnapshot(page, ctx, options) {
2513
2892
  if (rect.bottom < 0 || rect.top > window.innerHeight + 200) return false;
2514
2893
  return true;
2515
2894
  }
2895
+ function isFillable(el) {
2896
+ if (el.tagName === "TEXTAREA" || el.tagName === "SELECT") return true;
2897
+ if (el.hasAttribute("contenteditable") && el.getAttribute("contenteditable") !== "false") return true;
2898
+ if (el.tagName !== "INPUT") return false;
2899
+ const t = (el.type || "text").toLowerCase();
2900
+ return !["button", "submit", "reset", "image", "checkbox", "radio", "file", "hidden", "range", "color"].includes(t);
2901
+ }
2902
+ function unreachableReason(el) {
2903
+ const rect = el.getBoundingClientRect();
2904
+ const style = getComputedStyle(el);
2905
+ let opacity = 1;
2906
+ for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
2907
+ const o = parseFloat(n === el ? style.opacity : getComputedStyle(n).opacity);
2908
+ const animating = o < 1 && typeof n.getAnimations === "function" && n.getAnimations().length > 0;
2909
+ if (!isNaN(o) && !animating) opacity *= o;
2910
+ }
2911
+ if (opacity < 0.05) return "transparent";
2912
+ const docW = Math.max(document.documentElement.scrollWidth, window.innerWidth);
2913
+ const docH = Math.max(document.documentElement.scrollHeight, window.innerHeight);
2914
+ const left = rect.left + window.scrollX, right = rect.right + window.scrollX;
2915
+ const top = rect.top + window.scrollY, bottom = rect.bottom + window.scrollY;
2916
+ if (right <= 0 || left >= docW || bottom <= 0 || top >= docH) return "offscreen";
2917
+ const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
2918
+ let clipped = false;
2919
+ for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
2920
+ const s = getComputedStyle(a);
2921
+ const clipsX = /hidden|clip/.test(s.overflowX), clipsY = /hidden|clip/.test(s.overflowY);
2922
+ if (!clipsX && !clipsY) continue;
2923
+ const ar = a.getBoundingClientRect();
2924
+ const outside = clipsX && (cx < ar.left || cx > ar.right) || clipsY && (cy < ar.top || cy > ar.bottom);
2925
+ const tiny = clipsY && ar.height <= 2 || clipsX && ar.width <= 2;
2926
+ if (tiny) return "clipped";
2927
+ if (outside) clipped = true;
2928
+ }
2929
+ const inViewport = cx >= 0 && cy >= 0 && cx < window.innerWidth && cy < window.innerHeight;
2930
+ if (inViewport) {
2931
+ const hit = document.elementFromPoint(cx, cy);
2932
+ if (hit && (hit === el || el.contains(hit))) return null;
2933
+ return clipped ? "clipped" : null;
2934
+ }
2935
+ return null;
2936
+ }
2937
+ const baitName = /^(website|url|homepage|fax|honeypot|honey|hp|hp_[\w-]*|bot|bot_[\w-]*|trap|_gotcha|leave_?blank|do_?not_?fill)$/i;
2938
+ function looksLikeBait(el) {
2939
+ const name = el.getAttribute("name") || "", id = el.id || "";
2940
+ if (baitName.test(name) || baitName.test(id)) return true;
2941
+ const type = (el.getAttribute("type") || "text").toLowerCase();
2942
+ const textual = /^(text|email|url|tel|search|number)$/.test(type);
2943
+ return textual && el.getAttribute("tabindex") === "-1";
2944
+ }
2516
2945
  function buildSelector(el) {
2517
2946
  const path13 = [];
2518
2947
  let current = el;
@@ -2555,6 +2984,11 @@ async function takeSnapshot(page, ctx, options) {
2555
2984
  if (results.length >= maxElements2) break;
2556
2985
  if (interactiveOnly2 && !isInteractive(el)) continue;
2557
2986
  if (!isVisible(el)) continue;
2987
+ let suspect;
2988
+ if (isFillable(el)) {
2989
+ if (unreachableReason(el)) continue;
2990
+ if (looksLikeBait(el)) suspect = "honeypot?";
2991
+ }
2558
2992
  const tag = el.tagName.toLowerCase();
2559
2993
  const role = el.getAttribute("role") || "";
2560
2994
  const type = el.type || "";
@@ -2567,7 +3001,8 @@ async function takeSnapshot(page, ctx, options) {
2567
3001
  checked: el.checked || false,
2568
3002
  disabled: el.disabled || false,
2569
3003
  selector: buildSelector(el),
2570
- isVisible: true
3004
+ isVisible: true,
3005
+ suspect
2571
3006
  });
2572
3007
  }
2573
3008
  return results;
@@ -2588,6 +3023,7 @@ async function takeSnapshot(page, ctx, options) {
2588
3023
  const state = [];
2589
3024
  if (el.disabled) state.push("disabled");
2590
3025
  if (el.checked) state.push("checked");
3026
+ if (el.suspect) state.push(el.suspect);
2591
3027
  let description = "";
2592
3028
  if (el.placeholder && el.name !== el.placeholder) description = `placeholder="${el.placeholder}"`;
2593
3029
  if (el.type && !["text", "submit", "button", ""].includes(el.type)) {
@@ -3936,6 +4372,8 @@ async function executeAction(page, step, ctx, monitor) {
3936
4372
 
3937
4373
  // src/lib/stale-monitor.ts
3938
4374
  var DEFAULT_STALE_TIMEOUT_MS = 2e4;
4375
+ var PROBE_BUDGET_MS = 3e3;
4376
+ var UNRESPONSIVE_GRACE_MS = 8e3;
3939
4377
  var StaleStateMonitor = class _StaleStateMonitor {
3940
4378
  page;
3941
4379
  timeoutMs;
@@ -3947,21 +4385,24 @@ var StaleStateMonitor = class _StaleStateMonitor {
3947
4385
  }
3948
4386
  async snapshot() {
3949
4387
  try {
3950
- const [url, state] = await Promise.all([
3951
- Promise.resolve(this.page.url()),
4388
+ const url = this.page.url();
4389
+ let unresponsive = false;
4390
+ const state = await withBudget(
3952
4391
  this.page.evaluate(() => ({
3953
4392
  documentReadyState: document.readyState,
3954
4393
  bodyTextLength: document.body?.innerText?.length || 0,
3955
4394
  elementCount: document.querySelectorAll("*").length
3956
- })).catch(() => ({
3957
- documentReadyState: "unknown",
3958
- bodyTextLength: 0,
3959
- elementCount: 0
3960
- }))
3961
- ]);
4395
+ })),
4396
+ PROBE_BUDGET_MS,
4397
+ "state probe"
4398
+ ).catch((e) => {
4399
+ unresponsive = e instanceof BudgetExceededError;
4400
+ return { documentReadyState: "unknown", bodyTextLength: 0, elementCount: 0 };
4401
+ });
3962
4402
  return {
3963
4403
  url,
3964
4404
  ...state,
4405
+ unresponsive,
3965
4406
  timestamp: Date.now()
3966
4407
  };
3967
4408
  } catch {
@@ -4005,6 +4446,8 @@ var StaleStateMonitor = class _StaleStateMonitor {
4005
4446
  return new Promise((resolve, reject) => {
4006
4447
  let resolved = false;
4007
4448
  let checkInterval = null;
4449
+ let probing = false;
4450
+ let unresponsiveSince = null;
4008
4451
  const cleanup = () => {
4009
4452
  resolved = true;
4010
4453
  if (checkInterval) clearInterval(checkInterval);
@@ -4012,11 +4455,25 @@ var StaleStateMonitor = class _StaleStateMonitor {
4012
4455
  this.timer = null;
4013
4456
  };
4014
4457
  checkInterval = setInterval(async () => {
4015
- if (resolved) return;
4458
+ if (resolved || probing) return;
4016
4459
  const elapsed = Date.now() - this.lastActivity;
4017
4460
  if (elapsed < this.timeoutMs) return;
4461
+ probing = true;
4018
4462
  try {
4019
4463
  const currentSnapshot = await this.snapshot();
4464
+ if (resolved) return;
4465
+ if (currentSnapshot.unresponsive) {
4466
+ unresponsiveSince ??= Date.now();
4467
+ if (Date.now() - unresponsiveSince < UNRESPONSIVE_GRACE_MS) return;
4468
+ cleanup();
4469
+ reject(new StaleStateError(
4470
+ `The tab stopped responding: its JavaScript did not answer for ${Math.round((Date.now() - unresponsiveSince) / 1e3)}s (a blocking dialog, a frozen re-render, or a navigation that never finished)`,
4471
+ this.timeoutMs,
4472
+ true
4473
+ ));
4474
+ return;
4475
+ }
4476
+ unresponsiveSince = null;
4020
4477
  if (_StaleStateMonitor.hasChanged(beforeSnapshot, currentSnapshot)) {
4021
4478
  this.lastActivity = Date.now();
4022
4479
  return;
@@ -4024,6 +4481,8 @@ var StaleStateMonitor = class _StaleStateMonitor {
4024
4481
  } catch {
4025
4482
  this.lastActivity = Date.now();
4026
4483
  return;
4484
+ } finally {
4485
+ probing = false;
4027
4486
  }
4028
4487
  cleanup();
4029
4488
  reject(new StaleStateError(
@@ -4047,10 +4506,13 @@ var StaleStateMonitor = class _StaleStateMonitor {
4047
4506
  };
4048
4507
  var StaleStateError = class extends Error {
4049
4508
  timeoutMs;
4050
- constructor(message, timeoutMs) {
4509
+ /** True when the page stopped answering at all (vs. answering but not changing). */
4510
+ unresponsive;
4511
+ constructor(message, timeoutMs, unresponsive = false) {
4051
4512
  super(message);
4052
4513
  this.name = "StaleStateError";
4053
4514
  this.timeoutMs = timeoutMs;
4515
+ this.unresponsive = unresponsive;
4054
4516
  }
4055
4517
  };
4056
4518
 
@@ -4261,10 +4723,14 @@ async function capturePageState(page, ctx, opts) {
4261
4723
  url = page.url();
4262
4724
  } catch {
4263
4725
  }
4264
- const title = await page.title().catch(() => "");
4726
+ const title = await withBudget(page.title(), 2e3, "title").catch(() => "");
4265
4727
  if (!screenshot2) return { url, title };
4266
4728
  try {
4267
- const buf = await page.screenshot({ type: "jpeg", quality: 50, fullPage: false });
4729
+ const buf = await withBudget(
4730
+ page.screenshot({ type: "jpeg", quality: 50, fullPage: false, timeout: 12e3 }),
4731
+ 13e3,
4732
+ "screenshot"
4733
+ );
4268
4734
  const screenshotUrl = saveScreenshot(buf, `${namePrefix}-${Date.now()}.jpg`, ctx.screenshotDir, ctx.publicUrl);
4269
4735
  return { url, title, screenshotUrl };
4270
4736
  } catch {
@@ -4636,10 +5102,37 @@ var ApiCapture = class {
4636
5102
  await new Promise((r) => setTimeout(r, 100));
4637
5103
  }
4638
5104
  }
5105
+ /** Requests fired but not yet answered. */
5106
+ hasPending() {
5107
+ return this.pendingRequests.size > 0;
5108
+ }
4639
5109
  getResults() {
4640
5110
  return buildCapturedApi(this.requests);
4641
5111
  }
5112
+ /** Every HTTP >= 400 response, compact, in capture order. */
5113
+ getServerErrors() {
5114
+ return serverErrorsFrom(this.requests);
5115
+ }
4642
5116
  };
5117
+ var SERVER_ERROR_BODY_MAX = 600;
5118
+ function serverErrorsFrom(requests) {
5119
+ const out = [];
5120
+ for (const r of requests) {
5121
+ if (r.responseStatus < 400) continue;
5122
+ let body;
5123
+ if (r.responseBody !== void 0 && r.responseBody !== null) {
5124
+ const raw = typeof r.responseBody === "string" ? r.responseBody : JSON.stringify(r.responseBody);
5125
+ const flat = raw.replace(/\s+/g, " ").trim();
5126
+ body = flat.length > SERVER_ERROR_BODY_MAX ? flat.slice(0, SERVER_ERROR_BODY_MAX) + "\u2026" : flat;
5127
+ if (/^\s*<(!doctype|html)/i.test(raw)) {
5128
+ const m = raw.match(/<title[^>]*>([^<]*)<\/title>|<h1[^>]*>([^<]*)<\/h1>/i);
5129
+ body = m ? `[html] ${(m[1] || m[2] || "").trim()}` : "[html page]";
5130
+ }
5131
+ }
5132
+ out.push({ stepIndex: r.triggeredAtStep, method: r.method, url: r.url, status: r.responseStatus, body });
5133
+ }
5134
+ return out;
5135
+ }
4643
5136
  function extractAuth(requests) {
4644
5137
  const auth2 = { cookies: {}, tokens: {} };
4645
5138
  for (const req of requests) {
@@ -4888,10 +5381,36 @@ function classifyError(err, step) {
4888
5381
  if (step.type === "solve-captcha") return "captcha-unsolvable";
4889
5382
  return "action-failed";
4890
5383
  }
5384
+ async function refineError(page, step, ctx, errorType, message) {
5385
+ if (errorType !== "stale-state" && errorType !== "timeout") return { errorType, message };
5386
+ const raw = step.selector;
5387
+ if (typeof raw !== "string") return { errorType, message };
5388
+ let resolved;
5389
+ try {
5390
+ resolved = resolveSelector(raw, ctx);
5391
+ } catch {
5392
+ return { errorType, message };
5393
+ }
5394
+ try {
5395
+ const probe = page.evaluate((sel) => !!document.querySelector(sel), resolved);
5396
+ const budget = new Promise((r) => setTimeout(() => r(null), TIMING.FAILURE_PROBE));
5397
+ const found = await Promise.race([probe, budget]);
5398
+ if (found !== false) return { errorType, message };
5399
+ } catch {
5400
+ return { errorType, message };
5401
+ }
5402
+ const shown = resolved === raw ? `"${raw}"` : `"${raw}" (\u2192 ${resolved})`;
5403
+ return {
5404
+ errorType: "element-not-found",
5405
+ message: `No element matches ${shown} on the current page \u2014 the step waited for it to appear and it never did. It most likely existed when you looked and was re-rendered away before this step ran. (${message})`
5406
+ };
5407
+ }
4891
5408
  function isRetryable(errorType) {
4892
5409
  return errorType === "stale-state" || errorType === "timeout" || errorType === "element-not-found";
4893
5410
  }
4894
- function getSuggestion(errorType, step) {
5411
+ var UNRESPONSIVE_SUGGESTION = "The tab's JavaScript stopped answering during this step. Run a single `read` or `snapshot` on its own to see if it recovered (it usually does). If this step froze it, don't repeat it as-is: for a route change in a single-page app use `evaluate` with `location.assign(url)`; for a click, use a fresh `@e` ref from `find`; avoid `evaluate` calls that scroll or re-render.";
5412
+ function getSuggestion(errorType, step, message = "") {
5413
+ if (message.includes("tab stopped responding")) return UNRESPONSIVE_SUGGESTION;
4895
5414
  const usedEphemeralRef = typeof step.selector === "string" && step.selector.startsWith("@e");
4896
5415
  if (usedEphemeralRef && (errorType === "element-not-found" || errorType === "timeout" || errorType === "stale-state")) {
4897
5416
  return "The @e ref went stale \u2014 the page (likely a React/SPA) re-rendered and dropped it since the snapshot. Take a fresh `snapshot`/`find` right before acting, or save the element as a durable `@a:` anchor with the `remember` tool.";
@@ -4900,7 +5419,7 @@ function getSuggestion(errorType, step) {
4900
5419
  case "stale-state":
4901
5420
  return "The page stopped responding. The step may have triggered a very slow load or the server may be unreachable.";
4902
5421
  case "element-not-found":
4903
- return `Selector not found. The page structure may have changed. Take a screenshot to inspect the current state.`;
5422
+ return "Selector not found. Either it is wrong, or the page re-rendered the element away (SPAs swap DOM nodes on every state change, so a selector that worked a second ago can return nothing now). Take a fresh `snapshot`/`find` immediately before acting and retry; if it keeps vanishing, `wait-for` it first.";
4904
5423
  case "navigation-failed":
4905
5424
  return "Navigation failed. The URL may be unreachable, blocked, or require authentication.";
4906
5425
  case "captcha-unsolvable":
@@ -4912,10 +5431,15 @@ function getSuggestion(errorType, step) {
4912
5431
  }
4913
5432
  }
4914
5433
  var PipelineRunner = class {
4915
- constructor(ctx) {
5434
+ constructor(ctx, hooks) {
4916
5435
  this.ctx = ctx;
5436
+ this.hooks = hooks;
4917
5437
  }
4918
5438
  ctx;
5439
+ hooks;
5440
+ /** Live progress, readable while run() is still pending — lets an outer
5441
+ * watchdog report WHICH step hung and what already finished. */
5442
+ progress = { stepIndex: -1, results: [] };
4919
5443
  async run(initialPage, pipeline) {
4920
5444
  const tracker = new TabTracker(initialPage.context(), initialPage);
4921
5445
  try {
@@ -4933,6 +5457,7 @@ var PipelineRunner = class {
4933
5457
  const continueOnError = opts.continueOnError ?? false;
4934
5458
  const results = [];
4935
5459
  const obstacles = [];
5460
+ this.progress.results = results;
4936
5461
  const navStep = pipeline.steps.find((s) => s.type === "navigate");
4937
5462
  try {
4938
5463
  const host = new URL(navStep?.url || safePageUrl(initialPage) || "http://x").hostname;
@@ -4950,27 +5475,41 @@ var PipelineRunner = class {
4950
5475
  if (capture) capture.start();
4951
5476
  const finishCapture = async () => {
4952
5477
  if (!capture) return void 0;
5478
+ await capture.drain(TIMING.CAPTURE_SETTLE, TIMING.CAPTURE_PENDING_MAX);
4953
5479
  capture.stop();
5480
+ for (const se of capture.getServerErrors()) {
5481
+ const r = results.find((x) => x.stepIndex === se.stepIndex);
5482
+ if (r) (r.serverErrors ??= []).push(se);
5483
+ }
4954
5484
  return capture.getResults();
4955
5485
  };
4956
5486
  for (let i = 0; i < pipeline.steps.length; i++) {
4957
5487
  if (capture) capture.setStep(i);
4958
5488
  const step = pipeline.steps[i];
5489
+ this.progress.stepIndex = i;
4959
5490
  const page = tracker.active();
4960
5491
  const urlBefore = safePageUrl(page);
4961
5492
  const monitor = new StaleStateMonitor(page, staleTimeoutMs);
4962
5493
  let stepResult;
4963
5494
  try {
5495
+ await this.hooks?.before(page, step);
4964
5496
  stepResult = await monitor.withMonitoring(async () => {
4965
5497
  const r = await executeAction(page, step, this.ctx, monitor);
5498
+ if (step.type === "navigate") await this.hooks?.after(page, step.url);
4966
5499
  r.stepIndex = i;
4967
5500
  return r;
4968
5501
  });
4969
5502
  } catch (err) {
5503
+ if (err instanceof HeadedRequiredError) {
5504
+ Object.assign(err, { stepIndex: i, results, obstacles });
5505
+ await finishCapture();
5506
+ throw err;
5507
+ }
4970
5508
  const asError = err instanceof Error ? err : new Error(String(err));
4971
- const errorType = classifyError(asError, step);
5509
+ const refined = await refineError(tracker.active(), step, this.ctx, classifyError(asError, step), asError.message);
5510
+ const errorType = refined.errorType;
4972
5511
  const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
4973
- stepResult = failedStepResult(step, asError.message, Date.now() - startTime, i);
5512
+ stepResult = failedStepResult(step, refined.message, Date.now() - startTime, i);
4974
5513
  results.push(stepResult);
4975
5514
  recordAnchor(step, false);
4976
5515
  return {
@@ -4984,9 +5523,9 @@ var PipelineRunner = class {
4984
5523
  failedAtStep: i,
4985
5524
  failedStep: step,
4986
5525
  errorType,
4987
- message: asError.message,
5526
+ message: refined.message,
4988
5527
  pageState,
4989
- suggestion: getSuggestion(errorType, step),
5528
+ suggestion: getSuggestion(errorType, step, refined.message),
4990
5529
  retryable: isRetryable(errorType)
4991
5530
  },
4992
5531
  durationMs: Date.now() - startTime,
@@ -5020,8 +5559,16 @@ var PipelineRunner = class {
5020
5559
  }
5021
5560
  results.push(stepResult);
5022
5561
  if (!stepResult.ok && !continueOnError) {
5562
+ const refined = await refineError(
5563
+ tracker.active(),
5564
+ step,
5565
+ this.ctx,
5566
+ classifyError(new Error(stepResult.error || ""), step),
5567
+ stepResult.error || "Step failed"
5568
+ );
5569
+ stepResult.error = refined.message;
5570
+ const errorType = refined.errorType;
5023
5571
  const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
5024
- const errorType = classifyError(new Error(stepResult.error || ""), step);
5025
5572
  return {
5026
5573
  ok: false,
5027
5574
  completedSteps: i,
@@ -5035,7 +5582,7 @@ var PipelineRunner = class {
5035
5582
  errorType,
5036
5583
  message: stepResult.error || "Step failed",
5037
5584
  pageState,
5038
- suggestion: getSuggestion(errorType, step),
5585
+ suggestion: getSuggestion(errorType, step, refined.message),
5039
5586
  retryable: isRetryable(errorType)
5040
5587
  },
5041
5588
  durationMs: Date.now() - startTime,
@@ -5045,7 +5592,7 @@ var PipelineRunner = class {
5045
5592
  if (step.type === "navigate" && continueOnObstacle) {
5046
5593
  const obstacleStart = Date.now();
5047
5594
  const obstaclePage = tracker.active();
5048
- const obstacle = await detectObstacles(obstaclePage);
5595
+ const obstacle = await withBudget(detectObstacles(obstaclePage), 1e4, "obstacle check").catch(() => null);
5049
5596
  if (obstacle) {
5050
5597
  const resolution = await resolveObstacle(obstaclePage, obstacle, this.ctx, monitor);
5051
5598
  obstacles.push({
@@ -5743,7 +6290,7 @@ var CdpRelay = class {
5743
6290
  return {};
5744
6291
  case "Browser.close":
5745
6292
  return {};
5746
- // patchright disconnect — don't kill the user's Chrome
6293
+ // Disconnect without closing the user's Chrome.
5747
6294
  case "Target.setDiscoverTargets":
5748
6295
  return {};
5749
6296
  case "Target.getTargets":
@@ -5786,7 +6333,7 @@ var CdpRelay = class {
5786
6333
  return await Promise.race([
5787
6334
  first,
5788
6335
  new Promise((_, reject) => {
5789
- const t = setTimeout(() => reject(new Error("screenshot timed out (no compositor frame)")), 1e4);
6336
+ const t = setTimeout(() => reject(new Error("screenshot timed out (no compositor frame)")), 5e3);
5790
6337
  t.unref?.();
5791
6338
  })
5792
6339
  ]);
@@ -5845,13 +6392,31 @@ var CdpRelay = class {
5845
6392
 
5846
6393
  // src/lib/execution/pipeline-executor.ts
5847
6394
  var log18 = createLogger("iframer");
6395
+ function blankPipelineResult(pipeline, modeUsed, error) {
6396
+ return {
6397
+ ok: false,
6398
+ completedSteps: 0,
6399
+ totalSteps: pipeline.steps.length,
6400
+ results: [],
6401
+ obstacles: [],
6402
+ error: {
6403
+ failedAtStep: 0,
6404
+ failedStep: pipeline.steps[0],
6405
+ pageState: { url: "", title: "" },
6406
+ ...error
6407
+ },
6408
+ durationMs: 0,
6409
+ modeUsed,
6410
+ finalState: { url: "", title: "" }
6411
+ };
6412
+ }
5848
6413
  var PipelineExecutor = class {
5849
6414
  constructor(deps) {
5850
6415
  this.deps = deps;
5851
6416
  }
5852
6417
  deps;
5853
- /** Runtime elicitation hook, set per-call via execute(). Consumed once by
5854
- * executeLocal and immediately cleared. */
6418
+ /** Shared runtime hook read by local and extension execution;
6419
+ * execute() clears it when the call finishes. */
5855
6420
  pendingElicitOtp;
5856
6421
  /** One pipeline per real tab at a time: chrome.debugger and the CDP relay
5857
6422
  * can't share a tab, so concurrent executes on the same tab queue up here. */
@@ -5882,8 +6447,24 @@ var PipelineExecutor = class {
5882
6447
  const forcedMode = opts.mode;
5883
6448
  const autoEscalate = opts.autoEscalate !== false;
5884
6449
  const instanceId = opts.instanceId || DEFAULT_INSTANCE;
6450
+ if (forcedMode === "extension") {
6451
+ return blankPipelineResult(pipeline, "extension", {
6452
+ errorType: "action-failed",
6453
+ message: 'mode="extension" requires options.tabId (the real-Chrome tab to drive).',
6454
+ suggestion: "Call the `tabs` tool (or GET /extension/tabs) to list open tabs, then pass that tab id. Nothing was launched.",
6455
+ retryable: false
6456
+ });
6457
+ }
5885
6458
  const firstNav = pipeline.steps.find((s) => s.type === "navigate");
5886
6459
  const domain = firstNav ? new URL(firstNav.url).hostname : null;
6460
+ if (!firstNav && !this.deps.daemon.findLiveMode(instanceId)) {
6461
+ return blankPipelineResult(pipeline, forcedMode || "headless", {
6462
+ errorType: "action-failed",
6463
+ message: `No navigate step and no live browser for instanceId "${instanceId}" to act on.`,
6464
+ suggestion: "Add a navigate step, or reattach to a live window (check `status` / `iframer instances` for instanceId). To drive a real Chrome tab, pass options.tabId from the `tabs` tool. Nothing was launched.",
6465
+ retryable: false
6466
+ });
6467
+ }
5887
6468
  const availableModes = this.deps.availableModes();
5888
6469
  const liveMode = forcedMode ? null : this.deps.daemon.findLiveMode(instanceId);
5889
6470
  let mode;
@@ -5897,6 +6478,7 @@ var PipelineExecutor = class {
5897
6478
  mode = availableModes[0] || "headless";
5898
6479
  }
5899
6480
  let result = await this.executeWithMode(userId, token, pipeline, mode, instanceId);
6481
+ mode = result.modeUsed || mode;
5900
6482
  if (!result.ok && autoEscalate && domain && result.error?.errorType === "bot-blocked") {
5901
6483
  const failedMode = mode;
5902
6484
  if (domain) this.deps.domainModes.recordFailure(domain, failedMode, result.error?.message || "blocked");
@@ -5933,11 +6515,11 @@ var PipelineExecutor = class {
5933
6515
  }
5934
6516
  return result;
5935
6517
  }
5936
- async executeWithMode(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE) {
6518
+ async executeWithMode(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE, respectful = false) {
5937
6519
  if (mode === "docker-headful") {
5938
- return this.executeDocker(userId, token, pipeline);
6520
+ return this.executeDocker(userId, token, pipeline, respectful);
5939
6521
  }
5940
- return this.executeLocal(userId, token, pipeline, mode, instanceId);
6522
+ return this.executeLocal(userId, token, pipeline, mode, instanceId, respectful);
5941
6523
  }
5942
6524
  /** Execute against the user's real Chrome tab via the extension CDP relay,
5943
6525
  * using the SAME PipelineRunner as every other mode. No session inject/extract
@@ -5946,6 +6528,7 @@ var PipelineExecutor = class {
5946
6528
  const startTime = Date.now();
5947
6529
  const relay = new CdpRelay(tabId, clientId, pipeline.options?.focus);
5948
6530
  let browser;
6531
+ let runner;
5949
6532
  try {
5950
6533
  await relay.start();
5951
6534
  browser = await import_playwright_core.chromium.connectOverCDP(relay.httpEndpoint(), { timeout: 3e4 });
@@ -5958,7 +6541,8 @@ var PipelineExecutor = class {
5958
6541
  if (!page) throw new Error("no page available for the tab (is it still open?)");
5959
6542
  const ctx = this.deps.refStore.makeContext(userId, token);
5960
6543
  if (this.pendingElicitOtp) ctx.elicitOtp = this.pendingElicitOtp;
5961
- const runner = new PipelineRunner(ctx);
6544
+ ctx.driver = "extension";
6545
+ runner = new PipelineRunner(ctx);
5962
6546
  const typeChars = pipeline.steps.reduce((n, s) => {
5963
6547
  const v = s.value;
5964
6548
  return (s.type === "human-type" || s.type === "type-code") && typeof v === "string" ? n + v.length : n;
@@ -5975,7 +6559,7 @@ var PipelineExecutor = class {
5975
6559
  watchdog = setTimeout(
5976
6560
  () => reject(
5977
6561
  new Error(
5978
- `pipeline exceeded ${Math.round(capMs / 1e3)}s \u2014 the tab may have stopped rendering (minimized window?). Un-minimize the Chrome window or retry with options.focus=true.`
6562
+ `pipeline exceeded ${Math.round(capMs / 1e3)}s \u2014 a call into the tab never returned.`
5979
6563
  )
5980
6564
  ),
5981
6565
  capMs
@@ -5999,23 +6583,26 @@ var PipelineExecutor = class {
5999
6583
  } catch (err) {
6000
6584
  const msg = getErrorMessage(err);
6001
6585
  const stalled = msg.includes("pipeline exceeded");
6586
+ const done = runner ? runner.progress.results.filter((r) => r.ok) : [];
6587
+ const hungAt = stalled && runner && runner.progress.stepIndex >= 0 ? runner.progress.stepIndex : 0;
6588
+ const hungStep = pipeline.steps[hungAt];
6002
6589
  return {
6003
6590
  ok: false,
6004
- completedSteps: 0,
6591
+ completedSteps: stalled ? done.length : 0,
6005
6592
  totalSteps: pipeline.steps.length,
6006
- results: [],
6593
+ results: stalled ? done : [],
6007
6594
  finalState: { url: "", title: "" },
6008
6595
  obstacles: [],
6009
6596
  durationMs: Date.now() - startTime,
6010
6597
  modeUsed: "extension",
6011
6598
  error: {
6012
- failedAtStep: 0,
6013
- failedStep: pipeline.steps[0],
6014
- errorType: "action-failed",
6015
- message: `Extension mode failed: ${msg}`,
6599
+ failedAtStep: hungAt,
6600
+ failedStep: hungStep,
6601
+ errorType: stalled ? "stale-state" : "action-failed",
6602
+ message: stalled ? `Extension mode: step ${hungAt} (${hungStep?.type}) never returned; ${msg}` : `Extension mode failed: ${msg}`,
6016
6603
  pageState: { url: "", title: "" },
6017
- suggestion: stalled ? "STOP retrying and tell the user what happened: the Chrome tab being driven stopped responding \u2014 its window is likely minimized or the page is wedged. Ask them to un-minimize the Chrome window (leaving it behind other windows is fine), or ask permission to rerun with options.focus=true to bring it to the front." : "Ensure the iframer extension is connected (green dot) and the tab is still open. See chrome://extensions.",
6018
- retryable: !stalled
6604
+ suggestion: stalled ? `Step ${hungAt} (${hungStep?.type}) hung inside the tab. Steps before it completed. Run a single \`read\` or \`snapshot\` on its own to check the tab recovered (it usually does), then continue from that step in its own execute call. Don't repeat the same step unchanged: for a route change use \`evaluate\` with \`location.assign(url)\`, for a click use a fresh \`@e\` ref from \`find\`. If the tab stays unresponsive, tell the user.` : "Ensure the iframer extension is connected (green dot) and the tab is still open. See chrome://extensions.",
6605
+ retryable: stalled
6019
6606
  }
6020
6607
  };
6021
6608
  } finally {
@@ -6030,7 +6617,7 @@ var PipelineExecutor = class {
6030
6617
  }
6031
6618
  }
6032
6619
  /** Execute via Docker session-manager. */
6033
- async executeDocker(userId, token, pipeline) {
6620
+ async executeDocker(userId, token, pipeline, respectful = false) {
6034
6621
  let session = getSession(userId);
6035
6622
  if (!session) {
6036
6623
  const firstNav = pipeline.steps.find((s) => s.type === "navigate");
@@ -6039,7 +6626,7 @@ var PipelineExecutor = class {
6039
6626
  }
6040
6627
  resetTimeout(userId);
6041
6628
  const ctx = this.deps.refStore.makeContext(userId, token);
6042
- const runner = new PipelineRunner(ctx);
6629
+ const runner = new PipelineRunner(ctx, protectionHooks(false, respectful));
6043
6630
  const result = await runner.run(session.page, pipeline);
6044
6631
  if (result.ok) {
6045
6632
  const blockResult = await detectBlock(session.page);
@@ -6066,7 +6653,7 @@ var PipelineExecutor = class {
6066
6653
  return result;
6067
6654
  }
6068
6655
  /** Execute via local Chrome daemon (headless + binary-headful). */
6069
- async executeLocal(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE) {
6656
+ async executeLocal(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE, respectful = false) {
6070
6657
  const startTime = Date.now();
6071
6658
  let acquired = false;
6072
6659
  try {
@@ -6088,8 +6675,38 @@ var PipelineExecutor = class {
6088
6675
  const ctx = this.deps.refStore.makeContext(userId, token);
6089
6676
  if (sessionData) ctx.sessionData = sessionData;
6090
6677
  if (this.pendingElicitOtp) ctx.elicitOtp = this.pendingElicitOtp;
6091
- const runner = new PipelineRunner(ctx);
6092
- const result = await runner.run(page, pipeline);
6678
+ const runner = new PipelineRunner(ctx, protectionHooks(mode === "headless", respectful));
6679
+ let result;
6680
+ try {
6681
+ result = await runner.run(page, pipeline);
6682
+ } catch (err) {
6683
+ if (!(err instanceof HeadedRequiredError)) throw err;
6684
+ this.deps.daemon.release(mode, instanceId);
6685
+ acquired = false;
6686
+ await this.deps.daemon.stopMode(mode, instanceId);
6687
+ const available = this.deps.availableModes();
6688
+ const headed = available.includes("binary-headful") ? "binary-headful" : "docker-headful";
6689
+ if (!available.includes(headed)) throw new Error("A headed browser is required but unavailable.");
6690
+ await new Promise((resolve) => setTimeout(resolve, PROTECTED_INTERVAL_MS));
6691
+ const index = err.stepIndex;
6692
+ const remaining = pipeline.steps.slice(index);
6693
+ const insertedNavigation = remaining[0]?.type !== "navigate";
6694
+ if (insertedNavigation) remaining.unshift({ type: "navigate", url: err.url });
6695
+ const resumed = await this.executeWithMode(userId, token, { ...pipeline, steps: remaining }, headed, instanceId, true);
6696
+ const offset = index - Number(insertedNavigation);
6697
+ resumed.results = [...err.results, ...resumed.results.filter((r) => !insertedNavigation || r.stepIndex > 0).map((r) => ({ ...r, stepIndex: r.stepIndex + offset }))];
6698
+ resumed.completedSteps = index + Math.max(0, resumed.completedSteps - Number(insertedNavigation));
6699
+ resumed.totalSteps = pipeline.steps.length;
6700
+ resumed.obstacles = [...err.obstacles, ...resumed.obstacles];
6701
+ if (resumed.error) {
6702
+ resumed.error.failedAtStep = Math.max(index, resumed.error.failedAtStep + offset);
6703
+ resumed.error.failedStep = pipeline.steps[resumed.error.failedAtStep];
6704
+ }
6705
+ resumed.durationMs = Date.now() - startTime;
6706
+ resumed.modeEscalated = true;
6707
+ resumed.modeUsed = headed;
6708
+ return resumed;
6709
+ }
6093
6710
  if (result.ok) {
6094
6711
  const blockResult = await detectBlock(page);
6095
6712
  if (blockResult.blocked) {
@@ -6180,12 +6797,30 @@ var FetchService = class {
6180
6797
  const { browser, name: browserName } = await getBrowserWithFallback(preferredBrowser);
6181
6798
  context = await browser.newContext(stealthContextOptions({ locale, extraHTTPHeaders: { ...headers } }, userId ?? void 0));
6182
6799
  if (sessionData) await injectCookies(context, sessionData);
6183
- const page = await context.newPage();
6800
+ let page = await context.newPage();
6184
6801
  await applyStealthToPage(page);
6185
6802
  await page.goto(url, { waitUntil: waitUntil || "domcontentloaded", timeout: TIMEOUTS.NAVIGATION });
6803
+ let protection = false;
6804
+ const pivotIfDetected = async () => {
6805
+ if (protection || !await detectSiteProtection(page)) return;
6806
+ protection = true;
6807
+ const targetUrl = page.url();
6808
+ const cookies = await context.cookies();
6809
+ await context.close();
6810
+ context = await (await getHeadedBrowser()).newContext(stealthContextOptions({ locale, extraHTTPHeaders: { ...headers } }, userId ?? void 0));
6811
+ await context.addCookies(cookies);
6812
+ page = await context.newPage();
6813
+ await applyStealthToPage(page);
6814
+ await new Promise((resolve) => setTimeout(resolve, PROTECTED_INTERVAL_MS));
6815
+ await paceProtectedDomain(new URL(targetUrl).hostname);
6816
+ await page.goto(targetUrl, { waitUntil, timeout: TIMEOUTS.NAVIGATION });
6817
+ };
6818
+ await pivotIfDetected();
6186
6819
  if (sessionData) await injectStorage(page, sessionData);
6187
6820
  if (waitForSelector) await page.waitForSelector(waitForSelector, { timeout: TIMEOUTS.SELECTOR_WAIT });
6188
6821
  for (const action of actions) {
6822
+ await pivotIfDetected();
6823
+ if (protection) await paceProtectedDomain(new URL(page.url()).hostname);
6189
6824
  switch (action.type) {
6190
6825
  case "click":
6191
6826
  await page.click(action.selector);
@@ -6216,6 +6851,7 @@ var FetchService = class {
6216
6851
  break;
6217
6852
  }
6218
6853
  }
6854
+ await pivotIfDetected();
6219
6855
  const finalUrl = page.url();
6220
6856
  const html = returnHtml ? await page.content() : void 0;
6221
6857
  const result = extract2 ? await page.evaluate(extract2) : void 0;
@@ -6521,6 +7157,11 @@ var Iframer = class {
6521
7157
  listInstances() {
6522
7158
  return this.daemon.instancesInfo();
6523
7159
  }
7160
+ /** Close browsers that never navigated anywhere. See
7161
+ * BrowserDaemon.sweepBlankInstances — called from the server's reap tick. */
7162
+ sweepBlankBrowsers() {
7163
+ return this.daemon.sweepBlankInstances();
7164
+ }
6524
7165
  /** Kill all browser instances and reset state. Next execute call will
6525
7166
  * launch a fresh browser automatically — no manual restart needed. */
6526
7167
  async restartBrowser() {
@@ -6937,6 +7578,10 @@ var reapTimer = setInterval(async () => {
6937
7578
  await reapOrphanBrowsers();
6938
7579
  } catch {
6939
7580
  }
7581
+ try {
7582
+ iframer.sweepBlankBrowsers();
7583
+ } catch {
7584
+ }
6940
7585
  const idleMs = Date.now() - lastActivity;
6941
7586
  if (idleMs > IDLE_EXIT_MS && !iframer.browserHealth().alive && !extensionBridge.hasClients()) {
6942
7587
  gracefulShutdown(`idle for ${Math.round(idleMs / 6e4)}min with no browsers`);