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.
package/dist/cli.js CHANGED
@@ -902,6 +902,54 @@ function findChromeExecutable() {
902
902
  if (import_fs3.default.existsSync("/usr/bin/google-chrome-stable")) return "/usr/bin/google-chrome-stable";
903
903
  return void 0;
904
904
  }
905
+ function armHeadedIdleTimer() {
906
+ if (headedIdleTimer) clearTimeout(headedIdleTimer);
907
+ headedIdleTimer = setTimeout(() => {
908
+ if (!headedBrowser) return;
909
+ const pages = headedBrowser.contexts().reduce((n, c) => n + c.pages().length, 0);
910
+ if (pages > 0) {
911
+ armHeadedIdleTimer();
912
+ return;
913
+ }
914
+ log2.info("idle timeout for the pivot headed browser, closing");
915
+ void closeHeadedBrowser();
916
+ }, HEADED_IDLE_MS);
917
+ }
918
+ async function getHeadedBrowser() {
919
+ if (!headedBrowser?.isConnected()) {
920
+ const marker = `--iframer-key=pivot-headed-${process.pid}-${Date.now()}`;
921
+ headedBrowser = await import_patchright.chromium.launch({ headless: false, args: [...STEALTH_ARGS, marker] });
922
+ headedPid = findChromePidByMarker(marker);
923
+ if (headedPid) {
924
+ registerBrowser({
925
+ key: "pivot::headed",
926
+ chromePid: headedPid,
927
+ ownerPid: process.pid,
928
+ marker,
929
+ launchedAt: (/* @__PURE__ */ new Date()).toISOString()
930
+ });
931
+ } else {
932
+ log2.warn("could not resolve Chrome PID for the pivot headed browser \u2014 force-kill unavailable");
933
+ }
934
+ }
935
+ armHeadedIdleTimer();
936
+ return headedBrowser;
937
+ }
938
+ async function closeHeadedBrowser() {
939
+ if (headedIdleTimer) {
940
+ clearTimeout(headedIdleTimer);
941
+ headedIdleTimer = null;
942
+ }
943
+ if (headedBrowser) {
944
+ await headedBrowser.close().catch(() => {
945
+ });
946
+ headedBrowser = null;
947
+ }
948
+ if (headedPid !== null) {
949
+ unregisterBrowser(headedPid);
950
+ headedPid = null;
951
+ }
952
+ }
905
953
  async function getBrowser(_name = "chromium") {
906
954
  if (cachedBrowser) {
907
955
  if (cachedBrowser.isConnected()) return cachedBrowser;
@@ -919,6 +967,7 @@ async function getBrowser(_name = "chromium") {
919
967
  return cachedBrowser;
920
968
  }
921
969
  async function closeBrowser() {
970
+ await closeHeadedBrowser();
922
971
  if (!cachedBrowser) return;
923
972
  try {
924
973
  await cachedBrowser.close();
@@ -955,7 +1004,7 @@ async function launchHeadful(displayNum) {
955
1004
  log2.debug(`headful: ${executablePath || "patchright chromium"}, extensions: ${hasExtensions}`);
956
1005
  return import_patchright.chromium.launch(launchOpts);
957
1006
  }
958
- var import_fs3, import_patchright, log2, UBLOCK_PATH, cachedBrowser;
1007
+ var import_fs3, import_patchright, log2, UBLOCK_PATH, cachedBrowser, headedBrowser, headedPid, headedIdleTimer, HEADED_IDLE_MS;
959
1008
  var init_launcher = __esm({
960
1009
  "src/lib/browser/launcher.ts"() {
961
1010
  "use strict";
@@ -963,9 +1012,14 @@ var init_launcher = __esm({
963
1012
  import_patchright = require("patchright");
964
1013
  init_stealth();
965
1014
  init_logger();
1015
+ init_registry();
966
1016
  log2 = createLogger("launcher");
967
1017
  UBLOCK_PATH = "/extensions/uBlock0.chromium";
968
1018
  cachedBrowser = null;
1019
+ headedBrowser = null;
1020
+ headedPid = null;
1021
+ headedIdleTimer = null;
1022
+ HEADED_IDLE_MS = 5 * 60 * 1e3;
969
1023
  }
970
1024
  });
971
1025
 
@@ -1023,7 +1077,13 @@ var init_constants = __esm({
1023
1077
  /** Scroll step delay */
1024
1078
  SCROLL_DELAY: 150,
1025
1079
  /** Stale state check interval */
1026
- STALE_CHECK_INTERVAL: 2e3
1080
+ STALE_CHECK_INTERVAL: 2e3,
1081
+ /** captureApi: grace period after the last step for a submit's XHR to fire */
1082
+ CAPTURE_SETTLE: 300,
1083
+ /** captureApi: max wait for in-flight requests to answer before stopping */
1084
+ CAPTURE_PENDING_MAX: 3e3,
1085
+ /** Post-failure probe ("does the selector exist right now?") budget */
1086
+ FAILURE_PROBE: 3e3
1027
1087
  };
1028
1088
  CAPTCHA_GRID = {
1029
1089
  /** Pixels from bframe top to grid start (reCAPTCHA) */
@@ -1061,6 +1121,10 @@ var init_constants = __esm({
1061
1121
  DEFAULT_STALE: 2e4,
1062
1122
  NAVIGATION: 6e4,
1063
1123
  SELECTOR_WAIT: 1e4,
1124
+ // Actionability wait for click. Under the 20s stale timer on purpose, so
1125
+ // Playwright's own reason (not visible, covered by another element…) reaches
1126
+ // the agent instead of a generic "no state change".
1127
+ CLICK: 8e3,
1064
1128
  TOTP_INPUT: 5e3,
1065
1129
  API_REQUEST: 18e4,
1066
1130
  HEALTH_CHECK: 3e3,
@@ -1817,7 +1881,7 @@ var init_cloak_browser = __esm({
1817
1881
  function keyOf(mode, instanceId) {
1818
1882
  return `${mode}::${instanceId}`;
1819
1883
  }
1820
- var import_patchright2, import_crypto2, log7, DEFAULT_IDLE_TIMEOUT, CLOSE_GRACE_MS, DEFAULT_INSTANCE, sleep3, BrowserDaemon;
1884
+ var import_patchright2, import_crypto2, log7, DEFAULT_IDLE_TIMEOUT, CLOSE_GRACE_MS, BUSY_MAX_MS, BLANK_IDLE_MS, DEFAULT_INSTANCE, sleep3, BrowserDaemon;
1821
1885
  var init_daemon = __esm({
1822
1886
  "src/lib/browser/daemon.ts"() {
1823
1887
  "use strict";
@@ -1830,6 +1894,8 @@ var init_daemon = __esm({
1830
1894
  log7 = createLogger("daemon");
1831
1895
  DEFAULT_IDLE_TIMEOUT = 5 * 60 * 1e3;
1832
1896
  CLOSE_GRACE_MS = 5e3;
1897
+ BUSY_MAX_MS = 30 * 60 * 1e3;
1898
+ BLANK_IDLE_MS = 2 * 60 * 1e3;
1833
1899
  DEFAULT_INSTANCE = "default";
1834
1900
  sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
1835
1901
  BrowserDaemon = class {
@@ -1918,7 +1984,8 @@ var init_daemon = __esm({
1918
1984
  createdAt: /* @__PURE__ */ new Date(),
1919
1985
  chromePid,
1920
1986
  marker,
1921
- active: 0
1987
+ active: 0,
1988
+ busySince: null
1922
1989
  };
1923
1990
  this.instances.set(key, instance);
1924
1991
  this.resetIdleTimer(key);
@@ -1930,14 +1997,19 @@ var init_daemon = __esm({
1930
1997
  * Always pair with release() in a finally block. */
1931
1998
  acquire(mode, instanceId = DEFAULT_INSTANCE) {
1932
1999
  const instance = this.instances.get(keyOf(mode, instanceId));
1933
- if (instance) instance.active++;
2000
+ if (!instance) return;
2001
+ if (instance.active === 0) instance.busySince = Date.now();
2002
+ instance.active++;
1934
2003
  }
1935
2004
  release(mode, instanceId = DEFAULT_INSTANCE) {
1936
2005
  const key = keyOf(mode, instanceId);
1937
2006
  const instance = this.instances.get(key);
1938
2007
  if (!instance) return;
1939
2008
  instance.active = Math.max(0, instance.active - 1);
1940
- if (instance.active === 0) this.resetIdleTimer(key);
2009
+ if (instance.active === 0) {
2010
+ instance.busySince = null;
2011
+ this.resetIdleTimer(key);
2012
+ }
1941
2013
  }
1942
2014
  isRunning(mode, instanceId = DEFAULT_INSTANCE) {
1943
2015
  const instance = this.instances.get(keyOf(mode, instanceId));
@@ -1948,6 +2020,35 @@ var init_daemon = __esm({
1948
2020
  return false;
1949
2021
  }
1950
2022
  }
2023
+ /** Close instances that never left about:blank and have been idle a while.
2024
+ * Those are accidents — a pipeline that failed before its first navigate, or
2025
+ * a mode picked for a call that never needed a browser — and in headful mode
2026
+ * each one is an empty window sitting on the user's desktop. Called on the
2027
+ * server's reap tick, so it also catches instances whose idle timer was lost
2028
+ * (e.g. the daemon was restarted under them). */
2029
+ sweepBlankInstances() {
2030
+ const stopped = [];
2031
+ const now = Date.now();
2032
+ for (const [key, inst] of this.instances.entries()) {
2033
+ if (inst.active > 0) continue;
2034
+ if (now - inst.createdAt.getTime() < BLANK_IDLE_MS) continue;
2035
+ let blank = false;
2036
+ try {
2037
+ const pages = inst.context.pages();
2038
+ blank = pages.length === 0 || pages.every((p) => {
2039
+ const u = p.url();
2040
+ return u === "about:blank" || u === "" || u === "chrome://newtab/";
2041
+ });
2042
+ } catch {
2043
+ continue;
2044
+ }
2045
+ if (!blank) continue;
2046
+ log7.info(`blank-orphan sweep: ${key} never left about:blank, stopping`);
2047
+ stopped.push(key);
2048
+ this.stopKey(key).catch((err) => log7.warn(`blank sweep stop failed for ${key}: ${err}`));
2049
+ }
2050
+ return stopped;
2051
+ }
1951
2052
  /** Distinct modes that currently have at least one live instance. */
1952
2053
  runningModes() {
1953
2054
  return [...new Set(this.liveInstances().map((i) => i.mode))];
@@ -2066,8 +2167,14 @@ var init_daemon = __esm({
2066
2167
  setTimeout(() => {
2067
2168
  const instance = this.instances.get(key);
2068
2169
  if (instance && instance.active > 0) {
2069
- this.resetIdleTimer(key);
2070
- return;
2170
+ const busyMs = instance.busySince ? Date.now() - instance.busySince : 0;
2171
+ if (busyMs < BUSY_MAX_MS) {
2172
+ this.resetIdleTimer(key);
2173
+ return;
2174
+ }
2175
+ log7.warn(`${key} reported busy for ${Math.round(busyMs / 6e4)}m \u2014 treating active=${instance.active} as leaked, stopping`);
2176
+ instance.active = 0;
2177
+ instance.busySince = null;
2071
2178
  }
2072
2179
  log7.info(`Idle timeout for ${key}, stopping...`);
2073
2180
  this.stopKey(key).catch((err) => log7.warn(`idle stop failed for ${key}: ${err}`));
@@ -2273,6 +2380,59 @@ var init_ref_store = __esm({
2273
2380
  }
2274
2381
  });
2275
2382
 
2383
+ // src/lib/site-protection.ts
2384
+ async function paceProtectedDomain(domain) {
2385
+ const now = Date.now();
2386
+ const slot = Math.max(now, nextRequest.get(domain) || 0);
2387
+ nextRequest.set(domain, slot + PROTECTED_INTERVAL_MS);
2388
+ if (slot > now) await new Promise((resolve) => setTimeout(resolve, slot - now));
2389
+ }
2390
+ async function detectSiteProtection(page) {
2391
+ const cookies = await page.context().cookies(page.url()).catch(() => []);
2392
+ if (cookies.some((c) => /^(incap_ses_|visid_incap_|nlbi_)/i.test(c.name))) return "imperva";
2393
+ if (cookies.some((c) => /^(?:_abck|bm_sz|ak_bmsc|bm_sv)$/.test(c.name))) return "akamai";
2394
+ const html = await page.content().catch(() => "");
2395
+ if (/\/[_]Incapsula_Resource|incapsula incident id|powered by imperva/i.test(html)) return "imperva";
2396
+ if (/errors\.edgesuite\.net|akamai bot manager/i.test(html)) return "akamai";
2397
+ return null;
2398
+ }
2399
+ function protectionHooks(headless, respectful = false) {
2400
+ return {
2401
+ before: async (page, step) => {
2402
+ const protection = await detectSiteProtection(page);
2403
+ if (protection) {
2404
+ respectful = true;
2405
+ if (headless) throw new HeadedRequiredError(page.url());
2406
+ }
2407
+ if (respectful) await paceProtectedDomain(new URL(step.url || page.url()).hostname);
2408
+ },
2409
+ after: async (page) => {
2410
+ const protection = await detectSiteProtection(page);
2411
+ if (!protection) return;
2412
+ respectful = true;
2413
+ if (headless) throw new HeadedRequiredError(page.url());
2414
+ }
2415
+ };
2416
+ }
2417
+ var PROTECTED_INTERVAL_MS, nextRequest, HeadedRequiredError;
2418
+ var init_site_protection = __esm({
2419
+ "src/lib/site-protection.ts"() {
2420
+ "use strict";
2421
+ PROTECTED_INTERVAL_MS = 3e3;
2422
+ nextRequest = /* @__PURE__ */ new Map();
2423
+ HeadedRequiredError = class extends Error {
2424
+ constructor(url) {
2425
+ super("This site requires a headed browser.");
2426
+ this.url = url;
2427
+ }
2428
+ url;
2429
+ stepIndex = 0;
2430
+ results = [];
2431
+ obstacles = [];
2432
+ };
2433
+ }
2434
+ });
2435
+
2276
2436
  // src/lib/actions/types.ts
2277
2437
  function failedStepResult(step, error, durationMs, stepIndex = -1) {
2278
2438
  return { stepIndex, step, ok: false, error, durationMs };
@@ -2416,6 +2576,24 @@ async function humanType(page, selector, text, opts = {}) {
2416
2576
  await sleep4(randRange(TIMING.POST_CLICK));
2417
2577
  }
2418
2578
  await assertFocused(page, selector, !opts.skipClick);
2579
+ if (opts.clear) {
2580
+ const hadText = await page.evaluate(() => {
2581
+ const el = document.activeElement;
2582
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
2583
+ el.select();
2584
+ return el.value.length > 0;
2585
+ }
2586
+ if (el?.isContentEditable) {
2587
+ window.getSelection()?.selectAllChildren(el);
2588
+ return (el.textContent || "").length > 0;
2589
+ }
2590
+ return false;
2591
+ });
2592
+ if (hadText) {
2593
+ await page.keyboard.press("Backspace");
2594
+ await sleep4(randRange(TIMING.CHAR_DELAY));
2595
+ }
2596
+ }
2419
2597
  const factor = SPEED_FACTOR[opts.speed || "normal"] ?? 1;
2420
2598
  for (const char of text) {
2421
2599
  await page.keyboard.type(char);
@@ -2577,7 +2755,41 @@ var init_humanize = __esm({
2577
2755
  }
2578
2756
  });
2579
2757
 
2758
+ // src/lib/budget.ts
2759
+ function withBudget(p, ms, label = "page call") {
2760
+ p.catch(() => void 0);
2761
+ let timer;
2762
+ return Promise.race([
2763
+ p,
2764
+ new Promise((_, reject) => {
2765
+ timer = setTimeout(() => reject(new BudgetExceededError(label, ms)), ms);
2766
+ timer.unref?.();
2767
+ })
2768
+ ]).finally(() => {
2769
+ if (timer) clearTimeout(timer);
2770
+ });
2771
+ }
2772
+ var BudgetExceededError;
2773
+ var init_budget = __esm({
2774
+ "src/lib/budget.ts"() {
2775
+ "use strict";
2776
+ BudgetExceededError = class extends Error {
2777
+ constructor(label, ms) {
2778
+ super(`${label} did not answer within ${ms}ms`);
2779
+ this.name = "BudgetExceededError";
2780
+ }
2781
+ };
2782
+ }
2783
+ });
2784
+
2580
2785
  // src/lib/actions/resolve-selector.ts
2786
+ function assertCssSelector(selector) {
2787
+ if (PLAYWRIGHT_ONLY.test(selector)) {
2788
+ throw new Error(
2789
+ `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.`
2790
+ );
2791
+ }
2792
+ }
2581
2793
  function resolveSelector(selector, ctx) {
2582
2794
  if (selector.startsWith("@a:")) {
2583
2795
  const name = selector.slice(3);
@@ -2598,23 +2810,160 @@ function resolveSelector(selector, ctx) {
2598
2810
  }
2599
2811
  return ref.selector;
2600
2812
  }
2813
+ assertCssSelector(selector);
2601
2814
  return selector;
2602
2815
  }
2603
2816
  function anchorNameOf(selector) {
2604
2817
  return typeof selector === "string" && selector.startsWith("@a:") ? selector.slice(3) : null;
2605
2818
  }
2819
+ var PLAYWRIGHT_ONLY;
2606
2820
  var init_resolve_selector = __esm({
2607
2821
  "src/lib/actions/resolve-selector.ts"() {
2608
2822
  "use strict";
2823
+ 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/;
2824
+ }
2825
+ });
2826
+
2827
+ // src/lib/reachability.ts
2828
+ function isHoneypotReason(reason) {
2829
+ return !!reason && HONEYPOT_REASONS.has(reason);
2830
+ }
2831
+ function looksLikeBaitField(attrs) {
2832
+ const name = attrs.name || "";
2833
+ const id = attrs.id || "";
2834
+ if (HONEYPOT_NAME_RE.test(name) || HONEYPOT_NAME_RE.test(id)) return true;
2835
+ const textual = !attrs.type || /^(text|email|url|tel|search|number)$/i.test(attrs.type);
2836
+ if (textual && attrs.tabindex === "-1") return true;
2837
+ return false;
2838
+ }
2839
+ function honeypotRefusalMessage(selector, reason, name) {
2840
+ const what = name ? `"${name}" (${selector})` : selector;
2841
+ 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.`;
2842
+ }
2843
+ async function probeField(page, selector) {
2844
+ const r = await page.evaluate((sel) => {
2845
+ const el = document.querySelector(sel);
2846
+ if (!el) return { found: false };
2847
+ const attrs = {
2848
+ name: el.getAttribute("name"),
2849
+ id: el.id || null,
2850
+ tabindex: el.getAttribute("tabindex"),
2851
+ type: el.getAttribute("type")
2852
+ };
2853
+ const tag = el.tagName.toLowerCase();
2854
+ {
2855
+ const r0 = el.getBoundingClientRect();
2856
+ const cx0 = r0.left + r0.width / 2, cy0 = r0.top + r0.height / 2;
2857
+ const dx = cx0 < 0 || cx0 >= window.innerWidth ? cx0 - window.innerWidth / 2 : 0;
2858
+ const dy = cy0 < 0 || cy0 >= window.innerHeight ? cy0 - window.innerHeight / 2 : 0;
2859
+ if (dx || dy) try {
2860
+ window.scrollBy(dx, dy);
2861
+ } catch {
2862
+ }
2863
+ }
2864
+ const rect = el.getBoundingClientRect();
2865
+ let reason = null;
2866
+ if (rect.width === 0 || rect.height === 0) reason = "zero-size";
2867
+ const style = getComputedStyle(el);
2868
+ if (!reason && (style.display === "none" || style.visibility === "hidden")) reason = "hidden";
2869
+ if (!reason) {
2870
+ let opacity = 1;
2871
+ for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
2872
+ const o = parseFloat(getComputedStyle(n).opacity);
2873
+ const animating = o < 1 && typeof n.getAnimations === "function" && n.getAnimations().length > 0;
2874
+ if (!isNaN(o) && !animating) opacity *= o;
2875
+ }
2876
+ if (opacity < 0.05) reason = "transparent";
2877
+ }
2878
+ if (!reason) {
2879
+ const docW = Math.max(document.documentElement.scrollWidth, window.innerWidth);
2880
+ const docH = Math.max(document.documentElement.scrollHeight, window.innerHeight);
2881
+ const left = rect.left + window.scrollX, right = rect.right + window.scrollX;
2882
+ const top = rect.top + window.scrollY, bottom = rect.bottom + window.scrollY;
2883
+ if (right <= 0 || left >= docW || bottom <= 0 || top >= docH) reason = "offscreen";
2884
+ }
2885
+ if (!reason) {
2886
+ const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
2887
+ const inViewport = cx >= 0 && cy >= 0 && cx < window.innerWidth && cy < window.innerHeight;
2888
+ let clipped = false;
2889
+ for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
2890
+ const s = getComputedStyle(a);
2891
+ const clipsX = /hidden|clip/.test(s.overflowX), clipsY = /hidden|clip/.test(s.overflowY);
2892
+ if (!clipsX && !clipsY) continue;
2893
+ const ar = a.getBoundingClientRect();
2894
+ const outside = clipsX && (cx < ar.left || cx > ar.right) || clipsY && (cy < ar.top || cy > ar.bottom);
2895
+ const tiny = clipsY && ar.height <= 2 || clipsX && ar.width <= 2;
2896
+ if (outside || tiny) {
2897
+ clipped = true;
2898
+ break;
2899
+ }
2900
+ }
2901
+ if (inViewport) {
2902
+ const hit = document.elementFromPoint(cx, cy);
2903
+ if (!(hit && (hit === el || el.contains(hit)))) reason = clipped ? "clipped" : "covered";
2904
+ } else if (clipped) {
2905
+ reason = "clipped";
2906
+ }
2907
+ }
2908
+ return { found: true, reason, attrs, tag };
2909
+ }, selector);
2910
+ if (!r.found) return { found: false, hiddenReason: null, suspiciousName: false };
2911
+ return {
2912
+ found: true,
2913
+ hiddenReason: r.reason ?? null,
2914
+ suspiciousName: looksLikeBaitField(r.attrs),
2915
+ tag: r.tag,
2916
+ name: r.attrs.name ?? void 0
2917
+ };
2918
+ }
2919
+ var HONEYPOT_REASONS, HONEYPOT_NAME_RE;
2920
+ var init_reachability = __esm({
2921
+ "src/lib/reachability.ts"() {
2922
+ "use strict";
2923
+ HONEYPOT_REASONS = /* @__PURE__ */ new Set(["zero-size", "hidden", "transparent", "offscreen", "clipped"]);
2924
+ HONEYPOT_NAME_RE = /^(website|url|homepage|fax|honeypot|honey|hp|hp_[\w-]*|bot|bot_[\w-]*|trap|_gotcha|leave_?blank|do_?not_?fill)$/i;
2609
2925
  }
2610
2926
  });
2611
2927
 
2612
2928
  // src/lib/actions/handlers/navigation.ts
2613
- async function navigate(page, step, ctx) {
2614
- await page.goto(step.url, {
2615
- waitUntil: step.waitUntil || "domcontentloaded",
2616
- timeout: TIMEOUTS.NAVIGATION
2617
- });
2929
+ async function navigate(page, step, ctx, monitor) {
2930
+ const waitUntil = step.waitUntil || "domcontentloaded";
2931
+ const urlBefore = page.url();
2932
+ const onDialog = (d) => {
2933
+ (d.type() === "beforeunload" ? d.accept() : d.dismiss()).catch(() => void 0);
2934
+ };
2935
+ page.on("dialog", onDialog);
2936
+ try {
2937
+ const nav = page.goto(step.url, { waitUntil, timeout: TIMEOUTS.NAVIGATION });
2938
+ nav.catch(() => void 0);
2939
+ let suspectTimer;
2940
+ const outcome = await Promise.race([
2941
+ nav.then(() => "done"),
2942
+ new Promise((r) => {
2943
+ suspectTimer = setTimeout(() => r("slow"), NAV_COMMIT_SUSPECT_MS);
2944
+ })
2945
+ ]).finally(() => clearTimeout(suspectTimer));
2946
+ if (outcome === "slow") {
2947
+ if (page.url() !== urlBefore) {
2948
+ await nav;
2949
+ } else {
2950
+ log9.warn(`navigate: no commit after ${NAV_COMMIT_SUSPECT_MS}ms, falling back to location.assign`);
2951
+ monitor?.reportActivity();
2952
+ await withBudget(page.evaluate((u) => {
2953
+ location.assign(u);
2954
+ }, step.url), 3e3, "location.assign").catch(() => void 0);
2955
+ try {
2956
+ await page.waitForURL((u) => u.toString() !== urlBefore, { timeout: 15e3, waitUntil });
2957
+ } catch {
2958
+ throw new Error(
2959
+ `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.`
2960
+ );
2961
+ }
2962
+ }
2963
+ }
2964
+ } finally {
2965
+ page.off("dialog", onDialog);
2966
+ }
2618
2967
  const stealthScript = contextStealthScripts.get(page.context()) ?? STEALTH_SCRIPT;
2619
2968
  try {
2620
2969
  await page.evaluate(stealthScript);
@@ -2630,14 +2979,37 @@ async function navigate(page, step, ctx) {
2630
2979
  }
2631
2980
  }
2632
2981
  async function click(page, step, ctx) {
2633
- await page.click(resolveSelector(step.selector, ctx));
2982
+ await page.click(resolveSelector(step.selector, ctx), { timeout: TIMEOUTS.CLICK });
2983
+ }
2984
+ async function isCombobox(page, selector) {
2985
+ return withBudget(
2986
+ page.evaluate((sel) => {
2987
+ const el = document.querySelector(sel);
2988
+ if (!el) return false;
2989
+ const role = el.getAttribute("role");
2990
+ return role === "combobox" || el.hasAttribute("aria-autocomplete") || el.getAttribute("aria-haspopup") === "listbox" || el.hasAttribute("list") || !!el.closest('[role="combobox"]');
2991
+ }, selector),
2992
+ 3e3
2993
+ ).catch(() => false);
2634
2994
  }
2635
2995
  async function fill(page, step, ctx) {
2636
2996
  const selector = resolveSelector(step.selector, ctx);
2637
2997
  const value = step.value;
2998
+ if (!step.force) {
2999
+ let probe = null;
3000
+ try {
3001
+ probe = await probeField(page, selector);
3002
+ } catch {
3003
+ probe = null;
3004
+ }
3005
+ if (probe?.found && isHoneypotReason(probe.hiddenReason)) {
3006
+ throw new Error(honeypotRefusalMessage(step.selector, probe.hiddenReason, probe.name));
3007
+ }
3008
+ }
3009
+ const combobox = await isCombobox(page, selector);
2638
3010
  await page.fill(selector, value);
2639
3011
  const stuck = await page.evaluate(
2640
- ({ sel, val }) => {
3012
+ ({ sel, val, keepFocus }) => {
2641
3013
  const el = document.querySelector(sel);
2642
3014
  if (!el) return false;
2643
3015
  const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
@@ -2653,13 +3025,15 @@ async function fill(page, step, ctx) {
2653
3025
  }
2654
3026
  el.dispatchEvent(new Event("input", { bubbles: true }));
2655
3027
  el.dispatchEvent(new Event("change", { bubbles: true }));
2656
- try {
2657
- el.blur();
2658
- } catch {
3028
+ if (!keepFocus) {
3029
+ try {
3030
+ el.blur();
3031
+ } catch {
3032
+ }
2659
3033
  }
2660
3034
  return el.value === val;
2661
3035
  },
2662
- { sel: selector, val: value }
3036
+ { sel: selector, val: value, keepFocus: combobox }
2663
3037
  );
2664
3038
  if (!stuck) {
2665
3039
  const loc = page.locator(selector);
@@ -2667,10 +3041,12 @@ async function fill(page, step, ctx) {
2667
3041
  await loc.click();
2668
3042
  await loc.fill("");
2669
3043
  await loc.pressSequentially(value, { delay: 15 });
2670
- await page.evaluate((sel) => {
2671
- const el = document.querySelector(sel);
2672
- el?.blur?.();
2673
- }, selector);
3044
+ if (!combobox) {
3045
+ await page.evaluate((sel) => {
3046
+ const el = document.querySelector(sel);
3047
+ el?.blur?.();
3048
+ }, selector);
3049
+ }
2674
3050
  } catch {
2675
3051
  }
2676
3052
  }
@@ -2694,7 +3070,7 @@ async function rightClick(page, step, ctx) {
2694
3070
  }
2695
3071
  }
2696
3072
  async function humanTypeStep(page, step, ctx) {
2697
- await humanType(page, resolveSelector(step.selector, ctx), step.value, { skipClick: step.skipClick, speed: step.speed });
3073
+ await humanType(page, resolveSelector(step.selector, ctx), step.value, { skipClick: step.skipClick, speed: step.speed, clear: step.clear });
2698
3074
  }
2699
3075
  async function selectOption(page, step, ctx) {
2700
3076
  const selector = resolveSelector(step.selector, ctx);
@@ -2737,7 +3113,29 @@ async function scroll(page, step, ctx) {
2737
3113
  { dy: step.deltaY ?? 0, sel: selector }
2738
3114
  );
2739
3115
  }
2740
- async function keyboard(page, step) {
3116
+ async function selectAllInFocused(page) {
3117
+ await page.evaluate(() => {
3118
+ const el = document.activeElement;
3119
+ if (el && (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement)) {
3120
+ el.select();
3121
+ } else if (el && el.isContentEditable) {
3122
+ const sel = window.getSelection();
3123
+ sel?.selectAllChildren(el);
3124
+ } else {
3125
+ window.getSelection()?.selectAllChildren(document.body);
3126
+ }
3127
+ });
3128
+ }
3129
+ async function keyboard(page, step, ctx) {
3130
+ if ((step.meta || step.ctrl) && step.key.toLowerCase() === "a" && !step.shift && !step.alt) {
3131
+ await selectAllInFocused(page);
3132
+ return;
3133
+ }
3134
+ if ((step.meta || step.ctrl) && ctx.driver === "extension") {
3135
+ throw new Error(
3136
+ `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.`
3137
+ );
3138
+ }
2741
3139
  const mods = [
2742
3140
  step.meta ? "Meta" : null,
2743
3141
  step.ctrl ? "Control" : null,
@@ -2808,7 +3206,7 @@ async function typeCode(page, step, ctx) {
2808
3206
  }
2809
3207
  return { typed: code.length };
2810
3208
  }
2811
- var log9;
3209
+ var log9, NAV_COMMIT_SUSPECT_MS;
2812
3210
  var init_navigation = __esm({
2813
3211
  "src/lib/actions/handlers/navigation.ts"() {
2814
3212
  "use strict";
@@ -2818,8 +3216,11 @@ var init_navigation = __esm({
2818
3216
  init_persistence();
2819
3217
  init_logger();
2820
3218
  init_constants();
3219
+ init_budget();
2821
3220
  init_resolve_selector();
3221
+ init_reachability();
2822
3222
  log9 = createLogger("actions");
3223
+ NAV_COMMIT_SUSPECT_MS = 12e3;
2823
3224
  }
2824
3225
  });
2825
3226
 
@@ -2941,6 +3342,56 @@ async function takeSnapshot(page, ctx, options) {
2941
3342
  if (rect.bottom < 0 || rect.top > window.innerHeight + 200) return false;
2942
3343
  return true;
2943
3344
  }
3345
+ function isFillable(el) {
3346
+ if (el.tagName === "TEXTAREA" || el.tagName === "SELECT") return true;
3347
+ if (el.hasAttribute("contenteditable") && el.getAttribute("contenteditable") !== "false") return true;
3348
+ if (el.tagName !== "INPUT") return false;
3349
+ const t = (el.type || "text").toLowerCase();
3350
+ return !["button", "submit", "reset", "image", "checkbox", "radio", "file", "hidden", "range", "color"].includes(t);
3351
+ }
3352
+ function unreachableReason(el) {
3353
+ const rect = el.getBoundingClientRect();
3354
+ const style = getComputedStyle(el);
3355
+ let opacity = 1;
3356
+ for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
3357
+ const o = parseFloat(n === el ? style.opacity : getComputedStyle(n).opacity);
3358
+ const animating = o < 1 && typeof n.getAnimations === "function" && n.getAnimations().length > 0;
3359
+ if (!isNaN(o) && !animating) opacity *= o;
3360
+ }
3361
+ if (opacity < 0.05) return "transparent";
3362
+ const docW = Math.max(document.documentElement.scrollWidth, window.innerWidth);
3363
+ const docH = Math.max(document.documentElement.scrollHeight, window.innerHeight);
3364
+ const left = rect.left + window.scrollX, right = rect.right + window.scrollX;
3365
+ const top = rect.top + window.scrollY, bottom = rect.bottom + window.scrollY;
3366
+ if (right <= 0 || left >= docW || bottom <= 0 || top >= docH) return "offscreen";
3367
+ const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
3368
+ let clipped = false;
3369
+ for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
3370
+ const s = getComputedStyle(a);
3371
+ const clipsX = /hidden|clip/.test(s.overflowX), clipsY = /hidden|clip/.test(s.overflowY);
3372
+ if (!clipsX && !clipsY) continue;
3373
+ const ar = a.getBoundingClientRect();
3374
+ const outside = clipsX && (cx < ar.left || cx > ar.right) || clipsY && (cy < ar.top || cy > ar.bottom);
3375
+ const tiny = clipsY && ar.height <= 2 || clipsX && ar.width <= 2;
3376
+ if (tiny) return "clipped";
3377
+ if (outside) clipped = true;
3378
+ }
3379
+ const inViewport = cx >= 0 && cy >= 0 && cx < window.innerWidth && cy < window.innerHeight;
3380
+ if (inViewport) {
3381
+ const hit = document.elementFromPoint(cx, cy);
3382
+ if (hit && (hit === el || el.contains(hit))) return null;
3383
+ return clipped ? "clipped" : null;
3384
+ }
3385
+ return null;
3386
+ }
3387
+ const baitName = /^(website|url|homepage|fax|honeypot|honey|hp|hp_[\w-]*|bot|bot_[\w-]*|trap|_gotcha|leave_?blank|do_?not_?fill)$/i;
3388
+ function looksLikeBait(el) {
3389
+ const name = el.getAttribute("name") || "", id = el.id || "";
3390
+ if (baitName.test(name) || baitName.test(id)) return true;
3391
+ const type = (el.getAttribute("type") || "text").toLowerCase();
3392
+ const textual = /^(text|email|url|tel|search|number)$/.test(type);
3393
+ return textual && el.getAttribute("tabindex") === "-1";
3394
+ }
2944
3395
  function buildSelector(el) {
2945
3396
  const path14 = [];
2946
3397
  let current = el;
@@ -2983,6 +3434,11 @@ async function takeSnapshot(page, ctx, options) {
2983
3434
  if (results.length >= maxElements2) break;
2984
3435
  if (interactiveOnly2 && !isInteractive(el)) continue;
2985
3436
  if (!isVisible(el)) continue;
3437
+ let suspect;
3438
+ if (isFillable(el)) {
3439
+ if (unreachableReason(el)) continue;
3440
+ if (looksLikeBait(el)) suspect = "honeypot?";
3441
+ }
2986
3442
  const tag = el.tagName.toLowerCase();
2987
3443
  const role = el.getAttribute("role") || "";
2988
3444
  const type = el.type || "";
@@ -2995,7 +3451,8 @@ async function takeSnapshot(page, ctx, options) {
2995
3451
  checked: el.checked || false,
2996
3452
  disabled: el.disabled || false,
2997
3453
  selector: buildSelector(el),
2998
- isVisible: true
3454
+ isVisible: true,
3455
+ suspect
2999
3456
  });
3000
3457
  }
3001
3458
  return results;
@@ -3016,6 +3473,7 @@ async function takeSnapshot(page, ctx, options) {
3016
3473
  const state = [];
3017
3474
  if (el.disabled) state.push("disabled");
3018
3475
  if (el.checked) state.push("checked");
3476
+ if (el.suspect) state.push(el.suspect);
3019
3477
  let description = "";
3020
3478
  if (el.placeholder && el.name !== el.placeholder) description = `placeholder="${el.placeholder}"`;
3021
3479
  if (el.type && !["text", "submit", "button", ""].includes(el.type)) {
@@ -4533,12 +4991,15 @@ var init_registry2 = __esm({
4533
4991
  });
4534
4992
 
4535
4993
  // src/lib/stale-monitor.ts
4536
- var DEFAULT_STALE_TIMEOUT_MS, StaleStateMonitor, StaleStateError;
4994
+ var DEFAULT_STALE_TIMEOUT_MS, PROBE_BUDGET_MS, UNRESPONSIVE_GRACE_MS, StaleStateMonitor, StaleStateError;
4537
4995
  var init_stale_monitor = __esm({
4538
4996
  "src/lib/stale-monitor.ts"() {
4539
4997
  "use strict";
4540
4998
  init_constants();
4999
+ init_budget();
4541
5000
  DEFAULT_STALE_TIMEOUT_MS = 2e4;
5001
+ PROBE_BUDGET_MS = 3e3;
5002
+ UNRESPONSIVE_GRACE_MS = 8e3;
4542
5003
  StaleStateMonitor = class _StaleStateMonitor {
4543
5004
  page;
4544
5005
  timeoutMs;
@@ -4550,21 +5011,24 @@ var init_stale_monitor = __esm({
4550
5011
  }
4551
5012
  async snapshot() {
4552
5013
  try {
4553
- const [url, state] = await Promise.all([
4554
- Promise.resolve(this.page.url()),
5014
+ const url = this.page.url();
5015
+ let unresponsive = false;
5016
+ const state = await withBudget(
4555
5017
  this.page.evaluate(() => ({
4556
5018
  documentReadyState: document.readyState,
4557
5019
  bodyTextLength: document.body?.innerText?.length || 0,
4558
5020
  elementCount: document.querySelectorAll("*").length
4559
- })).catch(() => ({
4560
- documentReadyState: "unknown",
4561
- bodyTextLength: 0,
4562
- elementCount: 0
4563
- }))
4564
- ]);
5021
+ })),
5022
+ PROBE_BUDGET_MS,
5023
+ "state probe"
5024
+ ).catch((e) => {
5025
+ unresponsive = e instanceof BudgetExceededError;
5026
+ return { documentReadyState: "unknown", bodyTextLength: 0, elementCount: 0 };
5027
+ });
4565
5028
  return {
4566
5029
  url,
4567
5030
  ...state,
5031
+ unresponsive,
4568
5032
  timestamp: Date.now()
4569
5033
  };
4570
5034
  } catch {
@@ -4608,6 +5072,8 @@ var init_stale_monitor = __esm({
4608
5072
  return new Promise((resolve, reject) => {
4609
5073
  let resolved = false;
4610
5074
  let checkInterval = null;
5075
+ let probing = false;
5076
+ let unresponsiveSince = null;
4611
5077
  const cleanup = () => {
4612
5078
  resolved = true;
4613
5079
  if (checkInterval) clearInterval(checkInterval);
@@ -4615,11 +5081,25 @@ var init_stale_monitor = __esm({
4615
5081
  this.timer = null;
4616
5082
  };
4617
5083
  checkInterval = setInterval(async () => {
4618
- if (resolved) return;
5084
+ if (resolved || probing) return;
4619
5085
  const elapsed = Date.now() - this.lastActivity;
4620
5086
  if (elapsed < this.timeoutMs) return;
5087
+ probing = true;
4621
5088
  try {
4622
5089
  const currentSnapshot = await this.snapshot();
5090
+ if (resolved) return;
5091
+ if (currentSnapshot.unresponsive) {
5092
+ unresponsiveSince ??= Date.now();
5093
+ if (Date.now() - unresponsiveSince < UNRESPONSIVE_GRACE_MS) return;
5094
+ cleanup();
5095
+ reject(new StaleStateError(
5096
+ `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)`,
5097
+ this.timeoutMs,
5098
+ true
5099
+ ));
5100
+ return;
5101
+ }
5102
+ unresponsiveSince = null;
4623
5103
  if (_StaleStateMonitor.hasChanged(beforeSnapshot, currentSnapshot)) {
4624
5104
  this.lastActivity = Date.now();
4625
5105
  return;
@@ -4627,6 +5107,8 @@ var init_stale_monitor = __esm({
4627
5107
  } catch {
4628
5108
  this.lastActivity = Date.now();
4629
5109
  return;
5110
+ } finally {
5111
+ probing = false;
4630
5112
  }
4631
5113
  cleanup();
4632
5114
  reject(new StaleStateError(
@@ -4650,10 +5132,13 @@ var init_stale_monitor = __esm({
4650
5132
  };
4651
5133
  StaleStateError = class extends Error {
4652
5134
  timeoutMs;
4653
- constructor(message, timeoutMs) {
5135
+ /** True when the page stopped answering at all (vs. answering but not changing). */
5136
+ unresponsive;
5137
+ constructor(message, timeoutMs, unresponsive = false) {
4654
5138
  super(message);
4655
5139
  this.name = "StaleStateError";
4656
5140
  this.timeoutMs = timeoutMs;
5141
+ this.unresponsive = unresponsive;
4657
5142
  }
4658
5143
  };
4659
5144
  }
@@ -4882,10 +5367,14 @@ async function capturePageState(page, ctx, opts) {
4882
5367
  url = page.url();
4883
5368
  } catch {
4884
5369
  }
4885
- const title = await page.title().catch(() => "");
5370
+ const title = await withBudget(page.title(), 2e3, "title").catch(() => "");
4886
5371
  if (!screenshot2) return { url, title };
4887
5372
  try {
4888
- const buf = await page.screenshot({ type: "jpeg", quality: 50, fullPage: false });
5373
+ const buf = await withBudget(
5374
+ page.screenshot({ type: "jpeg", quality: 50, fullPage: false, timeout: 12e3 }),
5375
+ 13e3,
5376
+ "screenshot"
5377
+ );
4889
5378
  const screenshotUrl = saveScreenshot(buf, `${namePrefix}-${Date.now()}.jpg`, ctx.screenshotDir, ctx.publicUrl);
4890
5379
  return { url, title, screenshotUrl };
4891
5380
  } catch {
@@ -4896,6 +5385,7 @@ var init_page_state = __esm({
4896
5385
  "src/lib/page-state.ts"() {
4897
5386
  "use strict";
4898
5387
  init_screenshot();
5388
+ init_budget();
4899
5389
  }
4900
5390
  });
4901
5391
 
@@ -5097,6 +5587,24 @@ function buildCurl(method, url, headers, auth, body) {
5097
5587
  parts.push(` '${url}'`);
5098
5588
  return parts.join(" \\\n");
5099
5589
  }
5590
+ function serverErrorsFrom(requests) {
5591
+ const out = [];
5592
+ for (const r of requests) {
5593
+ if (r.responseStatus < 400) continue;
5594
+ let body;
5595
+ if (r.responseBody !== void 0 && r.responseBody !== null) {
5596
+ const raw = typeof r.responseBody === "string" ? r.responseBody : JSON.stringify(r.responseBody);
5597
+ const flat = raw.replace(/\s+/g, " ").trim();
5598
+ body = flat.length > SERVER_ERROR_BODY_MAX ? flat.slice(0, SERVER_ERROR_BODY_MAX) + "\u2026" : flat;
5599
+ if (/^\s*<(!doctype|html)/i.test(raw)) {
5600
+ const m = raw.match(/<title[^>]*>([^<]*)<\/title>|<h1[^>]*>([^<]*)<\/h1>/i);
5601
+ body = m ? `[html] ${(m[1] || m[2] || "").trim()}` : "[html page]";
5602
+ }
5603
+ }
5604
+ out.push({ stepIndex: r.triggeredAtStep, method: r.method, url: r.url, status: r.responseStatus, body });
5605
+ }
5606
+ return out;
5607
+ }
5100
5608
  function extractAuth(requests) {
5101
5609
  const auth = { cookies: {}, tokens: {} };
5102
5610
  for (const req of requests) {
@@ -5182,7 +5690,7 @@ function buildCapturedApi(requests) {
5182
5690
  }
5183
5691
  return apis.sort((a, b) => b.endpoints.length - a.endpoints.length);
5184
5692
  }
5185
- var SKIP_RESOURCE_TYPES, SKIP_EXTENSIONS, BROWSER_NOISE_HEADERS, AUTH_HEADER_PATTERNS, ID_PATTERNS, ApiCapture;
5693
+ var SKIP_RESOURCE_TYPES, SKIP_EXTENSIONS, BROWSER_NOISE_HEADERS, AUTH_HEADER_PATTERNS, ID_PATTERNS, ApiCapture, SERVER_ERROR_BODY_MAX;
5186
5694
  var init_api_capture = __esm({
5187
5695
  "src/lib/api-capture.ts"() {
5188
5696
  "use strict";
@@ -5352,10 +5860,19 @@ var init_api_capture = __esm({
5352
5860
  await new Promise((r) => setTimeout(r, 100));
5353
5861
  }
5354
5862
  }
5863
+ /** Requests fired but not yet answered. */
5864
+ hasPending() {
5865
+ return this.pendingRequests.size > 0;
5866
+ }
5355
5867
  getResults() {
5356
5868
  return buildCapturedApi(this.requests);
5357
5869
  }
5870
+ /** Every HTTP >= 400 response, compact, in capture order. */
5871
+ getServerErrors() {
5872
+ return serverErrorsFrom(this.requests);
5873
+ }
5358
5874
  };
5875
+ SERVER_ERROR_BODY_MAX = 600;
5359
5876
  }
5360
5877
  });
5361
5878
 
@@ -5534,10 +6051,35 @@ function classifyError(err, step) {
5534
6051
  if (step.type === "solve-captcha") return "captcha-unsolvable";
5535
6052
  return "action-failed";
5536
6053
  }
6054
+ async function refineError(page, step, ctx, errorType, message) {
6055
+ if (errorType !== "stale-state" && errorType !== "timeout") return { errorType, message };
6056
+ const raw = step.selector;
6057
+ if (typeof raw !== "string") return { errorType, message };
6058
+ let resolved;
6059
+ try {
6060
+ resolved = resolveSelector(raw, ctx);
6061
+ } catch {
6062
+ return { errorType, message };
6063
+ }
6064
+ try {
6065
+ const probe = page.evaluate((sel) => !!document.querySelector(sel), resolved);
6066
+ const budget = new Promise((r) => setTimeout(() => r(null), TIMING.FAILURE_PROBE));
6067
+ const found = await Promise.race([probe, budget]);
6068
+ if (found !== false) return { errorType, message };
6069
+ } catch {
6070
+ return { errorType, message };
6071
+ }
6072
+ const shown = resolved === raw ? `"${raw}"` : `"${raw}" (\u2192 ${resolved})`;
6073
+ return {
6074
+ errorType: "element-not-found",
6075
+ 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})`
6076
+ };
6077
+ }
5537
6078
  function isRetryable(errorType) {
5538
6079
  return errorType === "stale-state" || errorType === "timeout" || errorType === "element-not-found";
5539
6080
  }
5540
- function getSuggestion(errorType, step) {
6081
+ function getSuggestion(errorType, step, message = "") {
6082
+ if (message.includes("tab stopped responding")) return UNRESPONSIVE_SUGGESTION;
5541
6083
  const usedEphemeralRef = typeof step.selector === "string" && step.selector.startsWith("@e");
5542
6084
  if (usedEphemeralRef && (errorType === "element-not-found" || errorType === "timeout" || errorType === "stale-state")) {
5543
6085
  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.";
@@ -5546,7 +6088,7 @@ function getSuggestion(errorType, step) {
5546
6088
  case "stale-state":
5547
6089
  return "The page stopped responding. The step may have triggered a very slow load or the server may be unreachable.";
5548
6090
  case "element-not-found":
5549
- return `Selector not found. The page structure may have changed. Take a screenshot to inspect the current state.`;
6091
+ 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.";
5550
6092
  case "navigation-failed":
5551
6093
  return "Navigation failed. The URL may be unreachable, blocked, or require authentication.";
5552
6094
  case "captcha-unsolvable":
@@ -5557,10 +6099,11 @@ function getSuggestion(errorType, step) {
5557
6099
  return void 0;
5558
6100
  }
5559
6101
  }
5560
- var DEFAULT_STALE_TIMEOUT_MS2, PipelineRunner;
6102
+ var DEFAULT_STALE_TIMEOUT_MS2, UNRESPONSIVE_SUGGESTION, PipelineRunner;
5561
6103
  var init_pipeline = __esm({
5562
6104
  "src/lib/pipeline.ts"() {
5563
6105
  "use strict";
6106
+ init_site_protection();
5564
6107
  init_registry2();
5565
6108
  init_types();
5566
6109
  init_stale_monitor();
@@ -5572,12 +6115,20 @@ var init_pipeline = __esm({
5572
6115
  init_constants();
5573
6116
  init_component_map();
5574
6117
  init_resolve_selector();
6118
+ init_constants();
6119
+ init_budget();
5575
6120
  DEFAULT_STALE_TIMEOUT_MS2 = 2e4;
6121
+ 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.";
5576
6122
  PipelineRunner = class {
5577
- constructor(ctx) {
6123
+ constructor(ctx, hooks) {
5578
6124
  this.ctx = ctx;
6125
+ this.hooks = hooks;
5579
6126
  }
5580
6127
  ctx;
6128
+ hooks;
6129
+ /** Live progress, readable while run() is still pending — lets an outer
6130
+ * watchdog report WHICH step hung and what already finished. */
6131
+ progress = { stepIndex: -1, results: [] };
5581
6132
  async run(initialPage, pipeline) {
5582
6133
  const tracker = new TabTracker(initialPage.context(), initialPage);
5583
6134
  try {
@@ -5595,6 +6146,7 @@ var init_pipeline = __esm({
5595
6146
  const continueOnError = opts.continueOnError ?? false;
5596
6147
  const results = [];
5597
6148
  const obstacles = [];
6149
+ this.progress.results = results;
5598
6150
  const navStep = pipeline.steps.find((s) => s.type === "navigate");
5599
6151
  try {
5600
6152
  const host = new URL(navStep?.url || safePageUrl(initialPage) || "http://x").hostname;
@@ -5612,27 +6164,41 @@ var init_pipeline = __esm({
5612
6164
  if (capture) capture.start();
5613
6165
  const finishCapture = async () => {
5614
6166
  if (!capture) return void 0;
6167
+ await capture.drain(TIMING.CAPTURE_SETTLE, TIMING.CAPTURE_PENDING_MAX);
5615
6168
  capture.stop();
6169
+ for (const se of capture.getServerErrors()) {
6170
+ const r = results.find((x) => x.stepIndex === se.stepIndex);
6171
+ if (r) (r.serverErrors ??= []).push(se);
6172
+ }
5616
6173
  return capture.getResults();
5617
6174
  };
5618
6175
  for (let i = 0; i < pipeline.steps.length; i++) {
5619
6176
  if (capture) capture.setStep(i);
5620
6177
  const step = pipeline.steps[i];
6178
+ this.progress.stepIndex = i;
5621
6179
  const page = tracker.active();
5622
6180
  const urlBefore = safePageUrl(page);
5623
6181
  const monitor = new StaleStateMonitor(page, staleTimeoutMs);
5624
6182
  let stepResult;
5625
6183
  try {
6184
+ await this.hooks?.before(page, step);
5626
6185
  stepResult = await monitor.withMonitoring(async () => {
5627
6186
  const r = await executeAction(page, step, this.ctx, monitor);
6187
+ if (step.type === "navigate") await this.hooks?.after(page, step.url);
5628
6188
  r.stepIndex = i;
5629
6189
  return r;
5630
6190
  });
5631
6191
  } catch (err) {
6192
+ if (err instanceof HeadedRequiredError) {
6193
+ Object.assign(err, { stepIndex: i, results, obstacles });
6194
+ await finishCapture();
6195
+ throw err;
6196
+ }
5632
6197
  const asError = err instanceof Error ? err : new Error(String(err));
5633
- const errorType = classifyError(asError, step);
6198
+ const refined = await refineError(tracker.active(), step, this.ctx, classifyError(asError, step), asError.message);
6199
+ const errorType = refined.errorType;
5634
6200
  const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
5635
- stepResult = failedStepResult(step, asError.message, Date.now() - startTime, i);
6201
+ stepResult = failedStepResult(step, refined.message, Date.now() - startTime, i);
5636
6202
  results.push(stepResult);
5637
6203
  recordAnchor(step, false);
5638
6204
  return {
@@ -5646,9 +6212,9 @@ var init_pipeline = __esm({
5646
6212
  failedAtStep: i,
5647
6213
  failedStep: step,
5648
6214
  errorType,
5649
- message: asError.message,
6215
+ message: refined.message,
5650
6216
  pageState,
5651
- suggestion: getSuggestion(errorType, step),
6217
+ suggestion: getSuggestion(errorType, step, refined.message),
5652
6218
  retryable: isRetryable(errorType)
5653
6219
  },
5654
6220
  durationMs: Date.now() - startTime,
@@ -5682,8 +6248,16 @@ var init_pipeline = __esm({
5682
6248
  }
5683
6249
  results.push(stepResult);
5684
6250
  if (!stepResult.ok && !continueOnError) {
6251
+ const refined = await refineError(
6252
+ tracker.active(),
6253
+ step,
6254
+ this.ctx,
6255
+ classifyError(new Error(stepResult.error || ""), step),
6256
+ stepResult.error || "Step failed"
6257
+ );
6258
+ stepResult.error = refined.message;
6259
+ const errorType = refined.errorType;
5685
6260
  const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
5686
- const errorType = classifyError(new Error(stepResult.error || ""), step);
5687
6261
  return {
5688
6262
  ok: false,
5689
6263
  completedSteps: i,
@@ -5697,7 +6271,7 @@ var init_pipeline = __esm({
5697
6271
  errorType,
5698
6272
  message: stepResult.error || "Step failed",
5699
6273
  pageState,
5700
- suggestion: getSuggestion(errorType, step),
6274
+ suggestion: getSuggestion(errorType, step, refined.message),
5701
6275
  retryable: isRetryable(errorType)
5702
6276
  },
5703
6277
  durationMs: Date.now() - startTime,
@@ -5707,7 +6281,7 @@ var init_pipeline = __esm({
5707
6281
  if (step.type === "navigate" && continueOnObstacle) {
5708
6282
  const obstacleStart = Date.now();
5709
6283
  const obstaclePage = tracker.active();
5710
- const obstacle = await detectObstacles(obstaclePage);
6284
+ const obstacle = await withBudget(detectObstacles(obstaclePage), 1e4, "obstacle check").catch(() => null);
5711
6285
  if (obstacle) {
5712
6286
  const resolution = await resolveObstacle(obstaclePage, obstacle, this.ctx, monitor);
5713
6287
  obstacles.push({
@@ -10119,7 +10693,7 @@ var init_cdp_relay = __esm({
10119
10693
  return {};
10120
10694
  case "Browser.close":
10121
10695
  return {};
10122
- // patchright disconnect — don't kill the user's Chrome
10696
+ // Disconnect without closing the user's Chrome.
10123
10697
  case "Target.setDiscoverTargets":
10124
10698
  return {};
10125
10699
  case "Target.getTargets":
@@ -10162,7 +10736,7 @@ var init_cdp_relay = __esm({
10162
10736
  return await Promise.race([
10163
10737
  first,
10164
10738
  new Promise((_, reject) => {
10165
- const t = setTimeout(() => reject(new Error("screenshot timed out (no compositor frame)")), 1e4);
10739
+ const t = setTimeout(() => reject(new Error("screenshot timed out (no compositor frame)")), 5e3);
10166
10740
  t.unref?.();
10167
10741
  })
10168
10742
  ]);
@@ -10222,10 +10796,29 @@ var init_cdp_relay = __esm({
10222
10796
  });
10223
10797
 
10224
10798
  // src/lib/execution/pipeline-executor.ts
10799
+ function blankPipelineResult(pipeline, modeUsed, error) {
10800
+ return {
10801
+ ok: false,
10802
+ completedSteps: 0,
10803
+ totalSteps: pipeline.steps.length,
10804
+ results: [],
10805
+ obstacles: [],
10806
+ error: {
10807
+ failedAtStep: 0,
10808
+ failedStep: pipeline.steps[0],
10809
+ pageState: { url: "", title: "" },
10810
+ ...error
10811
+ },
10812
+ durationMs: 0,
10813
+ modeUsed,
10814
+ finalState: { url: "", title: "" }
10815
+ };
10816
+ }
10225
10817
  var import_playwright_core, log18, PipelineExecutor;
10226
10818
  var init_pipeline_executor = __esm({
10227
10819
  "src/lib/execution/pipeline-executor.ts"() {
10228
10820
  "use strict";
10821
+ init_site_protection();
10229
10822
  import_playwright_core = require("playwright-core");
10230
10823
  init_daemon();
10231
10824
  init_pipeline();
@@ -10245,8 +10838,8 @@ var init_pipeline_executor = __esm({
10245
10838
  this.deps = deps;
10246
10839
  }
10247
10840
  deps;
10248
- /** Runtime elicitation hook, set per-call via execute(). Consumed once by
10249
- * executeLocal and immediately cleared. */
10841
+ /** Shared runtime hook read by local and extension execution;
10842
+ * execute() clears it when the call finishes. */
10250
10843
  pendingElicitOtp;
10251
10844
  /** One pipeline per real tab at a time: chrome.debugger and the CDP relay
10252
10845
  * can't share a tab, so concurrent executes on the same tab queue up here. */
@@ -10277,8 +10870,24 @@ var init_pipeline_executor = __esm({
10277
10870
  const forcedMode = opts.mode;
10278
10871
  const autoEscalate = opts.autoEscalate !== false;
10279
10872
  const instanceId = opts.instanceId || DEFAULT_INSTANCE;
10873
+ if (forcedMode === "extension") {
10874
+ return blankPipelineResult(pipeline, "extension", {
10875
+ errorType: "action-failed",
10876
+ message: 'mode="extension" requires options.tabId (the real-Chrome tab to drive).',
10877
+ suggestion: "Call the `tabs` tool (or GET /extension/tabs) to list open tabs, then pass that tab id. Nothing was launched.",
10878
+ retryable: false
10879
+ });
10880
+ }
10280
10881
  const firstNav = pipeline.steps.find((s) => s.type === "navigate");
10281
10882
  const domain = firstNav ? new URL(firstNav.url).hostname : null;
10883
+ if (!firstNav && !this.deps.daemon.findLiveMode(instanceId)) {
10884
+ return blankPipelineResult(pipeline, forcedMode || "headless", {
10885
+ errorType: "action-failed",
10886
+ message: `No navigate step and no live browser for instanceId "${instanceId}" to act on.`,
10887
+ 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.",
10888
+ retryable: false
10889
+ });
10890
+ }
10282
10891
  const availableModes = this.deps.availableModes();
10283
10892
  const liveMode = forcedMode ? null : this.deps.daemon.findLiveMode(instanceId);
10284
10893
  let mode;
@@ -10292,6 +10901,7 @@ var init_pipeline_executor = __esm({
10292
10901
  mode = availableModes[0] || "headless";
10293
10902
  }
10294
10903
  let result = await this.executeWithMode(userId, token, pipeline, mode, instanceId);
10904
+ mode = result.modeUsed || mode;
10295
10905
  if (!result.ok && autoEscalate && domain && result.error?.errorType === "bot-blocked") {
10296
10906
  const failedMode = mode;
10297
10907
  if (domain) this.deps.domainModes.recordFailure(domain, failedMode, result.error?.message || "blocked");
@@ -10328,11 +10938,11 @@ var init_pipeline_executor = __esm({
10328
10938
  }
10329
10939
  return result;
10330
10940
  }
10331
- async executeWithMode(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE) {
10941
+ async executeWithMode(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE, respectful = false) {
10332
10942
  if (mode === "docker-headful") {
10333
- return this.executeDocker(userId, token, pipeline);
10943
+ return this.executeDocker(userId, token, pipeline, respectful);
10334
10944
  }
10335
- return this.executeLocal(userId, token, pipeline, mode, instanceId);
10945
+ return this.executeLocal(userId, token, pipeline, mode, instanceId, respectful);
10336
10946
  }
10337
10947
  /** Execute against the user's real Chrome tab via the extension CDP relay,
10338
10948
  * using the SAME PipelineRunner as every other mode. No session inject/extract
@@ -10341,6 +10951,7 @@ var init_pipeline_executor = __esm({
10341
10951
  const startTime = Date.now();
10342
10952
  const relay = new CdpRelay(tabId, clientId, pipeline.options?.focus);
10343
10953
  let browser;
10954
+ let runner;
10344
10955
  try {
10345
10956
  await relay.start();
10346
10957
  browser = await import_playwright_core.chromium.connectOverCDP(relay.httpEndpoint(), { timeout: 3e4 });
@@ -10353,7 +10964,8 @@ var init_pipeline_executor = __esm({
10353
10964
  if (!page) throw new Error("no page available for the tab (is it still open?)");
10354
10965
  const ctx = this.deps.refStore.makeContext(userId, token);
10355
10966
  if (this.pendingElicitOtp) ctx.elicitOtp = this.pendingElicitOtp;
10356
- const runner = new PipelineRunner(ctx);
10967
+ ctx.driver = "extension";
10968
+ runner = new PipelineRunner(ctx);
10357
10969
  const typeChars = pipeline.steps.reduce((n, s) => {
10358
10970
  const v = s.value;
10359
10971
  return (s.type === "human-type" || s.type === "type-code") && typeof v === "string" ? n + v.length : n;
@@ -10370,7 +10982,7 @@ var init_pipeline_executor = __esm({
10370
10982
  watchdog = setTimeout(
10371
10983
  () => reject(
10372
10984
  new Error(
10373
- `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.`
10985
+ `pipeline exceeded ${Math.round(capMs / 1e3)}s \u2014 a call into the tab never returned.`
10374
10986
  )
10375
10987
  ),
10376
10988
  capMs
@@ -10394,23 +11006,26 @@ var init_pipeline_executor = __esm({
10394
11006
  } catch (err) {
10395
11007
  const msg = getErrorMessage(err);
10396
11008
  const stalled = msg.includes("pipeline exceeded");
11009
+ const done = runner ? runner.progress.results.filter((r) => r.ok) : [];
11010
+ const hungAt = stalled && runner && runner.progress.stepIndex >= 0 ? runner.progress.stepIndex : 0;
11011
+ const hungStep = pipeline.steps[hungAt];
10397
11012
  return {
10398
11013
  ok: false,
10399
- completedSteps: 0,
11014
+ completedSteps: stalled ? done.length : 0,
10400
11015
  totalSteps: pipeline.steps.length,
10401
- results: [],
11016
+ results: stalled ? done : [],
10402
11017
  finalState: { url: "", title: "" },
10403
11018
  obstacles: [],
10404
11019
  durationMs: Date.now() - startTime,
10405
11020
  modeUsed: "extension",
10406
11021
  error: {
10407
- failedAtStep: 0,
10408
- failedStep: pipeline.steps[0],
10409
- errorType: "action-failed",
10410
- message: `Extension mode failed: ${msg}`,
11022
+ failedAtStep: hungAt,
11023
+ failedStep: hungStep,
11024
+ errorType: stalled ? "stale-state" : "action-failed",
11025
+ message: stalled ? `Extension mode: step ${hungAt} (${hungStep?.type}) never returned; ${msg}` : `Extension mode failed: ${msg}`,
10411
11026
  pageState: { url: "", title: "" },
10412
- 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.",
10413
- retryable: !stalled
11027
+ 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.",
11028
+ retryable: stalled
10414
11029
  }
10415
11030
  };
10416
11031
  } finally {
@@ -10425,7 +11040,7 @@ var init_pipeline_executor = __esm({
10425
11040
  }
10426
11041
  }
10427
11042
  /** Execute via Docker session-manager. */
10428
- async executeDocker(userId, token, pipeline) {
11043
+ async executeDocker(userId, token, pipeline, respectful = false) {
10429
11044
  let session = getSession(userId);
10430
11045
  if (!session) {
10431
11046
  const firstNav = pipeline.steps.find((s) => s.type === "navigate");
@@ -10434,7 +11049,7 @@ var init_pipeline_executor = __esm({
10434
11049
  }
10435
11050
  resetTimeout(userId);
10436
11051
  const ctx = this.deps.refStore.makeContext(userId, token);
10437
- const runner = new PipelineRunner(ctx);
11052
+ const runner = new PipelineRunner(ctx, protectionHooks(false, respectful));
10438
11053
  const result = await runner.run(session.page, pipeline);
10439
11054
  if (result.ok) {
10440
11055
  const blockResult = await detectBlock(session.page);
@@ -10461,7 +11076,7 @@ var init_pipeline_executor = __esm({
10461
11076
  return result;
10462
11077
  }
10463
11078
  /** Execute via local Chrome daemon (headless + binary-headful). */
10464
- async executeLocal(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE) {
11079
+ async executeLocal(userId, token, pipeline, mode, instanceId = DEFAULT_INSTANCE, respectful = false) {
10465
11080
  const startTime = Date.now();
10466
11081
  let acquired = false;
10467
11082
  try {
@@ -10483,8 +11098,38 @@ var init_pipeline_executor = __esm({
10483
11098
  const ctx = this.deps.refStore.makeContext(userId, token);
10484
11099
  if (sessionData) ctx.sessionData = sessionData;
10485
11100
  if (this.pendingElicitOtp) ctx.elicitOtp = this.pendingElicitOtp;
10486
- const runner = new PipelineRunner(ctx);
10487
- const result = await runner.run(page, pipeline);
11101
+ const runner = new PipelineRunner(ctx, protectionHooks(mode === "headless", respectful));
11102
+ let result;
11103
+ try {
11104
+ result = await runner.run(page, pipeline);
11105
+ } catch (err) {
11106
+ if (!(err instanceof HeadedRequiredError)) throw err;
11107
+ this.deps.daemon.release(mode, instanceId);
11108
+ acquired = false;
11109
+ await this.deps.daemon.stopMode(mode, instanceId);
11110
+ const available = this.deps.availableModes();
11111
+ const headed = available.includes("binary-headful") ? "binary-headful" : "docker-headful";
11112
+ if (!available.includes(headed)) throw new Error("A headed browser is required but unavailable.");
11113
+ await new Promise((resolve) => setTimeout(resolve, PROTECTED_INTERVAL_MS));
11114
+ const index = err.stepIndex;
11115
+ const remaining = pipeline.steps.slice(index);
11116
+ const insertedNavigation = remaining[0]?.type !== "navigate";
11117
+ if (insertedNavigation) remaining.unshift({ type: "navigate", url: err.url });
11118
+ const resumed = await this.executeWithMode(userId, token, { ...pipeline, steps: remaining }, headed, instanceId, true);
11119
+ const offset = index - Number(insertedNavigation);
11120
+ resumed.results = [...err.results, ...resumed.results.filter((r) => !insertedNavigation || r.stepIndex > 0).map((r) => ({ ...r, stepIndex: r.stepIndex + offset }))];
11121
+ resumed.completedSteps = index + Math.max(0, resumed.completedSteps - Number(insertedNavigation));
11122
+ resumed.totalSteps = pipeline.steps.length;
11123
+ resumed.obstacles = [...err.obstacles, ...resumed.obstacles];
11124
+ if (resumed.error) {
11125
+ resumed.error.failedAtStep = Math.max(index, resumed.error.failedAtStep + offset);
11126
+ resumed.error.failedStep = pipeline.steps[resumed.error.failedAtStep];
11127
+ }
11128
+ resumed.durationMs = Date.now() - startTime;
11129
+ resumed.modeEscalated = true;
11130
+ resumed.modeUsed = headed;
11131
+ return resumed;
11132
+ }
10488
11133
  if (result.ok) {
10489
11134
  const blockResult = await detectBlock(page);
10490
11135
  if (blockResult.blocked) {
@@ -10557,6 +11202,8 @@ var FetchService;
10557
11202
  var init_fetch_service = __esm({
10558
11203
  "src/lib/execution/fetch-service.ts"() {
10559
11204
  "use strict";
11205
+ init_site_protection();
11206
+ init_launcher();
10560
11207
  init_launcher();
10561
11208
  init_stealth();
10562
11209
  init_humanize();
@@ -10587,12 +11234,30 @@ var init_fetch_service = __esm({
10587
11234
  const { browser, name: browserName } = await getBrowserWithFallback(preferredBrowser);
10588
11235
  context = await browser.newContext(stealthContextOptions({ locale, extraHTTPHeaders: { ...headers } }, userId ?? void 0));
10589
11236
  if (sessionData) await injectCookies(context, sessionData);
10590
- const page = await context.newPage();
11237
+ let page = await context.newPage();
10591
11238
  await applyStealthToPage(page);
10592
11239
  await page.goto(url, { waitUntil: waitUntil || "domcontentloaded", timeout: TIMEOUTS.NAVIGATION });
11240
+ let protection = false;
11241
+ const pivotIfDetected = async () => {
11242
+ if (protection || !await detectSiteProtection(page)) return;
11243
+ protection = true;
11244
+ const targetUrl = page.url();
11245
+ const cookies = await context.cookies();
11246
+ await context.close();
11247
+ context = await (await getHeadedBrowser()).newContext(stealthContextOptions({ locale, extraHTTPHeaders: { ...headers } }, userId ?? void 0));
11248
+ await context.addCookies(cookies);
11249
+ page = await context.newPage();
11250
+ await applyStealthToPage(page);
11251
+ await new Promise((resolve) => setTimeout(resolve, PROTECTED_INTERVAL_MS));
11252
+ await paceProtectedDomain(new URL(targetUrl).hostname);
11253
+ await page.goto(targetUrl, { waitUntil, timeout: TIMEOUTS.NAVIGATION });
11254
+ };
11255
+ await pivotIfDetected();
10593
11256
  if (sessionData) await injectStorage(page, sessionData);
10594
11257
  if (waitForSelector) await page.waitForSelector(waitForSelector, { timeout: TIMEOUTS.SELECTOR_WAIT });
10595
11258
  for (const action of actions) {
11259
+ await pivotIfDetected();
11260
+ if (protection) await paceProtectedDomain(new URL(page.url()).hostname);
10596
11261
  switch (action.type) {
10597
11262
  case "click":
10598
11263
  await page.click(action.selector);
@@ -10623,6 +11288,7 @@ var init_fetch_service = __esm({
10623
11288
  break;
10624
11289
  }
10625
11290
  }
11291
+ await pivotIfDetected();
10626
11292
  const finalUrl = page.url();
10627
11293
  const html = returnHtml ? await page.content() : void 0;
10628
11294
  const result = extract2 ? await page.evaluate(extract2) : void 0;
@@ -10979,6 +11645,11 @@ var init_iframer = __esm({
10979
11645
  listInstances() {
10980
11646
  return this.daemon.instancesInfo();
10981
11647
  }
11648
+ /** Close browsers that never navigated anywhere. See
11649
+ * BrowserDaemon.sweepBlankInstances — called from the server's reap tick. */
11650
+ sweepBlankBrowsers() {
11651
+ return this.daemon.sweepBlankInstances();
11652
+ }
10982
11653
  /** Kill all browser instances and reset state. Next execute call will
10983
11654
  * launch a fresh browser automatically — no manual restart needed. */
10984
11655
  async restartBrowser() {
@@ -11118,6 +11789,13 @@ ${api.domain} (${api.baseUrl})`);
11118
11789
  }
11119
11790
  }
11120
11791
  }
11792
+ const serverErrors = (data.results || []).flatMap((r) => r.serverErrors || []);
11793
+ if (serverErrors.length > 0) {
11794
+ lines.push("\n--- Server errors (HTTP >= 400) ---");
11795
+ for (const se of serverErrors) {
11796
+ lines.push(` [step ${se.stepIndex}] ${se.method} ${se.url} \u2192 ${se.status}${se.body ? `: ${se.body}` : ""}`);
11797
+ }
11798
+ }
11121
11799
  if (data.error) {
11122
11800
  lines.push("\n--- Failure ---");
11123
11801
  if (typeof data.error === "string") {
@@ -11128,6 +11806,10 @@ ${api.domain} (${api.baseUrl})`);
11128
11806
  lines.push(`Message: ${data.error.message}`);
11129
11807
  lines.push(`Retryable: ${data.error.retryable}`);
11130
11808
  if (data.error.suggestion) lines.push(`Suggestion: ${data.error.suggestion}`);
11809
+ const atFailure = serverErrors.filter((se) => se.stepIndex === data.error?.failedAtStep);
11810
+ for (const se of atFailure) {
11811
+ lines.push(`Server responded: ${se.method} ${se.url} \u2192 ${se.status}${se.body ? `: ${se.body}` : ""}`);
11812
+ }
11131
11813
  if (data.error.pageState?.url) lines.push(`URL at failure: ${data.error.pageState.url}`);
11132
11814
  }
11133
11815
  }
@@ -11544,6 +12226,21 @@ function parseFlag(args2, flag, hasValue = true) {
11544
12226
  function hasFlag(args2, flag) {
11545
12227
  return args2.includes(flag);
11546
12228
  }
12229
+ var LAUNCH_MODES = ["headless", "binary-headful", "docker-headful"];
12230
+ function parseModeFlag(args2) {
12231
+ const mode = parseFlag(args2, "--mode");
12232
+ if (!mode) return void 0;
12233
+ if (LAUNCH_MODES.includes(mode)) return mode;
12234
+ if (mode === "extension") {
12235
+ console.error("\n --mode extension is not available from the CLI: it needs a tab id.");
12236
+ console.error(" Use the `tabs` tool from your agent, or POST /extension/execute with a tabId.\n");
12237
+ process.exit(1);
12238
+ }
12239
+ console.error(`
12240
+ Unknown --mode "${mode}". Use one of: ${LAUNCH_MODES.join(", ")}.
12241
+ `);
12242
+ process.exit(1);
12243
+ }
11547
12244
  function handleResponse(data, screenshotPath) {
11548
12245
  const { screenshot: screenshot2, tileScreenshots, ...rest } = data;
11549
12246
  if (screenshot2 && screenshotPath) {
@@ -12018,7 +12715,7 @@ async function main() {
12018
12715
  process.exit(1);
12019
12716
  }
12020
12717
  const options = { ...inputOptions };
12021
- const mode = parseFlag(args, "--mode");
12718
+ const mode = parseModeFlag(args);
12022
12719
  if (mode) options.mode = mode;
12023
12720
  if (hasFlag(args, "--capture-api")) options.captureApi = true;
12024
12721
  if (hasFlag(args, "--continue-on-error")) options.continueOnError = true;
@@ -12103,7 +12800,7 @@ Screenshot: ${shot}`);
12103
12800
  const url = args[0];
12104
12801
  const outPath = parseFlag(args, "--output") || parseFlag(args, "-o") || "/tmp/iframer-screenshot.jpg";
12105
12802
  if (url && url.startsWith("http")) {
12106
- const mode = parseFlag(args, "--mode") || "headless";
12803
+ const mode = parseModeFlag(args) || "headless";
12107
12804
  const annotate = hasFlag(args, "--annotate");
12108
12805
  const docker = await isDockerRunning();
12109
12806
  const steps = [
@@ -12334,7 +13031,7 @@ Screenshot: ${shot}`);
12334
13031
  process.exit(1);
12335
13032
  }
12336
13033
  const options = { captureApi: true };
12337
- const mode = parseFlag(args, "--mode");
13034
+ const mode = parseModeFlag(args);
12338
13035
  if (mode) options.mode = mode;
12339
13036
  const docker = await isDockerRunning();
12340
13037
  let result;