iframer-toolkit 0.10.6 → 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,10 +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
+ }
905
918
  async function getHeadedBrowser() {
906
- if (!headedBrowser?.isConnected()) headedBrowser = await import_patchright.chromium.launch({ headless: false, args: STEALTH_ARGS });
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();
907
936
  return headedBrowser;
908
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
+ }
909
953
  async function getBrowser(_name = "chromium") {
910
954
  if (cachedBrowser) {
911
955
  if (cachedBrowser.isConnected()) return cachedBrowser;
@@ -923,11 +967,7 @@ async function getBrowser(_name = "chromium") {
923
967
  return cachedBrowser;
924
968
  }
925
969
  async function closeBrowser() {
926
- if (headedBrowser) {
927
- await headedBrowser.close().catch(() => {
928
- });
929
- headedBrowser = null;
930
- }
970
+ await closeHeadedBrowser();
931
971
  if (!cachedBrowser) return;
932
972
  try {
933
973
  await cachedBrowser.close();
@@ -964,7 +1004,7 @@ async function launchHeadful(displayNum) {
964
1004
  log2.debug(`headful: ${executablePath || "patchright chromium"}, extensions: ${hasExtensions}`);
965
1005
  return import_patchright.chromium.launch(launchOpts);
966
1006
  }
967
- var import_fs3, import_patchright, log2, UBLOCK_PATH, cachedBrowser, headedBrowser;
1007
+ var import_fs3, import_patchright, log2, UBLOCK_PATH, cachedBrowser, headedBrowser, headedPid, headedIdleTimer, HEADED_IDLE_MS;
968
1008
  var init_launcher = __esm({
969
1009
  "src/lib/browser/launcher.ts"() {
970
1010
  "use strict";
@@ -972,10 +1012,14 @@ var init_launcher = __esm({
972
1012
  import_patchright = require("patchright");
973
1013
  init_stealth();
974
1014
  init_logger();
1015
+ init_registry();
975
1016
  log2 = createLogger("launcher");
976
1017
  UBLOCK_PATH = "/extensions/uBlock0.chromium";
977
1018
  cachedBrowser = null;
978
1019
  headedBrowser = null;
1020
+ headedPid = null;
1021
+ headedIdleTimer = null;
1022
+ HEADED_IDLE_MS = 5 * 60 * 1e3;
979
1023
  }
980
1024
  });
981
1025
 
@@ -1033,7 +1077,13 @@ var init_constants = __esm({
1033
1077
  /** Scroll step delay */
1034
1078
  SCROLL_DELAY: 150,
1035
1079
  /** Stale state check interval */
1036
- 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
1037
1087
  };
1038
1088
  CAPTCHA_GRID = {
1039
1089
  /** Pixels from bframe top to grid start (reCAPTCHA) */
@@ -1071,6 +1121,10 @@ var init_constants = __esm({
1071
1121
  DEFAULT_STALE: 2e4,
1072
1122
  NAVIGATION: 6e4,
1073
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,
1074
1128
  TOTP_INPUT: 5e3,
1075
1129
  API_REQUEST: 18e4,
1076
1130
  HEALTH_CHECK: 3e3,
@@ -1827,7 +1881,7 @@ var init_cloak_browser = __esm({
1827
1881
  function keyOf(mode, instanceId) {
1828
1882
  return `${mode}::${instanceId}`;
1829
1883
  }
1830
- 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;
1831
1885
  var init_daemon = __esm({
1832
1886
  "src/lib/browser/daemon.ts"() {
1833
1887
  "use strict";
@@ -1840,6 +1894,8 @@ var init_daemon = __esm({
1840
1894
  log7 = createLogger("daemon");
1841
1895
  DEFAULT_IDLE_TIMEOUT = 5 * 60 * 1e3;
1842
1896
  CLOSE_GRACE_MS = 5e3;
1897
+ BUSY_MAX_MS = 30 * 60 * 1e3;
1898
+ BLANK_IDLE_MS = 2 * 60 * 1e3;
1843
1899
  DEFAULT_INSTANCE = "default";
1844
1900
  sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
1845
1901
  BrowserDaemon = class {
@@ -1928,7 +1984,8 @@ var init_daemon = __esm({
1928
1984
  createdAt: /* @__PURE__ */ new Date(),
1929
1985
  chromePid,
1930
1986
  marker,
1931
- active: 0
1987
+ active: 0,
1988
+ busySince: null
1932
1989
  };
1933
1990
  this.instances.set(key, instance);
1934
1991
  this.resetIdleTimer(key);
@@ -1940,14 +1997,19 @@ var init_daemon = __esm({
1940
1997
  * Always pair with release() in a finally block. */
1941
1998
  acquire(mode, instanceId = DEFAULT_INSTANCE) {
1942
1999
  const instance = this.instances.get(keyOf(mode, instanceId));
1943
- if (instance) instance.active++;
2000
+ if (!instance) return;
2001
+ if (instance.active === 0) instance.busySince = Date.now();
2002
+ instance.active++;
1944
2003
  }
1945
2004
  release(mode, instanceId = DEFAULT_INSTANCE) {
1946
2005
  const key = keyOf(mode, instanceId);
1947
2006
  const instance = this.instances.get(key);
1948
2007
  if (!instance) return;
1949
2008
  instance.active = Math.max(0, instance.active - 1);
1950
- if (instance.active === 0) this.resetIdleTimer(key);
2009
+ if (instance.active === 0) {
2010
+ instance.busySince = null;
2011
+ this.resetIdleTimer(key);
2012
+ }
1951
2013
  }
1952
2014
  isRunning(mode, instanceId = DEFAULT_INSTANCE) {
1953
2015
  const instance = this.instances.get(keyOf(mode, instanceId));
@@ -1958,6 +2020,35 @@ var init_daemon = __esm({
1958
2020
  return false;
1959
2021
  }
1960
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
+ }
1961
2052
  /** Distinct modes that currently have at least one live instance. */
1962
2053
  runningModes() {
1963
2054
  return [...new Set(this.liveInstances().map((i) => i.mode))];
@@ -2076,8 +2167,14 @@ var init_daemon = __esm({
2076
2167
  setTimeout(() => {
2077
2168
  const instance = this.instances.get(key);
2078
2169
  if (instance && instance.active > 0) {
2079
- this.resetIdleTimer(key);
2080
- 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;
2081
2178
  }
2082
2179
  log7.info(`Idle timeout for ${key}, stopping...`);
2083
2180
  this.stopKey(key).catch((err) => log7.warn(`idle stop failed for ${key}: ${err}`));
@@ -2479,6 +2576,24 @@ async function humanType(page, selector, text, opts = {}) {
2479
2576
  await sleep4(randRange(TIMING.POST_CLICK));
2480
2577
  }
2481
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
+ }
2482
2597
  const factor = SPEED_FACTOR[opts.speed || "normal"] ?? 1;
2483
2598
  for (const char of text) {
2484
2599
  await page.keyboard.type(char);
@@ -2640,7 +2755,41 @@ var init_humanize = __esm({
2640
2755
  }
2641
2756
  });
2642
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
+
2643
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
+ }
2644
2793
  function resolveSelector(selector, ctx) {
2645
2794
  if (selector.startsWith("@a:")) {
2646
2795
  const name = selector.slice(3);
@@ -2661,23 +2810,160 @@ function resolveSelector(selector, ctx) {
2661
2810
  }
2662
2811
  return ref.selector;
2663
2812
  }
2813
+ assertCssSelector(selector);
2664
2814
  return selector;
2665
2815
  }
2666
2816
  function anchorNameOf(selector) {
2667
2817
  return typeof selector === "string" && selector.startsWith("@a:") ? selector.slice(3) : null;
2668
2818
  }
2819
+ var PLAYWRIGHT_ONLY;
2669
2820
  var init_resolve_selector = __esm({
2670
2821
  "src/lib/actions/resolve-selector.ts"() {
2671
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;
2672
2925
  }
2673
2926
  });
2674
2927
 
2675
2928
  // src/lib/actions/handlers/navigation.ts
2676
- async function navigate(page, step, ctx) {
2677
- await page.goto(step.url, {
2678
- waitUntil: step.waitUntil || "domcontentloaded",
2679
- timeout: TIMEOUTS.NAVIGATION
2680
- });
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
+ }
2681
2967
  const stealthScript = contextStealthScripts.get(page.context()) ?? STEALTH_SCRIPT;
2682
2968
  try {
2683
2969
  await page.evaluate(stealthScript);
@@ -2693,14 +2979,37 @@ async function navigate(page, step, ctx) {
2693
2979
  }
2694
2980
  }
2695
2981
  async function click(page, step, ctx) {
2696
- 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);
2697
2994
  }
2698
2995
  async function fill(page, step, ctx) {
2699
2996
  const selector = resolveSelector(step.selector, ctx);
2700
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);
2701
3010
  await page.fill(selector, value);
2702
3011
  const stuck = await page.evaluate(
2703
- ({ sel, val }) => {
3012
+ ({ sel, val, keepFocus }) => {
2704
3013
  const el = document.querySelector(sel);
2705
3014
  if (!el) return false;
2706
3015
  const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
@@ -2716,13 +3025,15 @@ async function fill(page, step, ctx) {
2716
3025
  }
2717
3026
  el.dispatchEvent(new Event("input", { bubbles: true }));
2718
3027
  el.dispatchEvent(new Event("change", { bubbles: true }));
2719
- try {
2720
- el.blur();
2721
- } catch {
3028
+ if (!keepFocus) {
3029
+ try {
3030
+ el.blur();
3031
+ } catch {
3032
+ }
2722
3033
  }
2723
3034
  return el.value === val;
2724
3035
  },
2725
- { sel: selector, val: value }
3036
+ { sel: selector, val: value, keepFocus: combobox }
2726
3037
  );
2727
3038
  if (!stuck) {
2728
3039
  const loc = page.locator(selector);
@@ -2730,10 +3041,12 @@ async function fill(page, step, ctx) {
2730
3041
  await loc.click();
2731
3042
  await loc.fill("");
2732
3043
  await loc.pressSequentially(value, { delay: 15 });
2733
- await page.evaluate((sel) => {
2734
- const el = document.querySelector(sel);
2735
- el?.blur?.();
2736
- }, selector);
3044
+ if (!combobox) {
3045
+ await page.evaluate((sel) => {
3046
+ const el = document.querySelector(sel);
3047
+ el?.blur?.();
3048
+ }, selector);
3049
+ }
2737
3050
  } catch {
2738
3051
  }
2739
3052
  }
@@ -2757,7 +3070,7 @@ async function rightClick(page, step, ctx) {
2757
3070
  }
2758
3071
  }
2759
3072
  async function humanTypeStep(page, step, ctx) {
2760
- 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 });
2761
3074
  }
2762
3075
  async function selectOption(page, step, ctx) {
2763
3076
  const selector = resolveSelector(step.selector, ctx);
@@ -2800,7 +3113,29 @@ async function scroll(page, step, ctx) {
2800
3113
  { dy: step.deltaY ?? 0, sel: selector }
2801
3114
  );
2802
3115
  }
2803
- 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
+ }
2804
3139
  const mods = [
2805
3140
  step.meta ? "Meta" : null,
2806
3141
  step.ctrl ? "Control" : null,
@@ -2871,7 +3206,7 @@ async function typeCode(page, step, ctx) {
2871
3206
  }
2872
3207
  return { typed: code.length };
2873
3208
  }
2874
- var log9;
3209
+ var log9, NAV_COMMIT_SUSPECT_MS;
2875
3210
  var init_navigation = __esm({
2876
3211
  "src/lib/actions/handlers/navigation.ts"() {
2877
3212
  "use strict";
@@ -2881,8 +3216,11 @@ var init_navigation = __esm({
2881
3216
  init_persistence();
2882
3217
  init_logger();
2883
3218
  init_constants();
3219
+ init_budget();
2884
3220
  init_resolve_selector();
3221
+ init_reachability();
2885
3222
  log9 = createLogger("actions");
3223
+ NAV_COMMIT_SUSPECT_MS = 12e3;
2886
3224
  }
2887
3225
  });
2888
3226
 
@@ -3004,6 +3342,56 @@ async function takeSnapshot(page, ctx, options) {
3004
3342
  if (rect.bottom < 0 || rect.top > window.innerHeight + 200) return false;
3005
3343
  return true;
3006
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
+ }
3007
3395
  function buildSelector(el) {
3008
3396
  const path14 = [];
3009
3397
  let current = el;
@@ -3046,6 +3434,11 @@ async function takeSnapshot(page, ctx, options) {
3046
3434
  if (results.length >= maxElements2) break;
3047
3435
  if (interactiveOnly2 && !isInteractive(el)) continue;
3048
3436
  if (!isVisible(el)) continue;
3437
+ let suspect;
3438
+ if (isFillable(el)) {
3439
+ if (unreachableReason(el)) continue;
3440
+ if (looksLikeBait(el)) suspect = "honeypot?";
3441
+ }
3049
3442
  const tag = el.tagName.toLowerCase();
3050
3443
  const role = el.getAttribute("role") || "";
3051
3444
  const type = el.type || "";
@@ -3058,7 +3451,8 @@ async function takeSnapshot(page, ctx, options) {
3058
3451
  checked: el.checked || false,
3059
3452
  disabled: el.disabled || false,
3060
3453
  selector: buildSelector(el),
3061
- isVisible: true
3454
+ isVisible: true,
3455
+ suspect
3062
3456
  });
3063
3457
  }
3064
3458
  return results;
@@ -3079,6 +3473,7 @@ async function takeSnapshot(page, ctx, options) {
3079
3473
  const state = [];
3080
3474
  if (el.disabled) state.push("disabled");
3081
3475
  if (el.checked) state.push("checked");
3476
+ if (el.suspect) state.push(el.suspect);
3082
3477
  let description = "";
3083
3478
  if (el.placeholder && el.name !== el.placeholder) description = `placeholder="${el.placeholder}"`;
3084
3479
  if (el.type && !["text", "submit", "button", ""].includes(el.type)) {
@@ -4596,12 +4991,15 @@ var init_registry2 = __esm({
4596
4991
  });
4597
4992
 
4598
4993
  // src/lib/stale-monitor.ts
4599
- var DEFAULT_STALE_TIMEOUT_MS, StaleStateMonitor, StaleStateError;
4994
+ var DEFAULT_STALE_TIMEOUT_MS, PROBE_BUDGET_MS, UNRESPONSIVE_GRACE_MS, StaleStateMonitor, StaleStateError;
4600
4995
  var init_stale_monitor = __esm({
4601
4996
  "src/lib/stale-monitor.ts"() {
4602
4997
  "use strict";
4603
4998
  init_constants();
4999
+ init_budget();
4604
5000
  DEFAULT_STALE_TIMEOUT_MS = 2e4;
5001
+ PROBE_BUDGET_MS = 3e3;
5002
+ UNRESPONSIVE_GRACE_MS = 8e3;
4605
5003
  StaleStateMonitor = class _StaleStateMonitor {
4606
5004
  page;
4607
5005
  timeoutMs;
@@ -4613,21 +5011,24 @@ var init_stale_monitor = __esm({
4613
5011
  }
4614
5012
  async snapshot() {
4615
5013
  try {
4616
- const [url, state] = await Promise.all([
4617
- Promise.resolve(this.page.url()),
5014
+ const url = this.page.url();
5015
+ let unresponsive = false;
5016
+ const state = await withBudget(
4618
5017
  this.page.evaluate(() => ({
4619
5018
  documentReadyState: document.readyState,
4620
5019
  bodyTextLength: document.body?.innerText?.length || 0,
4621
5020
  elementCount: document.querySelectorAll("*").length
4622
- })).catch(() => ({
4623
- documentReadyState: "unknown",
4624
- bodyTextLength: 0,
4625
- elementCount: 0
4626
- }))
4627
- ]);
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
+ });
4628
5028
  return {
4629
5029
  url,
4630
5030
  ...state,
5031
+ unresponsive,
4631
5032
  timestamp: Date.now()
4632
5033
  };
4633
5034
  } catch {
@@ -4671,6 +5072,8 @@ var init_stale_monitor = __esm({
4671
5072
  return new Promise((resolve, reject) => {
4672
5073
  let resolved = false;
4673
5074
  let checkInterval = null;
5075
+ let probing = false;
5076
+ let unresponsiveSince = null;
4674
5077
  const cleanup = () => {
4675
5078
  resolved = true;
4676
5079
  if (checkInterval) clearInterval(checkInterval);
@@ -4678,11 +5081,25 @@ var init_stale_monitor = __esm({
4678
5081
  this.timer = null;
4679
5082
  };
4680
5083
  checkInterval = setInterval(async () => {
4681
- if (resolved) return;
5084
+ if (resolved || probing) return;
4682
5085
  const elapsed = Date.now() - this.lastActivity;
4683
5086
  if (elapsed < this.timeoutMs) return;
5087
+ probing = true;
4684
5088
  try {
4685
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;
4686
5103
  if (_StaleStateMonitor.hasChanged(beforeSnapshot, currentSnapshot)) {
4687
5104
  this.lastActivity = Date.now();
4688
5105
  return;
@@ -4690,6 +5107,8 @@ var init_stale_monitor = __esm({
4690
5107
  } catch {
4691
5108
  this.lastActivity = Date.now();
4692
5109
  return;
5110
+ } finally {
5111
+ probing = false;
4693
5112
  }
4694
5113
  cleanup();
4695
5114
  reject(new StaleStateError(
@@ -4713,10 +5132,13 @@ var init_stale_monitor = __esm({
4713
5132
  };
4714
5133
  StaleStateError = class extends Error {
4715
5134
  timeoutMs;
4716
- 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) {
4717
5138
  super(message);
4718
5139
  this.name = "StaleStateError";
4719
5140
  this.timeoutMs = timeoutMs;
5141
+ this.unresponsive = unresponsive;
4720
5142
  }
4721
5143
  };
4722
5144
  }
@@ -4945,10 +5367,14 @@ async function capturePageState(page, ctx, opts) {
4945
5367
  url = page.url();
4946
5368
  } catch {
4947
5369
  }
4948
- const title = await page.title().catch(() => "");
5370
+ const title = await withBudget(page.title(), 2e3, "title").catch(() => "");
4949
5371
  if (!screenshot2) return { url, title };
4950
5372
  try {
4951
- 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
+ );
4952
5378
  const screenshotUrl = saveScreenshot(buf, `${namePrefix}-${Date.now()}.jpg`, ctx.screenshotDir, ctx.publicUrl);
4953
5379
  return { url, title, screenshotUrl };
4954
5380
  } catch {
@@ -4959,6 +5385,7 @@ var init_page_state = __esm({
4959
5385
  "src/lib/page-state.ts"() {
4960
5386
  "use strict";
4961
5387
  init_screenshot();
5388
+ init_budget();
4962
5389
  }
4963
5390
  });
4964
5391
 
@@ -5160,6 +5587,24 @@ function buildCurl(method, url, headers, auth, body) {
5160
5587
  parts.push(` '${url}'`);
5161
5588
  return parts.join(" \\\n");
5162
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
+ }
5163
5608
  function extractAuth(requests) {
5164
5609
  const auth = { cookies: {}, tokens: {} };
5165
5610
  for (const req of requests) {
@@ -5245,7 +5690,7 @@ function buildCapturedApi(requests) {
5245
5690
  }
5246
5691
  return apis.sort((a, b) => b.endpoints.length - a.endpoints.length);
5247
5692
  }
5248
- 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;
5249
5694
  var init_api_capture = __esm({
5250
5695
  "src/lib/api-capture.ts"() {
5251
5696
  "use strict";
@@ -5415,10 +5860,19 @@ var init_api_capture = __esm({
5415
5860
  await new Promise((r) => setTimeout(r, 100));
5416
5861
  }
5417
5862
  }
5863
+ /** Requests fired but not yet answered. */
5864
+ hasPending() {
5865
+ return this.pendingRequests.size > 0;
5866
+ }
5418
5867
  getResults() {
5419
5868
  return buildCapturedApi(this.requests);
5420
5869
  }
5870
+ /** Every HTTP >= 400 response, compact, in capture order. */
5871
+ getServerErrors() {
5872
+ return serverErrorsFrom(this.requests);
5873
+ }
5421
5874
  };
5875
+ SERVER_ERROR_BODY_MAX = 600;
5422
5876
  }
5423
5877
  });
5424
5878
 
@@ -5597,10 +6051,35 @@ function classifyError(err, step) {
5597
6051
  if (step.type === "solve-captcha") return "captcha-unsolvable";
5598
6052
  return "action-failed";
5599
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
+ }
5600
6078
  function isRetryable(errorType) {
5601
6079
  return errorType === "stale-state" || errorType === "timeout" || errorType === "element-not-found";
5602
6080
  }
5603
- function getSuggestion(errorType, step) {
6081
+ function getSuggestion(errorType, step, message = "") {
6082
+ if (message.includes("tab stopped responding")) return UNRESPONSIVE_SUGGESTION;
5604
6083
  const usedEphemeralRef = typeof step.selector === "string" && step.selector.startsWith("@e");
5605
6084
  if (usedEphemeralRef && (errorType === "element-not-found" || errorType === "timeout" || errorType === "stale-state")) {
5606
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.";
@@ -5609,7 +6088,7 @@ function getSuggestion(errorType, step) {
5609
6088
  case "stale-state":
5610
6089
  return "The page stopped responding. The step may have triggered a very slow load or the server may be unreachable.";
5611
6090
  case "element-not-found":
5612
- 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.";
5613
6092
  case "navigation-failed":
5614
6093
  return "Navigation failed. The URL may be unreachable, blocked, or require authentication.";
5615
6094
  case "captcha-unsolvable":
@@ -5620,7 +6099,7 @@ function getSuggestion(errorType, step) {
5620
6099
  return void 0;
5621
6100
  }
5622
6101
  }
5623
- var DEFAULT_STALE_TIMEOUT_MS2, PipelineRunner;
6102
+ var DEFAULT_STALE_TIMEOUT_MS2, UNRESPONSIVE_SUGGESTION, PipelineRunner;
5624
6103
  var init_pipeline = __esm({
5625
6104
  "src/lib/pipeline.ts"() {
5626
6105
  "use strict";
@@ -5636,7 +6115,10 @@ var init_pipeline = __esm({
5636
6115
  init_constants();
5637
6116
  init_component_map();
5638
6117
  init_resolve_selector();
6118
+ init_constants();
6119
+ init_budget();
5639
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.";
5640
6122
  PipelineRunner = class {
5641
6123
  constructor(ctx, hooks) {
5642
6124
  this.ctx = ctx;
@@ -5644,6 +6126,9 @@ var init_pipeline = __esm({
5644
6126
  }
5645
6127
  ctx;
5646
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: [] };
5647
6132
  async run(initialPage, pipeline) {
5648
6133
  const tracker = new TabTracker(initialPage.context(), initialPage);
5649
6134
  try {
@@ -5661,6 +6146,7 @@ var init_pipeline = __esm({
5661
6146
  const continueOnError = opts.continueOnError ?? false;
5662
6147
  const results = [];
5663
6148
  const obstacles = [];
6149
+ this.progress.results = results;
5664
6150
  const navStep = pipeline.steps.find((s) => s.type === "navigate");
5665
6151
  try {
5666
6152
  const host = new URL(navStep?.url || safePageUrl(initialPage) || "http://x").hostname;
@@ -5678,12 +6164,18 @@ var init_pipeline = __esm({
5678
6164
  if (capture) capture.start();
5679
6165
  const finishCapture = async () => {
5680
6166
  if (!capture) return void 0;
6167
+ await capture.drain(TIMING.CAPTURE_SETTLE, TIMING.CAPTURE_PENDING_MAX);
5681
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
+ }
5682
6173
  return capture.getResults();
5683
6174
  };
5684
6175
  for (let i = 0; i < pipeline.steps.length; i++) {
5685
6176
  if (capture) capture.setStep(i);
5686
6177
  const step = pipeline.steps[i];
6178
+ this.progress.stepIndex = i;
5687
6179
  const page = tracker.active();
5688
6180
  const urlBefore = safePageUrl(page);
5689
6181
  const monitor = new StaleStateMonitor(page, staleTimeoutMs);
@@ -5703,9 +6195,10 @@ var init_pipeline = __esm({
5703
6195
  throw err;
5704
6196
  }
5705
6197
  const asError = err instanceof Error ? err : new Error(String(err));
5706
- 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;
5707
6200
  const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
5708
- stepResult = failedStepResult(step, asError.message, Date.now() - startTime, i);
6201
+ stepResult = failedStepResult(step, refined.message, Date.now() - startTime, i);
5709
6202
  results.push(stepResult);
5710
6203
  recordAnchor(step, false);
5711
6204
  return {
@@ -5719,9 +6212,9 @@ var init_pipeline = __esm({
5719
6212
  failedAtStep: i,
5720
6213
  failedStep: step,
5721
6214
  errorType,
5722
- message: asError.message,
6215
+ message: refined.message,
5723
6216
  pageState,
5724
- suggestion: getSuggestion(errorType, step),
6217
+ suggestion: getSuggestion(errorType, step, refined.message),
5725
6218
  retryable: isRetryable(errorType)
5726
6219
  },
5727
6220
  durationMs: Date.now() - startTime,
@@ -5755,8 +6248,16 @@ var init_pipeline = __esm({
5755
6248
  }
5756
6249
  results.push(stepResult);
5757
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;
5758
6260
  const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
5759
- const errorType = classifyError(new Error(stepResult.error || ""), step);
5760
6261
  return {
5761
6262
  ok: false,
5762
6263
  completedSteps: i,
@@ -5770,7 +6271,7 @@ var init_pipeline = __esm({
5770
6271
  errorType,
5771
6272
  message: stepResult.error || "Step failed",
5772
6273
  pageState,
5773
- suggestion: getSuggestion(errorType, step),
6274
+ suggestion: getSuggestion(errorType, step, refined.message),
5774
6275
  retryable: isRetryable(errorType)
5775
6276
  },
5776
6277
  durationMs: Date.now() - startTime,
@@ -5780,7 +6281,7 @@ var init_pipeline = __esm({
5780
6281
  if (step.type === "navigate" && continueOnObstacle) {
5781
6282
  const obstacleStart = Date.now();
5782
6283
  const obstaclePage = tracker.active();
5783
- const obstacle = await detectObstacles(obstaclePage);
6284
+ const obstacle = await withBudget(detectObstacles(obstaclePage), 1e4, "obstacle check").catch(() => null);
5784
6285
  if (obstacle) {
5785
6286
  const resolution = await resolveObstacle(obstaclePage, obstacle, this.ctx, monitor);
5786
6287
  obstacles.push({
@@ -10235,7 +10736,7 @@ var init_cdp_relay = __esm({
10235
10736
  return await Promise.race([
10236
10737
  first,
10237
10738
  new Promise((_, reject) => {
10238
- 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);
10239
10740
  t.unref?.();
10240
10741
  })
10241
10742
  ]);
@@ -10295,6 +10796,24 @@ var init_cdp_relay = __esm({
10295
10796
  });
10296
10797
 
10297
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
+ }
10298
10817
  var import_playwright_core, log18, PipelineExecutor;
10299
10818
  var init_pipeline_executor = __esm({
10300
10819
  "src/lib/execution/pipeline-executor.ts"() {
@@ -10351,8 +10870,24 @@ var init_pipeline_executor = __esm({
10351
10870
  const forcedMode = opts.mode;
10352
10871
  const autoEscalate = opts.autoEscalate !== false;
10353
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
+ }
10354
10881
  const firstNav = pipeline.steps.find((s) => s.type === "navigate");
10355
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
+ }
10356
10891
  const availableModes = this.deps.availableModes();
10357
10892
  const liveMode = forcedMode ? null : this.deps.daemon.findLiveMode(instanceId);
10358
10893
  let mode;
@@ -10416,6 +10951,7 @@ var init_pipeline_executor = __esm({
10416
10951
  const startTime = Date.now();
10417
10952
  const relay = new CdpRelay(tabId, clientId, pipeline.options?.focus);
10418
10953
  let browser;
10954
+ let runner;
10419
10955
  try {
10420
10956
  await relay.start();
10421
10957
  browser = await import_playwright_core.chromium.connectOverCDP(relay.httpEndpoint(), { timeout: 3e4 });
@@ -10428,7 +10964,8 @@ var init_pipeline_executor = __esm({
10428
10964
  if (!page) throw new Error("no page available for the tab (is it still open?)");
10429
10965
  const ctx = this.deps.refStore.makeContext(userId, token);
10430
10966
  if (this.pendingElicitOtp) ctx.elicitOtp = this.pendingElicitOtp;
10431
- const runner = new PipelineRunner(ctx);
10967
+ ctx.driver = "extension";
10968
+ runner = new PipelineRunner(ctx);
10432
10969
  const typeChars = pipeline.steps.reduce((n, s) => {
10433
10970
  const v = s.value;
10434
10971
  return (s.type === "human-type" || s.type === "type-code") && typeof v === "string" ? n + v.length : n;
@@ -10445,7 +10982,7 @@ var init_pipeline_executor = __esm({
10445
10982
  watchdog = setTimeout(
10446
10983
  () => reject(
10447
10984
  new Error(
10448
- `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.`
10449
10986
  )
10450
10987
  ),
10451
10988
  capMs
@@ -10469,23 +11006,26 @@ var init_pipeline_executor = __esm({
10469
11006
  } catch (err) {
10470
11007
  const msg = getErrorMessage(err);
10471
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];
10472
11012
  return {
10473
11013
  ok: false,
10474
- completedSteps: 0,
11014
+ completedSteps: stalled ? done.length : 0,
10475
11015
  totalSteps: pipeline.steps.length,
10476
- results: [],
11016
+ results: stalled ? done : [],
10477
11017
  finalState: { url: "", title: "" },
10478
11018
  obstacles: [],
10479
11019
  durationMs: Date.now() - startTime,
10480
11020
  modeUsed: "extension",
10481
11021
  error: {
10482
- failedAtStep: 0,
10483
- failedStep: pipeline.steps[0],
10484
- errorType: "action-failed",
10485
- 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}`,
10486
11026
  pageState: { url: "", title: "" },
10487
- 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.",
10488
- 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
10489
11029
  }
10490
11030
  };
10491
11031
  } finally {
@@ -11105,6 +11645,11 @@ var init_iframer = __esm({
11105
11645
  listInstances() {
11106
11646
  return this.daemon.instancesInfo();
11107
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
+ }
11108
11653
  /** Kill all browser instances and reset state. Next execute call will
11109
11654
  * launch a fresh browser automatically — no manual restart needed. */
11110
11655
  async restartBrowser() {
@@ -11244,6 +11789,13 @@ ${api.domain} (${api.baseUrl})`);
11244
11789
  }
11245
11790
  }
11246
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
+ }
11247
11799
  if (data.error) {
11248
11800
  lines.push("\n--- Failure ---");
11249
11801
  if (typeof data.error === "string") {
@@ -11254,6 +11806,10 @@ ${api.domain} (${api.baseUrl})`);
11254
11806
  lines.push(`Message: ${data.error.message}`);
11255
11807
  lines.push(`Retryable: ${data.error.retryable}`);
11256
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
+ }
11257
11813
  if (data.error.pageState?.url) lines.push(`URL at failure: ${data.error.pageState.url}`);
11258
11814
  }
11259
11815
  }
@@ -11670,6 +12226,21 @@ function parseFlag(args2, flag, hasValue = true) {
11670
12226
  function hasFlag(args2, flag) {
11671
12227
  return args2.includes(flag);
11672
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
+ }
11673
12244
  function handleResponse(data, screenshotPath) {
11674
12245
  const { screenshot: screenshot2, tileScreenshots, ...rest } = data;
11675
12246
  if (screenshot2 && screenshotPath) {
@@ -12144,7 +12715,7 @@ async function main() {
12144
12715
  process.exit(1);
12145
12716
  }
12146
12717
  const options = { ...inputOptions };
12147
- const mode = parseFlag(args, "--mode");
12718
+ const mode = parseModeFlag(args);
12148
12719
  if (mode) options.mode = mode;
12149
12720
  if (hasFlag(args, "--capture-api")) options.captureApi = true;
12150
12721
  if (hasFlag(args, "--continue-on-error")) options.continueOnError = true;
@@ -12229,7 +12800,7 @@ Screenshot: ${shot}`);
12229
12800
  const url = args[0];
12230
12801
  const outPath = parseFlag(args, "--output") || parseFlag(args, "-o") || "/tmp/iframer-screenshot.jpg";
12231
12802
  if (url && url.startsWith("http")) {
12232
- const mode = parseFlag(args, "--mode") || "headless";
12803
+ const mode = parseModeFlag(args) || "headless";
12233
12804
  const annotate = hasFlag(args, "--annotate");
12234
12805
  const docker = await isDockerRunning();
12235
12806
  const steps = [
@@ -12460,7 +13031,7 @@ Screenshot: ${shot}`);
12460
13031
  process.exit(1);
12461
13032
  }
12462
13033
  const options = { captureApi: true };
12463
- const mode = parseFlag(args, "--mode");
13034
+ const mode = parseModeFlag(args);
12464
13035
  if (mode) options.mode = mode;
12465
13036
  const docker = await isDockerRunning();
12466
13037
  let result;