@ulpi/browse 1.0.6 → 1.1.1

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/browse.cjs CHANGED
@@ -124,7 +124,7 @@ var require_package = __commonJS({
124
124
  "package.json"(exports2, module2) {
125
125
  module2.exports = {
126
126
  name: "@ulpi/browse",
127
- version: "1.0.6",
127
+ version: "1.1.1",
128
128
  repository: {
129
129
  type: "git",
130
130
  url: "https://github.com/ulpi-io/browse"
@@ -149,8 +149,7 @@ var require_package = __commonJS({
149
149
  "dist/",
150
150
  "skill/",
151
151
  "LICENSE",
152
- "README.md",
153
- "BENCHMARKS.md"
152
+ "README.md"
154
153
  ],
155
154
  keywords: [
156
155
  "browser",
@@ -174,7 +173,7 @@ const __import_meta_url = require("url").pathToFileURL(__filename).href;' --defi
174
173
  test: "vitest run",
175
174
  start: "tsx src/server.ts",
176
175
  postinstall: "npx playwright install chromium",
177
- benchmark: "tsx benchmark.ts"
176
+ benchmark: "echo 'Benchmarks moved to separate repo'"
178
177
  },
179
178
  type: "module",
180
179
  devDependencies: {
@@ -209,15 +208,31 @@ function installSkill(targetDir) {
209
208
  }
210
209
  const skillDir = path3.join(dir, ".claude", "skills", "browse");
211
210
  fs3.mkdirSync(skillDir, { recursive: true });
212
- const skillSource = path3.resolve(path3.dirname((0, import_url.fileURLToPath)(__import_meta_url)), "..", "skill", "SKILL.md");
213
- const skillDest = path3.join(skillDir, "SKILL.md");
214
- if (!fs3.existsSync(skillSource)) {
215
- console.error(`SKILL.md not found at ${skillSource}`);
211
+ const skillSourceDir = path3.resolve(path3.dirname((0, import_url.fileURLToPath)(__import_meta_url)), "..", "skill");
212
+ if (!fs3.existsSync(skillSourceDir)) {
213
+ console.error(`Skill directory not found at ${skillSourceDir}`);
216
214
  console.error("Is @ulpi/browse installed correctly?");
217
215
  process.exit(1);
218
216
  }
219
- fs3.copyFileSync(skillSource, skillDest);
220
- console.log(`Skill installed: ${path3.relative(dir, skillDest)}`);
217
+ const mdFiles = fs3.readdirSync(skillSourceDir).filter((f) => f.endsWith(".md"));
218
+ if (mdFiles.length === 0) {
219
+ console.error(`No .md files found in ${skillSourceDir}`);
220
+ process.exit(1);
221
+ }
222
+ for (const file of mdFiles) {
223
+ fs3.copyFileSync(path3.join(skillSourceDir, file), path3.join(skillDir, file));
224
+ console.log(`Skill installed: ${path3.relative(dir, path3.join(skillDir, file))}`);
225
+ }
226
+ const refsSourceDir = path3.join(skillSourceDir, "references");
227
+ if (fs3.existsSync(refsSourceDir)) {
228
+ const refsDestDir = path3.join(skillDir, "references");
229
+ fs3.mkdirSync(refsDestDir, { recursive: true });
230
+ const refFiles = fs3.readdirSync(refsSourceDir).filter((f) => f.endsWith(".md"));
231
+ for (const file of refFiles) {
232
+ fs3.copyFileSync(path3.join(refsSourceDir, file), path3.join(refsDestDir, file));
233
+ console.log(`Skill installed: ${path3.relative(dir, path3.join(refsDestDir, file))}`);
234
+ }
235
+ }
221
236
  const settingsPath = path3.join(dir, ".claude", "settings.json");
222
237
  let settings = {};
223
238
  if (fs3.existsSync(settingsPath)) {
@@ -718,6 +733,10 @@ var init_browser_manager = __esm({
718
733
  ownsBrowser = false;
719
734
  // Whether this instance uses a persistent browser context (profile mode)
720
735
  isPersistent = false;
736
+ // ─── Handoff state ──────────────────────────────────────
737
+ isHeaded = false;
738
+ consecutiveFailures = 0;
739
+ onCrashCallback;
721
740
  constructor(buffers) {
722
741
  this.buffers = buffers || new SessionBuffers();
723
742
  }
@@ -737,6 +756,7 @@ var init_browser_manager = __esm({
737
756
  async launch(onCrash) {
738
757
  this.browser = await import_playwright.chromium.launch({ headless: true });
739
758
  this.ownsBrowser = true;
759
+ this.onCrashCallback = onCrash;
740
760
  this.browser.on("disconnected", () => {
741
761
  if (onCrash) onCrash();
742
762
  });
@@ -775,9 +795,9 @@ var init_browser_manager = __esm({
775
795
  });
776
796
  } catch (err) {
777
797
  if (err.message?.includes("Failed to launch") || err.message?.includes("Target closed")) {
778
- const fs16 = await import("fs");
798
+ const fs17 = await import("fs");
779
799
  console.error(`[browse] Profile directory corrupted, recreating: ${profileDir}`);
780
- fs16.rmSync(profileDir, { recursive: true, force: true });
800
+ fs17.rmSync(profileDir, { recursive: true, force: true });
781
801
  context = await import_playwright.chromium.launchPersistentContext(profileDir, {
782
802
  headless: process.env.BROWSE_HEADED !== "1",
783
803
  viewport: { width: 1920, height: 1080 }
@@ -844,6 +864,230 @@ var init_browser_manager = __esm({
844
864
  getIsPersistent() {
845
865
  return this.isPersistent;
846
866
  }
867
+ // ─── React DevTools ──────────────────────────────────
868
+ reactDevToolsEnabled = false;
869
+ getReactDevToolsEnabled() {
870
+ return this.reactDevToolsEnabled;
871
+ }
872
+ setReactDevToolsEnabled(enabled) {
873
+ this.reactDevToolsEnabled = enabled;
874
+ }
875
+ getIsHeaded() {
876
+ return this.isHeaded;
877
+ }
878
+ // ─── Failure tracking (auto-suggest handoff) ────────────
879
+ incrementFailures() {
880
+ this.consecutiveFailures++;
881
+ }
882
+ resetFailures() {
883
+ this.consecutiveFailures = 0;
884
+ }
885
+ getFailureHint() {
886
+ if (this.consecutiveFailures >= 3 && !this.isHeaded) {
887
+ return `HINT: ${this.consecutiveFailures} consecutive failures. Consider using 'handoff' to let the user help.`;
888
+ }
889
+ return null;
890
+ }
891
+ // ─── State save/restore (shared by handoff/resume) ──────
892
+ async saveState() {
893
+ if (!this.context) throw new Error("Browser not launched");
894
+ const cookies = await this.context.cookies();
895
+ const pages = [];
896
+ for (const [id, page] of this.pages) {
897
+ const url = page.url();
898
+ let storage = null;
899
+ try {
900
+ storage = await page.evaluate(() => ({
901
+ localStorage: { ...localStorage },
902
+ sessionStorage: { ...sessionStorage }
903
+ }));
904
+ } catch {
905
+ }
906
+ pages.push({
907
+ url: url === "about:blank" ? "" : url,
908
+ isActive: id === this.activeTabId,
909
+ storage
910
+ });
911
+ }
912
+ return { cookies, pages };
913
+ }
914
+ async applyContextOptions(context) {
915
+ if (Object.keys(this.extraHeaders).length > 0) {
916
+ await context.setExtraHTTPHeaders(this.extraHeaders);
917
+ }
918
+ if (this.offline) {
919
+ await context.setOffline(true);
920
+ }
921
+ if (this.initScript) {
922
+ await context.addInitScript(this.initScript);
923
+ }
924
+ for (const r of this.userRoutes) {
925
+ if (r.action === "block") {
926
+ await context.route(r.pattern, (route) => route.abort("blockedbyclient"));
927
+ } else {
928
+ await context.route(r.pattern, (route) => route.fulfill({ status: r.status || 200, body: r.body || "", contentType: "text/plain" }));
929
+ }
930
+ }
931
+ if (this.domainFilter) {
932
+ const df = this.domainFilter;
933
+ await context.route("**/*", (route) => {
934
+ const url = route.request().url();
935
+ if (df.isAllowed(url)) {
936
+ route.fallback();
937
+ } else {
938
+ route.abort("blockedbyclient");
939
+ }
940
+ });
941
+ }
942
+ }
943
+ async restoreState(state) {
944
+ if (!this.context) throw new Error("Browser not launched");
945
+ if (state.cookies.length > 0) {
946
+ await this.context.addCookies(state.cookies);
947
+ }
948
+ await this.applyContextOptions(this.context);
949
+ let activeId = null;
950
+ for (const saved of state.pages) {
951
+ const page = await this.context.newPage();
952
+ const id = this.nextTabId++;
953
+ this.pages.set(id, page);
954
+ this.wirePageEvents(page);
955
+ if (saved.url) {
956
+ await page.goto(saved.url, { waitUntil: "domcontentloaded", timeout: 15e3 }).catch(() => {
957
+ });
958
+ }
959
+ if (saved.storage) {
960
+ try {
961
+ await page.evaluate((s) => {
962
+ if (s.localStorage) {
963
+ for (const [k, v] of Object.entries(s.localStorage)) localStorage.setItem(k, v);
964
+ }
965
+ if (s.sessionStorage) {
966
+ for (const [k, v] of Object.entries(s.sessionStorage)) sessionStorage.setItem(k, v);
967
+ }
968
+ }, saved.storage);
969
+ } catch {
970
+ }
971
+ }
972
+ if (saved.isActive) activeId = id;
973
+ }
974
+ if (this.pages.size === 0) {
975
+ await this.newTab();
976
+ } else if (activeId !== null) {
977
+ this.activeTabId = activeId;
978
+ }
979
+ this.clearRefs();
980
+ }
981
+ // ─── Handoff: headless ↔ headed swap ─────────────────────
982
+ async handoff(message) {
983
+ if (this.isHeaded) {
984
+ return `Already in headed mode at ${this.getCurrentUrl()}`;
985
+ }
986
+ if (this.isPersistent) {
987
+ throw new Error("Handoff not supported in profile mode \u2014 the browser is already visible. Use cookie-import or auth login instead.");
988
+ }
989
+ if (!this.browser || !this.context) {
990
+ throw new Error("Browser not launched");
991
+ }
992
+ const state = await this.saveState();
993
+ const currentUrl = this.getCurrentUrl();
994
+ let newBrowser;
995
+ try {
996
+ newBrowser = await import_playwright.chromium.launch({ headless: false, timeout: 15e3 });
997
+ } catch (err) {
998
+ return `Cannot open headed browser \u2014 ${err.message}. Headless browser still running.`;
999
+ }
1000
+ try {
1001
+ const newContext = await newBrowser.newContext({
1002
+ viewport: this.currentDevice?.viewport || { width: 1920, height: 1080 },
1003
+ ...this.customUserAgent ? { userAgent: this.customUserAgent } : {},
1004
+ ...this.currentDevice ? {
1005
+ deviceScaleFactor: this.currentDevice.deviceScaleFactor,
1006
+ isMobile: this.currentDevice.isMobile,
1007
+ hasTouch: this.currentDevice.hasTouch
1008
+ } : {}
1009
+ });
1010
+ const oldContext = this.context;
1011
+ this.browser = newBrowser;
1012
+ this.context = newContext;
1013
+ this.pages.clear();
1014
+ this.nextTabId = 1;
1015
+ this.tabSnapshots.clear();
1016
+ this.activeFramePerTab.clear();
1017
+ const onCrash = this.onCrashCallback;
1018
+ this.browser.on("disconnected", () => {
1019
+ if (onCrash) onCrash();
1020
+ });
1021
+ await this.restoreState(state);
1022
+ this.isHeaded = true;
1023
+ this.ownsBrowser = true;
1024
+ if (oldContext) {
1025
+ await oldContext.close().catch(() => {
1026
+ });
1027
+ }
1028
+ return [
1029
+ `Handoff active \u2014 browser opened in visible mode.`,
1030
+ `Reason: ${message || "manual intervention needed"}`,
1031
+ `URL: ${currentUrl}`,
1032
+ ``,
1033
+ `The user can now interact with the visible browser.`,
1034
+ `When done, run: browse resume`
1035
+ ].join("\n");
1036
+ } catch (err) {
1037
+ await newBrowser.close().catch(() => {
1038
+ });
1039
+ return `Handoff failed during state restore \u2014 ${err.message}. Headless browser still running.`;
1040
+ }
1041
+ }
1042
+ async resume() {
1043
+ if (!this.isHeaded) {
1044
+ throw new Error('Not in handoff mode \u2014 run "handoff" first.');
1045
+ }
1046
+ if (!this.browser || !this.context) {
1047
+ throw new Error("Browser not launched");
1048
+ }
1049
+ const state = await this.saveState();
1050
+ const userUrl = this.getCurrentUrl();
1051
+ let newBrowser;
1052
+ try {
1053
+ newBrowser = await import_playwright.chromium.launch({ headless: true });
1054
+ } catch (err) {
1055
+ return `Cannot launch headless browser \u2014 ${err.message}. Headed browser still running.`;
1056
+ }
1057
+ try {
1058
+ const newContext = await newBrowser.newContext({
1059
+ viewport: this.currentDevice?.viewport || { width: 1920, height: 1080 },
1060
+ ...this.customUserAgent ? { userAgent: this.customUserAgent } : {},
1061
+ ...this.currentDevice ? {
1062
+ deviceScaleFactor: this.currentDevice.deviceScaleFactor,
1063
+ isMobile: this.currentDevice.isMobile,
1064
+ hasTouch: this.currentDevice.hasTouch
1065
+ } : {}
1066
+ });
1067
+ const oldBrowser = this.browser;
1068
+ this.browser = newBrowser;
1069
+ this.context = newContext;
1070
+ this.pages.clear();
1071
+ this.nextTabId = 1;
1072
+ this.tabSnapshots.clear();
1073
+ this.activeFramePerTab.clear();
1074
+ const onCrash = this.onCrashCallback;
1075
+ this.browser.on("disconnected", () => {
1076
+ if (onCrash) onCrash();
1077
+ });
1078
+ await this.restoreState(state);
1079
+ this.isHeaded = false;
1080
+ this.consecutiveFailures = 0;
1081
+ oldBrowser.removeAllListeners("disconnected");
1082
+ oldBrowser.close().catch(() => {
1083
+ });
1084
+ return userUrl;
1085
+ } catch (err) {
1086
+ await newBrowser.close().catch(() => {
1087
+ });
1088
+ return `Resume failed during state restore \u2014 ${err.message}. Headed browser still running.`;
1089
+ }
1090
+ }
847
1091
  // ─── Tab Management ────────────────────────────────────────
848
1092
  async newTab(url) {
849
1093
  if (!this.context) throw new Error("Browser not launched");
@@ -1308,8 +1552,8 @@ var init_browser_manager = __esm({
1308
1552
  // ─── Video Recording ──────────────────────────────────────
1309
1553
  async startVideoRecording(dir) {
1310
1554
  if (this.videoRecording) throw new Error("Video recording already active");
1311
- const fs16 = await import("fs");
1312
- fs16.mkdirSync(dir, { recursive: true });
1555
+ const fs17 = await import("fs");
1556
+ fs17.mkdirSync(dir, { recursive: true });
1313
1557
  this.videoRecording = { dir, startedAt: Date.now() };
1314
1558
  const viewport = this.currentDevice?.viewport || { width: 1920, height: 1080 };
1315
1559
  await this.recreateContext({
@@ -2505,6 +2749,21 @@ async function handleWriteCommand(command, args, bm, domainFilter) {
2505
2749
  await page.waitForLoadState(state2, { timeout: timeout2 });
2506
2750
  return `Load state reached: ${state2}`;
2507
2751
  }
2752
+ if (selector === "--download") {
2753
+ const pathOrTimeout = args[1];
2754
+ const timeout2 = args[2] ? parseInt(args[2], 10) : pathOrTimeout && /^\d+$/.test(pathOrTimeout) ? parseInt(pathOrTimeout, 10) : 3e4;
2755
+ const savePath = pathOrTimeout && !/^\d+$/.test(pathOrTimeout) ? pathOrTimeout : null;
2756
+ const download = await page.waitForEvent("download", { timeout: timeout2 });
2757
+ const filename = download.suggestedFilename();
2758
+ const failure = await download.failure();
2759
+ if (failure) return `Download failed: ${filename} \u2014 ${failure}`;
2760
+ if (savePath) {
2761
+ await download.saveAs(savePath);
2762
+ return `Downloaded: ${savePath} (${filename})`;
2763
+ }
2764
+ const tmpPath = await download.path();
2765
+ return `Downloaded: ${filename} (saved to ${tmpPath})`;
2766
+ }
2508
2767
  if (/^\d+$/.test(selector)) {
2509
2768
  const ms = parseInt(selector, 10);
2510
2769
  await page.waitForTimeout(ms);
@@ -2961,6 +3220,11 @@ var init_write = __esm({
2961
3220
  });
2962
3221
 
2963
3222
  // src/snapshot.ts
3223
+ var snapshot_exports = {};
3224
+ __export(snapshot_exports, {
3225
+ handleSnapshot: () => handleSnapshot,
3226
+ parseSnapshotArgs: () => parseSnapshotArgs
3227
+ });
2964
3228
  function parseSnapshotArgs(args) {
2965
3229
  const opts = {};
2966
3230
  for (let i = 0; i < args.length; i++) {
@@ -3683,11 +3947,11 @@ var init_lib = __esm({
3683
3947
  }
3684
3948
  }
3685
3949
  },
3686
- addToPath: function addToPath(path13, added, removed, oldPosInc, options) {
3687
- var last = path13.lastComponent;
3950
+ addToPath: function addToPath(path14, added, removed, oldPosInc, options) {
3951
+ var last = path14.lastComponent;
3688
3952
  if (last && !options.oneChangePerToken && last.added === added && last.removed === removed) {
3689
3953
  return {
3690
- oldPos: path13.oldPos + oldPosInc,
3954
+ oldPos: path14.oldPos + oldPosInc,
3691
3955
  lastComponent: {
3692
3956
  count: last.count + 1,
3693
3957
  added,
@@ -3697,7 +3961,7 @@ var init_lib = __esm({
3697
3961
  };
3698
3962
  } else {
3699
3963
  return {
3700
- oldPos: path13.oldPos + oldPosInc,
3964
+ oldPos: path14.oldPos + oldPosInc,
3701
3965
  lastComponent: {
3702
3966
  count: 1,
3703
3967
  added,
@@ -4767,6 +5031,385 @@ var init_record_export = __esm({
4767
5031
  }
4768
5032
  });
4769
5033
 
5034
+ // src/react-devtools.ts
5035
+ var react_devtools_exports = {};
5036
+ __export(react_devtools_exports, {
5037
+ ensureHook: () => ensureHook,
5038
+ getContext: () => getContext,
5039
+ getErrors: () => getErrors,
5040
+ getHydration: () => getHydration,
5041
+ getOwners: () => getOwners,
5042
+ getProfiler: () => getProfiler,
5043
+ getProps: () => getProps,
5044
+ getRenders: () => getRenders,
5045
+ getSuspense: () => getSuspense,
5046
+ getTree: () => getTree,
5047
+ injectHook: () => injectHook,
5048
+ isEnabled: () => isEnabled,
5049
+ removeHook: () => removeHook,
5050
+ requireEnabled: () => requireEnabled,
5051
+ requireReact: () => requireReact
5052
+ });
5053
+ async function ensureHook() {
5054
+ if (fs13.existsSync(HOOK_PATH)) {
5055
+ return fs13.readFileSync(HOOK_PATH, "utf8");
5056
+ }
5057
+ fs13.mkdirSync(CACHE_DIR, { recursive: true });
5058
+ const res = await fetch(HOOK_URL);
5059
+ if (!res.ok) {
5060
+ throw new Error(
5061
+ `Failed to download React DevTools hook (HTTP ${res.status}).
5062
+ Manual fallback: npm install -g react-devtools-core, then copy installHook.js to ${HOOK_PATH}`
5063
+ );
5064
+ }
5065
+ const script = await res.text();
5066
+ fs13.writeFileSync(HOOK_PATH, script);
5067
+ return script;
5068
+ }
5069
+ async function injectHook(bm) {
5070
+ const hookScript = await ensureHook();
5071
+ const context = bm.getContext();
5072
+ if (!context) throw new Error("No browser context available");
5073
+ await context.addInitScript(hookScript);
5074
+ bm.setReactDevToolsEnabled(true);
5075
+ }
5076
+ function removeHook(bm) {
5077
+ bm.setReactDevToolsEnabled(false);
5078
+ }
5079
+ function isEnabled(bm) {
5080
+ return bm.getReactDevToolsEnabled();
5081
+ }
5082
+ function requireEnabled(bm) {
5083
+ if (!isEnabled(bm)) {
5084
+ throw new Error(
5085
+ 'React DevTools not enabled. Run "browse react-devtools enable" first.'
5086
+ );
5087
+ }
5088
+ }
5089
+ async function requireReact(page) {
5090
+ const hasReact = await page.evaluate(() => {
5091
+ const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
5092
+ if (!hook) return false;
5093
+ if (hook.rendererInterfaces && hook.rendererInterfaces.size > 0) return true;
5094
+ if (hook.renderers && hook.renderers.size > 0) return true;
5095
+ return false;
5096
+ });
5097
+ if (!hasReact) {
5098
+ throw new Error("No React detected on this page.");
5099
+ }
5100
+ }
5101
+ async function getTree(bm, page) {
5102
+ requireEnabled(bm);
5103
+ await requireReact(page);
5104
+ const tree = await page.evaluate(() => {
5105
+ const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
5106
+ function walkFiber(fiber, depth) {
5107
+ const lines2 = [];
5108
+ if (!fiber) return lines2;
5109
+ const name = fiber.type?.displayName || fiber.type?.name || (typeof fiber.type === "string" ? fiber.type : null);
5110
+ if (name) {
5111
+ const indent = " ".repeat(depth);
5112
+ let extra = "";
5113
+ if (fiber.tag === 13) {
5114
+ const resolved = fiber.memoizedState === null;
5115
+ extra = resolved ? " (resolved)" : " (pending)";
5116
+ }
5117
+ lines2.push(`${indent}${name}${extra}`);
5118
+ }
5119
+ let child = fiber.child;
5120
+ while (child) {
5121
+ lines2.push(...walkFiber(child, name ? depth + 1 : depth));
5122
+ child = child.sibling;
5123
+ }
5124
+ return lines2;
5125
+ }
5126
+ const rendererID = hook.renderers?.keys().next().value ?? 1;
5127
+ const roots = hook.getFiberRoots?.(rendererID);
5128
+ if (!roots || roots.size === 0) return "(no fiber roots found)";
5129
+ const root = roots.values().next().value;
5130
+ const current = root.current;
5131
+ if (!current) return "(no current fiber)";
5132
+ const lines = walkFiber(current, 0);
5133
+ return lines.join("\n") || "(empty tree)";
5134
+ });
5135
+ return tree || "(no component tree available)";
5136
+ }
5137
+ async function getProps(bm, page, selector) {
5138
+ requireEnabled(bm);
5139
+ await requireReact(page);
5140
+ const resolved = bm.resolveRef(selector);
5141
+ const element = "locator" in resolved ? resolved.locator : page.locator(resolved.selector);
5142
+ const handle = await element.elementHandle();
5143
+ if (!handle) throw new Error(`Element not found: ${selector}`);
5144
+ const result = await page.evaluate((el) => {
5145
+ let fiber = null;
5146
+ for (const key of Object.keys(el)) {
5147
+ if (key.startsWith("__reactFiber$") || key.startsWith("__reactInternalInstance$")) {
5148
+ fiber = el[key];
5149
+ break;
5150
+ }
5151
+ }
5152
+ if (!fiber) return "No React fiber found for this element";
5153
+ while (fiber && typeof fiber.type === "string") {
5154
+ fiber = fiber.return;
5155
+ }
5156
+ if (!fiber) return "No React component found";
5157
+ const name = fiber.type?.displayName || fiber.type?.name || "Anonymous";
5158
+ const props = fiber.memoizedProps || {};
5159
+ const state = fiber.memoizedState;
5160
+ const propEntries = [];
5161
+ for (const [k, v] of Object.entries(props)) {
5162
+ if (k === "children") continue;
5163
+ if (typeof v === "function") {
5164
+ propEntries.push(`${k}: [Function]`);
5165
+ continue;
5166
+ }
5167
+ try {
5168
+ propEntries.push(`${k}: ${JSON.stringify(v)}`);
5169
+ } catch {
5170
+ propEntries.push(`${k}: [Complex]`);
5171
+ }
5172
+ }
5173
+ const stateEntries = [];
5174
+ let hookNode = state;
5175
+ let hookIdx = 0;
5176
+ while (hookNode && typeof hookNode === "object" && "memoizedState" in hookNode) {
5177
+ const val = hookNode.memoizedState;
5178
+ if (val !== void 0 && val !== null) {
5179
+ try {
5180
+ stateEntries.push(`hook[${hookIdx}]: ${JSON.stringify(val)}`);
5181
+ } catch {
5182
+ stateEntries.push(`hook[${hookIdx}]: [Complex]`);
5183
+ }
5184
+ }
5185
+ hookNode = hookNode.next;
5186
+ hookIdx++;
5187
+ }
5188
+ let output = `Component: ${name}
5189
+ `;
5190
+ if (propEntries.length) output += `
5191
+ Props:
5192
+ ${propEntries.map((e) => ` ${e}`).join("\n")}
5193
+ `;
5194
+ if (stateEntries.length) output += `
5195
+ State:
5196
+ ${stateEntries.map((e) => ` ${e}`).join("\n")}
5197
+ `;
5198
+ return output;
5199
+ }, handle);
5200
+ await handle.dispose();
5201
+ return result;
5202
+ }
5203
+ async function getSuspense(bm, page) {
5204
+ requireEnabled(bm);
5205
+ await requireReact(page);
5206
+ const result = await page.evaluate(() => {
5207
+ const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
5208
+ const rendererID = hook.renderers?.keys().next().value ?? 1;
5209
+ const roots = hook.getFiberRoots?.(rendererID);
5210
+ if (!roots || roots.size === 0) return "No fiber roots found";
5211
+ const root = roots.values().next().value;
5212
+ const boundaries = [];
5213
+ function walk(fiber, path14) {
5214
+ if (!fiber) return;
5215
+ if (fiber.tag === 13) {
5216
+ const resolved = fiber.memoizedState === null;
5217
+ const parent = path14.length > 0 ? path14[path14.length - 1] : "root";
5218
+ boundaries.push(`Suspense in ${parent} \u2014 ${resolved ? "resolved (children visible)" : "pending (showing fallback)"}`);
5219
+ }
5220
+ const name = fiber.type?.displayName || fiber.type?.name || null;
5221
+ const newPath = name ? [...path14, name] : path14;
5222
+ let child = fiber.child;
5223
+ while (child) {
5224
+ walk(child, newPath);
5225
+ child = child.sibling;
5226
+ }
5227
+ }
5228
+ walk(root.current, []);
5229
+ return boundaries.length > 0 ? boundaries.join("\n") : "No Suspense boundaries found";
5230
+ });
5231
+ return result;
5232
+ }
5233
+ async function getErrors(bm, page) {
5234
+ requireEnabled(bm);
5235
+ await requireReact(page);
5236
+ const result = await page.evaluate(() => {
5237
+ const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
5238
+ const rendererID = hook.renderers?.keys().next().value ?? 1;
5239
+ const roots = hook.getFiberRoots?.(rendererID);
5240
+ if (!roots || roots.size === 0) return "No fiber roots found";
5241
+ const root = roots.values().next().value;
5242
+ const errors = [];
5243
+ function walk(fiber) {
5244
+ if (!fiber) return;
5245
+ if (fiber.tag === 1 && fiber.type?.prototype?.componentDidCatch) {
5246
+ const name = fiber.type.displayName || fiber.type.name || "ErrorBoundary";
5247
+ const hasError = fiber.memoizedState?.error;
5248
+ errors.push(`${name} \u2014 ${hasError ? "caught: " + String(hasError) : "no error caught"}`);
5249
+ }
5250
+ let child = fiber.child;
5251
+ while (child) {
5252
+ walk(child);
5253
+ child = child.sibling;
5254
+ }
5255
+ }
5256
+ walk(root.current);
5257
+ return errors.length > 0 ? errors.join("\n") : "No error boundaries found";
5258
+ });
5259
+ return result;
5260
+ }
5261
+ async function getProfiler(bm, page) {
5262
+ requireEnabled(bm);
5263
+ await requireReact(page);
5264
+ const result = await page.evaluate(() => {
5265
+ const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
5266
+ const rendererID = hook.renderers?.keys().next().value ?? 1;
5267
+ const roots = hook.getFiberRoots?.(rendererID);
5268
+ if (!roots || roots.size === 0) return "No fiber roots found";
5269
+ const root = roots.values().next().value;
5270
+ const timings = [];
5271
+ function walk(fiber) {
5272
+ if (!fiber) return;
5273
+ const name = fiber.type?.displayName || fiber.type?.name;
5274
+ if (name && fiber.actualDuration !== void 0) {
5275
+ timings.push(`${name}: ${fiber.actualDuration.toFixed(1)}ms (self: ${fiber.selfBaseDuration?.toFixed(1) || "?"}ms)`);
5276
+ }
5277
+ let child = fiber.child;
5278
+ while (child) {
5279
+ walk(child);
5280
+ child = child.sibling;
5281
+ }
5282
+ }
5283
+ walk(root.current);
5284
+ return timings.length > 0 ? timings.join("\n") : "No profiling data (requires React profiling build)";
5285
+ });
5286
+ return result;
5287
+ }
5288
+ async function getHydration(bm, page) {
5289
+ requireEnabled(bm);
5290
+ await requireReact(page);
5291
+ const result = await page.evaluate(() => {
5292
+ const timing = window.__NEXT_BROWSER_REACT_TIMING__;
5293
+ if (timing && timing.length > 0) {
5294
+ const hydrationEntries = timing.filter((t) => t.label.includes("hydrat") || t.label.includes("Hydrat")).map((t) => `${t.label}: ${(t.endTime - t.startTime).toFixed(1)}ms`);
5295
+ if (hydrationEntries.length > 0) return hydrationEntries.join("\n");
5296
+ }
5297
+ const entries = performance.getEntriesByType("measure").filter((e) => e.name.toLowerCase().includes("hydrat"));
5298
+ if (entries.length > 0) {
5299
+ return entries.map((e) => `${e.name}: ${e.duration.toFixed(1)}ms`).join("\n");
5300
+ }
5301
+ return "No hydration timing data. Requires React profiling build or Next.js dev mode.";
5302
+ });
5303
+ return result;
5304
+ }
5305
+ async function getRenders(bm, page) {
5306
+ requireEnabled(bm);
5307
+ await requireReact(page);
5308
+ const result = await page.evaluate(() => {
5309
+ const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
5310
+ const rendererID = hook.renderers?.keys().next().value ?? 1;
5311
+ const roots = hook.getFiberRoots?.(rendererID);
5312
+ if (!roots || roots.size === 0) return "No fiber roots found";
5313
+ const root = roots.values().next().value;
5314
+ const rendered = [];
5315
+ function walk(fiber) {
5316
+ if (!fiber) return;
5317
+ const name = fiber.type?.displayName || fiber.type?.name;
5318
+ if (name && fiber.alternate && fiber.actualDuration > 0) {
5319
+ rendered.push(`${name} (${fiber.actualDuration.toFixed(1)}ms)`);
5320
+ }
5321
+ let child = fiber.child;
5322
+ while (child) {
5323
+ walk(child);
5324
+ child = child.sibling;
5325
+ }
5326
+ }
5327
+ walk(root.current);
5328
+ return rendered.length > 0 ? `Re-rendered:
5329
+ ${rendered.join("\n")}` : "No components re-rendered since last commit";
5330
+ });
5331
+ return result;
5332
+ }
5333
+ async function getOwners(bm, page, selector) {
5334
+ requireEnabled(bm);
5335
+ await requireReact(page);
5336
+ const resolved = bm.resolveRef(selector);
5337
+ const element = "locator" in resolved ? resolved.locator : page.locator(resolved.selector);
5338
+ const handle = await element.elementHandle();
5339
+ if (!handle) throw new Error(`Element not found: ${selector}`);
5340
+ const result = await page.evaluate((el) => {
5341
+ let fiber = null;
5342
+ for (const key of Object.keys(el)) {
5343
+ if (key.startsWith("__reactFiber$") || key.startsWith("__reactInternalInstance$")) {
5344
+ fiber = el[key];
5345
+ break;
5346
+ }
5347
+ }
5348
+ if (!fiber) return "No React fiber found for this element";
5349
+ const chain = [];
5350
+ let current = fiber;
5351
+ while (current) {
5352
+ const name = current.type?.displayName || current.type?.name;
5353
+ if (name && typeof current.type !== "string") {
5354
+ chain.push(name);
5355
+ }
5356
+ current = current.return;
5357
+ }
5358
+ return chain.length > 0 ? chain.join(" \u2192 ") : "No component owners found";
5359
+ }, handle);
5360
+ await handle.dispose();
5361
+ return result;
5362
+ }
5363
+ async function getContext(bm, page, selector) {
5364
+ requireEnabled(bm);
5365
+ await requireReact(page);
5366
+ const resolved = bm.resolveRef(selector);
5367
+ const element = "locator" in resolved ? resolved.locator : page.locator(resolved.selector);
5368
+ const handle = await element.elementHandle();
5369
+ if (!handle) throw new Error(`Element not found: ${selector}`);
5370
+ const result = await page.evaluate((el) => {
5371
+ let fiber = null;
5372
+ for (const key of Object.keys(el)) {
5373
+ if (key.startsWith("__reactFiber$") || key.startsWith("__reactInternalInstance$")) {
5374
+ fiber = el[key];
5375
+ break;
5376
+ }
5377
+ }
5378
+ if (!fiber) return "No React fiber found for this element";
5379
+ while (fiber && typeof fiber.type === "string") fiber = fiber.return;
5380
+ if (!fiber) return "No React component found";
5381
+ const deps = fiber.dependencies;
5382
+ if (!deps || !deps.firstContext) return "No context consumed by this component";
5383
+ const contexts = [];
5384
+ let ctx = deps.firstContext;
5385
+ while (ctx) {
5386
+ const name = ctx.context?.displayName || ctx.context?._debugLabel || "UnnamedContext";
5387
+ const value = ctx.context?._currentValue;
5388
+ try {
5389
+ contexts.push(`${name}: ${JSON.stringify(value, null, 2)}`);
5390
+ } catch {
5391
+ contexts.push(`${name}: [Complex value]`);
5392
+ }
5393
+ ctx = ctx.next;
5394
+ }
5395
+ return contexts.length > 0 ? contexts.join("\n\n") : "No context consumed";
5396
+ }, handle);
5397
+ await handle.dispose();
5398
+ return result;
5399
+ }
5400
+ var fs13, path11, os3, CACHE_DIR, HOOK_PATH, HOOK_URL;
5401
+ var init_react_devtools = __esm({
5402
+ "src/react-devtools.ts"() {
5403
+ "use strict";
5404
+ fs13 = __toESM(require("fs"), 1);
5405
+ path11 = __toESM(require("path"), 1);
5406
+ os3 = __toESM(require("os"), 1);
5407
+ CACHE_DIR = path11.join(os3.homedir(), ".cache", "browse", "react-devtools");
5408
+ HOOK_PATH = path11.join(CACHE_DIR, "installHook.js");
5409
+ HOOK_URL = "https://unpkg.com/react-devtools-core@latest/dist/installHook.js";
5410
+ }
5411
+ });
5412
+
4770
5413
  // src/commands/meta.ts
4771
5414
  async function handleMetaCommand(command, args, bm, shutdown2, sessionManager2, currentSession) {
4772
5415
  switch (command) {
@@ -4849,7 +5492,7 @@ async function handleMetaCommand(command, args, bm, shutdown2, sessionManager2,
4849
5492
  const lines = entries.map(
4850
5493
  (e) => `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
4851
5494
  ).join("\n") + "\n";
4852
- fs13.appendFileSync(consolePath, lines);
5495
+ fs14.appendFileSync(consolePath, lines);
4853
5496
  buffers.lastConsoleFlushed = buffers.consoleTotalAdded;
4854
5497
  }
4855
5498
  const newNetworkCount = buffers.networkTotalAdded - buffers.lastNetworkFlushed;
@@ -4859,7 +5502,7 @@ async function handleMetaCommand(command, args, bm, shutdown2, sessionManager2,
4859
5502
  const lines = entries.map(
4860
5503
  (e) => `[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} \u2192 ${e.status || "pending"} (${e.duration || "?"}ms, ${e.size || "?"}B)`
4861
5504
  ).join("\n") + "\n";
4862
- fs13.appendFileSync(networkPath, lines);
5505
+ fs14.appendFileSync(networkPath, lines);
4863
5506
  buffers.lastNetworkFlushed = buffers.networkTotalAdded;
4864
5507
  }
4865
5508
  }
@@ -4876,22 +5519,22 @@ async function handleMetaCommand(command, args, bm, shutdown2, sessionManager2,
4876
5519
  const statesDir = `${LOCAL_DIR}/states`;
4877
5520
  const statePath = `${statesDir}/${name}.json`;
4878
5521
  if (subcommand === "list") {
4879
- if (!fs13.existsSync(statesDir)) return "(no saved states)";
4880
- const files = fs13.readdirSync(statesDir).filter((f) => f.endsWith(".json"));
5522
+ if (!fs14.existsSync(statesDir)) return "(no saved states)";
5523
+ const files = fs14.readdirSync(statesDir).filter((f) => f.endsWith(".json"));
4881
5524
  if (files.length === 0) return "(no saved states)";
4882
5525
  const lines = [];
4883
5526
  for (const file of files) {
4884
5527
  const fp = `${statesDir}/${file}`;
4885
- const stat = fs13.statSync(fp);
5528
+ const stat = fs14.statSync(fp);
4886
5529
  lines.push(` ${file.replace(".json", "")} ${stat.size}B ${new Date(stat.mtimeMs).toISOString()}`);
4887
5530
  }
4888
5531
  return lines.join("\n");
4889
5532
  }
4890
5533
  if (subcommand === "show") {
4891
- if (!fs13.existsSync(statePath)) {
5534
+ if (!fs14.existsSync(statePath)) {
4892
5535
  throw new Error(`State file not found: ${statePath}`);
4893
5536
  }
4894
- const data = JSON.parse(fs13.readFileSync(statePath, "utf-8"));
5537
+ const data = JSON.parse(fs14.readFileSync(statePath, "utf-8"));
4895
5538
  const cookieCount = data.cookies?.length || 0;
4896
5539
  const originCount = data.origins?.length || 0;
4897
5540
  const storageItems = (data.origins || []).reduce((sum, o) => sum + (o.localStorage?.length || 0), 0);
@@ -4916,15 +5559,15 @@ async function handleMetaCommand(command, args, bm, shutdown2, sessionManager2,
4916
5559
  const context = bm.getContext();
4917
5560
  if (!context) throw new Error("No browser context");
4918
5561
  const state = await context.storageState();
4919
- fs13.mkdirSync(statesDir, { recursive: true });
4920
- fs13.writeFileSync(statePath, JSON.stringify(state, null, 2), { mode: 384 });
5562
+ fs14.mkdirSync(statesDir, { recursive: true });
5563
+ fs14.writeFileSync(statePath, JSON.stringify(state, null, 2), { mode: 384 });
4921
5564
  return `State saved: ${statePath}`;
4922
5565
  }
4923
5566
  if (subcommand === "load") {
4924
- if (!fs13.existsSync(statePath)) {
5567
+ if (!fs14.existsSync(statePath)) {
4925
5568
  throw new Error(`State file not found: ${statePath}. Run "browse state save ${name}" first.`);
4926
5569
  }
4927
- const stateData = JSON.parse(fs13.readFileSync(statePath, "utf-8"));
5570
+ const stateData = JSON.parse(fs14.readFileSync(statePath, "utf-8"));
4928
5571
  const context = bm.getContext();
4929
5572
  if (!context) throw new Error("No browser context");
4930
5573
  const warnings = [];
@@ -5083,9 +5726,9 @@ ${legend.join("\n")}`;
5083
5726
  try {
5084
5727
  for (const vp of viewports) {
5085
5728
  await page.setViewportSize({ width: vp.width, height: vp.height });
5086
- const path13 = `${prefix}-${vp.name}.png`;
5087
- await page.screenshot({ path: path13, fullPage: true });
5088
- results.push(`${vp.name} (${vp.width}x${vp.height}): ${path13}`);
5729
+ const path14 = `${prefix}-${vp.name}.png`;
5730
+ await page.screenshot({ path: path14, fullPage: true });
5731
+ results.push(`${vp.name} (${vp.width}x${vp.height}): ${path14}`);
5089
5732
  }
5090
5733
  } finally {
5091
5734
  if (originalViewport) {
@@ -5229,13 +5872,13 @@ ${legend.join("\n")}`;
5229
5872
  const diffArgs = args.filter((a) => a !== "--full");
5230
5873
  const baseline = diffArgs[0];
5231
5874
  if (!baseline) throw new Error("Usage: browse screenshot-diff <baseline> [current] [--threshold 0.1] [--full]");
5232
- if (!fs13.existsSync(baseline)) throw new Error(`Baseline file not found: ${baseline}`);
5875
+ if (!fs14.existsSync(baseline)) throw new Error(`Baseline file not found: ${baseline}`);
5233
5876
  let thresholdPct = 0.1;
5234
5877
  const threshIdx = diffArgs.indexOf("--threshold");
5235
5878
  if (threshIdx !== -1 && diffArgs[threshIdx + 1]) {
5236
5879
  thresholdPct = parseFloat(diffArgs[threshIdx + 1]);
5237
5880
  }
5238
- const baselineBuffer = fs13.readFileSync(baseline);
5881
+ const baselineBuffer = fs14.readFileSync(baseline);
5239
5882
  let currentBuffer;
5240
5883
  let currentPath;
5241
5884
  for (let i = 1; i < diffArgs.length; i++) {
@@ -5249,8 +5892,8 @@ ${legend.join("\n")}`;
5249
5892
  }
5250
5893
  }
5251
5894
  if (currentPath) {
5252
- if (!fs13.existsSync(currentPath)) throw new Error(`Current screenshot not found: ${currentPath}`);
5253
- currentBuffer = fs13.readFileSync(currentPath);
5895
+ if (!fs14.existsSync(currentPath)) throw new Error(`Current screenshot not found: ${currentPath}`);
5896
+ currentBuffer = fs14.readFileSync(currentPath);
5254
5897
  } else {
5255
5898
  const page = bm.getPage();
5256
5899
  currentBuffer = await page.screenshot({ fullPage: isFullPageDiff });
@@ -5260,7 +5903,7 @@ ${legend.join("\n")}`;
5260
5903
  const extIdx = baseline.lastIndexOf(".");
5261
5904
  const diffPath = extIdx > 0 ? baseline.slice(0, extIdx) + "-diff" + baseline.slice(extIdx) : baseline + "-diff.png";
5262
5905
  if (!result.passed && result.diffImage) {
5263
- fs13.writeFileSync(diffPath, result.diffImage);
5906
+ fs14.writeFileSync(diffPath, result.diffImage);
5264
5907
  }
5265
5908
  return [
5266
5909
  `Pixels: ${result.totalPixels}`,
@@ -5396,7 +6039,7 @@ ${legend.join("\n")}`;
5396
6039
  const { formatAsHar: formatAsHar2 } = await Promise.resolve().then(() => (init_har(), har_exports));
5397
6040
  const har = formatAsHar2(sessionBuffers.networkBuffer, recording.startTime);
5398
6041
  const harPath = args[1] || (currentSession ? `${currentSession.outputDir}/recording.har` : `${LOCAL_DIR}/browse-recording.har`);
5399
- fs13.writeFileSync(harPath, JSON.stringify(har, null, 2));
6042
+ fs14.writeFileSync(harPath, JSON.stringify(har, null, 2));
5400
6043
  const entryCount = har.log.entries.length;
5401
6044
  return `HAR saved: ${harPath} (${entryCount} entries)`;
5402
6045
  }
@@ -5459,6 +6102,20 @@ ${output.trim()}`;
5459
6102
  Manual: npm install -g @ulpi/browse`;
5460
6103
  }
5461
6104
  }
6105
+ // ─── Handoff ────────────────────────────────────────
6106
+ case "handoff": {
6107
+ const message = args.join(" ") || "User takeover requested";
6108
+ return await bm.handoff(message);
6109
+ }
6110
+ case "resume": {
6111
+ const url = await bm.resume();
6112
+ const { handleSnapshot: handleSnapshot2 } = await Promise.resolve().then(() => (init_snapshot(), snapshot_exports));
6113
+ const snapshot = await handleSnapshot2(["-i"], bm);
6114
+ return `Resumed \u2014 back to headless.
6115
+ URL: ${url}
6116
+
6117
+ ${snapshot}`;
6118
+ }
5462
6119
  // ─── Semantic Locator ──────────────────────────────
5463
6120
  case "find": {
5464
6121
  const root = bm.getLocatorRoot();
@@ -5616,7 +6273,7 @@ Manual: npm install -g @ulpi/browse`;
5616
6273
  }
5617
6274
  const filePath = args[2];
5618
6275
  if (filePath) {
5619
- fs13.writeFileSync(filePath, output);
6276
+ fs14.writeFileSync(filePath, output);
5620
6277
  return `Exported ${steps.length} steps as ${format}: ${filePath}`;
5621
6278
  }
5622
6279
  return output;
@@ -5662,11 +6319,59 @@ Manual: npm install -g @ulpi/browse`;
5662
6319
  }
5663
6320
  throw new Error("Usage: browse profile list | delete <name> | clean [--older-than <days>]");
5664
6321
  }
6322
+ // ─── React DevTools ──────────────────────────────────
6323
+ case "react-devtools": {
6324
+ const sub = args[0];
6325
+ if (!sub) throw new Error(
6326
+ "Usage: browse react-devtools enable|disable|tree|props|suspense|errors|profiler|hydration|renders|owners|context"
6327
+ );
6328
+ const rd = await Promise.resolve().then(() => (init_react_devtools(), react_devtools_exports));
6329
+ switch (sub) {
6330
+ case "enable": {
6331
+ if (rd.isEnabled(bm)) return "React DevTools already enabled.";
6332
+ await rd.injectHook(bm);
6333
+ await bm.getPage().reload();
6334
+ return "React DevTools enabled. Page reloaded.";
6335
+ }
6336
+ case "disable": {
6337
+ rd.removeHook(bm);
6338
+ return "React DevTools disabled. Takes effect on next navigation.";
6339
+ }
6340
+ case "tree":
6341
+ return await rd.getTree(bm, bm.getPage());
6342
+ case "props": {
6343
+ if (!args[1]) throw new Error("Usage: browse react-devtools props <selector|@ref>");
6344
+ return await rd.getProps(bm, bm.getPage(), args[1]);
6345
+ }
6346
+ case "suspense":
6347
+ return await rd.getSuspense(bm, bm.getPage());
6348
+ case "errors":
6349
+ return await rd.getErrors(bm, bm.getPage());
6350
+ case "profiler":
6351
+ return await rd.getProfiler(bm, bm.getPage());
6352
+ case "hydration":
6353
+ return await rd.getHydration(bm, bm.getPage());
6354
+ case "renders":
6355
+ return await rd.getRenders(bm, bm.getPage());
6356
+ case "owners": {
6357
+ if (!args[1]) throw new Error("Usage: browse react-devtools owners <selector|@ref>");
6358
+ return await rd.getOwners(bm, bm.getPage(), args[1]);
6359
+ }
6360
+ case "context": {
6361
+ if (!args[1]) throw new Error("Usage: browse react-devtools context <selector|@ref>");
6362
+ return await rd.getContext(bm, bm.getPage(), args[1]);
6363
+ }
6364
+ default:
6365
+ throw new Error(
6366
+ `Unknown subcommand: ${sub}. Use: enable|disable|tree|props|suspense|errors|profiler|hydration|renders|owners|context`
6367
+ );
6368
+ }
6369
+ }
5665
6370
  default:
5666
6371
  throw new Error(`Unknown meta command: ${command}`);
5667
6372
  }
5668
6373
  }
5669
- var fs13, LOCAL_DIR;
6374
+ var fs14, LOCAL_DIR;
5670
6375
  var init_meta = __esm({
5671
6376
  "src/commands/meta.ts"() {
5672
6377
  "use strict";
@@ -5674,7 +6379,7 @@ var init_meta = __esm({
5674
6379
  init_constants();
5675
6380
  init_sanitize();
5676
6381
  init_lib();
5677
- fs13 = __toESM(require("fs"), 1);
6382
+ fs14 = __toESM(require("fs"), 1);
5678
6383
  LOCAL_DIR = process.env.BROWSE_LOCAL_DIR || "/tmp";
5679
6384
  }
5680
6385
  });
@@ -5732,7 +6437,7 @@ function flushSessionBuffers(session, final) {
5732
6437
  const lines = newEntries.map(
5733
6438
  (e) => `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
5734
6439
  ).join("\n") + "\n";
5735
- fs14.appendFileSync(consolePath, lines);
6440
+ fs15.appendFileSync(consolePath, lines);
5736
6441
  buffers.lastConsoleFlushed = buffers.consoleTotalAdded;
5737
6442
  }
5738
6443
  let newNetworkCount = buffers.networkTotalAdded - buffers.lastNetworkFlushed;
@@ -5757,7 +6462,7 @@ function flushSessionBuffers(session, final) {
5757
6462
  const lines = prefix.map(
5758
6463
  (e) => `[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} \u2192 ${e.status || "pending"} (${e.duration || "?"}ms, ${e.size || "?"}B)`
5759
6464
  ).join("\n") + "\n";
5760
- fs14.appendFileSync(networkPath, lines);
6465
+ fs15.appendFileSync(networkPath, lines);
5761
6466
  buffers.lastNetworkFlushed += prefixLen;
5762
6467
  }
5763
6468
  }
@@ -5886,6 +6591,7 @@ async function handleCommand(body, session, opts) {
5886
6591
  headers: { "Content-Type": "application/json" }
5887
6592
  });
5888
6593
  }
6594
+ session.manager.resetFailures();
5889
6595
  if (session.recording && !RECORDING_SKIP.has(command)) {
5890
6596
  session.recording.push({ command, args, timestamp: Date.now() });
5891
6597
  }
@@ -5910,7 +6616,10 @@ ${result}
5910
6616
  headers: { "Content-Type": "text/plain" }
5911
6617
  });
5912
6618
  } catch (err) {
5913
- const friendlyError = rewriteError(err.message);
6619
+ session.manager.incrementFailures();
6620
+ let friendlyError = rewriteError(err.message);
6621
+ const hint = session.manager.getFailureHint();
6622
+ if (hint) friendlyError += "\n" + hint;
5914
6623
  if (opts.jsonMode) {
5915
6624
  return new Response(JSON.stringify({ success: false, error: friendlyError, command }), {
5916
6625
  status: 500,
@@ -5944,9 +6653,9 @@ async function shutdown() {
5944
6653
  await activeRuntime?.close?.().catch(() => {
5945
6654
  });
5946
6655
  try {
5947
- const currentState = JSON.parse(fs14.readFileSync(STATE_FILE, "utf-8"));
6656
+ const currentState = JSON.parse(fs15.readFileSync(STATE_FILE, "utf-8"));
5948
6657
  if (currentState.pid === process.pid || currentState.token === AUTH_TOKEN) {
5949
- fs14.unlinkSync(STATE_FILE);
6658
+ fs15.unlinkSync(STATE_FILE);
5950
6659
  }
5951
6660
  } catch {
5952
6661
  }
@@ -5963,15 +6672,15 @@ async function start() {
5963
6672
  const { BrowserManager: BrowserManager2, getProfileDir: getProfileDir2 } = await Promise.resolve().then(() => (init_browser_manager(), browser_manager_exports));
5964
6673
  const { SessionBuffers: SessionBuffers2 } = await Promise.resolve().then(() => (init_buffers(), buffers_exports));
5965
6674
  const profileDir = getProfileDir2(LOCAL_DIR2, profileName);
5966
- fs14.mkdirSync(profileDir, { recursive: true });
6675
+ fs15.mkdirSync(profileDir, { recursive: true });
5967
6676
  const bm = new BrowserManager2();
5968
6677
  await bm.launchPersistent(profileDir, () => {
5969
6678
  if (isShuttingDown) return;
5970
6679
  console.error("[browse] Chromium disconnected (profile mode). Shutting down.");
5971
6680
  shutdown();
5972
6681
  });
5973
- const outputDir = path11.join(LOCAL_DIR2, "sessions", profileName);
5974
- fs14.mkdirSync(outputDir, { recursive: true });
6682
+ const outputDir = path12.join(LOCAL_DIR2, "sessions", profileName);
6683
+ fs15.mkdirSync(outputDir, { recursive: true });
5975
6684
  profileSession = {
5976
6685
  id: profileName,
5977
6686
  manager: bm,
@@ -6065,7 +6774,7 @@ async function start() {
6065
6774
  const context = session.manager.getContext();
6066
6775
  if (context) {
6067
6776
  try {
6068
- const stateData = JSON.parse(fs14.readFileSync(stateFilePath, "utf-8"));
6777
+ const stateData = JSON.parse(fs15.readFileSync(stateFilePath, "utf-8"));
6069
6778
  if (stateData.cookies?.length) {
6070
6779
  await context.addCookies(stateData.cookies);
6071
6780
  }
@@ -6092,7 +6801,7 @@ async function start() {
6092
6801
  port,
6093
6802
  token: AUTH_TOKEN,
6094
6803
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
6095
- serverPath: path11.resolve(path11.dirname((0, import_url2.fileURLToPath)(__import_meta_url)), "server.ts")
6804
+ serverPath: path12.resolve(path12.dirname((0, import_url2.fileURLToPath)(__import_meta_url)), "server.ts")
6096
6805
  };
6097
6806
  if (profileName) {
6098
6807
  state.profile = profileName;
@@ -6100,12 +6809,12 @@ async function start() {
6100
6809
  if (DEBUG_PORT > 0) {
6101
6810
  state.debugPort = DEBUG_PORT;
6102
6811
  }
6103
- fs14.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 384 });
6812
+ fs15.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 384 });
6104
6813
  console.log(`[browse] Server running on http://127.0.0.1:${port} (PID: ${process.pid})`);
6105
6814
  console.log(`[browse] State file: ${STATE_FILE}`);
6106
6815
  console.log(`[browse] Idle timeout: ${IDLE_TIMEOUT_MS / 1e3}s`);
6107
6816
  }
6108
- var fs14, path11, crypto3, http, import_url2, net2, AUTH_TOKEN, DEBUG_PORT, BROWSE_PORT, BROWSE_INSTANCE, INSTANCE_SUFFIX, LOCAL_DIR2, STATE_FILE, IDLE_TIMEOUT_MS, sessionManager, browser, profileSession, activeRuntime, isShuttingDown, isRemoteBrowser, policyChecker, READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS, RECORDING_SKIP, PAGE_CONTENT_COMMANDS, BOUNDARY_NONCE, flushInterval, sessionCleanupInterval;
6817
+ var fs15, path12, crypto3, http, import_url2, net2, AUTH_TOKEN, DEBUG_PORT, BROWSE_PORT, BROWSE_INSTANCE, INSTANCE_SUFFIX, LOCAL_DIR2, STATE_FILE, IDLE_TIMEOUT_MS, sessionManager, browser, profileSession, activeRuntime, isShuttingDown, isRemoteBrowser, policyChecker, READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS, RECORDING_SKIP, PAGE_CONTENT_COMMANDS, BOUNDARY_NONCE, flushInterval, sessionCleanupInterval;
6109
6818
  var init_server = __esm({
6110
6819
  "src/server.ts"() {
6111
6820
  "use strict";
@@ -6116,8 +6825,8 @@ var init_server = __esm({
6116
6825
  init_meta();
6117
6826
  init_policy();
6118
6827
  init_constants();
6119
- fs14 = __toESM(require("fs"), 1);
6120
- path11 = __toESM(require("path"), 1);
6828
+ fs15 = __toESM(require("fs"), 1);
6829
+ path12 = __toESM(require("path"), 1);
6121
6830
  crypto3 = __toESM(require("crypto"), 1);
6122
6831
  http = __toESM(require("http"), 1);
6123
6832
  import_url2 = require("url");
@@ -6231,7 +6940,10 @@ var init_server = __esm({
6231
6940
  "cookie-import",
6232
6941
  "doctor",
6233
6942
  "upgrade",
6234
- "profile"
6943
+ "handoff",
6944
+ "resume",
6945
+ "profile",
6946
+ "react-devtools"
6235
6947
  ]);
6236
6948
  RECORDING_SKIP = /* @__PURE__ */ new Set([
6237
6949
  "record",
@@ -6299,8 +7011,8 @@ __export(cli_exports, {
6299
7011
  resolveServerScript: () => resolveServerScript
6300
7012
  });
6301
7013
  module.exports = __toCommonJS(cli_exports);
6302
- var fs15 = __toESM(require("fs"), 1);
6303
- var path12 = __toESM(require("path"), 1);
7014
+ var fs16 = __toESM(require("fs"), 1);
7015
+ var path13 = __toESM(require("path"), 1);
6304
7016
  var import_child_process3 = require("child_process");
6305
7017
  var import_url3 = require("url");
6306
7018
  init_constants();
@@ -6354,50 +7066,50 @@ var INSTANCE_SUFFIX2 = BROWSE_PORT2 ? `-${BROWSE_PORT2}` : BROWSE_INSTANCE2 ? `-
6354
7066
  function resolveLocalDir() {
6355
7067
  if (process.env.BROWSE_LOCAL_DIR) {
6356
7068
  try {
6357
- fs15.mkdirSync(process.env.BROWSE_LOCAL_DIR, { recursive: true });
7069
+ fs16.mkdirSync(process.env.BROWSE_LOCAL_DIR, { recursive: true });
6358
7070
  } catch {
6359
7071
  }
6360
7072
  return process.env.BROWSE_LOCAL_DIR;
6361
7073
  }
6362
7074
  let dir = process.cwd();
6363
7075
  for (let i = 0; i < 20; i++) {
6364
- if (fs15.existsSync(path12.join(dir, ".git")) || fs15.existsSync(path12.join(dir, ".claude"))) {
6365
- const browseDir = path12.join(dir, ".browse");
7076
+ if (fs16.existsSync(path13.join(dir, ".git")) || fs16.existsSync(path13.join(dir, ".claude"))) {
7077
+ const browseDir = path13.join(dir, ".browse");
6366
7078
  try {
6367
- fs15.mkdirSync(browseDir, { recursive: true });
6368
- const gi = path12.join(browseDir, ".gitignore");
6369
- if (!fs15.existsSync(gi)) {
6370
- fs15.writeFileSync(gi, "*\n");
7079
+ fs16.mkdirSync(browseDir, { recursive: true });
7080
+ const gi = path13.join(browseDir, ".gitignore");
7081
+ if (!fs16.existsSync(gi)) {
7082
+ fs16.writeFileSync(gi, "*\n");
6371
7083
  }
6372
7084
  } catch {
6373
7085
  }
6374
7086
  return browseDir;
6375
7087
  }
6376
- const parent = path12.dirname(dir);
7088
+ const parent = path13.dirname(dir);
6377
7089
  if (parent === dir) break;
6378
7090
  dir = parent;
6379
7091
  }
6380
7092
  return "/tmp";
6381
7093
  }
6382
7094
  var LOCAL_DIR3 = resolveLocalDir();
6383
- var STATE_FILE2 = process.env.BROWSE_STATE_FILE || path12.join(LOCAL_DIR3, `browse-server${INSTANCE_SUFFIX2}.json`);
7095
+ var STATE_FILE2 = process.env.BROWSE_STATE_FILE || path13.join(LOCAL_DIR3, `browse-server${INSTANCE_SUFFIX2}.json`);
6384
7096
  var MAX_START_WAIT = 8e3;
6385
7097
  var LOCK_FILE = STATE_FILE2 + ".lock";
6386
7098
  var LOCK_STALE_MS = DEFAULTS.LOCK_STALE_THRESHOLD_MS;
6387
7099
  var __filename_cli = (0, import_url3.fileURLToPath)(__import_meta_url);
6388
- var __dirname_cli = path12.dirname(__filename_cli);
7100
+ var __dirname_cli = path13.dirname(__filename_cli);
6389
7101
  function resolveServerScript(env = process.env, metaDir = __dirname_cli) {
6390
7102
  if (env.BROWSE_SERVER_SCRIPT) {
6391
7103
  return env.BROWSE_SERVER_SCRIPT;
6392
7104
  }
6393
7105
  if (metaDir.startsWith("/")) {
6394
- const direct = path12.resolve(metaDir, "server.ts");
6395
- if (fs15.existsSync(direct)) {
7106
+ const direct = path13.resolve(metaDir, "server.ts");
7107
+ if (fs16.existsSync(direct)) {
6396
7108
  return direct;
6397
7109
  }
6398
7110
  }
6399
7111
  const selfPath = (0, import_url3.fileURLToPath)(__import_meta_url);
6400
- if (fs15.existsSync(selfPath)) {
7112
+ if (fs16.existsSync(selfPath)) {
6401
7113
  return "__self__";
6402
7114
  }
6403
7115
  throw new Error(
@@ -6407,7 +7119,7 @@ function resolveServerScript(env = process.env, metaDir = __dirname_cli) {
6407
7119
  var SERVER_SCRIPT = resolveServerScript();
6408
7120
  function readState() {
6409
7121
  try {
6410
- const data = fs15.readFileSync(STATE_FILE2, "utf-8");
7122
+ const data = fs16.readFileSync(STATE_FILE2, "utf-8");
6411
7123
  return JSON.parse(data);
6412
7124
  } catch {
6413
7125
  return null;
@@ -6423,7 +7135,7 @@ function isProcessAlive(pid) {
6423
7135
  }
6424
7136
  async function listInstances() {
6425
7137
  try {
6426
- const files = fs15.readdirSync(LOCAL_DIR3).filter(
7138
+ const files = fs16.readdirSync(LOCAL_DIR3).filter(
6427
7139
  (f) => f.startsWith("browse-server") && f.endsWith(".json") && !f.endsWith(".lock")
6428
7140
  );
6429
7141
  if (files.length === 0) {
@@ -6433,7 +7145,7 @@ async function listInstances() {
6433
7145
  let found = false;
6434
7146
  for (const file of files) {
6435
7147
  try {
6436
- const data = JSON.parse(fs15.readFileSync(path12.join(LOCAL_DIR3, file), "utf-8"));
7148
+ const data = JSON.parse(fs16.readFileSync(path13.join(LOCAL_DIR3, file), "utf-8"));
6437
7149
  if (!data.pid || !data.port) continue;
6438
7150
  const alive = isProcessAlive(data.pid);
6439
7151
  let status = "dead";
@@ -6456,7 +7168,7 @@ async function listInstances() {
6456
7168
  found = true;
6457
7169
  if (!alive) {
6458
7170
  try {
6459
- fs15.unlinkSync(path12.join(LOCAL_DIR3, file));
7171
+ fs16.unlinkSync(path13.join(LOCAL_DIR3, file));
6460
7172
  } catch {
6461
7173
  }
6462
7174
  }
@@ -6479,15 +7191,15 @@ function isBrowseProcess(pid) {
6479
7191
  }
6480
7192
  function acquireLock() {
6481
7193
  try {
6482
- fs15.writeFileSync(LOCK_FILE, String(process.pid), { flag: "wx" });
7194
+ fs16.writeFileSync(LOCK_FILE, String(process.pid), { flag: "wx" });
6483
7195
  return true;
6484
7196
  } catch (err) {
6485
7197
  if (err.code === "EEXIST") {
6486
7198
  try {
6487
- const stat = fs15.statSync(LOCK_FILE);
7199
+ const stat = fs16.statSync(LOCK_FILE);
6488
7200
  if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
6489
7201
  try {
6490
- fs15.unlinkSync(LOCK_FILE);
7202
+ fs16.unlinkSync(LOCK_FILE);
6491
7203
  } catch {
6492
7204
  }
6493
7205
  return acquireLock();
@@ -6501,7 +7213,7 @@ function acquireLock() {
6501
7213
  }
6502
7214
  function releaseLock() {
6503
7215
  try {
6504
- fs15.unlinkSync(LOCK_FILE);
7216
+ fs16.unlinkSync(LOCK_FILE);
6505
7217
  } catch {
6506
7218
  }
6507
7219
  }
@@ -6518,7 +7230,7 @@ async function startServer() {
6518
7230
  }
6519
7231
  await sleep(100);
6520
7232
  }
6521
- if (!fs15.existsSync(LOCK_FILE) || fs15.readFileSync(LOCK_FILE, "utf-8").trim() !== String(process.pid)) {
7233
+ if (!fs16.existsSync(LOCK_FILE) || fs16.readFileSync(LOCK_FILE, "utf-8").trim() !== String(process.pid)) {
6522
7234
  const state = readState();
6523
7235
  if (state && isProcessAlive(state.pid)) return state;
6524
7236
  throw new Error("Server failed to start (another process is starting it)");
@@ -6528,7 +7240,7 @@ async function startServer() {
6528
7240
  try {
6529
7241
  const oldState = readState();
6530
7242
  if (oldState && !isProcessAlive(oldState.pid)) {
6531
- fs15.unlinkSync(STATE_FILE2);
7243
+ fs16.unlinkSync(STATE_FILE2);
6532
7244
  }
6533
7245
  } catch {
6534
7246
  }
@@ -6602,7 +7314,7 @@ async function ensureServer() {
6602
7314
  }
6603
7315
  if (state) {
6604
7316
  try {
6605
- fs15.unlinkSync(STATE_FILE2);
7317
+ fs16.unlinkSync(STATE_FILE2);
6606
7318
  } catch {
6607
7319
  }
6608
7320
  }
@@ -6612,21 +7324,21 @@ async function ensureServer() {
6612
7324
  }
6613
7325
  function cleanOrphanedServers() {
6614
7326
  try {
6615
- const files = fs15.readdirSync(LOCAL_DIR3);
7327
+ const files = fs16.readdirSync(LOCAL_DIR3);
6616
7328
  for (const file of files) {
6617
7329
  if (!file.startsWith("browse-server") || !file.endsWith(".json") || file.endsWith(".lock")) continue;
6618
- const filePath = path12.join(LOCAL_DIR3, file);
7330
+ const filePath = path13.join(LOCAL_DIR3, file);
6619
7331
  if (filePath === STATE_FILE2) continue;
6620
7332
  try {
6621
- const data = JSON.parse(fs15.readFileSync(filePath, "utf-8"));
7333
+ const data = JSON.parse(fs16.readFileSync(filePath, "utf-8"));
6622
7334
  if (!data.pid) {
6623
- fs15.unlinkSync(filePath);
7335
+ fs16.unlinkSync(filePath);
6624
7336
  continue;
6625
7337
  }
6626
7338
  const suffixMatch = file.match(/browse-server-(\d+)\.json$/);
6627
7339
  if (suffixMatch && data.port === parseInt(suffixMatch[1], 10) && isProcessAlive(data.pid)) continue;
6628
7340
  if (!isProcessAlive(data.pid)) {
6629
- fs15.unlinkSync(filePath);
7341
+ fs16.unlinkSync(filePath);
6630
7342
  continue;
6631
7343
  }
6632
7344
  if (isBrowseProcess(data.pid)) {
@@ -6637,7 +7349,7 @@ function cleanOrphanedServers() {
6637
7349
  }
6638
7350
  } catch {
6639
7351
  try {
6640
- fs15.unlinkSync(filePath);
7352
+ fs16.unlinkSync(filePath);
6641
7353
  } catch {
6642
7354
  }
6643
7355
  }
@@ -6677,7 +7389,8 @@ var SAFE_TO_RETRY = /* @__PURE__ */ new Set([
6677
7389
  "box",
6678
7390
  "errors",
6679
7391
  "doctor",
6680
- "upgrade"
7392
+ "upgrade",
7393
+ "react-devtools"
6681
7394
  ]);
6682
7395
  var PAGE_INDEPENDENT = /* @__PURE__ */ new Set(["devices", "status"]);
6683
7396
  async function sendCommand(state, command, args, retries = 0, sessionId) {
@@ -6738,7 +7451,7 @@ async function sendCommand(state, command, args, retries = 0, sessionId) {
6738
7451
  await sleep(300);
6739
7452
  }
6740
7453
  try {
6741
- fs15.unlinkSync(STATE_FILE2);
7454
+ fs16.unlinkSync(STATE_FILE2);
6742
7455
  } catch {
6743
7456
  }
6744
7457
  if (command === "restart") {
@@ -6942,22 +7655,25 @@ Interaction: click <sel> | rightclick <sel> | dblclick <sel>
6942
7655
  check <sel> | uncheck <sel> | drag <src> <tgt>
6943
7656
  type <text> | press <key> | keydown <key> | keyup <key>
6944
7657
  keyboard inserttext <text>
6945
- scroll [sel|up|down] | scrollinto <sel>
7658
+ scroll [sel|up|down] | scrollinto <sel> | scrollintoview <sel>
6946
7659
  swipe <up|down|left|right> [px]
6947
7660
  wait <sel|ms|--url|--text|--fn|--load|--network-idle>
6948
7661
  viewport <WxH> | highlight <sel> | download <sel> [path]
7662
+ upload <sel> <files...>
7663
+ dialog-accept [text] | dialog-dismiss
6949
7664
  Mouse: mouse move <x> <y> | mouse down [btn] | mouse up [btn]
6950
7665
  mouse wheel <dy> [dx]
6951
7666
  Settings: set geo <lat> <lng> | set media <dark|light|no-preference>
6952
7667
  Device: emulate <device> | emulate reset | devices [filter]
6953
7668
  Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
6954
- element-state <sel> | box <sel>
7669
+ element-state <sel> | box <sel> | dialog
6955
7670
  console [--clear] | errors [--clear] | network [--clear]
6956
7671
  cookies | storage [set <k> <v>] | perf
6957
7672
  value <sel> | count <sel> | clipboard [write <text>]
6958
7673
  Visual: screenshot [sel|@ref] [path] [--full] [--clip x,y,w,h]
6959
7674
  screenshot --annotate | pdf [path] | responsive [prefix]
6960
7675
  Snapshot: snapshot [-i] [-f] [-V] [-c] [-C] [-d N] [-s sel]
7676
+ snapshot-diff
6961
7677
  Find: find role|text|label|placeholder|testid|alt|title <query>
6962
7678
  find first|last <sel> | find nth <n> <sel>
6963
7679
  Compare: diff <url1> <url2> | screenshot-diff <baseline> [current]
@@ -6973,10 +7689,13 @@ Recording: har start | har stop [path]
6973
7689
  Tabs: tabs | tab <id> | newtab [url] | closetab [id]
6974
7690
  Frames: frame <sel> | frame main
6975
7691
  Sessions: sessions | session-close <id>
7692
+ Profiles: --profile <name> | profile list|delete|clean
6976
7693
  Auth: auth save <name> <url> <user> <pass|--password-stdin>
6977
7694
  auth login <name> | auth list | auth delete <name>
6978
7695
  cookie-import --list | cookie-import <browser> [--domain <d>] [--profile <p>]
6979
- State: state save|load|list|show [name]
7696
+ State: state save|load|list|show|clean [name]
7697
+ Handoff: handoff [reason] | resume
7698
+ React: react-devtools enable|disable|tree|props|suspense|errors|profiler
6980
7699
  Debug: inspect (requires BROWSE_DEBUG_PORT)
6981
7700
  Server: status | instances | stop | restart | doctor | upgrade
6982
7701
  Setup: install-skill [path]
@@ -7023,7 +7742,7 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
7023
7742
  }
7024
7743
  if (process.env.__BROWSE_SERVER_MODE === "1") {
7025
7744
  Promise.resolve().then(() => init_server());
7026
- } else if (process.argv[1] && fs15.realpathSync(process.argv[1]) === fs15.realpathSync(__filename_cli)) {
7745
+ } else if (process.argv[1] && fs16.realpathSync(process.argv[1]) === fs16.realpathSync(__filename_cli)) {
7027
7746
  main().catch((err) => {
7028
7747
  console.error(`[browse] ${err.message}`);
7029
7748
  process.exit(1);