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.
@@ -35,6 +35,23 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
35
35
  mod
36
36
  ));
37
37
 
38
+ // src/lib/paths.ts
39
+ var paths_exports = {};
40
+ __export(paths_exports, {
41
+ getDataDir: () => getDataDir
42
+ });
43
+ function getDataDir() {
44
+ return process.env.IFRAMER_DATA_DIR || import_path.default.join(import_os.default.homedir(), ".iframer");
45
+ }
46
+ var import_path, import_os;
47
+ var init_paths = __esm({
48
+ "src/lib/paths.ts"() {
49
+ "use strict";
50
+ import_path = __toESM(require("path"));
51
+ import_os = __toESM(require("os"));
52
+ }
53
+ });
54
+
38
55
  // src/lib/session/persistence.ts
39
56
  var persistence_exports = {};
40
57
  __export(persistence_exports, {
@@ -96,23 +113,6 @@ var init_persistence = __esm({
96
113
  }
97
114
  });
98
115
 
99
- // src/lib/paths.ts
100
- var paths_exports = {};
101
- __export(paths_exports, {
102
- getDataDir: () => getDataDir
103
- });
104
- function getDataDir() {
105
- return process.env.IFRAMER_DATA_DIR || import_path.default.join(import_os.default.homedir(), ".iframer");
106
- }
107
- var import_path, import_os;
108
- var init_paths = __esm({
109
- "src/lib/paths.ts"() {
110
- "use strict";
111
- import_path = __toESM(require("path"));
112
- import_os = __toESM(require("os"));
113
- }
114
- });
115
-
116
116
  // index.ts
117
117
  var import_express = __toESM(require("express"));
118
118
  var import_path12 = __toESM(require("path"));
@@ -127,11 +127,11 @@ var import_path11 = __toESM(require("path"));
127
127
  var import_url = require("url");
128
128
 
129
129
  // src/lib/browser/session-manager.ts
130
- var import_child_process = require("child_process");
131
- var import_fs2 = __toESM(require("fs"));
130
+ var import_child_process2 = require("child_process");
131
+ var import_fs3 = __toESM(require("fs"));
132
132
 
133
133
  // src/lib/browser/launcher.ts
134
- var import_fs = __toESM(require("fs"));
134
+ var import_fs2 = __toESM(require("fs"));
135
135
  var import_patchright = require("patchright");
136
136
 
137
137
  // src/lib/browser/stealth.ts
@@ -542,27 +542,213 @@ function createLogger(tag) {
542
542
  };
543
543
  }
544
544
 
545
+ // src/lib/browser/registry.ts
546
+ var import_fs = __toESM(require("fs"));
547
+ var import_path2 = __toESM(require("path"));
548
+ var import_child_process = require("child_process");
549
+ init_paths();
550
+ var log = createLogger("registry");
551
+ function browsersDir() {
552
+ const dir = import_path2.default.join(getDataDir(), "browsers");
553
+ import_fs.default.mkdirSync(dir, { recursive: true });
554
+ return dir;
555
+ }
556
+ function serverInfoPath() {
557
+ return import_path2.default.join(getDataDir(), "server.json");
558
+ }
559
+ function isPidAlive(pid) {
560
+ if (!Number.isInteger(pid) || pid <= 1) return false;
561
+ try {
562
+ process.kill(pid, 0);
563
+ return true;
564
+ } catch {
565
+ return false;
566
+ }
567
+ }
568
+ function pidMatchesMarker(pid, marker) {
569
+ if (!isPidAlive(pid)) return false;
570
+ try {
571
+ const cmd = (0, import_child_process.execSync)(`ps -o command= -p ${pid}`, { encoding: "utf8" });
572
+ return cmd.includes(marker);
573
+ } catch {
574
+ return false;
575
+ }
576
+ }
577
+ function findChromePidByMarker(marker) {
578
+ try {
579
+ const out = (0, import_child_process.execSync)(`pgrep -f -- "${marker}"`, { encoding: "utf8" }).trim();
580
+ const pids = out.split("\n").map((s) => parseInt(s, 10)).filter((n) => Number.isInteger(n) && n !== process.pid);
581
+ if (pids.length === 0) return null;
582
+ return Math.min(...pids);
583
+ } catch {
584
+ return null;
585
+ }
586
+ }
587
+ function registerBrowser(rec) {
588
+ try {
589
+ import_fs.default.writeFileSync(import_path2.default.join(browsersDir(), `${rec.chromePid}.json`), JSON.stringify(rec, null, 2));
590
+ } catch (err) {
591
+ log.warn(`failed to write browser record for pid ${rec.chromePid}: ${err}`);
592
+ }
593
+ }
594
+ function unregisterBrowser(chromePid) {
595
+ try {
596
+ import_fs.default.unlinkSync(import_path2.default.join(browsersDir(), `${chromePid}.json`));
597
+ } catch {
598
+ }
599
+ }
600
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
601
+ async function forceKillBrowser(rec) {
602
+ if (!isPidAlive(rec.chromePid)) return true;
603
+ if (!pidMatchesMarker(rec.chromePid, rec.marker)) {
604
+ return true;
605
+ }
606
+ try {
607
+ process.kill(rec.chromePid, "SIGKILL");
608
+ } catch {
609
+ }
610
+ const deadline = Date.now() + 2e3;
611
+ while (Date.now() < deadline) {
612
+ if (!isPidAlive(rec.chromePid)) return true;
613
+ await sleep(100);
614
+ }
615
+ return !isPidAlive(rec.chromePid);
616
+ }
617
+ async function reapOrphanBrowsers() {
618
+ let reaped = 0;
619
+ let skipped = 0;
620
+ let files = [];
621
+ try {
622
+ files = import_fs.default.readdirSync(browsersDir()).filter((f) => f.endsWith(".json"));
623
+ } catch {
624
+ return { reaped, skipped };
625
+ }
626
+ for (const file of files) {
627
+ const full = import_path2.default.join(browsersDir(), file);
628
+ let rec;
629
+ try {
630
+ rec = JSON.parse(import_fs.default.readFileSync(full, "utf8"));
631
+ } catch {
632
+ try {
633
+ import_fs.default.unlinkSync(full);
634
+ } catch {
635
+ }
636
+ continue;
637
+ }
638
+ if (!isPidAlive(rec.chromePid) || !pidMatchesMarker(rec.chromePid, rec.marker)) {
639
+ try {
640
+ import_fs.default.unlinkSync(full);
641
+ } catch {
642
+ }
643
+ continue;
644
+ }
645
+ if (isPidAlive(rec.ownerPid)) {
646
+ skipped++;
647
+ continue;
648
+ }
649
+ log.info(`reaping orphan Chrome pid=${rec.chromePid} (${rec.key}), owner ${rec.ownerPid} is dead`);
650
+ if (await forceKillBrowser(rec)) {
651
+ try {
652
+ import_fs.default.unlinkSync(full);
653
+ } catch {
654
+ }
655
+ reaped++;
656
+ } else {
657
+ log.warn(`failed to kill orphan Chrome pid=${rec.chromePid} \u2014 leaving record for next sweep`);
658
+ }
659
+ }
660
+ return { reaped, skipped };
661
+ }
662
+ function writeServerInfo(info) {
663
+ import_fs.default.writeFileSync(serverInfoPath(), JSON.stringify(info, null, 2));
664
+ }
665
+ function readServerInfo() {
666
+ try {
667
+ const info = JSON.parse(import_fs.default.readFileSync(serverInfoPath(), "utf8"));
668
+ if (!Number.isInteger(info.pid) || !Number.isInteger(info.port)) return null;
669
+ return info;
670
+ } catch {
671
+ return null;
672
+ }
673
+ }
674
+ function clearServerInfo(pid) {
675
+ const info = readServerInfo();
676
+ if (info && info.pid === pid) {
677
+ try {
678
+ import_fs.default.unlinkSync(serverInfoPath());
679
+ } catch {
680
+ }
681
+ }
682
+ }
683
+
545
684
  // src/lib/browser/launcher.ts
546
- var log = createLogger("launcher");
685
+ var log2 = createLogger("launcher");
547
686
  var UBLOCK_PATH = "/extensions/uBlock0.chromium";
548
687
  function findChromeExecutable() {
549
688
  if (process.env.CHROME_EXECUTABLE) return process.env.CHROME_EXECUTABLE;
550
- if (import_fs.default.existsSync("/usr/bin/google-chrome-stable")) return "/usr/bin/google-chrome-stable";
689
+ if (import_fs2.default.existsSync("/usr/bin/google-chrome-stable")) return "/usr/bin/google-chrome-stable";
551
690
  return void 0;
552
691
  }
553
692
  var cachedBrowser = null;
554
693
  var headedBrowser = null;
694
+ var headedPid = null;
695
+ var headedIdleTimer = null;
696
+ var HEADED_IDLE_MS = 5 * 60 * 1e3;
697
+ function armHeadedIdleTimer() {
698
+ if (headedIdleTimer) clearTimeout(headedIdleTimer);
699
+ headedIdleTimer = setTimeout(() => {
700
+ if (!headedBrowser) return;
701
+ const pages = headedBrowser.contexts().reduce((n, c) => n + c.pages().length, 0);
702
+ if (pages > 0) {
703
+ armHeadedIdleTimer();
704
+ return;
705
+ }
706
+ log2.info("idle timeout for the pivot headed browser, closing");
707
+ void closeHeadedBrowser();
708
+ }, HEADED_IDLE_MS);
709
+ }
555
710
  async function getHeadedBrowser() {
556
- if (!headedBrowser?.isConnected()) headedBrowser = await import_patchright.chromium.launch({ headless: false, args: STEALTH_ARGS });
711
+ if (!headedBrowser?.isConnected()) {
712
+ const marker = `--iframer-key=pivot-headed-${process.pid}-${Date.now()}`;
713
+ headedBrowser = await import_patchright.chromium.launch({ headless: false, args: [...STEALTH_ARGS, marker] });
714
+ headedPid = findChromePidByMarker(marker);
715
+ if (headedPid) {
716
+ registerBrowser({
717
+ key: "pivot::headed",
718
+ chromePid: headedPid,
719
+ ownerPid: process.pid,
720
+ marker,
721
+ launchedAt: (/* @__PURE__ */ new Date()).toISOString()
722
+ });
723
+ } else {
724
+ log2.warn("could not resolve Chrome PID for the pivot headed browser \u2014 force-kill unavailable");
725
+ }
726
+ }
727
+ armHeadedIdleTimer();
557
728
  return headedBrowser;
558
729
  }
