chromeflow 0.9.8 → 0.9.9

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.
Files changed (2) hide show
  1. package/bin/chromeflow.mjs +90 -18
  2. package/package.json +1 -1
@@ -24775,21 +24775,41 @@ function registerBrowserTools(server, bridge) {
24775
24775
  "open_page",
24776
24776
  `Navigate to a URL. By default reuses the active tab. Set new_tab=true to open alongside the current tab without losing it. After navigating, call get_page_text to read the page \u2014 do NOT take a screenshot.
24777
24777
 
24778
- Set background=true (only with new_tab=true) to open the new tab WITHOUT switching focus to it. Use this when the current tab has a partially-filled form whose page auto-saves on focus loss (e.g. eBay seller listings) \u2014 switching away would trigger the auto-save and corrupt the in-progress draft.`,
24778
+ Set background=true (only with new_tab=true) to open the new tab WITHOUT switching focus to it. Use this when the current tab has a partially-filled form whose page auto-saves on focus loss (e.g. eBay seller listings) \u2014 switching away would trigger the auto-save and corrupt the in-progress draft.
24779
+
24780
+ After tabs.onUpdated fires status=complete, chromeflow also runs a 6s settle check (document.readyState=complete, no visible spinner element, 250ms of mutation quiet). If a spinner is still visible at the end of the window, the response carries \`stuck_spinner: true\` with the matching selector \u2014 the canonical case is an SPA route that left a permanent .spinner-wrapper because the API request died. Set expect_selector to wait for a known-good element to appear before considering the page settled \u2014 the response carries \`expect_selector_appeared: false\` if it never showed up.`,
24779
24781
  {
24780
24782
  url: external_exports.string().url().describe("The URL to navigate to"),
24781
24783
  new_tab: external_exports.boolean().optional().describe("Open in a new tab instead of replacing the current one (default false)"),
24782
- background: external_exports.boolean().optional().describe("If new_tab=true, do not switch focus to the new tab. Default false. Ignored when new_tab is false.")
24784
+ background: external_exports.boolean().optional().describe("If new_tab=true, do not switch focus to the new tab. Default false. Ignored when new_tab is false."),
24785
+ expect_selector: external_exports.string().optional().describe("CSS selector for an element that must be present before the page is considered settled. The settle check waits up to 6s for it; if it never appears, the response carries expect_selector_appeared:false so you can detect dead-spinner routes (e.g. Outlier /en/expert/tasks).")
24783
24786
  },
24784
- async ({ url, new_tab, background }) => {
24787
+ async ({ url, new_tab, background, expect_selector }) => {
24785
24788
  const block = isBlockedUrl(url);
24786
24789
  if (block.blocked) {
24787
24790
  return { content: [{ type: "text", text: `open_page refused: ${block.reason}` }] };
24788
24791
  }
24789
- await bridge.request({ type: "navigate", url, newTab: new_tab ?? false, background: background ?? false });
24790
- return {
24791
- content: [{ type: "text", text: `Navigated to ${url}${new_tab ? background ? " (new background tab)" : " (new tab)" : ""}` }]
24792
- };
24792
+ const response = await bridge.request({
24793
+ type: "navigate",
24794
+ url,
24795
+ newTab: new_tab ?? false,
24796
+ background: background ?? false,
24797
+ expect_selector
24798
+ });
24799
+ const r = response;
24800
+ const newTabBit = new_tab ? background ? " (new background tab)" : " (new tab)" : "";
24801
+ let text = `Navigated to ${url}${newTabBit}`;
24802
+ if (r.stuck_spinner) {
24803
+ text += `
24804
+
24805
+ \u26A0 stuck_spinner: true \u2014 page settled with a visible spinner (${r.spinner_selector ?? "unknown selector"}) still on screen after 6s. Current URL: ${r.current_url ?? url}. The route may be dead (Outlier /en/expert/tasks pattern) \u2014 navigate elsewhere instead of reloading, or wait and try get_page_text to see if it ever recovers.`;
24806
+ }
24807
+ if (expect_selector && r.expect_selector_appeared === false) {
24808
+ text += `
24809
+
24810
+ \u26A0 expect_selector "${expect_selector}" never appeared within the 6s settle window. The page may be partially loaded or stuck.`;
24811
+ }
24812
+ return { content: [{ type: "text", text }] };
24793
24813
  }
24794
24814
  );
24795
24815
  server.tool(
@@ -25052,16 +25072,25 @@ ${lines.join("\n")}${r.warning ?? ""}${captchaLine}${oauthLine}` }] };
25052
25072
  );
25053
25073
  server.tool(
25054
25074
  "type_text",
25055
- `Type text into the currently focused element via CDP keystrokes (produces isTrusted=true events). Use when fill_input fails because the page validates isTrusted (CodeMirror/Monaco/Ace editors, shadow DOM inputs, isTrusted-gated forms). The caller is responsible for focusing the target first (via click_element or execute_script). Pass \`frame: "iframe.selector"\` to type into a same-origin iframe's first editable element.`,
25075
+ `Type text into the currently focused element via CDP keystrokes (produces isTrusted=true events). Use when fill_input fails because the page validates isTrusted (CodeMirror/Monaco/Ace editors, shadow DOM inputs, isTrusted-gated forms). Pass \`into_selector\` to focus the target before typing (shadow-piercing CSS) \u2014 combined with \`clear_first: true\`, this collapses the old "wait_for_click \u2192 execute_script selectAll \u2192 type_text" pattern into a single call. Pass \`frame: "iframe.selector"\` to type into a same-origin iframe's first editable element.`,
25056
25076
  {
25057
25077
  text: external_exports.string().describe("The text to type into the focused element"),
25078
+ into_selector: external_exports.string().optional().describe(
25079
+ "CSS selector for the element to focus before typing (shadow-piercing \u2014 resolves selectors that find_text returns for closed-shadow-root content, e.g. Outlier-style Radix portals). When omitted, types into whatever is currently focused (the caller is responsible for focusing first via click_element)."
25080
+ ),
25081
+ clear_first: external_exports.boolean().optional().describe(
25082
+ "Only with into_selector: run document.execCommand('selectAll') + 'delete' on the focused element before typing. Use to overwrite tiptap / ProseMirror editors and similar contenteditable surfaces in one call."
25083
+ ),
25058
25084
  frame: external_exports.string().optional().describe(
25059
25085
  "CSS selector for an iframe whose contents you want to type into (e.g. 'iframe.se-rte-frame__summary'). Same-origin only. Before typing, the first contenteditable/input inside the iframe is focused; after typing, input/change events are dispatched in the iframe's context."
25060
25086
  )
25061
25087
  },
25062
- async ({ text, frame }) => {
25088
+ async ({ text, frame, into_selector, clear_first }) => {
25063
25089
  const timeoutMs = Math.max(3e4, text.length * 90 + 15e3);
25064
- const response = await bridge.request({ type: "type_text", text, frame }, timeoutMs);
25090
+ const response = await bridge.request(
25091
+ { type: "type_text", text, frame, into_selector, clear_first },
25092
+ timeoutMs
25093
+ );
25065
25094
  const r = response;
25066
25095
  return {
25067
25096
  content: [{ type: "text", text: r.message ?? (r.success ? "Text typed successfully" : "Failed to type text") }]
@@ -25275,7 +25304,18 @@ Never use take_screenshot just to read page content \u2014 paginate with startIn
25275
25304
  async ({ selector, startIndex }) => {
25276
25305
  const response = await bridge.request({ type: "get_page_text", selector, startIndex });
25277
25306
  if (response.type !== "page_text_response") throw new Error("Unexpected response");
25278
- const text = response.text;
25307
+ const r = response;
25308
+ let text = r.text;
25309
+ if (r.selector_in_shadow) {
25310
+ text = `[note: selector "${selector}" matched inside a closed shadow root \u2014 chromeflow tools that pierce (get_page_text, find_text, click_element, fill_input) see it, but execute_script cannot. Don't drop to screenshots.]
25311
+
25312
+ ` + text;
25313
+ }
25314
+ if (!selector && r.shadow_hosts_seen && r.shadow_hosts_seen > 0) {
25315
+ text = `[note: ${r.shadow_hosts_seen} shadow host${r.shadow_hosts_seen === 1 ? "" : "s"} detected on this page \u2014 call list_frames to see them. If execute_script returns an empty document, switch to find_text / get_page_text / click_element / fill_input \u2014 those pierce closed shadow roots.]
25316
+
25317
+ ` + text;
25318
+ }
25279
25319
  return {
25280
25320
  content: [{ type: "text", text: text || "(no text found on page)" }]
25281
25321
  };
@@ -25579,18 +25619,28 @@ Current URL: ${activeTab.url}`;
25579
25619
  const r = response;
25580
25620
  const navLine = r.navigated && r.after_url ? `
25581
25621
  \u2192 Navigated: ${r.after_url}` : "";
25622
+ let focusLine = "";
25623
+ const f = r.focused_after ?? null;
25624
+ if (f && !["button", "a"].includes(f.tag)) {
25625
+ const idBit = f.id ? `#${f.id}` : "";
25626
+ const nameBit = f.name ? ` name="${f.name}"` : "";
25627
+ const aria = f.aria_label ? ` aria-label="${f.aria_label.slice(0, 30)}"` : "";
25628
+ const valueBit = f.value_preview ? ` value="${f.value_preview.slice(0, 30)}"` : "";
25629
+ focusLine = `
25630
+ \u2192 Focused: <${f.tag}${idBit}${nameBit}${aria}${valueBit}>`;
25631
+ }
25582
25632
  if (!r.success) {
25583
25633
  return {
25584
25634
  content: [
25585
25635
  {
25586
25636
  type: "text",
25587
- text: `Could not click "${textHint}": ${r.message}${navLine}`
25637
+ text: `Could not click "${textHint}": ${r.message}${navLine}${focusLine}`
25588
25638
  }
25589
25639
  ]
25590
25640
  };
25591
25641
  }
25592
25642
  return {
25593
- content: [{ type: "text", text: `${r.message}${navLine}` }]
25643
+ content: [{ type: "text", text: `${r.message}${navLine}${focusLine}` }]
25594
25644
  };
25595
25645
  }
25596
25646
  );
@@ -25791,10 +25841,12 @@ ${lines.join("\n")}`
25791
25841
  );
25792
25842
  server.tool(
25793
25843
  "list_frames",
25794
- `List every top-level iframe/frame on the active page, with its origin, whether its contentDocument is accessible (same-origin), and its on-screen position.
25844
+ `List every top-level iframe/frame on the active page, with its origin, whether its contentDocument is accessible (same-origin), and its on-screen position. Also reports shadow-host inventory so you can spot pages whose visible content is rendered inside closed shadow roots (Radix portals, Stencil/Lit, custom web components).
25795
25845
 
25796
25846
  Use this BEFORE calling find_text({frame: "..."}) or other frame-targeted tools \u2014 it shows you which frames exist and which are reachable. Knowing a frame is cross-origin up front means you can route to read_attachment (for the frame's src URL) or take_screenshot instead of getting a "frame not accessible" error from another tool.
25797
25847
 
25848
+ Also use this as a quick diagnostic when execute_script returns an empty document on a page you can clearly see \u2014 non-zero \`shadow_hosts\` (especially closed roots) means switch to find_text / get_page_text / click_element / fill_input, which pierce shadow DOM via the extension's privileged API.
25849
+
25798
25850
  Per-frame fields:
25799
25851
  - selector: CSS selector you can pass to other tools' \`frame\` parameter
25800
25852
  - src: the iframe's src attribute (may be empty for about:blank frames)
@@ -25803,14 +25855,34 @@ Per-frame fields:
25803
25855
  - title: the iframe's title attribute, often the most human-readable identifier
25804
25856
  - x, y, width, height: bounding-box position in viewport CSS pixels
25805
25857
 
25806
- Note: this returns top-level frames only. Nested cross-origin frame trees are not enumerated.`,
25858
+ Per-shadow-host fields:
25859
+ - selector: short CSS hint for the host element (tag, id, or .class)
25860
+ - open: true if the shadow root is exposed via \`el.shadowRoot\` (most web components), false if it is closed (Radix portals, Stencil/Lit defaults) \u2014 closed roots are invisible to execute_script but pierced by chromeflow's other tools.
25861
+ - depth: nesting depth (0 = top-level host attached directly to the document)
25862
+
25863
+ Note: this returns top-level frames only. Nested cross-origin frame trees are not enumerated. Shadow hosts are capped at 25 to keep the response compact.`,
25807
25864
  {},
25808
25865
  async () => {
25809
25866
  const response = await bridge.request({ type: "list_frames" });
25810
25867
  if (response.type !== "list_frames_response") throw new Error(`Unexpected response: ${response.type}`);
25811
25868
  const r = response;
25869
+ const hosts = r.shadow_hosts ?? [];
25870
+ const closedCount = hosts.filter((h) => !h.open).length;
25871
+ const openCount = hosts.length - closedCount;
25872
+ let shadowSection = "";
25873
+ if (hosts.length > 0) {
25874
+ const hostLines = hosts.map((h) => {
25875
+ const kind = h.open ? "open" : "closed";
25876
+ const indent = " ".repeat(h.depth);
25877
+ return ` ${indent}${h.selector} [${kind}]`;
25878
+ });
25879
+ shadowSection = `
25880
+
25881
+ Shadow hosts (${hosts.length}: ${openCount} open, ${closedCount} closed):` + (closedCount > 0 ? "\n (Closed roots are invisible to execute_script. Use find_text / get_page_text / click_element / fill_input \u2014 they pierce.)" : "") + "\n" + hostLines.join("\n");
25882
+ }
25812
25883
  if (r.frames.length === 0) {
25813
- return { content: [{ type: "text", text: "No iframes or frames on this page." }] };
25884
+ const noFrames = "No iframes or frames on this page.";
25885
+ return { content: [{ type: "text", text: hosts.length > 0 ? `${noFrames}${shadowSection}` : noFrames }] };
25814
25886
  }
25815
25887
  const lines = r.frames.map((f) => {
25816
25888
  const access = f.accessible ? "accessible" : "cross-origin";
@@ -25820,13 +25892,13 @@ Note: this returns top-level frames only. Nested cross-origin frame trees are no
25820
25892
  src: ${f.src}` : ""}`;
25821
25893
  });
25822
25894
  return { content: [{ type: "text", text: `Found ${r.frames.length} frame${r.frames.length === 1 ? "" : "s"}:
25823
- ${lines.join("\n")}` }] };
25895
+ ${lines.join("\n")}${shadowSection}` }] };
25824
25896
  }
25825
25897
  );
25826
25898
  }
25827
25899
 
25828
25900
  // packages/mcp-server/src/index.ts
25829
- var PACKAGE_VERSION = true ? "0.9.8" : "dev";
25901
+ var PACKAGE_VERSION = true ? "0.9.9" : "dev";
25830
25902
  main().catch((err) => {
25831
25903
  console.error("[chromeflow] Fatal error:", err);
25832
25904
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chromeflow",
3
- "version": "0.9.8",
3
+ "version": "0.9.9",
4
4
  "description": "MCP server for chromeflow — lets Claude Code or Codex CLI drive your real Chrome browser with sessions intact. Plugin install recommended; npx chromeflow for manual MCP wiring.",
5
5
  "type": "module",
6
6
  "main": "./bin/chromeflow.mjs",