730
+ async function closeHeadedBrowser() {
731
+ if (headedIdleTimer) {
732
+ clearTimeout(headedIdleTimer);
733
+ headedIdleTimer = null;
734
+ }
735
+ if (headedBrowser) {
736
+ await headedBrowser.close().catch(() => {
737
+ });
738
+ headedBrowser = null;
739
+ }
740
+ if (headedPid !== null) {
741
+ unregisterBrowser(headedPid);
742
+ headedPid = null;
743
+ }
744
+ }
559
745
  async function getBrowser(_name = "chromium") {
560
746
  if (cachedBrowser) {
561
747
  if (cachedBrowser.isConnected()) return cachedBrowser;
562
748
  try {
563
749
  await cachedBrowser.close();
564
750
  } catch (e) {
565
- log.warn(`stale browser close failed: ${e}`);
751
+ log2.warn(`stale browser close failed: ${e}`);
566
752
  }
567
753
  cachedBrowser = null;
568
754
  }
@@ -573,16 +759,12 @@ async function getBrowser(_name = "chromium") {
573
759
  return cachedBrowser;
574
760
  }
575
761
  async function closeBrowser() {
576
- if (headedBrowser) {
577
- await headedBrowser.close().catch(() => {
578
- });
579
- headedBrowser = null;
580
- }
762
+ await closeHeadedBrowser();
581
763
  if (!cachedBrowser) return;
582
764
  try {
583
765
  await cachedBrowser.close();
584
766
  } catch (e) {
585
- log.warn(`closeBrowser failed: ${e}`);
767
+ log2.warn(`closeBrowser failed: ${e}`);
586
768
  }
587
769
  cachedBrowser = null;
588
770
  }
@@ -591,7 +773,7 @@ async function getBrowserWithFallback(_preferred) {
591
773
  }
592
774
  async function launchHeadful(displayNum) {
593
775
  const executablePath = findChromeExecutable();
594
- const hasExtensions = import_fs.default.existsSync(UBLOCK_PATH);
776
+ const hasExtensions = import_fs2.default.existsSync(UBLOCK_PATH);
595
777
  const args = [
596
778
  "--no-sandbox",
597
779
  "--disable-setuid-sandbox",
@@ -611,7 +793,7 @@ async function launchHeadful(displayNum) {
611
793
  env: { ...process.env, DISPLAY: `:${displayNum}` }
612
794
  };
613
795
  if (executablePath) launchOpts.executablePath = executablePath;
614
- log.debug(`headful: ${executablePath || "patchright chromium"}, extensions: ${hasExtensions}`);
796
+ log2.debug(`headful: ${executablePath || "patchright chromium"}, extensions: ${hasExtensions}`);
615
797
  return import_patchright.chromium.launch(launchOpts);
616
798
  }
617
799
 
@@ -668,7 +850,13 @@ var TIMING = {
668
850
  /** Scroll step delay */
669
851
  SCROLL_DELAY: 150,
670
852
  /** Stale state check interval */
671
- STALE_CHECK_INTERVAL: 2e3
853
+ STALE_CHECK_INTERVAL: 2e3,
854
+ /** captureApi: grace period after the last step for a submit's XHR to fire */
855
+ CAPTURE_SETTLE: 300,
856
+ /** captureApi: max wait for in-flight requests to answer before stopping */
857
+ CAPTURE_PENDING_MAX: 3e3,
858
+ /** Post-failure probe ("does the selector exist right now?") budget */
859
+ FAILURE_PROBE: 3e3
672
860
  };
673
861
  var CAPTCHA_GRID = {
674
862
  /** Pixels from bframe top to grid start (reCAPTCHA) */
@@ -706,6 +894,10 @@ var TIMEOUTS = {
706
894
  DEFAULT_STALE: 2e4,
707
895
  NAVIGATION: 6e4,
708
896
  SELECTOR_WAIT: 1e4,
897
+ // Actionability wait for click. Under the 20s stale timer on purpose, so
898
+ // Playwright's own reason (not visible, covered by another element…) reaches
899
+ // the agent instead of a generic "no state change".
900
+ CLICK: 8e3,
709
901
  TOTP_INPUT: 5e3,
710
902
  API_REQUEST: 18e4,
711
903
  HEALTH_CHECK: 3e3,
@@ -752,7 +944,7 @@ function generateWindowsFingerprint() {
752
944
  }
753
945
 
754
946
  // src/lib/browser/session-manager.ts
755
- var log2 = createLogger("session");
947
+ var log3 = createLogger("session");
756
948
  var BASE_DISPLAY = parseInt(process.env.VNC_BASE_DISPLAY || "99", 10);
757
949
  var MAX_SESSIONS = parseInt(process.env.VNC_MAX_SESSIONS || "20", 10);
758
950
  var SESSION_TIMEOUT = parseInt(process.env.VNC_SESSION_TIMEOUT_MS || "300000", 10);
@@ -776,7 +968,7 @@ function waitForSocket(displayNum, timeoutMs = 5e3) {
776
968
  return new Promise((resolve, reject) => {
777
969
  const start = Date.now();
778
970
  const check = () => {
779
- if (import_fs2.default.existsSync(socketPath)) return resolve();
971
+ if (import_fs3.default.existsSync(socketPath)) return resolve();
780
972
  if (Date.now() - start > timeoutMs) return reject(new Error(`Xvfb socket not ready after ${timeoutMs}ms`));
781
973
  setTimeout(check, 100);
782
974
  };
@@ -798,17 +990,17 @@ async function startSession(userId) {
798
990
  const displayNum = allocateDisplay();
799
991
  const vncPort = 5900 + displayNum;
800
992
  const wsPort = 6080 + (displayNum - BASE_DISPLAY);
801
- const xvfb = (0, import_child_process.spawn)("Xvfb", [`:${displayNum}`, "-screen", "0", "1920x1080x24", "-ac"], {
993
+ const xvfb = (0, import_child_process2.spawn)("Xvfb", [`:${displayNum}`, "-screen", "0", "1920x1080x24", "-ac"], {
802
994
  stdio: "ignore"
803
995
  });
804
996
  await waitForSocket(displayNum);
805
- const x11vnc = (0, import_child_process.spawn)(
997
+ const x11vnc = (0, import_child_process2.spawn)(
806
998
  "x11vnc",
807
999
  ["-display", `:${displayNum}`, "-nopw", "-listen", "localhost", "-rfbport", String(vncPort), "-shared", "-forever"],
808
1000
  { stdio: "ignore" }
809
1001
  );
810
- const noVncPath = import_fs2.default.existsSync("/usr/share/novnc") ? "/usr/share/novnc" : "/usr/share/noVNC";
811
- const websockify = (0, import_child_process.spawn)("websockify", ["--web", noVncPath, String(wsPort), `localhost:${vncPort}`], {
1002
+ const noVncPath = import_fs3.default.existsSync("/usr/share/novnc") ? "/usr/share/novnc" : "/usr/share/noVNC";
1003
+ const websockify = (0, import_child_process2.spawn)("websockify", ["--web", noVncPath, String(wsPort), `localhost:${vncPort}`], {
812
1004
  stdio: "ignore"
813
1005
  });
814
1006
  await new Promise((r) => setTimeout(r, 500));
@@ -819,7 +1011,7 @@ async function startSession(userId) {
819
1011
  const stealthScript = buildStealthScript(fingerprint);
820
1012
  contextStealthScripts.set(context, stealthScript);
821
1013
  const page = await context.newPage();
822
- log2.debug(`fingerprint: ${fingerprint.userAgent.slice(0, 60)}... DPR=${fingerprint.deviceScaleFactor} screen=${fingerprint.screenWidth}x${fingerprint.screenHeight}`);
1014
+ log3.debug(`fingerprint: ${fingerprint.userAgent.slice(0, 60)}... DPR=${fingerprint.deviceScaleFactor} screen=${fingerprint.screenWidth}x${fingerprint.screenHeight}`);
823
1015
  const session = {
824
1016
  displayNum,
825
1017
  vncPort,
@@ -871,7 +1063,7 @@ async function stopSession(userId) {
871
1063
  killProcess(session.xvfb);
872
1064
  await new Promise((r) => setTimeout(r, 1e3));
873
1065
  try {
874
- import_fs2.default.unlinkSync(`/tmp/.X11-unix/X${session.displayNum}`);
1066
+ import_fs3.default.unlinkSync(`/tmp/.X11-unix/X${session.displayNum}`);
875
1067
  } catch {
876
1068
  }
877
1069
  freeDisplay(session.displayNum);
@@ -885,9 +1077,9 @@ async function cleanupAllSessions() {
885
1077
 
886
1078
  // src/lib/auth/crypto.ts
887
1079
  var import_crypto = __toESM(require("crypto"));
888
- var import_fs3 = __toESM(require("fs"));
1080
+ var import_fs4 = __toESM(require("fs"));
889
1081
  var import_os2 = __toESM(require("os"));
890
- var import_path2 = __toESM(require("path"));
1082
+ var import_path3 = __toESM(require("path"));
891
1083
  init_paths();
892
1084
  var SALT = "iframer-session";
893
1085
  var INFO = "encryption";
@@ -897,21 +1089,21 @@ var TAG_LENGTH = 16;
897
1089
  function getLocalToken() {
898
1090
  if (process.env.IFRAMER_SECRET) return process.env.IFRAMER_SECRET;
899
1091
  const candidates = [
900
- import_path2.default.join(getDataDir(), "secret"),
901
- import_path2.default.join(process.env.XDG_RUNTIME_DIR || import_os2.default.tmpdir(), "iframer-secret")
1092
+ import_path3.default.join(getDataDir(), "secret"),
1093
+ import_path3.default.join(process.env.XDG_RUNTIME_DIR || import_os2.default.tmpdir(), "iframer-secret")
902
1094
  ];
903
1095
  for (const file of candidates) {
904
1096
  try {
905
- const existing = import_fs3.default.readFileSync(file, "utf8").trim();
1097
+ const existing = import_fs4.default.readFileSync(file, "utf8").trim();
906
1098
  if (existing) return existing;
907
1099
  } catch {
908
1100
  }
909
1101
  }
910
1102
  for (const file of candidates) {
911
1103
  try {
912
- import_fs3.default.mkdirSync(import_path2.default.dirname(file), { recursive: true });
1104
+ import_fs4.default.mkdirSync(import_path3.default.dirname(file), { recursive: true });
913
1105
  const secret = import_crypto.default.randomBytes(32).toString("hex");
914
- import_fs3.default.writeFileSync(file, secret, { mode: 384 });
1106
+ import_fs4.default.writeFileSync(file, secret, { mode: 384 });
915
1107
  return secret;
916
1108
  } catch {
917
1109
  }
@@ -971,17 +1163,17 @@ function generateTOTP(secret, period = 30, digits = 6) {
971
1163
  init_persistence();
972
1164
 
973
1165
  // src/lib/screenshot.ts
974
- var import_fs4 = __toESM(require("fs"));
975
- var import_path3 = __toESM(require("path"));
976
- var log3 = createLogger("screenshot");
1166
+ var import_fs5 = __toESM(require("fs"));
1167
+ var import_path4 = __toESM(require("path"));
1168
+ var log4 = createLogger("screenshot");
977
1169
  var MAX_AGE_MS = parseInt(process.env.IFRAMER_SCREENSHOT_MAX_AGE_MS || String(24 * 60 * 60 * 1e3), 10);
978
1170
  var MAX_FILES = parseInt(process.env.IFRAMER_SCREENSHOT_MAX_FILES || "500", 10);
979
1171
  var PRUNE_THROTTLE_MS = 5 * 60 * 1e3;
980
1172
  var lastPruneAt = 0;
981
1173
  function saveScreenshot(buffer, filename, screenshotDir, publicUrl) {
982
- import_fs4.default.mkdirSync(screenshotDir, { recursive: true });
983
- const filePath = import_path3.default.join(screenshotDir, filename);
984
- import_fs4.default.writeFileSync(filePath, buffer);
1174
+ import_fs5.default.mkdirSync(screenshotDir, { recursive: true });
1175
+ const filePath = import_path4.default.join(screenshotDir, filename);
1176
+ import_fs5.default.writeFileSync(filePath, buffer);
985
1177
  maybePrune(screenshotDir);
986
1178
  return `${publicUrl}/screenshots/${filename}`;
987
1179
  }
@@ -996,10 +1188,10 @@ function pruneScreenshots(dir, opts = {}) {
996
1188
  const maxFiles = opts.maxFiles ?? MAX_FILES;
997
1189
  const now = opts.now ?? Date.now();
998
1190
  try {
999
- const entries = import_fs4.default.readdirSync(dir).filter((f) => f.endsWith(".jpg") || f.endsWith(".jpeg") || f.endsWith(".png")).map((f) => {
1000
- const full = import_path3.default.join(dir, f);
1191
+ const entries = import_fs5.default.readdirSync(dir).filter((f) => f.endsWith(".jpg") || f.endsWith(".jpeg") || f.endsWith(".png")).map((f) => {
1192
+ const full = import_path4.default.join(dir, f);
1001
1193
  try {
1002
- return { full, mtimeMs: import_fs4.default.statSync(full).mtimeMs };
1194
+ return { full, mtimeMs: import_fs5.default.statSync(full).mtimeMs };
1003
1195
  } catch {
1004
1196
  return null;
1005
1197
  }
@@ -1009,7 +1201,7 @@ function pruneScreenshots(dir, opts = {}) {
1009
1201
  for (const e of entries) {
1010
1202
  if (now - e.mtimeMs > maxAgeMs) {
1011
1203
  try {
1012
- import_fs4.default.unlinkSync(e.full);
1204
+ import_fs5.default.unlinkSync(e.full);
1013
1205
  removed++;
1014
1206
  } catch {
1015
1207
  }
@@ -1021,23 +1213,23 @@ function pruneScreenshots(dir, opts = {}) {
1021
1213
  survivors.sort((a, b) => a.mtimeMs - b.mtimeMs);
1022
1214
  for (const e of survivors.slice(0, survivors.length - maxFiles)) {
1023
1215
  try {
1024
- import_fs4.default.unlinkSync(e.full);
1216
+ import_fs5.default.unlinkSync(e.full);
1025
1217
  removed++;
1026
1218
  } catch {
1027
1219
  }
1028
1220
  }
1029
1221
  }
1030
- if (removed > 0) log3.debug(`pruned ${removed} old screenshot(s) from ${dir}`);
1222
+ if (removed > 0) log4.debug(`pruned ${removed} old screenshot(s) from ${dir}`);
1031
1223
  return removed;
1032
1224
  } catch (err) {
1033
- log3.warn(`screenshot prune failed: ${err instanceof Error ? err.message : String(err)}`);
1225
+ log4.warn(`screenshot prune failed: ${err instanceof Error ? err.message : String(err)}`);
1034
1226
  return 0;
1035
1227
  }
1036
1228
  }
1037
1229
 
1038
1230
  // src/lib/session/sqlite-store.ts
1039
- var import_path4 = __toESM(require("path"));
1040
- var import_fs5 = __toESM(require("fs"));
1231
+ var import_path5 = __toESM(require("path"));
1232
+ var import_fs6 = __toESM(require("fs"));
1041
1233
  var IS_BUN = typeof globalThis.Bun !== "undefined";
1042
1234
  function createBunDb(dbPath) {
1043
1235
  const { Database } = require("bun:sqlite");
@@ -1076,8 +1268,8 @@ function createNodeDb(dbPath) {
1076
1268
  var SqliteStore = class {
1077
1269
  db;
1078
1270
  constructor(dataDir) {
1079
- import_fs5.default.mkdirSync(dataDir, { recursive: true });
1080
- const dbPath = import_path4.default.join(dataDir, "iframer.db");
1271
+ import_fs6.default.mkdirSync(dataDir, { recursive: true });
1272
+ const dbPath = import_path5.default.join(dataDir, "iframer.db");
1081
1273
  this.db = IS_BUN ? createBunDb(dbPath) : createNodeDb(dbPath);
1082
1274
  this.db.run(`
1083
1275
  CREATE TABLE IF NOT EXISTS sessions (
@@ -1157,13 +1349,13 @@ var import_patchright2 = require("patchright");
1157
1349
  var import_crypto2 = require("crypto");
1158
1350
 
1159
1351
  // src/lib/browser/chrome-downloader.ts
1160
- var import_fs6 = __toESM(require("fs"));
1161
- var import_path5 = __toESM(require("path"));
1352
+ var import_fs7 = __toESM(require("fs"));
1353
+ var import_path6 = __toESM(require("path"));
1162
1354
  var import_os3 = __toESM(require("os"));
1163
- var import_child_process2 = require("child_process");
1164
- var log4 = createLogger("chrome");
1355
+ var import_child_process3 = require("child_process");
1356
+ var log5 = createLogger("chrome");
1165
1357
  var CHROME_VERSIONS_URL = "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json";
1166
- var DEFAULT_INSTALL_DIR = import_path5.default.join(import_os3.default.homedir(), ".iframer", "chrome");
1358
+ var DEFAULT_INSTALL_DIR = import_path6.default.join(import_os3.default.homedir(), ".iframer", "chrome");
1167
1359
  function getPlatform() {
1168
1360
  const arch = process.arch;
1169
1361
  const platform = process.platform;
@@ -1175,24 +1367,24 @@ function getPlatform() {
1175
1367
  function getChromeExecutablePath(installDir) {
1176
1368
  const platform = process.platform;
1177
1369
  if (platform === "darwin") {
1178
- const entries = import_fs6.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
1370
+ const entries = import_fs7.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
1179
1371
  const dir = entries[0] || "chrome-mac-arm64";
1180
- return import_path5.default.join(installDir, dir, "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing");
1372
+ return import_path6.default.join(installDir, dir, "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing");
1181
1373
  }
1182
1374
  if (platform === "linux") {
1183
- const entries = import_fs6.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
1375
+ const entries = import_fs7.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
1184
1376
  const dir = entries[0] || "chrome-linux64";
1185
- return import_path5.default.join(installDir, dir, "chrome");
1377
+ return import_path6.default.join(installDir, dir, "chrome");
1186
1378
  }
1187
1379
  if (platform === "win32") {
1188
- const entries = import_fs6.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
1380
+ const entries = import_fs7.default.readdirSync(installDir).filter((e) => e.startsWith("chrome-"));
1189
1381
  const dir = entries[0] || "chrome-win64";
1190
- return import_path5.default.join(installDir, dir, "chrome.exe");
1382
+ return import_path6.default.join(installDir, dir, "chrome.exe");
1191
1383
  }
1192
1384
  throw new Error(`Unsupported platform: ${platform}`);
1193
1385
  }
1194
1386
  async function downloadChrome(installDir = DEFAULT_INSTALL_DIR) {
1195
- log4.info("Downloading Chrome for Testing (first time only)...");
1387
+ log5.info("Downloading Chrome for Testing (first time only)...");
1196
1388
  const res = await fetch(CHROME_VERSIONS_URL);
1197
1389
  if (!res.ok) throw new Error(`Failed to fetch Chrome versions: ${res.status}`);
1198
1390
  const data = await res.json();
@@ -1203,35 +1395,35 @@ async function downloadChrome(installDir = DEFAULT_INSTALL_DIR) {
1203
1395
  if (!download2) throw new Error(`No Chrome for Testing download for platform: ${platform}`);
1204
1396
  const url = download2.url;
1205
1397
  const version = channel.version;
1206
- log4.debug(`Version ${version} for ${platform}`);
1207
- log4.debug(`URL: ${url}`);
1208
- import_fs6.default.mkdirSync(installDir, { recursive: true });
1209
- const zipPath = import_path5.default.join(installDir, "chrome.zip");
1398
+ log5.debug(`Version ${version} for ${platform}`);
1399
+ log5.debug(`URL: ${url}`);
1400
+ import_fs7.default.mkdirSync(installDir, { recursive: true });
1401
+ const zipPath = import_path6.default.join(installDir, "chrome.zip");
1210
1402
  const dlRes = await fetch(url);
1211
1403
  if (!dlRes.ok) throw new Error(`Download failed: ${dlRes.status}`);
1212
1404
  const buf = Buffer.from(await dlRes.arrayBuffer());
1213
- import_fs6.default.writeFileSync(zipPath, buf);
1214
- log4.info(`Downloaded ${(buf.length / 1024 / 1024).toFixed(1)}MB`);
1215
- (0, import_child_process2.execSync)(`unzip -o -q "${zipPath}" -d "${installDir}"`, { stdio: "inherit" });
1216
- import_fs6.default.unlinkSync(zipPath);
1405
+ import_fs7.default.writeFileSync(zipPath, buf);
1406
+ log5.info(`Downloaded ${(buf.length / 1024 / 1024).toFixed(1)}MB`);
1407
+ (0, import_child_process3.execSync)(`unzip -o -q "${zipPath}" -d "${installDir}"`, { stdio: "inherit" });
1408
+ import_fs7.default.unlinkSync(zipPath);
1217
1409
  const execPath = getChromeExecutablePath(installDir);
1218
- if (!import_fs6.default.existsSync(execPath)) {
1410
+ if (!import_fs7.default.existsSync(execPath)) {
1219
1411
  throw new Error(`Chrome executable not found after extraction: ${execPath}`);
1220
1412
  }
1221
1413
  if (process.platform !== "win32") {
1222
- import_fs6.default.chmodSync(execPath, 493);
1414
+ import_fs7.default.chmodSync(execPath, 493);
1223
1415
  }
1224
- import_fs6.default.writeFileSync(import_path5.default.join(installDir, "version.json"), JSON.stringify({ version, platform, downloadedAt: (/* @__PURE__ */ new Date()).toISOString() }));
1225
- log4.info(`Installed at: ${execPath}`);
1416
+ import_fs7.default.writeFileSync(import_path6.default.join(installDir, "version.json"), JSON.stringify({ version, platform, downloadedAt: (/* @__PURE__ */ new Date()).toISOString() }));
1417
+ log5.info(`Installed at: ${execPath}`);
1226
1418
  return execPath;
1227
1419
  }
1228
1420
  function findChromeForTesting() {
1229
1421
  if (process.env.CHROME_EXECUTABLE) {
1230
- if (import_fs6.default.existsSync(process.env.CHROME_EXECUTABLE)) return process.env.CHROME_EXECUTABLE;
1422
+ if (import_fs7.default.existsSync(process.env.CHROME_EXECUTABLE)) return process.env.CHROME_EXECUTABLE;
1231
1423
  }
1232
1424
  try {
1233
1425
  const execPath = getChromeExecutablePath(DEFAULT_INSTALL_DIR);
1234
- if (import_fs6.default.existsSync(execPath)) return execPath;
1426
+ if (import_fs7.default.existsSync(execPath)) return execPath;
1235
1427
  } catch {
1236
1428
  }
1237
1429
  return null;
@@ -1251,8 +1443,8 @@ function findChrome() {
1251
1443
  // Playwright/Patchright installed browsers (Docker containers)
1252
1444
  ...(() => {
1253
1445
  try {
1254
- const dirs = import_fs6.default.readdirSync("/ms-playwright").filter((d) => d.startsWith("chromium-")).sort().reverse();
1255
- return dirs.map((d) => import_path5.default.join("/ms-playwright", d, "chrome-linux", "chrome"));
1446
+ const dirs = import_fs7.default.readdirSync("/ms-playwright").filter((d) => d.startsWith("chromium-")).sort().reverse();
1447
+ return dirs.map((d) => import_path6.default.join("/ms-playwright", d, "chrome-linux", "chrome"));
1256
1448
  } catch {
1257
1449
  return [];
1258
1450
  }
@@ -1262,7 +1454,7 @@ function findChrome() {
1262
1454
  "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
1263
1455
  ];
1264
1456
  for (const p of systemPaths) {
1265
- if (import_fs6.default.existsSync(p)) return p;
1457
+ if (import_fs7.default.existsSync(p)) return p;
1266
1458
  }
1267
1459
  return null;
1268
1460
  }
@@ -1272,10 +1464,10 @@ async function ensureChrome() {
1272
1464
  try {
1273
1465
  return await downloadChrome();
1274
1466
  } catch (err) {
1275
- log4.error(`Failed to download Chrome for Testing: ${err instanceof Error ? err.message : String(err)}`);
1467
+ log5.error(`Failed to download Chrome for Testing: ${err instanceof Error ? err.message : String(err)}`);
1276
1468
  const system = findChrome();
1277
1469
  if (system) {
1278
- log4.warn(`Falling back to system Chrome: ${system}`);
1470
+ log5.warn(`Falling back to system Chrome: ${system}`);
1279
1471
  return system;
1280
1472
  }
1281
1473
  throw new Error("No Chrome found. Download failed and no system Chrome available.");
@@ -1283,7 +1475,7 @@ async function ensureChrome() {
1283
1475
  }
1284
1476
 
1285
1477
  // src/lib/browser/cloak-browser.ts
1286
- var log5 = createLogger("cloak");
1478
+ var log6 = createLogger("cloak");
1287
1479
  var _available = null;
1288
1480
  function cloakEnabled() {
1289
1481
  return process.env.IFRAMER_USE_CLOAKBROWSER === "1" || process.env.IFRAMER_USE_CLOAKBROWSER === "true";
@@ -1302,14 +1494,14 @@ async function ensureBinary() {
1302
1494
  try {
1303
1495
  const info = cloak.binaryInfo();
1304
1496
  if (!info.installed) {
1305
- log5.info("Downloading CloakBrowser binary...");
1497
+ log6.info("Downloading CloakBrowser binary...");
1306
1498
  await cloak.ensureBinary();
1307
- log5.info("CloakBrowser ready");
1499
+ log6.info("CloakBrowser ready");
1308
1500
  }
1309
1501
  _available = true;
1310
1502
  return true;
1311
1503
  } catch (err) {
1312
- log5.warn(`CloakBrowser setup failed: ${err instanceof Error ? err.message : String(err)}`);
1504
+ log6.warn(`CloakBrowser setup failed: ${err instanceof Error ? err.message : String(err)}`);
1313
1505
  _available = false;
1314
1506
  return false;
1315
1507
  }
@@ -1324,157 +1516,20 @@ async function launchCloakBrowser(options) {
1324
1516
  headless: options.headless,
1325
1517
  args: options.args
1326
1518
  });
1327
- log5.info(`CloakBrowser launched (headless=${options.headless})`);
1519
+ log6.info(`CloakBrowser launched (headless=${options.headless})`);
1328
1520
  return browser;
1329
1521
  } catch (err) {
1330
- log5.warn(`CloakBrowser launch failed: ${err instanceof Error ? err.message : String(err)}`);
1522
+ log6.warn(`CloakBrowser launch failed: ${err instanceof Error ? err.message : String(err)}`);
1331
1523
  return null;
1332
1524
  }
1333
1525
  }
1334
1526
 
1335
- // src/lib/browser/registry.ts
1336
- var import_fs7 = __toESM(require("fs"));
1337
- var import_path6 = __toESM(require("path"));
1338
- var import_child_process3 = require("child_process");
1339
- init_paths();
1340
- var log6 = createLogger("registry");
1341
- function browsersDir() {
1342
- const dir = import_path6.default.join(getDataDir(), "browsers");
1343
- import_fs7.default.mkdirSync(dir, { recursive: true });
1344
- return dir;
1345
- }
1346
- function serverInfoPath() {
1347
- return import_path6.default.join(getDataDir(), "server.json");
1348
- }
1349
- function isPidAlive(pid) {
1350
- if (!Number.isInteger(pid) || pid <= 1) return false;
1351
- try {
1352
- process.kill(pid, 0);
1353
- return true;
1354
- } catch {
1355
- return false;
1356
- }
1357
- }
1358
- function pidMatchesMarker(pid, marker) {
1359
- if (!isPidAlive(pid)) return false;
1360
- try {
1361
- const cmd = (0, import_child_process3.execSync)(`ps -o command= -p ${pid}`, { encoding: "utf8" });
1362
- return cmd.includes(marker);
1363
- } catch {
1364
- return false;
1365
- }
1366
- }
1367
- function findChromePidByMarker(marker) {
1368
- try {
1369
- const out = (0, import_child_process3.execSync)(`pgrep -f -- "${marker}"`, { encoding: "utf8" }).trim();
1370
- const pids = out.split("\n").map((s) => parseInt(s, 10)).filter((n) => Number.isInteger(n) && n !== process.pid);
1371
- if (pids.length === 0) return null;
1372
- return Math.min(...pids);
1373
- } catch {
1374
- return null;
1375
- }
1376
- }
1377
- function registerBrowser(rec) {
1378
- try {
1379
- import_fs7.default.writeFileSync(import_path6.default.join(browsersDir(), `${rec.chromePid}.json`), JSON.stringify(rec, null, 2));
1380
- } catch (err) {
1381
- log6.warn(`failed to write browser record for pid ${rec.chromePid}: ${err}`);
1382
- }
1383
- }
1384
- function unregisterBrowser(chromePid) {
1385
- try {
1386
- import_fs7.default.unlinkSync(import_path6.default.join(browsersDir(), `${chromePid}.json`));
1387
- } catch {
1388
- }
1389
- }
1390
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1391
- async function forceKillBrowser(rec) {
1392
- if (!isPidAlive(rec.chromePid)) return true;
1393
- if (!pidMatchesMarker(rec.chromePid, rec.marker)) {
1394
- return true;
1395
- }
1396
- try {
1397
- process.kill(rec.chromePid, "SIGKILL");
1398
- } catch {
1399
- }
1400
- const deadline = Date.now() + 2e3;
1401
- while (Date.now() < deadline) {
1402
- if (!isPidAlive(rec.chromePid)) return true;
1403
- await sleep(100);
1404
- }
1405
- return !isPidAlive(rec.chromePid);
1406
- }
1407
- async function reapOrphanBrowsers() {
1408
- let reaped = 0;
1409
- let skipped = 0;
1410
- let files = [];
1411
- try {
1412
- files = import_fs7.default.readdirSync(browsersDir()).filter((f) => f.endsWith(".json"));
1413
- } catch {
1414
- return { reaped, skipped };
1415
- }
1416
- for (const file of files) {
1417
- const full = import_path6.default.join(browsersDir(), file);
1418
- let rec;
1419
- try {
1420
- rec = JSON.parse(import_fs7.default.readFileSync(full, "utf8"));
1421
- } catch {
1422
- try {
1423
- import_fs7.default.unlinkSync(full);
1424
- } catch {
1425
- }
1426
- continue;
1427
- }
1428
- if (!isPidAlive(rec.chromePid) || !pidMatchesMarker(rec.chromePid, rec.marker)) {
1429
- try {
1430
- import_fs7.default.unlinkSync(full);
1431
- } catch {
1432
- }
1433
- continue;
1434
- }
1435
- if (isPidAlive(rec.ownerPid)) {
1436
- skipped++;
1437
- continue;
1438
- }
1439
- log6.info(`reaping orphan Chrome pid=${rec.chromePid} (${rec.key}), owner ${rec.ownerPid} is dead`);
1440
- if (await forceKillBrowser(rec)) {
1441
- try {
1442
- import_fs7.default.unlinkSync(full);
1443
- } catch {
1444
- }
1445
- reaped++;
1446
- } else {
1447
- log6.warn(`failed to kill orphan Chrome pid=${rec.chromePid} \u2014 leaving record for next sweep`);
1448
- }
1449
- }
1450
- return { reaped, skipped };
1451
- }
1452
- function writeServerInfo(info) {
1453
- import_fs7.default.writeFileSync(serverInfoPath(), JSON.stringify(info, null, 2));
1454
- }
1455
- function readServerInfo() {
1456
- try {
1457
- const info = JSON.parse(import_fs7.default.readFileSync(serverInfoPath(), "utf8"));
1458
- if (!Number.isInteger(info.pid) || !Number.isInteger(info.port)) return null;
1459
- return info;
1460
- } catch {
1461
- return null;
1462
- }
1463
- }
1464
- function clearServerInfo(pid) {
1465
- const info = readServerInfo();
1466
- if (info && info.pid === pid) {
1467
- try {
1468
- import_fs7.default.unlinkSync(serverInfoPath());
1469
- } catch {
1470
- }
1471
- }
1472
- }
1473
-
1474
1527
  // src/lib/browser/daemon.ts
1475
1528
  var log7 = createLogger("daemon");
1476
1529
  var DEFAULT_IDLE_TIMEOUT = 5 * 60 * 1e3;
1477
1530
  var CLOSE_GRACE_MS = 5e3;
1531
+ var BUSY_MAX_MS = 30 * 60 * 1e3;
1532
+ var BLANK_IDLE_MS = 2 * 60 * 1e3;
1478
1533
  var DEFAULT_INSTANCE = "default";
1479
1534
  function keyOf(mode, instanceId) {
1480
1535
  return `${mode}::${instanceId}`;
@@ -1566,7 +1621,8 @@ var BrowserDaemon = class {
1566
1621
  createdAt: /* @__PURE__ */ new Date(),
1567
1622
  chromePid,
1568
1623
  marker,
1569
- active: 0
1624
+ active: 0,
1625
+ busySince: null
1570
1626
  };
1571
1627
  this.instances.set(key, instance);
1572
1628
  this.resetIdleTimer(key);
@@ -1578,14 +1634,19 @@ var BrowserDaemon = class {
1578
1634
  * Always pair with release() in a finally block. */
1579
1635
  acquire(mode, instanceId = DEFAULT_INSTANCE) {
1580
1636
  const instance = this.instances.get(keyOf(mode, instanceId));
1581
- if (instance) instance.active++;
1637
+ if (!instance) return;
1638
+ if (instance.active === 0) instance.busySince = Date.now();
1639
+ instance.active++;
1582
1640
  }
1583
1641
  release(mode, instanceId = DEFAULT_INSTANCE) {
1584
1642
  const key = keyOf(mode, instanceId);
1585
1643
  const instance = this.instances.get(key);
1586
1644
  if (!instance) return;
1587
1645
  instance.active = Math.max(0, instance.active - 1);
1588
- if (instance.active === 0) this.resetIdleTimer(key);
1646
+ if (instance.active === 0) {
1647
+ instance.busySince = null;
1648
+ this.resetIdleTimer(key);
1649
+ }
1589
1650
  }
1590
1651
  isRunning(mode, instanceId = DEFAULT_INSTANCE) {
1591
1652
  const instance = this.instances.get(keyOf(mode, instanceId));
@@ -1596,6 +1657,35 @@ var BrowserDaemon = class {
1596
1657
  return false;
1597
1658
  }
1598
1659
  }
1660
+ /** Close instances that never left about:blank and have been idle a while.
1661
+ * Those are accidents — a pipeline that failed before its first navigate, or
1662
+ * a mode picked for a call that never needed a browser — and in headful mode
1663
+ * each one is an empty window sitting on the user's desktop. Called on the
1664
+ * server's reap tick, so it also catches instances whose idle timer was lost
1665
+ * (e.g. the daemon was restarted under them). */
1666
+ sweepBlankInstances() {
1667
+ const stopped = [];
1668
+ const now = Date.now();
1669
+ for (const [key, inst] of this.instances.entries()) {
1670
+ if (inst.active > 0) continue;
1671
+ if (now - inst.createdAt.getTime() < BLANK_IDLE_MS) continue;
1672
+ let blank = false;
1673
+ try {
1674
+ const pages = inst.context.pages();
1675
+ blank = pages.length === 0 || pages.every((p) => {
1676
+ const u = p.url();
1677
+ return u === "about:blank" || u === "" || u === "chrome://newtab/";
1678
+ });
1679
+ } catch {
1680
+ continue;
1681
+ }
1682
+ if (!blank) continue;
1683
+ log7.info(`blank-orphan sweep: ${key} never left about:blank, stopping`);
1684
+ stopped.push(key);
1685
+ this.stopKey(key).catch((err) => log7.warn(`blank sweep stop failed for ${key}: ${err}`));
1686
+ }
1687
+ return stopped;
1688
+ }
1599
1689
  /** Distinct modes that currently have at least one live instance. */
1600
1690
  runningModes() {
1601
1691
  return [...new Set(this.liveInstances().map((i) => i.mode))];
@@ -1714,8 +1804,14 @@ var BrowserDaemon = class {
1714
1804
  setTimeout(() => {
1715
1805
  const instance = this.instances.get(key);
1716
1806
  if (instance && instance.active > 0) {
1717
- this.resetIdleTimer(key);
1718
- return;
1807
+ const busyMs = instance.busySince ? Date.now() - instance.busySince : 0;
1808
+ if (busyMs < BUSY_MAX_MS) {
1809
+ this.resetIdleTimer(key);
1810
+ return;
1811
+ }
1812
+ log7.warn(`${key} reported busy for ${Math.round(busyMs / 6e4)}m \u2014 treating active=${instance.active} as leaked, stopping`);
1813
+ instance.active = 0;
1814
+ instance.busySince = null;
1719
1815
  }
1720
1816
  log7.info(`Idle timeout for ${key}, stopping...`);
1721
1817
  this.stopKey(key).catch((err) => log7.warn(`idle stop failed for ${key}: ${err}`));
@@ -2074,6 +2170,24 @@ async function humanType(page, selector, text, opts = {}) {
2074
2170
  await sleep3(randRange(TIMING.POST_CLICK));
2075
2171
  }
2076
2172
  await assertFocused(page, selector, !opts.skipClick);
2173
+ if (opts.clear) {
2174
+ const hadText = await page.evaluate(() => {
2175
+ const el = document.activeElement;
2176
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
2177
+ el.select();
2178
+ return el.value.length > 0;
2179
+ }
2180
+ if (el?.isContentEditable) {
2181
+ window.getSelection()?.selectAllChildren(el);
2182
+ return (el.textContent || "").length > 0;
2183
+ }
2184
+ return false;
2185
+ });
2186
+ if (hadText) {
2187
+ await page.keyboard.press("Backspace");
2188
+ await sleep3(randRange(TIMING.CHAR_DELAY));
2189
+ }
2190
+ }
2077
2191
  const factor = SPEED_FACTOR[opts.speed || "normal"] ?? 1;
2078
2192
  for (const char of text) {
2079
2193
  await page.keyboard.type(char);
@@ -2229,7 +2343,36 @@ function sleep3(ms) {
2229
2343
  // src/lib/actions/handlers/navigation.ts
2230
2344
  init_persistence();
2231
2345
 
2346
+ // src/lib/budget.ts
2347
+ var BudgetExceededError = class extends Error {
2348
+ constructor(label, ms) {
2349
+ super(`${label} did not answer within ${ms}ms`);
2350
+ this.name = "BudgetExceededError";
2351
+ }
2352
+ };
2353
+ function withBudget(p, ms, label = "page call") {
2354
+ p.catch(() => void 0);
2355
+ let timer;
2356
+ return Promise.race([
2357
+ p,
2358
+ new Promise((_, reject) => {
2359
+ timer = setTimeout(() => reject(new BudgetExceededError(label, ms)), ms);
2360
+ timer.unref?.();
2361
+ })
2362
+ ]).finally(() => {
2363
+ if (timer) clearTimeout(timer);
2364
+ });
2365
+ }
2366
+
2232
2367
  // src/lib/actions/resolve-selector.ts
2368
+ var PLAYWRIGHT_ONLY = /^\s*(text|xpath|role|id|data-testid|internal:[\w-]+)\s*=|:(has-text|text|text-is|text-matches|visible|nth-match|right-of|left-of|above|below|near)\b|\s>>\s/;
2369
+ function assertCssSelector(selector) {
2370
+ if (PLAYWRIGHT_ONLY.test(selector)) {
2371
+ throw new Error(
2372
+ `Unsupported selector "${selector}": Playwright text/role selectors (text=, :has-text(), :text-is(), >>) aren't supported. Locate the element by its text with a \`find\` step (e.g. { type: "find", text: "Texas" } or { role: "option", name: "Texas" }) and act on the @e ref it returns, in the same execute call.`
2373
+ );
2374
+ }
2375
+ }
2233
2376
  function resolveSelector(selector, ctx) {
2234
2377
  if (selector.startsWith("@a:")) {
2235
2378
  const name = selector.slice(3);
@@ -2250,19 +2393,149 @@ function resolveSelector(selector, ctx) {
2250
2393
  }
2251
2394
  return ref.selector;
2252
2395
  }
2396
+ assertCssSelector(selector);
2253
2397
  return selector;
2254
2398
  }
2255
2399
  function anchorNameOf(selector) {
2256
2400
  return typeof selector === "string" && selector.startsWith("@a:") ? selector.slice(3) : null;
2257
2401
  }
2258
2402
 
2403
+ // src/lib/reachability.ts
2404
+ var HONEYPOT_REASONS = /* @__PURE__ */ new Set(["zero-size", "hidden", "transparent", "offscreen", "clipped"]);
2405
+ var HONEYPOT_NAME_RE = /^(website|url|homepage|fax|honeypot|honey|hp|hp_[\w-]*|bot|bot_[\w-]*|trap|_gotcha|leave_?blank|do_?not_?fill)$/i;
2406
+ function isHoneypotReason(reason) {
2407
+ return !!reason && HONEYPOT_REASONS.has(reason);
2408
+ }
2409
+ function looksLikeBaitField(attrs) {
2410
+ const name = attrs.name || "";
2411
+ const id = attrs.id || "";
2412
+ if (HONEYPOT_NAME_RE.test(name) || HONEYPOT_NAME_RE.test(id)) return true;
2413
+ const textual = !attrs.type || /^(text|email|url|tel|search|number)$/i.test(attrs.type);
2414
+ if (textual && attrs.tabindex === "-1") return true;
2415
+ return false;
2416
+ }
2417
+ function honeypotRefusalMessage(selector, reason, name) {
2418
+ const what = name ? `"${name}" (${selector})` : selector;
2419
+ return `fill: refusing to fill ${what} \u2014 no human can reach this field (${reason}). It is almost certainly a honeypot: filling it marks the submission as a bot and the form fails with a generic error. Skip it. If you are certain it is a real field, retry with force: true.`;
2420
+ }
2421
+ async function probeField(page, selector) {
2422
+ const r = await page.evaluate((sel) => {
2423
+ const el = document.querySelector(sel);
2424
+ if (!el) return { found: false };
2425
+ const attrs = {
2426
+ name: el.getAttribute("name"),
2427
+ id: el.id || null,
2428
+ tabindex: el.getAttribute("tabindex"),
2429
+ type: el.getAttribute("type")
2430
+ };
2431
+ const tag = el.tagName.toLowerCase();
2432
+ {
2433
+ const r0 = el.getBoundingClientRect();
2434
+ const cx0 = r0.left + r0.width / 2, cy0 = r0.top + r0.height / 2;
2435
+ const dx = cx0 < 0 || cx0 >= window.innerWidth ? cx0 - window.innerWidth / 2 : 0;
2436
+ const dy = cy0 < 0 || cy0 >= window.innerHeight ? cy0 - window.innerHeight / 2 : 0;
2437
+ if (dx || dy) try {
2438
+ window.scrollBy(dx, dy);
2439
+ } catch {
2440
+ }
2441
+ }
2442
+ const rect = el.getBoundingClientRect();
2443
+ let reason = null;
2444
+ if (rect.width === 0 || rect.height === 0) reason = "zero-size";
2445
+ const style = getComputedStyle(el);
2446
+ if (!reason && (style.display === "none" || style.visibility === "hidden")) reason = "hidden";
2447
+ if (!reason) {
2448
+ let opacity = 1;
2449
+ for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
2450
+ const o = parseFloat(getComputedStyle(n).opacity);
2451
+ const animating = o < 1 && typeof n.getAnimations === "function" && n.getAnimations().length > 0;
2452
+ if (!isNaN(o) && !animating) opacity *= o;
2453
+ }
2454
+ if (opacity < 0.05) reason = "transparent";
2455
+ }
2456
+ if (!reason) {
2457
+ const docW = Math.max(document.documentElement.scrollWidth, window.innerWidth);
2458
+ const docH = Math.max(document.documentElement.scrollHeight, window.innerHeight);
2459
+ const left = rect.left + window.scrollX, right = rect.right + window.scrollX;
2460
+ const top = rect.top + window.scrollY, bottom = rect.bottom + window.scrollY;
2461
+ if (right <= 0 || left >= docW || bottom <= 0 || top >= docH) reason = "offscreen";
2462
+ }
2463
+ if (!reason) {
2464
+ const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
2465
+ const inViewport = cx >= 0 && cy >= 0 && cx < window.innerWidth && cy < window.innerHeight;
2466
+ let clipped = false;
2467
+ for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
2468
+ const s = getComputedStyle(a);
2469
+ const clipsX = /hidden|clip/.test(s.overflowX), clipsY = /hidden|clip/.test(s.overflowY);
2470
+ if (!clipsX && !clipsY) continue;
2471
+ const ar = a.getBoundingClientRect();
2472
+ const outside = clipsX && (cx < ar.left || cx > ar.right) || clipsY && (cy < ar.top || cy > ar.bottom);
2473
+ const tiny = clipsY && ar.height <= 2 || clipsX && ar.width <= 2;
2474
+ if (outside || tiny) {
2475
+ clipped = true;
2476
+ break;
2477
+ }
2478
+ }
2479
+ if (inViewport) {
2480
+ const hit = document.elementFromPoint(cx, cy);
2481
+ if (!(hit && (hit === el || el.contains(hit)))) reason = clipped ? "clipped" : "covered";
2482
+ } else if (clipped) {
2483
+ reason = "clipped";
2484
+ }
2485
+ }
2486
+ return { found: true, reason, attrs, tag };
2487
+ }, selector);
2488
+ if (!r.found) return { found: false, hiddenReason: null, suspiciousName: false };
2489
+ return {
2490
+ found: true,
2491
+ hiddenReason: r.reason ?? null,
2492
+ suspiciousName: looksLikeBaitField(r.attrs),
2493
+ tag: r.tag,
2494
+ name: r.attrs.name ?? void 0
2495
+ };
2496
+ }
2497
+
2259
2498
  // src/lib/actions/handlers/navigation.ts
2260
2499
  var log9 = createLogger("actions");
2261
- async function navigate(page, step, ctx) {
2262
- await page.goto(step.url, {
2263
- waitUntil: step.waitUntil || "domcontentloaded",
2264
- timeout: TIMEOUTS.NAVIGATION
2265
- });
2500
+ var NAV_COMMIT_SUSPECT_MS = 12e3;
2501
+ async function navigate(page, step, ctx, monitor) {
2502
+ const waitUntil = step.waitUntil || "domcontentloaded";
2503
+ const urlBefore = page.url();
2504
+ const onDialog = (d) => {
2505
+ (d.type() === "beforeunload" ? d.accept() : d.dismiss()).catch(() => void 0);
2506
+ };
2507
+ page.on("dialog", onDialog);
2508
+ try {
2509
+ const nav = page.goto(step.url, { waitUntil, timeout: TIMEOUTS.NAVIGATION });
2510
+ nav.catch(() => void 0);
2511
+ let suspectTimer;
2512
+ const outcome = await Promise.race([
2513
+ nav.then(() => "done"),
2514
+ new Promise((r) => {
2515
+ suspectTimer = setTimeout(() => r("slow"), NAV_COMMIT_SUSPECT_MS);
2516
+ })
2517
+ ]).finally(() => clearTimeout(suspectTimer));
2518
+ if (outcome === "slow") {
2519
+ if (page.url() !== urlBefore) {
2520
+ await nav;
2521
+ } else {
2522
+ log9.warn(`navigate: no commit after ${NAV_COMMIT_SUSPECT_MS}ms, falling back to location.assign`);
2523
+ monitor?.reportActivity();
2524
+ await withBudget(page.evaluate((u) => {
2525
+ location.assign(u);
2526
+ }, step.url), 3e3, "location.assign").catch(() => void 0);
2527
+ try {
2528
+ await page.waitForURL((u) => u.toString() !== urlBefore, { timeout: 15e3, waitUntil });
2529
+ } catch {
2530
+ throw new Error(
2531
+ `navigate: the page never left ${urlBefore} for ${step.url} (tried a normal navigation, then location.assign). The site is blocking the route change. Use a click on the site's own link to get there.`
2532
+ );
2533
+ }
2534
+ }
2535
+ }
2536
+ } finally {
2537
+ page.off("dialog", onDialog);
2538
+ }
2266
2539
  const stealthScript = contextStealthScripts.get(page.context()) ?? STEALTH_SCRIPT;
2267
2540
  try {
2268
2541
  await page.evaluate(stealthScript);
@@ -2278,14 +2551,37 @@ async function navigate(page, step, ctx) {
2278
2551
  }
2279
2552
  }
2280
2553
  async function click(page, step, ctx) {
2281
- await page.click(resolveSelector(step.selector, ctx));
2554
+ await page.click(resolveSelector(step.selector, ctx), { timeout: TIMEOUTS.CLICK });
2555
+ }
2556
+ async function isCombobox(page, selector) {
2557
+ return withBudget(
2558
+ page.evaluate((sel) => {
2559
+ const el = document.querySelector(sel);
2560
+ if (!el) return false;
2561
+ const role = el.getAttribute("role");
2562
+ return role === "combobox" || el.hasAttribute("aria-autocomplete") || el.getAttribute("aria-haspopup") === "listbox" || el.hasAttribute("list") || !!el.closest('[role="combobox"]');
2563
+ }, selector),
2564
+ 3e3
2565
+ ).catch(() => false);
2282
2566
  }
2283
2567
  async function fill(page, step, ctx) {
2284
2568
  const selector = resolveSelector(step.selector, ctx);
2285
2569
  const value = step.value;
2570
+ if (!step.force) {
2571
+ let probe = null;
2572
+ try {
2573
+ probe = await probeField(page, selector);
2574
+ } catch {
2575
+ probe = null;
2576
+ }
2577
+ if (probe?.found && isHoneypotReason(probe.hiddenReason)) {
2578
+ throw new Error(honeypotRefusalMessage(step.selector, probe.hiddenReason, probe.name));
2579
+ }
2580
+ }
2581
+ const combobox = await isCombobox(page, selector);
2286
2582
  await page.fill(selector, value);
2287
2583
  const stuck = await page.evaluate(
2288
- ({ sel, val }) => {
2584
+ ({ sel, val, keepFocus }) => {
2289
2585
  const el = document.querySelector(sel);
2290
2586
  if (!el) return false;
2291
2587
  const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
@@ -2301,13 +2597,15 @@ async function fill(page, step, ctx) {
2301
2597
  }
2302
2598
  el.dispatchEvent(new Event("input", { bubbles: true }));
2303
2599
  el.dispatchEvent(new Event("change", { bubbles: true }));
2304
- try {
2305
- el.blur();
2306
- } catch {
2600
+ if (!keepFocus) {
2601
+ try {
2602
+ el.blur();
2603
+ } catch {
2604
+ }
2307
2605
  }
2308
2606
  return el.value === val;
2309
2607
  },
2310
- { sel: selector, val: value }
2608
+ { sel: selector, val: value, keepFocus: combobox }
2311
2609
  );
2312
2610
  if (!stuck) {
2313
2611
  const loc = page.locator(selector);
@@ -2315,10 +2613,12 @@ async function fill(page, step, ctx) {
2315
2613
  await loc.click();
2316
2614
  await loc.fill("");
2317
2615
  await loc.pressSequentially(value, { delay: 15 });
2318
- await page.evaluate((sel) => {
2319
- const el = document.querySelector(sel);
2320
- el?.blur?.();
2321
- }, selector);
2616
+ if (!combobox) {
2617
+ await page.evaluate((sel) => {
2618
+ const el = document.querySelector(sel);
2619
+ el?.blur?.();
2620
+ }, selector);
2621
+ }
2322
2622
  } catch {
2323
2623
  }
2324
2624
  }
@@ -2342,7 +2642,7 @@ async function rightClick(page, step, ctx) {
2342
2642
  }
2343
2643
  }
2344
2644
  async function humanTypeStep(page, step, ctx) {
2345
- await humanType(page, resolveSelector(step.selector, ctx), step.value, { skipClick: step.skipClick, speed: step.speed });
2645
+ await humanType(page, resolveSelector(step.selector, ctx), step.value, { skipClick: step.skipClick, speed: step.speed, clear: step.clear });
2346
2646
  }
2347
2647
  async function selectOption(page, step, ctx) {
2348
2648
  const selector = resolveSelector(step.selector, ctx);
@@ -2385,7 +2685,29 @@ async function scroll(page, step, ctx) {
2385
2685
  { dy: step.deltaY ?? 0, sel: selector }
2386
2686
  );
2387
2687
  }
2388
- async function keyboard(page, step) {
2688
+ async function selectAllInFocused(page) {
2689
+ await page.evaluate(() => {
2690
+ const el = document.activeElement;
2691
+ if (el && (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement)) {
2692
+ el.select();
2693
+ } else if (el && el.isContentEditable) {
2694
+ const sel = window.getSelection();
2695
+ sel?.selectAllChildren(el);
2696
+ } else {
2697
+ window.getSelection()?.selectAllChildren(document.body);
2698
+ }
2699
+ });
2700
+ }
2701
+ async function keyboard(page, step, ctx) {
2702
+ if ((step.meta || step.ctrl) && step.key.toLowerCase() === "a" && !step.shift && !step.alt) {
2703
+ await selectAllInFocused(page);
2704
+ return;
2705
+ }
2706
+ if ((step.meta || step.ctrl) && ctx.driver === "extension") {
2707
+ throw new Error(
2708
+ `keyboard: ${step.meta ? "Cmd" : "Ctrl"}+${step.key} can't be sent in extension mode (the relay drops Cmd/Ctrl, so it would arrive as a plain "${step.key}"). Nothing was pressed. Instead: select-all \u2192 keyboard {key:"a", meta:true} (handled in-page); replace a field's text \u2192 fill, or human-type with clear:true; paste \u2192 the paste step.`
2709
+ );
2710
+ }
2389
2711
  const mods = [
2390
2712
  step.meta ? "Meta" : null,
2391
2713
  step.ctrl ? "Control" : null,
@@ -2570,6 +2892,56 @@ async function takeSnapshot(page, ctx, options) {
2570
2892
  if (rect.bottom < 0 || rect.top > window.innerHeight + 200) return false;
2571
2893
  return true;
2572
2894
  }
2895
+ function isFillable(el) {
2896
+ if (el.tagName === "TEXTAREA" || el.tagName === "SELECT") return true;
2897
+ if (el.hasAttribute("contenteditable") && el.getAttribute("contenteditable") !== "false") return true;
2898
+ if (el.tagName !== "INPUT") return false;
2899
+ const t = (el.type || "text").toLowerCase();
2900
+ return !["button", "submit", "reset", "image", "checkbox", "radio", "file", "hidden", "range", "color"].includes(t);
2901
+ }
2902
+ function unreachableReason(el) {
2903
+ const rect = el.getBoundingClientRect();
2904
+ const style = getComputedStyle(el);
2905
+ let opacity = 1;
2906
+ for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
2907
+ const o = parseFloat(n === el ? style.opacity : getComputedStyle(n).opacity);
2908
+ const animating = o < 1 && typeof n.getAnimations === "function" && n.getAnimations().length > 0;
2909
+ if (!isNaN(o) && !animating) opacity *= o;
2910
+ }
2911
+ if (opacity < 0.05) return "transparent";
2912
+ const docW = Math.max(document.documentElement.scrollWidth, window.innerWidth);
2913
+ const docH = Math.max(document.documentElement.scrollHeight, window.innerHeight);
2914
+ const left = rect.left + window.scrollX, right = rect.right + window.scrollX;
2915
+ const top = rect.top + window.scrollY, bottom = rect.bottom + window.scrollY;
2916
+ if (right <= 0 || left >= docW || bottom <= 0 || top >= docH) return "offscreen";
2917
+ const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
2918
+ let clipped = false;
2919
+ for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
2920
+ const s = getComputedStyle(a);
2921
+ const clipsX = /hidden|clip/.test(s.overflowX), clipsY = /hidden|clip/.test(s.overflowY);
2922
+ if (!clipsX && !clipsY) continue;
2923
+ const ar = a.getBoundingClientRect();
2924
+ const outside = clipsX && (cx < ar.left || cx > ar.right) || clipsY && (cy < ar.top || cy > ar.bottom);
2925
+ const tiny = clipsY && ar.height <= 2 || clipsX && ar.width <= 2;
2926
+ if (tiny) return "clipped";
2927
+ if (outside) clipped = true;
2928
+ }
2929
+ const inViewport = cx >= 0 && cy >= 0 && cx < window.innerWidth && cy < window.innerHeight;
2930
+ if (inViewport) {
2931
+ const hit = document.elementFromPoint(cx, cy);
2932
+ if (hit && (hit === el || el.contains(hit))) return null;
2933
+ return clipped ? "clipped" : null;
2934
+ }
2935
+ return null;
2936
+ }
2937
+ const baitName = /^(website|url|homepage|fax|honeypot|honey|hp|hp_[\w-]*|bot|bot_[\w-]*|trap|_gotcha|leave_?blank|do_?not_?fill)$/i;
2938
+ function looksLikeBait(el) {
2939
+ const name = el.getAttribute("name") || "", id = el.id || "";
2940
+ if (baitName.test(name) || baitName.test(id)) return true;
2941
+ const type = (el.getAttribute("type") || "text").toLowerCase();
2942
+ const textual = /^(text|email|url|tel|search|number)$/.test(type);
2943
+ return textual && el.getAttribute("tabindex") === "-1";
2944
+ }
2573
2945
  function buildSelector(el) {
2574
2946
  const path13 = [];
2575
2947
  let current = el;
@@ -2612,6 +2984,11 @@ async function takeSnapshot(page, ctx, options) {
2612
2984
  if (results.length >= maxElements2) break;
2613
2985
  if (interactiveOnly2 && !isInteractive(el)) continue;
2614
2986
  if (!isVisible(el)) continue;
2987
+ let suspect;
2988
+ if (isFillable(el)) {
2989
+ if (unreachableReason(el)) continue;
2990
+ if (looksLikeBait(el)) suspect = "honeypot?";
2991
+ }
2615
2992
  const tag = el.tagName.toLowerCase();
2616
2993
  const role = el.getAttribute("role") || "";
2617
2994
  const type = el.type || "";
@@ -2624,7 +3001,8 @@ async function takeSnapshot(page, ctx, options) {
2624
3001
  checked: el.checked || false,
2625
3002
  disabled: el.disabled || false,
2626
3003
  selector: buildSelector(el),
2627
- isVisible: true
3004
+ isVisible: true,
3005
+ suspect
2628
3006
  });
2629
3007
  }
2630
3008
  return results;
@@ -2645,6 +3023,7 @@ async function takeSnapshot(page, ctx, options) {
2645
3023
  const state = [];
2646
3024
  if (el.disabled) state.push("disabled");
2647
3025
  if (el.checked) state.push("checked");
3026
+ if (el.suspect) state.push(el.suspect);
2648
3027
  let description = "";
2649
3028
  if (el.placeholder && el.name !== el.placeholder) description = `placeholder="${el.placeholder}"`;
2650
3029
  if (el.type && !["text", "submit", "button", ""].includes(el.type)) {
@@ -3993,6 +4372,8 @@ async function executeAction(page, step, ctx, monitor) {
3993
4372
 
3994
4373
  // src/lib/stale-monitor.ts
3995
4374
  var DEFAULT_STALE_TIMEOUT_MS = 2e4;
4375
+ var PROBE_BUDGET_MS = 3e3;
4376
+ var UNRESPONSIVE_GRACE_MS = 8e3;
3996
4377
  var StaleStateMonitor = class _StaleStateMonitor {
3997
4378
  page;
3998
4379
  timeoutMs;
@@ -4004,21 +4385,24 @@ var StaleStateMonitor = class _StaleStateMonitor {
4004
4385
  }
4005
4386
  async snapshot() {
4006
4387
  try {
4007
- const [url, state] = await Promise.all([
4008
- Promise.resolve(this.page.url()),
4388
+ const url = this.page.url();
4389
+ let unresponsive = false;
4390
+ const state = await withBudget(
4009
4391
  this.page.evaluate(() => ({
4010
4392
  documentReadyState: document.readyState,
4011
4393
  bodyTextLength: document.body?.innerText?.length || 0,
4012
4394
  elementCount: document.querySelectorAll("*").length
4013
- })).catch(() => ({
4014
- documentReadyState: "unknown",
4015
- bodyTextLength: 0,
4016
- elementCount: 0
4017
- }))
4018
- ]);
4395
+ })),
4396
+ PROBE_BUDGET_MS,
4397
+ "state probe"
4398
+ ).catch((e) => {
4399
+ unresponsive = e instanceof BudgetExceededError;
4400
+ return { documentReadyState: "unknown", bodyTextLength: 0, elementCount: 0 };
4401
+ });
4019
4402
  return {
4020
4403
  url,
4021
4404
  ...state,
4405
+ unresponsive,
4022
4406
  timestamp: Date.now()
4023
4407
  };
4024
4408
  } catch {
@@ -4062,6 +4446,8 @@ var StaleStateMonitor = class _StaleStateMonitor {
4062
4446
  return new Promise((resolve, reject) => {
4063
4447
  let resolved = false;
4064
4448
  let checkInterval = null;
4449
+ let probing = false;
4450
+ let unresponsiveSince = null;
4065
4451
  const cleanup = () => {
4066
4452
  resolved = true;
4067
4453
  if (checkInterval) clearInterval(checkInterval);
@@ -4069,11 +4455,25 @@ var StaleStateMonitor = class _StaleStateMonitor {
4069
4455
  this.timer = null;
4070
4456
  };
4071
4457
  checkInterval = setInterval(async () => {
4072
- if (resolved) return;
4458
+ if (resolved || probing) return;
4073
4459
  const elapsed = Date.now() - this.lastActivity;
4074
4460
  if (elapsed < this.timeoutMs) return;
4461
+ probing = true;
4075
4462
  try {
4076
4463
  const currentSnapshot = await this.snapshot();
4464
+ if (resolved) return;
4465
+ if (currentSnapshot.unresponsive) {
4466
+ unresponsiveSince ??= Date.now();
4467
+ if (Date.now() - unresponsiveSince < UNRESPONSIVE_GRACE_MS) return;
4468
+ cleanup();
4469
+ reject(new StaleStateError(
4470
+ `The tab stopped responding: its JavaScript did not answer for ${Math.round((Date.now() - unresponsiveSince) / 1e3)}s (a blocking dialog, a frozen re-render, or a navigation that never finished)`,
4471
+ this.timeoutMs,
4472
+ true
4473
+ ));
4474
+ return;
4475
+ }
4476
+ unresponsiveSince = null;
4077
4477
  if (_StaleStateMonitor.hasChanged(beforeSnapshot, currentSnapshot)) {
4078
4478
  this.lastActivity = Date.now();
4079
4479
  return;
@@ -4081,6 +4481,8 @@ var StaleStateMonitor = class _StaleStateMonitor {
4081
4481
  } catch {
4082
4482
  this.lastActivity = Date.now();
4083
4483
  return;
4484
+ } finally {
4485
+ probing = false;
4084
4486
  }
4085
4487
  cleanup();
4086
4488
  reject(new StaleStateError(
@@ -4104,10 +4506,13 @@ var StaleStateMonitor = class _StaleStateMonitor {
4104
4506
  };
4105
4507
  var StaleStateError = class extends Error {
4106
4508
  timeoutMs;
4107
- constructor(message, timeoutMs) {
4509
+ /** True when the page stopped answering at all (vs. answering but not changing). */
4510
+ unresponsive;
4511
+ constructor(message, timeoutMs, unresponsive = false) {
4108
4512
  super(message);
4109
4513
  this.name = "StaleStateError";
4110
4514
  this.timeoutMs = timeoutMs;
4515
+ this.unresponsive = unresponsive;
4111
4516
  }
4112
4517
  };
4113
4518
 
@@ -4318,10 +4723,14 @@ async function capturePageState(page, ctx, opts) {
4318
4723
  url = page.url();
4319
4724
  } catch {
4320
4725
  }
4321
- const title = await page.title().catch(() => "");
4726
+ const title = await withBudget(page.title(), 2e3, "title").catch(() => "");
4322
4727
  if (!screenshot2) return { url, title };
4323
4728
  try {
4324
- const buf = await page.screenshot({ type: "jpeg", quality: 50, fullPage: false });
4729
+ const buf = await withBudget(
4730
+ page.screenshot({ type: "jpeg", quality: 50, fullPage: false, timeout: 12e3 }),
4731
+ 13e3,
4732
+ "screenshot"
4733
+ );
4325
4734
  const screenshotUrl = saveScreenshot(buf, `${namePrefix}-${Date.now()}.jpg`, ctx.screenshotDir, ctx.publicUrl);
4326
4735
  return { url, title, screenshotUrl };
4327
4736
  } catch {
@@ -4693,10 +5102,37 @@ var ApiCapture = class {
4693
5102
  await new Promise((r) => setTimeout(r, 100));
4694
5103
  }
4695
5104
  }
5105
+ /** Requests fired but not yet answered. */
5106
+ hasPending() {
5107
+ return this.pendingRequests.size > 0;
5108
+ }
4696
5109
  getResults() {
4697
5110
  return buildCapturedApi(this.requests);
4698
5111
  }
5112
+ /** Every HTTP >= 400 response, compact, in capture order. */
5113
+ getServerErrors() {
5114
+ return serverErrorsFrom(this.requests);
5115
+ }
4699
5116
  };
5117
+ var SERVER_ERROR_BODY_MAX = 600;
5118
+ function serverErrorsFrom(requests) {
5119
+ const out = [];
5120
+ for (const r of requests) {
5121
+ if (r.responseStatus < 400) continue;
5122
+ let body;
5123
+ if (r.responseBody !== void 0 && r.responseBody !== null) {
5124
+ const raw = typeof r.responseBody === "string" ? r.responseBody : JSON.stringify(r.responseBody);
5125
+ const flat = raw.replace(/\s+/g, " ").trim();
5126
+ body = flat.length > SERVER_ERROR_BODY_MAX ? flat.slice(0, SERVER_ERROR_BODY_MAX) + "\u2026" : flat;
5127
+ if (/^\s*<(!doctype|html)/i.test(raw)) {
5128
+ const m = raw.match(/<title[^>]*>([^<]*)<\/title>|<h1[^>]*>([^<]*)<\/h1>/i);
5129
+ body = m ? `[html] ${(m[1] || m[2] || "").trim()}` : "[html page]";
5130
+ }
5131
+ }
5132
+ out.push({ stepIndex: r.triggeredAtStep, method: r.method, url: r.url, status: r.responseStatus, body });
5133
+ }
5134
+ return out;
5135
+ }
4700
5136
  function extractAuth(requests) {
4701
5137
  const auth2 = { cookies: {}, tokens: {} };
4702
5138
  for (const req of requests) {
@@ -4945,10 +5381,36 @@ function classifyError(err, step) {
4945
5381
  if (step.type === "solve-captcha") return "captcha-unsolvable";
4946
5382
  return "action-failed";
4947
5383
  }
5384
+ async function refineError(page, step, ctx, errorType, message) {
5385
+ if (errorType !== "stale-state" && errorType !== "timeout") return { errorType, message };
5386
+ const raw = step.selector;
5387
+ if (typeof raw !== "string") return { errorType, message };
5388
+ let resolved;
5389
+ try {
5390
+ resolved = resolveSelector(raw, ctx);
5391
+ } catch {
5392
+ return { errorType, message };
5393
+ }
5394
+ try {
5395
+ const probe = page.evaluate((sel) => !!document.querySelector(sel), resolved);
5396
+ const budget = new Promise((r) => setTimeout(() => r(null), TIMING.FAILURE_PROBE));
5397
+ const found = await Promise.race([probe, budget]);
5398
+ if (found !== false) return { errorType, message };
5399
+ } catch {
5400
+ return { errorType, message };
5401
+ }
5402
+ const shown = resolved === raw ? `"${raw}"` : `"${raw}" (\u2192 ${resolved})`;
5403
+ return {
5404
+ errorType: "element-not-found",
5405
+ message: `No element matches ${shown} on the current page \u2014 the step waited for it to appear and it never did. It most likely existed when you looked and was re-rendered away before this step ran. (${message})`
5406
+ };
5407
+ }
4948
5408
  function isRetryable(errorType) {
4949
5409
  return errorType === "stale-state" || errorType === "timeout" || errorType === "element-not-found";
4950
5410
  }
4951
- function getSuggestion(errorType, step) {
5411
+ var UNRESPONSIVE_SUGGESTION = "The tab's JavaScript stopped answering during this step. Run a single `read` or `snapshot` on its own to see if it recovered (it usually does). If this step froze it, don't repeat it as-is: for a route change in a single-page app use `evaluate` with `location.assign(url)`; for a click, use a fresh `@e` ref from `find`; avoid `evaluate` calls that scroll or re-render.";
5412
+ function getSuggestion(errorType, step, message = "") {
5413
+ if (message.includes("tab stopped responding")) return UNRESPONSIVE_SUGGESTION;
4952
5414
  const usedEphemeralRef = typeof step.selector === "string" && step.selector.startsWith("@e");
4953
5415
  if (usedEphemeralRef && (errorType === "element-not-found" || errorType === "timeout" || errorType === "stale-state")) {
4954
5416
  return "The @e ref went stale \u2014 the page (likely a React/SPA) re-rendered and dropped it since the snapshot. Take a fresh `snapshot`/`find` right before acting, or save the element as a durable `@a:` anchor with the `remember` tool.";
@@ -4957,7 +5419,7 @@ function getSuggestion(errorType, step) {
4957
5419
  case "stale-state":
4958
5420
  return "The page stopped responding. The step may have triggered a very slow load or the server may be unreachable.";
4959
5421
  case "element-not-found":
4960
- return `Selector not found. The page structure may have changed. Take a screenshot to inspect the current state.`;
5422
+ return "Selector not found. Either it is wrong, or the page re-rendered the element away (SPAs swap DOM nodes on every state change, so a selector that worked a second ago can return nothing now). Take a fresh `snapshot`/`find` immediately before acting and retry; if it keeps vanishing, `wait-for` it first.";
4961
5423
  case "navigation-failed":
4962
5424
  return "Navigation failed. The URL may be unreachable, blocked, or require authentication.";
4963
5425
  case "captcha-unsolvable":
@@ -4975,6 +5437,9 @@ var PipelineRunner = class {
4975
5437
  }
4976
5438
  ctx;
4977
5439
  hooks;
5440
+ /** Live progress, readable while run() is still pending — lets an outer
5441
+ * watchdog report WHICH step hung and what already finished. */
5442
+ progress = { stepIndex: -1, results: [] };
4978
5443
  async run(initialPage, pipeline) {
4979
5444
  const tracker = new TabTracker(initialPage.context(), initialPage);
4980
5445
  try {
@@ -4992,6 +5457,7 @@ var PipelineRunner = class {
4992
5457
  const continueOnError = opts.continueOnError ?? false;
4993
5458
  const results = [];
4994
5459
  const obstacles = [];
5460
+ this.progress.results = results;
4995
5461
  const navStep = pipeline.steps.find((s) => s.type === "navigate");
4996
5462
  try {
4997
5463
  const host = new URL(navStep?.url || safePageUrl(initialPage) || "http://x").hostname;
@@ -5009,12 +5475,18 @@ var PipelineRunner = class {
5009
5475
  if (capture) capture.start();
5010
5476
  const finishCapture = async () => {
5011
5477
  if (!capture) return void 0;
5478
+ await capture.drain(TIMING.CAPTURE_SETTLE, TIMING.CAPTURE_PENDING_MAX);
5012
5479
  capture.stop();
5480
+ for (const se of capture.getServerErrors()) {
5481
+ const r = results.find((x) => x.stepIndex === se.stepIndex);
5482
+ if (r) (r.serverErrors ??= []).push(se);
5483
+ }
5013
5484
  return capture.getResults();
5014
5485
  };
5015
5486
  for (let i = 0; i < pipeline.steps.length; i++) {
5016
5487
  if (capture) capture.setStep(i);
5017
5488
  const step = pipeline.steps[i];
5489
+ this.progress.stepIndex = i;
5018
5490
  const page = tracker.active();
5019
5491
  const urlBefore = safePageUrl(page);
5020
5492
  const monitor = new StaleStateMonitor(page, staleTimeoutMs);
@@ -5034,9 +5506,10 @@ var PipelineRunner = class {
5034
5506
  throw err;
5035
5507
  }
5036
5508
  const asError = err instanceof Error ? err : new Error(String(err));
5037
- const errorType = classifyError(asError, step);
5509
+ const refined = await refineError(tracker.active(), step, this.ctx, classifyError(asError, step), asError.message);
5510
+ const errorType = refined.errorType;
5038
5511
  const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
5039
- stepResult = failedStepResult(step, asError.message, Date.now() - startTime, i);
5512
+ stepResult = failedStepResult(step, refined.message, Date.now() - startTime, i);
5040
5513
  results.push(stepResult);
5041
5514
  recordAnchor(step, false);
5042
5515
  return {
@@ -5050,9 +5523,9 @@ var PipelineRunner = class {
5050
5523
  failedAtStep: i,
5051
5524
  failedStep: step,
5052
5525
  errorType,
5053
- message: asError.message,
5526
+ message: refined.message,
5054
5527
  pageState,
5055
- suggestion: getSuggestion(errorType, step),
5528
+ suggestion: getSuggestion(errorType, step, refined.message),
5056
5529
  retryable: isRetryable(errorType)
5057
5530
  },
5058
5531
  durationMs: Date.now() - startTime,
@@ -5086,8 +5559,16 @@ var PipelineRunner = class {
5086
5559
  }
5087
5560
  results.push(stepResult);
5088
5561
  if (!stepResult.ok && !continueOnError) {
5562
+ const refined = await refineError(
5563
+ tracker.active(),
5564
+ step,
5565
+ this.ctx,
5566
+ classifyError(new Error(stepResult.error || ""), step),
5567
+ stepResult.error || "Step failed"
5568
+ );
5569
+ stepResult.error = refined.message;
5570
+ const errorType = refined.errorType;
5089
5571
  const pageState = await capturePageState(tracker.active(), this.ctx, { screenshot: true });
5090
- const errorType = classifyError(new Error(stepResult.error || ""), step);
5091
5572
  return {
5092
5573
  ok: false,
5093
5574
  completedSteps: i,
@@ -5101,7 +5582,7 @@ var PipelineRunner = class {
5101
5582
  errorType,
5102
5583
  message: stepResult.error || "Step failed",
5103
5584
  pageState,
5104
- suggestion: getSuggestion(errorType, step),
5585
+ suggestion: getSuggestion(errorType, step, refined.message),
5105
5586
  retryable: isRetryable(errorType)
5106
5587
  },
5107
5588
  durationMs: Date.now() - startTime,
@@ -5111,7 +5592,7 @@ var PipelineRunner = class {
5111
5592
  if (step.type === "navigate" && continueOnObstacle) {
5112
5593
  const obstacleStart = Date.now();
5113
5594
  const obstaclePage = tracker.active();
5114
- const obstacle = await detectObstacles(obstaclePage);
5595
+ const obstacle = await withBudget(detectObstacles(obstaclePage), 1e4, "obstacle check").catch(() => null);
5115
5596
  if (obstacle) {
5116
5597
  const resolution = await resolveObstacle(obstaclePage, obstacle, this.ctx, monitor);
5117
5598
  obstacles.push({
@@ -5852,7 +6333,7 @@ var CdpRelay = class {
5852
6333
  return await Promise.race([
5853
6334
  first,
5854
6335
  new Promise((_, reject) => {
5855
- const t = setTimeout(() => reject(new Error("screenshot timed out (no compositor frame)")), 1e4);
6336
+ const t = setTimeout(() => reject(new Error("screenshot timed out (no compositor frame)")), 5e3);
5856
6337
  t.unref?.();
5857
6338
  })
5858
6339
  ]);
@@ -5911,6 +6392,24 @@ var CdpRelay = class {
5911
6392
 
5912
6393
  // src/lib/execution/pipeline-executor.ts
5913
6394
  var log18 = createLogger("iframer");
6395
+ function blankPipelineResult(pipeline, modeUsed, error) {
6396
+ return {
6397
+ ok: false,
6398
+ completedSteps: 0,
6399
+ totalSteps: pipeline.steps.length,
6400
+ results: [],
6401
+ obstacles: [],
6402
+ error: {
6403
+ failedAtStep: 0,
6404
+ failedStep: pipeline.steps[0],
6405
+ pageState: { url: "", title: "" },
6406
+ ...error
6407
+ },
6408
+ durationMs: 0,
6409
+ modeUsed,
6410
+ finalState: { url: "", title: "" }
6411
+ };
6412
+ }
5914
6413
  var PipelineExecutor = class {
5915
6414
  constructor(deps) {
5916
6415
  this.deps = deps;
@@ -5948,8 +6447,24 @@ var PipelineExecutor = class {
5948
6447
  const forcedMode = opts.mode;
5949
6448
  const autoEscalate = opts.autoEscalate !== false;
5950
6449
  const instanceId = opts.instanceId || DEFAULT_INSTANCE;
6450
+ if (forcedMode === "extension") {
6451
+ return blankPipelineResult(pipeline, "extension", {
6452
+ errorType: "action-failed",
6453
+ message: 'mode="extension" requires options.tabId (the real-Chrome tab to drive).',
6454
+ suggestion: "Call the `tabs` tool (or GET /extension/tabs) to list open tabs, then pass that tab id. Nothing was launched.",
6455
+ retryable: false
6456
+ });
6457
+ }
5951
6458
  const firstNav = pipeline.steps.find((s) => s.type === "navigate");
5952
6459
  const domain = firstNav ? new URL(firstNav.url).hostname : null;
6460
+ if (!firstNav && !this.deps.daemon.findLiveMode(instanceId)) {
6461
+ return blankPipelineResult(pipeline, forcedMode || "headless", {
6462
+ errorType: "action-failed",
6463
+ message: `No navigate step and no live browser for instanceId "${instanceId}" to act on.`,
6464
+ suggestion: "Add a navigate step, or reattach to a live window (check `status` / `iframer instances` for instanceId). To drive a real Chrome tab, pass options.tabId from the `tabs` tool. Nothing was launched.",
6465
+ retryable: false
6466
+ });
6467
+ }
5953
6468
  const availableModes = this.deps.availableModes();
5954
6469
  const liveMode = forcedMode ? null : this.deps.daemon.findLiveMode(instanceId);
5955
6470
  let mode;
@@ -6013,6 +6528,7 @@ var PipelineExecutor = class {
6013
6528
  const startTime = Date.now();
6014
6529
  const relay = new CdpRelay(tabId, clientId, pipeline.options?.focus);
6015
6530
  let browser;
6531
+ let runner;
6016
6532
  try {
6017
6533
  await relay.start();
6018
6534
  browser = await import_playwright_core.chromium.connectOverCDP(relay.httpEndpoint(), { timeout: 3e4 });
@@ -6025,7 +6541,8 @@ var PipelineExecutor = class {
6025
6541
  if (!page) throw new Error("no page available for the tab (is it still open?)");
6026
6542
  const ctx = this.deps.refStore.makeContext(userId, token);
6027
6543
  if (this.pendingElicitOtp) ctx.elicitOtp = this.pendingElicitOtp;
6028
- const runner = new PipelineRunner(ctx);
6544
+ ctx.driver = "extension";
6545
+ runner = new PipelineRunner(ctx);
6029
6546
  const typeChars = pipeline.steps.reduce((n, s) => {
6030
6547
  const v = s.value;
6031
6548
  return (s.type === "human-type" || s.type === "type-code") && typeof v === "string" ? n + v.length : n;
@@ -6042,7 +6559,7 @@ var PipelineExecutor = class {
6042
6559
  watchdog = setTimeout(
6043
6560
  () => reject(
6044
6561
  new Error(
6045
- `pipeline exceeded ${Math.round(capMs / 1e3)}s \u2014 the tab may have stopped rendering (minimized window?). Un-minimize the Chrome window or retry with options.focus=true.`
6562
+ `pipeline exceeded ${Math.round(capMs / 1e3)}s \u2014 a call into the tab never returned.`
6046
6563
  )
6047
6564
  ),
6048
6565
  capMs
@@ -6066,23 +6583,26 @@ var PipelineExecutor = class {
6066
6583
  } catch (err) {
6067
6584
  const msg = getErrorMessage(err);
6068
6585
  const stalled = msg.includes("pipeline exceeded");
6586
+ const done = runner ? runner.progress.results.filter((r) => r.ok) : [];
6587
+ const hungAt = stalled && runner && runner.progress.stepIndex >= 0 ? runner.progress.stepIndex : 0;
6588
+ const hungStep = pipeline.steps[hungAt];
6069
6589
  return {
6070
6590
  ok: false,
6071
- completedSteps: 0,
6591
+ completedSteps: stalled ? done.length : 0,
6072
6592
  totalSteps: pipeline.steps.length,
6073
- results: [],
6593
+ results: stalled ? done : [],
6074
6594
  finalState: { url: "", title: "" },
6075
6595
  obstacles: [],
6076
6596
  durationMs: Date.now() - startTime,
6077
6597
  modeUsed: "extension",
6078
6598
  error: {
6079
- failedAtStep: 0,
6080
- failedStep: pipeline.steps[0],
6081
- errorType: "action-failed",
6082
- message: `Extension mode failed: ${msg}`,
6599
+ failedAtStep: hungAt,
6600
+ failedStep: hungStep,
6601
+ errorType: stalled ? "stale-state" : "action-failed",
6602
+ message: stalled ? `Extension mode: step ${hungAt} (${hungStep?.type}) never returned; ${msg}` : `Extension mode failed: ${msg}`,
6083
6603
  pageState: { url: "", title: "" },
6084
- 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.",
6085
- retryable: !stalled
6604
+ suggestion: stalled ? `Step ${hungAt} (${hungStep?.type}) hung inside the tab. Steps before it completed. Run a single \`read\` or \`snapshot\` on its own to check the tab recovered (it usually does), then continue from that step in its own execute call. Don't repeat the same step unchanged: for a route change use \`evaluate\` with \`location.assign(url)\`, for a click use a fresh \`@e\` ref from \`find\`. If the tab stays unresponsive, tell the user.` : "Ensure the iframer extension is connected (green dot) and the tab is still open. See chrome://extensions.",
6605
+ retryable: stalled
6086
6606
  }
6087
6607
  };
6088
6608
  } finally {
@@ -6637,6 +7157,11 @@ var Iframer = class {
6637
7157
  listInstances() {
6638
7158
  return this.daemon.instancesInfo();
6639
7159
  }
7160
+ /** Close browsers that never navigated anywhere. See
7161
+ * BrowserDaemon.sweepBlankInstances — called from the server's reap tick. */
7162
+ sweepBlankBrowsers() {
7163
+ return this.daemon.sweepBlankInstances();
7164
+ }
6640
7165
  /** Kill all browser instances and reset state. Next execute call will
6641
7166
  * launch a fresh browser automatically — no manual restart needed. */
6642
7167
  async restartBrowser() {
@@ -7053,6 +7578,10 @@ var reapTimer = setInterval(async () => {
7053
7578
  await reapOrphanBrowsers();
7054
7579
  } catch {
7055
7580
  }
7581
+ try {
7582
+ iframer.sweepBlankBrowsers();
7583
+ } catch {
7584
+ }
7056
7585
  const idleMs = Date.now() - lastActivity;
7057
7586
  if (idleMs > IDLE_EXIT_MS && !iframer.browserHealth().alive && !extensionBridge.hasClients()) {
7058
7587
  gracefulShutdown(`idle for ${Math.round(idleMs / 6e4)}min with no browsers`);