chromeflow 0.12.2 → 0.12.4

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 +388 -167
  2. package/package.json +1 -1
@@ -2988,7 +2988,7 @@ var require_compile = __commonJS({
2988
2988
  const schOrFunc = root.refs[ref];
2989
2989
  if (schOrFunc)
2990
2990
  return schOrFunc;
2991
- let _sch = resolve2.call(this, root, ref);
2991
+ let _sch = resolve3.call(this, root, ref);
2992
2992
  if (_sch === void 0) {
2993
2993
  const schema = (_a = root.localRefs) === null || _a === void 0 ? void 0 : _a[ref];
2994
2994
  const { schemaId } = this.opts;
@@ -3015,7 +3015,7 @@ var require_compile = __commonJS({
3015
3015
  function sameSchemaEnv(s1, s2) {
3016
3016
  return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
3017
3017
  }
3018
- function resolve2(root, ref) {
3018
+ function resolve3(root, ref) {
3019
3019
  let sch;
3020
3020
  while (typeof (sch = this.refs[ref]) == "string")
3021
3021
  ref = sch;
@@ -3590,55 +3590,55 @@ var require_fast_uri = __commonJS({
3590
3590
  }
3591
3591
  return uri;
3592
3592
  }
3593
- function resolve2(baseURI, relativeURI, options) {
3593
+ function resolve3(baseURI, relativeURI, options) {
3594
3594
  const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
3595
3595
  const resolved = resolveComponent(parse3(baseURI, schemelessOptions), parse3(relativeURI, schemelessOptions), schemelessOptions, true);
3596
3596
  schemelessOptions.skipEscape = true;
3597
3597
  return serialize(resolved, schemelessOptions);
3598
3598
  }
3599
- function resolveComponent(base, relative2, options, skipNormalization) {
3599
+ function resolveComponent(base, relative3, options, skipNormalization) {
3600
3600
  const target = {};
3601
3601
  if (!skipNormalization) {
3602
3602
  base = parse3(serialize(base, options), options);
3603
- relative2 = parse3(serialize(relative2, options), options);
3603
+ relative3 = parse3(serialize(relative3, options), options);
3604
3604
  }
3605
3605
  options = options || {};
3606
- if (!options.tolerant && relative2.scheme) {
3607
- target.scheme = relative2.scheme;
3608
- target.userinfo = relative2.userinfo;
3609
- target.host = relative2.host;
3610
- target.port = relative2.port;
3611
- target.path = removeDotSegments(relative2.path || "");
3612
- target.query = relative2.query;
3606
+ if (!options.tolerant && relative3.scheme) {
3607
+ target.scheme = relative3.scheme;
3608
+ target.userinfo = relative3.userinfo;
3609
+ target.host = relative3.host;
3610
+ target.port = relative3.port;
3611
+ target.path = removeDotSegments(relative3.path || "");
3612
+ target.query = relative3.query;
3613
3613
  } else {
3614
- if (relative2.userinfo !== void 0 || relative2.host !== void 0 || relative2.port !== void 0) {
3615
- target.userinfo = relative2.userinfo;
3616
- target.host = relative2.host;
3617
- target.port = relative2.port;
3618
- target.path = removeDotSegments(relative2.path || "");
3619
- target.query = relative2.query;
3614
+ if (relative3.userinfo !== void 0 || relative3.host !== void 0 || relative3.port !== void 0) {
3615
+ target.userinfo = relative3.userinfo;
3616
+ target.host = relative3.host;
3617
+ target.port = relative3.port;
3618
+ target.path = removeDotSegments(relative3.path || "");
3619
+ target.query = relative3.query;
3620
3620
  } else {
3621
- if (!relative2.path) {
3621
+ if (!relative3.path) {
3622
3622
  target.path = base.path;
3623
- if (relative2.query !== void 0) {
3624
- target.query = relative2.query;
3623
+ if (relative3.query !== void 0) {
3624
+ target.query = relative3.query;
3625
3625
  } else {
3626
3626
  target.query = base.query;
3627
3627
  }
3628
3628
  } else {
3629
- if (relative2.path[0] === "/") {
3630
- target.path = removeDotSegments(relative2.path);
3629
+ if (relative3.path[0] === "/") {
3630
+ target.path = removeDotSegments(relative3.path);
3631
3631
  } else {
3632
3632
  if ((base.userinfo !== void 0 || base.host !== void 0 || base.port !== void 0) && !base.path) {
3633
- target.path = "/" + relative2.path;
3633
+ target.path = "/" + relative3.path;
3634
3634
  } else if (!base.path) {
3635
- target.path = relative2.path;
3635
+ target.path = relative3.path;
3636
3636
  } else {
3637
- target.path = base.path.slice(0, base.path.lastIndexOf("/") + 1) + relative2.path;
3637
+ target.path = base.path.slice(0, base.path.lastIndexOf("/") + 1) + relative3.path;
3638
3638
  }
3639
3639
  target.path = removeDotSegments(target.path);
3640
3640
  }
3641
- target.query = relative2.query;
3641
+ target.query = relative3.query;
3642
3642
  }
3643
3643
  target.userinfo = base.userinfo;
3644
3644
  target.host = base.host;
@@ -3646,7 +3646,7 @@ var require_fast_uri = __commonJS({
3646
3646
  }
3647
3647
  target.scheme = base.scheme;
3648
3648
  }
3649
- target.fragment = relative2.fragment;
3649
+ target.fragment = relative3.fragment;
3650
3650
  return target;
3651
3651
  }
3652
3652
  function equal(uriA, uriB, options) {
@@ -3817,7 +3817,7 @@ var require_fast_uri = __commonJS({
3817
3817
  var fastUri = {
3818
3818
  SCHEMES,
3819
3819
  normalize,
3820
- resolve: resolve2,
3820
+ resolve: resolve3,
3821
3821
  resolveComponent,
3822
3822
  equal,
3823
3823
  serialize,
@@ -22495,7 +22495,7 @@ var Protocol = class {
22495
22495
  return;
22496
22496
  }
22497
22497
  const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3;
22498
- await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
22498
+ await new Promise((resolve3) => setTimeout(resolve3, pollInterval));
22499
22499
  options?.signal?.throwIfAborted();
22500
22500
  }
22501
22501
  } catch (error2) {
@@ -22512,7 +22512,7 @@ var Protocol = class {
22512
22512
  */
22513
22513
  request(request, resultSchema, options) {
22514
22514
  const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {};
22515
- return new Promise((resolve2, reject) => {
22515
+ return new Promise((resolve3, reject) => {
22516
22516
  const earlyReject = (error2) => {
22517
22517
  reject(error2);
22518
22518
  };
@@ -22590,7 +22590,7 @@ var Protocol = class {
22590
22590
  if (!parseResult.success) {
22591
22591
  reject(parseResult.error);
22592
22592
  } else {
22593
- resolve2(parseResult.data);
22593
+ resolve3(parseResult.data);
22594
22594
  }
22595
22595
  } catch (error2) {
22596
22596
  reject(error2);
@@ -22851,12 +22851,12 @@ var Protocol = class {
22851
22851
  }
22852
22852
  } catch {
22853
22853
  }
22854
- return new Promise((resolve2, reject) => {
22854
+ return new Promise((resolve3, reject) => {
22855
22855
  if (signal.aborted) {
22856
22856
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
22857
22857
  return;
22858
22858
  }
22859
- const timeoutId = setTimeout(resolve2, interval);
22859
+ const timeoutId = setTimeout(resolve3, interval);
22860
22860
  signal.addEventListener("abort", () => {
22861
22861
  clearTimeout(timeoutId);
22862
22862
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
@@ -23956,7 +23956,7 @@ var McpServer = class {
23956
23956
  let task = createTaskResult.task;
23957
23957
  const pollInterval = task.pollInterval ?? 5e3;
23958
23958
  while (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") {
23959
- await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
23959
+ await new Promise((resolve3) => setTimeout(resolve3, pollInterval));
23960
23960
  const updatedTask = await extra.taskStore.getTask(taskId);
23961
23961
  if (!updatedTask) {
23962
23962
  throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`);
@@ -24599,12 +24599,12 @@ var StdioServerTransport = class {
24599
24599
  this.onclose?.();
24600
24600
  }
24601
24601
  send(message) {
24602
- return new Promise((resolve2) => {
24602
+ return new Promise((resolve3) => {
24603
24603
  const json = serializeMessage(message);
24604
24604
  if (this._stdout.write(json)) {
24605
- resolve2();
24605
+ resolve3();
24606
24606
  } else {
24607
- this._stdout.once("drain", resolve2);
24607
+ this._stdout.once("drain", resolve3);
24608
24608
  }
24609
24609
  });
24610
24610
  }
@@ -24725,7 +24725,7 @@ var WsBridge = class {
24725
24725
  }
24726
24726
  }
24727
24727
  const requestId = crypto.randomUUID();
24728
- return new Promise((resolve2, reject) => {
24728
+ return new Promise((resolve3, reject) => {
24729
24729
  let lastProgressAt = Date.now();
24730
24730
  const fire = () => {
24731
24731
  this.pending.delete(requestId);
@@ -24738,7 +24738,7 @@ var WsBridge = class {
24738
24738
  timer = setTimeout(fire, timeoutMs);
24739
24739
  };
24740
24740
  this.pending.set(requestId, {
24741
- resolve: resolve2,
24741
+ resolve: resolve3,
24742
24742
  reject,
24743
24743
  timer,
24744
24744
  refresh
@@ -24761,9 +24761,11 @@ import { homedir } from "node:os";
24761
24761
  import { join, dirname } from "node:path";
24762
24762
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
24763
24763
  var PROMOTE_AT_SUCCESS = 2;
24764
+ var DEMOTE_AT_FAILS = 1;
24764
24765
  var PRUNE_AT_FAILS = 2;
24765
24766
  var PROVISIONAL_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
24766
- var ATOM_KEYS = ["tool", "target", "selector", "recovered_via", "signal", "fragile", "reason"];
24767
+ var MAX_PROVISIONAL_PER_ORIGIN = 20;
24768
+ var ATOM_KEYS = ["tool", "target", "selector", "recovered_via", "signal", "verification", "clear_first", "fragile", "reason"];
24767
24769
  function originKey(url) {
24768
24770
  if (!url) return void 0;
24769
24771
  try {
@@ -24779,6 +24781,9 @@ var FRAGILE_RE = /:nth-(of-type|child)\(|>\s*\w+:nth/;
24779
24781
  function signatureOf(steps) {
24780
24782
  return JSON.stringify(steps.map((a) => [a.tool, a.target, a.selector ?? ""]));
24781
24783
  }
24784
+ function flowCost(f) {
24785
+ return f.steps.reduce((c, s) => c + 1 + (s.fragile ? 1 : 0) + (s.recovered_via ? 0.5 : 0), 0);
24786
+ }
24782
24787
  function sanitizeAtom(a) {
24783
24788
  const out = {};
24784
24789
  for (const k of ATOM_KEYS) {
@@ -24795,6 +24800,27 @@ function autoLabel(k, steps) {
24795
24800
  }
24796
24801
  return `auto: ${tools} @ ${path2}`;
24797
24802
  }
24803
+ function actionableVia(recovered_via) {
24804
+ if (recovered_via && recovered_via.includes("fiber")) return "fiber";
24805
+ return null;
24806
+ }
24807
+ function renderStep(s, i) {
24808
+ const fragNote = s.fragile ? " \u26A0fragile \u2014 if it misses on the first try, do NOT retry it; rediscover" : "";
24809
+ if (s.tool === "type_text") {
24810
+ const sel = s.selector ?? s.target;
24811
+ const cf = s.clear_first ? ", clear_first=true" : "";
24812
+ return ` ${i + 1}. type_text(into_selector=${JSON.stringify(sel)}${cf})${fragNote}`;
24813
+ }
24814
+ if (s.tool === "click_element") {
24815
+ const isSel = s.target.startsWith("selector=");
24816
+ const targ = isSel ? `selector=${JSON.stringify(s.selector ?? s.target.replace(/^selector=/, ""))}` : `textHint=${JSON.stringify(s.target)}`;
24817
+ const via = actionableVia(s.recovered_via);
24818
+ const viaStr = via ? `, via=${JSON.stringify(via)}` : "";
24819
+ const ver = s.verification ? `, ${s.verification}` : s.signal === "navigated" || s.signal === "until_url_change" ? ", until_url_changes=true" : "";
24820
+ return ` ${i + 1}. click_element(${targ}${viaStr}${ver})${fragNote}`;
24821
+ }
24822
+ return ` ${i + 1}. ${s.tool} ${s.target}${s.fragile ? " \u26A0fragile" : ""}`;
24823
+ }
24798
24824
  var FlowStore = class {
24799
24825
  path;
24800
24826
  data;
@@ -24807,6 +24833,10 @@ var FlowStore = class {
24807
24833
  // origins whose recall hint already fired this session
24808
24834
  recalled = /* @__PURE__ */ new Set();
24809
24835
  // origins whose trusted flow was actually shown this session
24836
+ recalledFlows = /* @__PURE__ */ new Map();
24837
+ // the flows we showed, for mismatch/confirm attribution
24838
+ dinged = /* @__PURE__ */ new Set();
24839
+ // flow ids already failed this session (no double-count)
24810
24840
  lastOrigin;
24811
24841
  lastAutosaved = null;
24812
24842
  // most recent autosave, for save_flow to vouch for
@@ -24861,6 +24891,21 @@ var FlowStore = class {
24861
24891
  }
24862
24892
  if (changed) this.persist();
24863
24893
  }
24894
+ /**
24895
+ * Record a failed/mismatched replay against a flow: demote on first, prune on
24896
+ * second (via pruneExpired's PRUNE_AT_FAILS check). `dedupeKey` collapses the
24897
+ * repeated mismatch check (which runs on every observe) to one ding per flow
24898
+ * per session; explicit tool failures pass no key so each real failure counts.
24899
+ */
24900
+ failFlow(f, dedupeKey) {
24901
+ if (dedupeKey) {
24902
+ if (this.dinged.has(dedupeKey)) return;
24903
+ this.dinged.add(dedupeKey);
24904
+ }
24905
+ f.fail_count += 1;
24906
+ f.last_replay_ok = false;
24907
+ if (f.tier === "trusted" && f.fail_count >= DEMOTE_AT_FAILS) f.tier = "provisional";
24908
+ }
24864
24909
  /**
24865
24910
  * Update the "current origin". Crossing to a DIFFERENT origin first autosaves
24866
24911
  * the origin we are leaving — that boundary is our best server-side proxy for
@@ -24879,22 +24924,77 @@ var FlowStore = class {
24879
24924
  if (!atom) return;
24880
24925
  const k = originKey(url) ?? this.lastOrigin;
24881
24926
  if (!k) return;
24927
+ this.reconcileAgainstRecalled(k, atom);
24882
24928
  const list = this.buffer.get(k) ?? [];
24883
24929
  const sig = `${atom.tool}|${atom.target}|${atom.selector ?? ""}`;
24884
24930
  if (list.some((a) => `${a.tool}|${a.target}|${a.selector ?? ""}` === sig)) return;
24885
24931
  list.push(sanitizeAtom(atom));
24886
24932
  this.buffer.set(k, list);
24887
24933
  }
24934
+ /**
24935
+ * When the agent performs a notable step on an origin we recalled a flow for,
24936
+ * compare it to the recalled steps of the same tool:
24937
+ * - same locator -> the recalled step worked: mark the flow's replay OK.
24938
+ * - different locator (and the recalled one was never used) -> the stored
24939
+ * selector was wrong; the agent silently rediscovered -> fail the flow.
24940
+ * This catches the "wrong element but technically succeeded" case that the
24941
+ * explicit failure signal misses, and is what neutralises a drifted Reddit-style
24942
+ * shadow-DOM selector before it costs another session.
24943
+ */
24944
+ reconcileAgainstRecalled(k, atom) {
24945
+ const shown = this.recalledFlows.get(k);
24946
+ if (!shown) return;
24947
+ const atomLoc = atom.selector ?? atom.target;
24948
+ let changed = false;
24949
+ for (const f of shown) {
24950
+ const sameTool = f.steps.filter((s) => s.tool === atom.tool);
24951
+ if (sameTool.length === 0) continue;
24952
+ const usedRecalled = sameTool.some((s) => (s.selector ?? s.target) === atomLoc || s.target === atom.target);
24953
+ if (usedRecalled) {
24954
+ if (f.last_replay_ok !== true) {
24955
+ f.last_replay_ok = true;
24956
+ changed = true;
24957
+ }
24958
+ } else {
24959
+ const before = f.fail_count;
24960
+ this.failFlow(f, `mismatch:${f.id}`);
24961
+ if (f.fail_count !== before) changed = true;
24962
+ }
24963
+ }
24964
+ if (changed) this.persist();
24965
+ }
24888
24966
  /** Autosave a single origin's buffer as a provisional flow (or promote a match). */
24889
24967
  autoCommit(k) {
24890
24968
  const buf = this.buffer.get(k);
24891
24969
  if (!buf || buf.length === 0) return;
24892
24970
  this.buffer.delete(k);
24893
24971
  this.upsert(k, buf, null);
24972
+ if (buf.length > 1) {
24973
+ for (const atom of buf) this.upsert(k, [atom], null);
24974
+ }
24894
24975
  this.lastAutosaved = { key: k, sig: signatureOf(buf) };
24976
+ this.capProvisional(k);
24895
24977
  this.pruneExpired();
24896
24978
  this.persist();
24897
24979
  }
24980
+ /**
24981
+ * Backstop against unbounded growth on high-cardinality pages. A search-results
24982
+ * or feed page produces a fresh per-instance selector on every action (a person's
24983
+ * name, a post id), so each autosave is a distinct signature that can never dedup,
24984
+ * never recur, and never promote — it just piles up one dead provisional per action.
24985
+ * The notability gate already screens most of these out; this caps whatever slips
24986
+ * through. Trusted flows are never evicted; only the oldest provisional overflow is.
24987
+ */
24988
+ capProvisional(k) {
24989
+ const flows = this.data.origins[k];
24990
+ if (!flows) return;
24991
+ const provisional = flows.filter((f) => f.tier === "provisional");
24992
+ if (provisional.length <= MAX_PROVISIONAL_PER_ORIGIN) return;
24993
+ const doomed = new Set(
24994
+ [...provisional].sort((a, b) => Date.parse(a.last_verified) - Date.parse(b.last_verified)).slice(0, provisional.length - MAX_PROVISIONAL_PER_ORIGIN)
24995
+ );
24996
+ this.data.origins[k] = flows.filter((f) => !doomed.has(f));
24997
+ }
24898
24998
  /** Flush every buffered origin. Call on shutdown and for single-origin sessions. */
24899
24999
  flushAll() {
24900
25000
  for (const k of [...this.buffer.keys()]) this.autoCommit(k);
@@ -24912,11 +25012,12 @@ var FlowStore = class {
24912
25012
  if (existing) {
24913
25013
  existing.success_count += 1;
24914
25014
  existing.last_verified = now;
25015
+ existing.last_replay_ok = true;
24915
25016
  existing.chromeflow_version = this.version;
24916
25017
  if (label !== null) {
24917
25018
  existing.tier = "trusted";
24918
25019
  existing.task_label = label;
24919
- } else if (existing.success_count >= PROMOTE_AT_SUCCESS) {
25020
+ } else if (existing.success_count - existing.fail_count >= PROMOTE_AT_SUCCESS) {
24920
25021
  existing.tier = "trusted";
24921
25022
  }
24922
25023
  } else {
@@ -24929,41 +25030,45 @@ var FlowStore = class {
24929
25030
  last_verified: now,
24930
25031
  success_count: 1,
24931
25032
  fail_count: 0,
25033
+ last_replay_ok: true,
24932
25034
  chromeflow_version: this.version
24933
25035
  });
24934
25036
  }
24935
25037
  this.data.origins[k] = flows;
24936
25038
  return { saved: steps.length };
24937
25039
  }
24938
- /** Compact recall hint for an origin (TRUSTED flows only), at most once per origin per session. */
25040
+ /** Compact recall hint for an origin (RELIABLE trusted flows only), once per origin per session. */
24939
25041
  recallHint(url) {
24940
25042
  const k = originKey(url);
24941
25043
  if (!k || this.surfaced.has(k)) return "";
24942
- const flows = (this.data.origins[k] ?? []).filter((f) => f.tier === "trusted");
25044
+ const flows = (this.data.origins[k] ?? []).filter(
25045
+ (f) => f.tier === "trusted" && f.success_count > f.fail_count && f.last_replay_ok !== false
25046
+ );
24943
25047
  if (flows.length === 0) return "";
24944
25048
  this.surfaced.add(k);
24945
25049
  this.recalled.add(k);
24946
- const best = [...flows].sort((a, b) => b.success_count - a.success_count).slice(0, 3);
25050
+ const best = [...flows].sort((a, b) => {
25051
+ const ca = flowCost(a), cb = flowCost(b);
25052
+ if (ca !== cb) return ca - cb;
25053
+ return b.success_count - a.success_count;
25054
+ }).slice(0, 3);
25055
+ this.recalledFlows.set(k, best);
24947
25056
  const lines = best.map((f) => {
24948
- const steps = f.steps.map((s, i) => {
24949
- const via = s.recovered_via ? ` [via ${s.recovered_via}]` : "";
24950
- const sig = s.signal ? ` (${s.signal})` : "";
24951
- const frag = s.fragile ? " \u26A0fragile-selector" : "";
24952
- return ` ${i + 1}. ${s.tool} ${s.target}${sig}${via}${frag}`;
24953
- }).join("\n");
25057
+ const steps = f.steps.map((s, i) => renderStep(s, i)).join("\n");
24954
25058
  const stale = f.chromeflow_version !== this.version ? ` recorded on v${f.chromeflow_version}, re-verify` : "";
24955
25059
  return ` "${f.task_label}" (${f.steps.length} steps, ${f.success_count}x ok${stale}):
24956
25060
  ${steps}`;
24957
25061
  });
24958
25062
  return `
24959
25063
 
24960
- \u2139 known_flow for ${k} \u2014 prefer these proven steps over rediscovery (verify each as usual):
25064
+ \u2139 known_flow for ${k} \u2014 these calls worked before; prefer them over rediscovery, but VERIFY each. If a recalled step fails or its element isn't found on the first attempt, do NOT retry it \u2014 discard the hint and rediscover from scratch.
24961
25065
  ${lines.join("\n")}`;
24962
25066
  }
24963
25067
  /**
24964
- * A recalled trusted step failed on replay. Raise its fail_count; drop the flow
24965
- * once it crosses PRUNE_AT_FAILS. Gated on the flow having actually been recalled
24966
- * this session, so an unrelated failure can't ding a flow the agent never used.
25068
+ * A recalled step failed on replay (a click that returned success:false, or a
25069
+ * type_text that did not land). Demote the matching flow on the first miss,
25070
+ * prune on the second. Gated on the flow having actually been recalled this
25071
+ * session, so an unrelated failure can't ding a flow the agent never used.
24967
25072
  */
24968
25073
  observeFailure(url, selectorOrText) {
24969
25074
  const k = originKey(url) ?? this.lastOrigin;
@@ -24972,10 +25077,11 @@ ${lines.join("\n")}`;
24972
25077
  if (!flows) return;
24973
25078
  let changed = false;
24974
25079
  for (const f of flows) {
24975
- if (f.tier !== "trusted") continue;
24976
- const hit = f.steps.some((s) => s.selector === selectorOrText || s.target === selectorOrText || s.target === `selector=${selectorOrText}`);
25080
+ const hit = f.steps.some(
25081
+ (s) => s.selector === selectorOrText || s.target === selectorOrText || s.target === `selector=${selectorOrText}`
25082
+ );
24977
25083
  if (hit) {
24978
- f.fail_count += 1;
25084
+ this.failFlow(f);
24979
25085
  changed = true;
24980
25086
  }
24981
25087
  }
@@ -25029,15 +25135,10 @@ ${lines.join("\n")}`;
25029
25135
  }
25030
25136
  };
25031
25137
  function isFragileSelector(selector) {
25032
- return !!selector && FRAGILE_RE.test(selector);
25138
+ if (!selector) return false;
25139
+ return FRAGILE_RE.test(selector) || selector.includes(",");
25033
25140
  }
25034
25141
 
25035
- // packages/mcp-server/src/tools/browser.ts
25036
- import { writeFileSync as writeFileSync2, copyFileSync, readFileSync as readFileSync2 } from "fs";
25037
- import { tmpdir, homedir as homedir2 } from "os";
25038
- import { join as join2 } from "path";
25039
- import { execSync } from "child_process";
25040
-
25041
25142
  // packages/mcp-server/src/policy.ts
25042
25143
  function isBlockedUrl(rawUrl) {
25043
25144
  let parsed;
@@ -25063,8 +25164,8 @@ function isBlockedUrl(rawUrl) {
25063
25164
  return { blocked: false };
25064
25165
  }
25065
25166
 
25066
- // packages/mcp-server/src/tools/browser.ts
25067
- function registerBrowserTools(server, bridge, flowStore) {
25167
+ // packages/mcp-server/src/tools/browser/navigation.ts
25168
+ function registerNavigationTools(server, bridge, flowStore) {
25068
25169
  server.tool(
25069
25170
  "open_page",
25070
25171
  `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.
@@ -25118,6 +25219,38 @@ After tabs.onUpdated fires status=complete, chromeflow also runs a 6s settle che
25118
25219
  return { content: [{ type: "text", text }] };
25119
25220
  }
25120
25221
  );
25222
+ server.tool(
25223
+ "inspect_request_headers",
25224
+ `Capture the request headers Chrome sends to a URL \u2014 useful for diagnosing server-side bot detection. Returns method, URL, and all headers. Cookie values are redacted by default to avoid leaking session tokens into the agent context; pass redact_cookies: false to see them. By default opens a background tab for the inspection so your active tab keeps its scroll position and form state \u2014 set new_tab: false to use the active tab instead.`,
25225
+ {
25226
+ url: external_exports.string().url().describe("URL to navigate to and capture headers for"),
25227
+ redact_cookies: external_exports.boolean().optional().describe("Replace each cookie's value with [REDACTED]. Default true. Set false only when you genuinely need the cookie content for debugging."),
25228
+ new_tab: external_exports.boolean().optional().describe("Open the inspection in a background tab and close it when done. Default true (preserves the active tab's state). Set false to use the active tab \u2014 the active tab WILL navigate.")
25229
+ },
25230
+ async ({ url, redact_cookies = true, new_tab = true }) => {
25231
+ const block = isBlockedUrl(url);
25232
+ if (block.blocked) {
25233
+ return { content: [{ type: "text", text: `inspect_request_headers refused: ${block.reason}` }] };
25234
+ }
25235
+ const response = await bridge.request({ type: "inspect_request_headers", url, new_tab }, 3e4);
25236
+ const r = response;
25237
+ let text = r.message ?? "(no headers captured)";
25238
+ if (redact_cookies) {
25239
+ text = text.replace(/^(cookie:\s*)(.+)$/gim, (_m, prefix, body) => {
25240
+ const pairs = String(body).split(";").map((s) => s.trim()).filter(Boolean);
25241
+ const names = pairs.map((p) => p.split("=")[0]);
25242
+ return `${prefix}[REDACTED \u2014 ${pairs.length} cookies: ${names.join(", ")}]`;
25243
+ });
25244
+ }
25245
+ return {
25246
+ content: [{ type: "text", text }]
25247
+ };
25248
+ }
25249
+ );
25250
+ }
25251
+
25252
+ // packages/mcp-server/src/tools/browser/tabs.ts
25253
+ function registerTabTools(server, bridge, flowStore) {
25121
25254
  server.tool(
25122
25255
  "switch_to_tab",
25123
25256
  `Switch the active tab to a different open tab. Use this after open_page(new_tab=true) to switch back to the original tab, or to jump between tabs.
@@ -25201,6 +25334,40 @@ ${keptList}` }]
25201
25334
  };
25202
25335
  }
25203
25336
  );
25337
+ }
25338
+
25339
+ // packages/mcp-server/src/tools/browser/snapshot.ts
25340
+ function registerSnapshotTools(server, bridge) {
25341
+ server.tool(
25342
+ "interactive_snapshot",
25343
+ `Compact, accessibility-style list of the page's ACTIONABLE elements \u2014 each as [role] name \u2014 selector. Use this INSTEAD of get_page_text or take_screenshot when your goal is to ACT (click / type / select), not to read prose: it is far cheaper in tokens than dumping page text, and every line gives a ready-to-use selector for click_element / type_text. Pierces open AND closed shadow roots (Reddit faceplate-*, Radix/Stencil/Lit), which a raw accessibility tree misses. Returns the top elements by document order; pass max to widen. For reading article/body text, still use get_page_text.`,
25344
+ {
25345
+ max: external_exports.number().int().min(1).optional().describe("Max elements to return (default 60).")
25346
+ },
25347
+ async ({ max }) => {
25348
+ let response;
25349
+ try {
25350
+ response = await bridge.request({ type: "interactive_snapshot", max });
25351
+ } catch {
25352
+ return { content: [{ type: "text", text: "interactive_snapshot is unavailable (reload/update the chromeflow extension). Fall back to get_page_text + find_text for now." }] };
25353
+ }
25354
+ const items = response.items ?? [];
25355
+ if (items.length === 0) {
25356
+ return { content: [{ type: "text", text: "No actionable elements found (page may render inside a cross-origin iframe, or content is non-interactive)." }] };
25357
+ }
25358
+ const lines = items.map((it, i) => `${i + 1}. [${it.role}]${it.name ? " " + it.name : ""} \u2014 ${it.selector}`);
25359
+ return { content: [{ type: "text", text: `Actionable elements (${items.length}):
25360
+ ${lines.join("\n")}` }] };
25361
+ }
25362
+ );
25363
+ }
25364
+
25365
+ // packages/mcp-server/src/tools/browser/screenshot.ts
25366
+ import { writeFileSync as writeFileSync2, copyFileSync, readFileSync as readFileSync2 } from "fs";
25367
+ import { tmpdir, homedir as homedir2 } from "os";
25368
+ import { join as join2 } from "path";
25369
+ import { execSync } from "child_process";
25370
+ function registerScreenshotTools(server, bridge) {
25204
25371
  server.tool(
25205
25372
  "take_screenshot",
25206
25373
  `Capture a screenshot of the active tab. By default the image is returned to the agent inline UNLESS it exceeds ~500KB base64, in which case it's saved to a temp file and the path is returned instead (preserves the agent's context window). Set inline="always" to force inline regardless of size, or inline="never" to always write to a file. Set save_to or copy_to_clipboard to also share the image with the user. Reserved for cases where DOM lookup has already failed \u2014 use get_page_text and find_text for reading content.
@@ -25334,6 +25501,10 @@ The saved file path can be passed directly to set_file_input(hint, file_path) to
25334
25501
  };
25335
25502
  }
25336
25503
  );
25504
+ }
25505
+
25506
+ // packages/mcp-server/src/tools/browser/forms.ts
25507
+ function registerFormFieldTools(server, bridge) {
25337
25508
  server.tool(
25338
25509
  "get_form_fields",
25339
25510
  `Inventory form fields on the active page (inputs, textareas, selects, CodeMirror editors). Sorted top-to-bottom by y-position; includes fields below the fold.
@@ -25393,6 +25564,10 @@ To fill: fill_input("${r2.fields[0].label}", "<value>")` }] };
25393
25564
  ${lines.join("\n")}${r.warning ?? ""}${captchaLine}${oauthLine}` }] };
25394
25565
  }
25395
25566
  );
25567
+ }
25568
+
25569
+ // packages/mcp-server/src/tools/browser/typing.ts
25570
+ function registerTypingTools(server, bridge, flowStore) {
25396
25571
  server.tool(
25397
25572
  "type_text",
25398
25573
  `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.
@@ -25417,23 +25592,32 @@ ${lines.join("\n")}${r.warning ?? ""}${captchaLine}${oauthLine}` }] };
25417
25592
  timeoutMs
25418
25593
  );
25419
25594
  const r = response;
25595
+ const typeFailed = r.success === false || r.landed === false;
25596
+ const locator = r.resolved_selector || into_selector;
25420
25597
  let capturable = "";
25421
- if (into_selector && r.success !== false) {
25598
+ if (into_selector && !typeFailed) {
25422
25599
  flowStore.observe({
25423
25600
  tool: "type_text",
25424
- target: into_selector,
25425
- selector: into_selector,
25601
+ target: locator,
25602
+ selector: locator,
25426
25603
  signal: clear_first ? "type_text(clear_first)" : "type_text",
25427
- fragile: isFragileSelector(into_selector),
25604
+ clear_first: clear_first || void 0,
25605
+ fragile: isFragileSelector(locator),
25428
25606
  reason: "field needs real keystrokes (type_text, not fill_input)"
25429
25607
  });
25430
25608
  capturable = flowStore.capturableHint(void 0);
25609
+ } else if (into_selector && typeFailed) {
25610
+ flowStore.observeFailure(void 0, locator);
25431
25611
  }
25432
25612
  return {
25433
25613
  content: [{ type: "text", text: (r.message ?? (r.success ? "Text typed successfully" : "Failed to type text")) + capturable }]
25434
25614
  };
25435
25615
  }
25436
25616
  );
25617
+ }
25618
+
25619
+ // packages/mcp-server/src/tools/browser/files.ts
25620
+ function registerFileInputTools(server, bridge) {
25437
25621
  server.tool(
25438
25622
  "set_file_input",
25439
25623
  `Upload a file to a file input \u2014 works even when the input is hidden behind a custom drag-and-drop zone. Returns success=true only after an observable commit (file count goes up, input gets reset, or verify_selector appears within wait_ms). See CLAUDE.md for batch-upload guidance.
@@ -25489,6 +25673,10 @@ Provide file_path OR file_content, not both.`,
25489
25673
  };
25490
25674
  }
25491
25675
  );
25676
+ }
25677
+
25678
+ // packages/mcp-server/src/tools/browser/scripting.ts
25679
+ function registerScriptingTools(server, bridge) {
25492
25680
  server.tool(
25493
25681
  "execute_script",
25494
25682
  `Execute JavaScript in a tab's MAIN world (the page's own context, not the extension's isolated world). Use for reading framework state or DOM properties not visible in text \u2014 prefer get_page_text for visible content. Top-level \`return\` and \`await\` are supported.
@@ -25537,34 +25725,18 @@ PAGE ALERT: "${alert}" \u2014 the page showed a dialog with this message. Read i
25537
25725
  };
25538
25726
  }
25539
25727
  );
25540
- server.tool(
25541
- "inspect_request_headers",
25542
- `Capture the request headers Chrome sends to a URL \u2014 useful for diagnosing server-side bot detection. Returns method, URL, and all headers. Cookie values are redacted by default to avoid leaking session tokens into the agent context; pass redact_cookies: false to see them. By default opens a background tab for the inspection so your active tab keeps its scroll position and form state \u2014 set new_tab: false to use the active tab instead.`,
25543
- {
25544
- url: external_exports.string().url().describe("URL to navigate to and capture headers for"),
25545
- redact_cookies: external_exports.boolean().optional().describe("Replace each cookie's value with [REDACTED]. Default true. Set false only when you genuinely need the cookie content for debugging."),
25546
- new_tab: external_exports.boolean().optional().describe("Open the inspection in a background tab and close it when done. Default true (preserves the active tab's state). Set false to use the active tab \u2014 the active tab WILL navigate.")
25547
- },
25548
- async ({ url, redact_cookies = true, new_tab = true }) => {
25549
- const block = isBlockedUrl(url);
25550
- if (block.blocked) {
25551
- return { content: [{ type: "text", text: `inspect_request_headers refused: ${block.reason}` }] };
25552
- }
25553
- const response = await bridge.request({ type: "inspect_request_headers", url, new_tab }, 3e4);
25554
- const r = response;
25555
- let text = r.message ?? "(no headers captured)";
25556
- if (redact_cookies) {
25557
- text = text.replace(/^(cookie:\s*)(.+)$/gim, (_m, prefix, body) => {
25558
- const pairs = String(body).split(";").map((s) => s.trim()).filter(Boolean);
25559
- const names = pairs.map((p) => p.split("=")[0]);
25560
- return `${prefix}[REDACTED \u2014 ${pairs.length} cookies: ${names.join(", ")}]`;
25561
- });
25562
- }
25563
- return {
25564
- content: [{ type: "text", text }]
25565
- };
25566
- }
25567
- );
25728
+ }
25729
+
25730
+ // packages/mcp-server/src/tools/browser.ts
25731
+ function registerBrowserTools(server, bridge, flowStore) {
25732
+ registerNavigationTools(server, bridge, flowStore);
25733
+ registerTabTools(server, bridge, flowStore);
25734
+ registerSnapshotTools(server, bridge);
25735
+ registerScreenshotTools(server, bridge);
25736
+ registerFormFieldTools(server, bridge);
25737
+ registerTypingTools(server, bridge, flowStore);
25738
+ registerFileInputTools(server, bridge);
25739
+ registerScriptingTools(server, bridge);
25568
25740
  }
25569
25741
 
25570
25742
  // packages/mcp-server/src/tools/highlight.ts
@@ -25609,10 +25781,8 @@ Returns whether the element was found. Set valueToType only when the user must p
25609
25781
  );
25610
25782
  }
25611
25783
 
25612
- // packages/mcp-server/src/tools/capture.ts
25613
- import { appendFileSync, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
25614
- import { resolve, relative, isAbsolute, dirname as dirname2 } from "path";
25615
- function registerCaptureTools(server, bridge) {
25784
+ // packages/mcp-server/src/tools/capture/input.ts
25785
+ function registerInputTools(server, bridge) {
25616
25786
  server.tool(
25617
25787
  "fill_input",
25618
25788
  `Fill a form input by visible label / placeholder / aria-label (\`textHint\`) OR by direct CSS selector (\`selector\`). Pass exactly one.
@@ -25668,6 +25838,10 @@ Or pass selector="<css>" instead of textHint to bypass fuzzy matching entirely.`
25668
25838
  };
25669
25839
  }
25670
25840
  );
25841
+ }
25842
+
25843
+ // packages/mcp-server/src/tools/capture/extract.ts
25844
+ function registerExtractTools(server, bridge) {
25671
25845
  server.tool(
25672
25846
  "get_page_text",
25673
25847
  `Get the visible text content of the current page without taking a screenshot.
@@ -25770,6 +25944,12 @@ Pass level="error" to see only errors, or omit to see all levels.`,
25770
25944
  ${lines.join("\n")}` }] };
25771
25945
  }
25772
25946
  );
25947
+ }
25948
+
25949
+ // packages/mcp-server/src/tools/capture/files.ts
25950
+ import { appendFileSync, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
25951
+ import { resolve, relative, isAbsolute } from "path";
25952
+ function registerFileTools(server, bridge) {
25773
25953
  server.tool(
25774
25954
  "write_to_env",
25775
25955
  "Write a key=value pair to a .env file. Use this after capturing an API key or ID from the page.",
@@ -25884,6 +26064,12 @@ Size: ${r.size} bytes`
25884
26064
  };
25885
26065
  }
25886
26066
  );
26067
+ }
26068
+
26069
+ // packages/mcp-server/src/tools/capture/fetch.ts
26070
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4 } from "fs";
26071
+ import { resolve as resolve2, relative as relative2, isAbsolute as isAbsolute2, dirname as dirname2 } from "path";
26072
+ function registerFetchTools(server, bridge) {
25887
26073
  server.tool(
25888
26074
  "fetch_url",
25889
26075
  `Make an HTTP request to a URL from the extension's privileged context, bypassing the page's Content-Security-Policy.
@@ -25934,16 +26120,16 @@ Set binary=true for non-text responses (PDFs, images, zips) \u2014 the body is r
25934
26120
  \u26A0 anti_bot_detected: "${r.anti_bot_detected}" \u2014 response body matches a known block / challenge page. Don't parse as the expected JSON/HTML; the user's IP may be challenged or the endpoint may require a real browser context.` : "";
25935
26121
  if (to_file) {
25936
26122
  const cwd = process.cwd();
25937
- const resolved = isAbsolute(to_file) ? to_file : resolve(cwd, to_file);
25938
- const rel = relative(cwd, resolved);
25939
- if (rel.startsWith("..") || isAbsolute(rel)) {
26123
+ const resolved = isAbsolute2(to_file) ? to_file : resolve2(cwd, to_file);
26124
+ const rel = relative2(cwd, resolved);
26125
+ if (rel.startsWith("..") || isAbsolute2(rel)) {
25940
26126
  throw new Error(
25941
26127
  `Refusing to write fetch_url body outside the project directory. Target "${resolved}" is not under "${cwd}".`
25942
26128
  );
25943
26129
  }
25944
26130
  mkdirSync2(dirname2(resolved), { recursive: true });
25945
26131
  const buf = r.body_base64 ? Buffer.from(r.body_base64, "base64") : Buffer.from(r.body_text ?? "", "utf-8");
25946
- writeFileSync3(resolved, buf);
26132
+ writeFileSync4(resolved, buf);
25947
26133
  const hdrLines = Object.keys(r.headers).sort().map((k) => ` ${k}: ${r.headers[k]}`).join("\n");
25948
26134
  return {
25949
26135
  content: [{
@@ -25971,8 +26157,16 @@ ${r.body_text}` : "";
25971
26157
  );
25972
26158
  }
25973
26159
 
25974
- // packages/mcp-server/src/tools/flow.ts
25975
- function registerFlowTools(server, bridge, flowStore) {
26160
+ // packages/mcp-server/src/tools/capture.ts
26161
+ function registerCaptureTools(server, bridge) {
26162
+ registerInputTools(server, bridge);
26163
+ registerExtractTools(server, bridge);
26164
+ registerFileTools(server, bridge);
26165
+ registerFetchTools(server, bridge);
26166
+ }
26167
+
26168
+ // packages/mcp-server/src/tools/flow/click.ts
26169
+ function registerClickTools(server, bridge, flowStore) {
25976
26170
  server.tool(
25977
26171
  "click_element",
25978
26172
  `Click an interactive element by its visible text/aria-label (textHint) OR by direct CSS selector (selector). Pass exactly one.
@@ -26075,15 +26269,17 @@ Current URL: ${activeTab.url}`;
26075
26269
  const actionUrl = r.before_url ?? r.after_url;
26076
26270
  const nowUrl = r.after_url ?? r.before_url;
26077
26271
  const usedUntil = !!(until_selector || until_url_contains || until_text_contains || until_url_changes);
26078
- if (r.success && (r.recovered_via || r.navigated || usedUntil)) {
26272
+ const verification = until_url_changes ? "until_url_changes=true" : until_selector ? `until_selector=${JSON.stringify(until_selector)}` : until_url_contains ? `until_url_contains=${JSON.stringify(until_url_contains)}` : until_text_contains ? `until_text_contains=${JSON.stringify(until_text_contains)}` : expect_submit ? "expect_submit=true" : void 0;
26273
+ if (r.success && (r.recovered_via || r.navigated)) {
26079
26274
  flowStore.observe({
26080
26275
  tool: "click_element",
26081
26276
  target: textHint ?? `selector=${selector}`,
26082
26277
  selector,
26083
26278
  recovered_via: r.recovered_via,
26084
26279
  signal: r.navigated ? "navigated" : until_url_changes ? "until_url_change" : usedUntil ? "until_*" : r.recovered_via,
26280
+ verification,
26085
26281
  fragile: isFragileSelector(selector),
26086
- reason: r.recovered_via ? `click recovered via ${r.recovered_via}` : r.navigated ? "navigating submit/link" : "verified terminal click"
26282
+ reason: r.recovered_via ? `click recovered via ${r.recovered_via}` : "navigating submit/link"
26087
26283
  }, actionUrl);
26088
26284
  }
26089
26285
  flowStore.noteUrl(nowUrl);
@@ -26105,6 +26301,33 @@ Current URL: ${activeTab.url}`;
26105
26301
  };
26106
26302
  }
26107
26303
  );
26304
+ server.tool(
26305
+ "click_at_coordinates",
26306
+ `Dispatch a real CDP mouse click at viewport (x, y). The only way to interact with cross-origin iframes \u2014 \`click_element\` refuses cross-origin frames because \`find_text\` can't enter them, but a CDP-level mouse event resolves at the renderer process and reaches the iframe's content the way an OS-level click does.
26307
+
26308
+ Coordinates are viewport CSS pixels, NOT screen coordinates. \`list_frames\` reports each iframe at \`(x, y, width, height)\` in this same space, so to click 50px in / 80px down inside an iframe: \`click_at_coordinates(frame.x + 50, frame.y + 80)\`.
26309
+
26310
+ Runs the same humanlike sequence as \`click_element\` (bezier approach path, settle-hover micro-tremor, press, release, post-click micro-move) so behavioural fingerprinters can't distinguish the call from any other chromeflow click. Skips the activity probe \u2014 cross-origin iframe activity isn't observable from the parent.
26311
+
26312
+ Refuses obviously-bad coordinates (negative, > 10000). Use this only when DOM matching has failed and you have a known target position from \`list_frames\` or a screenshot.`,
26313
+ {
26314
+ x: external_exports.number().describe("Viewport CSS X coordinate (left=0). Get from list_frames or a screenshot grid."),
26315
+ y: external_exports.number().describe("Viewport CSS Y coordinate (top=0). Get from list_frames or a screenshot grid."),
26316
+ button: external_exports.enum(["left", "right", "middle"]).optional().describe('Mouse button (default "left").'),
26317
+ double: external_exports.boolean().optional().describe("Fire a double-click instead of a single click. Default false.")
26318
+ },
26319
+ async ({ x, y, button, double }) => {
26320
+ const response = await bridge.request({ type: "click_at_coordinates", x, y, button, double });
26321
+ const r = response;
26322
+ const navLine = r.navigated && r.after_url ? `
26323
+ \u2192 Navigated: ${r.after_url}` : "";
26324
+ return { content: [{ type: "text", text: `${r.message}${navLine}` }] };
26325
+ }
26326
+ );
26327
+ }
26328
+
26329
+ // packages/mcp-server/src/tools/flow/save.ts
26330
+ function registerSaveFlowTools(server, flowStore) {
26108
26331
  server.tool(
26109
26332
  "save_flow",
26110
26333
  `Trust the hard-won interaction steps chromeflow buffered for the current site, immediately, as a named flow. chromeflow auto-buffers only NOTABLE resolutions (a click that needed a fallback, a verified submit, a field that needed real keystrokes), and AUTOSAVES them as a provisional flow when you leave the site \u2014 so memory works even if you never call this. Provisional flows are not recalled until they have been independently re-observed, or until you vouch for them here. Calling save_flow promotes the buffered steps to TRUSTED right away (an explicit "I confirm this worked"), so they are recalled next session instead of waiting to earn it.
@@ -26118,6 +26341,10 @@ Call this when a response shows \`flow_capturable\` and you are confident the ta
26118
26341
  return { content: [{ type: "text", text: res.message }] };
26119
26342
  }
26120
26343
  );
26344
+ }
26345
+
26346
+ // packages/mcp-server/src/tools/flow/wait.ts
26347
+ function registerWaitTools(server, bridge) {
26121
26348
  server.tool(
26122
26349
  "wait_for_click",
26123
26350
  `Wait for the user to click (or interact with) the currently highlighted element, then return.
@@ -26160,29 +26387,6 @@ CDP re-dispatched: isTrusted=true click at (${r.target?.x ?? 0}, ${r.target?.y ?
26160
26387
  };
26161
26388
  }
26162
26389
  );
26163
- server.tool(
26164
- "click_at_coordinates",
26165
- `Dispatch a real CDP mouse click at viewport (x, y). The only way to interact with cross-origin iframes \u2014 \`click_element\` refuses cross-origin frames because \`find_text\` can't enter them, but a CDP-level mouse event resolves at the renderer process and reaches the iframe's content the way an OS-level click does.
26166
-
26167
- Coordinates are viewport CSS pixels, NOT screen coordinates. \`list_frames\` reports each iframe at \`(x, y, width, height)\` in this same space, so to click 50px in / 80px down inside an iframe: \`click_at_coordinates(frame.x + 50, frame.y + 80)\`.
26168
-
26169
- Runs the same humanlike sequence as \`click_element\` (bezier approach path, settle-hover micro-tremor, press, release, post-click micro-move) so behavioural fingerprinters can't distinguish the call from any other chromeflow click. Skips the activity probe \u2014 cross-origin iframe activity isn't observable from the parent.
26170
-
26171
- Refuses obviously-bad coordinates (negative, > 10000). Use this only when DOM matching has failed and you have a known target position from \`list_frames\` or a screenshot.`,
26172
- {
26173
- x: external_exports.number().describe("Viewport CSS X coordinate (left=0). Get from list_frames or a screenshot grid."),
26174
- y: external_exports.number().describe("Viewport CSS Y coordinate (top=0). Get from list_frames or a screenshot grid."),
26175
- button: external_exports.enum(["left", "right", "middle"]).optional().describe('Mouse button (default "left").'),
26176
- double: external_exports.boolean().optional().describe("Fire a double-click instead of a single click. Default false.")
26177
- },
26178
- async ({ x, y, button, double }) => {
26179
- const response = await bridge.request({ type: "click_at_coordinates", x, y, button, double });
26180
- const r = response;
26181
- const navLine = r.navigated && r.after_url ? `
26182
- \u2192 Navigated: ${r.after_url}` : "";
26183
- return { content: [{ type: "text", text: `${r.message}${navLine}` }] };
26184
- }
26185
- );
26186
26390
  server.tool(
26187
26391
  "wait_for",
26188
26392
  `Wait for one of: a CSS selector to appear, a text substring (or any of an array of substrings) to appear, or an existing element's subtree to mutate. Pass exactly one of \`selector\`, \`text\`, or \`change_in\`. Pierces open AND closed shadow roots (text \`scope_selector\` pierces too). Pass \`shadow_root: true\` when waiting for the host's shadowRoot to attach (post-SPA-navigation hydration). \`scope_selector\` limits text-mode search; \`regex: true\` interprets text as a case-insensitive regex; \`frame: "iframe.selector"\` waits inside a same-origin iframe (text mode).
@@ -26272,6 +26476,10 @@ Examples: scroll_to_element("#submit-btn"), scroll_to_element("Billing address")
26272
26476
  return { content: [{ type: "text", text: msg }] };
26273
26477
  }
26274
26478
  );
26479
+ }
26480
+
26481
+ // packages/mcp-server/src/tools/flow/find.ts
26482
+ function registerFindTools(server, bridge) {
26275
26483
  server.tool(
26276
26484
  "find_text",
26277
26485
  `Search the active page for text and return actionable matches (text, surrounding context, best-effort CSS selector, clickable flag). Use this instead of get_page_text when checking "is X on the page?" or locating a clickable target. Pierces open AND closed shadow roots. Pass \`frame: "iframe.selector"\` for same-origin iframe search.
@@ -26340,38 +26548,6 @@ ${lines.join("\n")}` }]
26340
26548
  };
26341
26549
  }
26342
26550
  );
26343
- server.tool(
26344
- "fill_form",
26345
- `Fill multiple form fields in a single call by targeting each field by its label text.
26346
- Use this instead of calling fill_input repeatedly \u2014 it fills all fields in one round trip and returns a per-field success report.
26347
- Ideal for forms with many textareas or inputs where each fill would otherwise require a separate tool call.
26348
- fields is an array of {label, value} pairs. label should match the field's visible label, placeholder, or aria-label.
26349
-
26350
- Each per-field result includes the matched element description (e.g. \`<input name="title" id="..." placeholder="...">\`) so Claude can spot when fill_form picked the wrong field.
26351
-
26352
- Pass \`exact: true\` for forms with short generic labels (like "Rate" or "Amount") that may collide with similarly-labeled neighbours \u2014 fields without an exact aria-label/placeholder/name/id/label-text match will return success=false instead of silently filling the wrong field.`,
26353
- {
26354
- fields: external_exports.array(
26355
- external_exports.object({
26356
- label: external_exports.string().describe("Visible label, placeholder, or aria-label of the field"),
26357
- value: external_exports.string().describe("Value to fill in")
26358
- })
26359
- ).describe("List of fields to fill"),
26360
- exact: external_exports.boolean().optional().describe("If true, refuse fuzzy text-walk matches for every field. Default false.")
26361
- },
26362
- async ({ fields, exact }) => {
26363
- const response = await bridge.request({ type: "fill_form", fields, exact });
26364
- const r = response;
26365
- const lines = r.results.map((f) => `${f.success ? "\u2713" : "\u2717"} "${f.label}": ${f.message}`);
26366
- return {
26367
- content: [{
26368
- type: "text",
26369
- text: `Filled ${r.succeeded}/${r.total} fields:
26370
- ${lines.join("\n")}`
26371
- }]
26372
- };
26373
- }
26374
- );
26375
26551
  server.tool(
26376
26552
  "list_frames",
26377
26553
  `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).
@@ -26430,8 +26606,53 @@ ${lines.join("\n")}${shadowSection}` }] };
26430
26606
  );
26431
26607
  }
26432
26608
 
26609
+ // packages/mcp-server/src/tools/flow/forms.ts
26610
+ function registerFillFormTools(server, bridge) {
26611
+ server.tool(
26612
+ "fill_form",
26613
+ `Fill multiple form fields in a single call by targeting each field by its label text.
26614
+ Use this instead of calling fill_input repeatedly \u2014 it fills all fields in one round trip and returns a per-field success report.
26615
+ Ideal for forms with many textareas or inputs where each fill would otherwise require a separate tool call.
26616
+ fields is an array of {label, value} pairs. label should match the field's visible label, placeholder, or aria-label.
26617
+
26618
+ Each per-field result includes the matched element description (e.g. \`<input name="title" id="..." placeholder="...">\`) so Claude can spot when fill_form picked the wrong field.
26619
+
26620
+ Pass \`exact: true\` for forms with short generic labels (like "Rate" or "Amount") that may collide with similarly-labeled neighbours \u2014 fields without an exact aria-label/placeholder/name/id/label-text match will return success=false instead of silently filling the wrong field.`,
26621
+ {
26622
+ fields: external_exports.array(
26623
+ external_exports.object({
26624
+ label: external_exports.string().describe("Visible label, placeholder, or aria-label of the field"),
26625
+ value: external_exports.string().describe("Value to fill in")
26626
+ })
26627
+ ).describe("List of fields to fill"),
26628
+ exact: external_exports.boolean().optional().describe("If true, refuse fuzzy text-walk matches for every field. Default false.")
26629
+ },
26630
+ async ({ fields, exact }) => {
26631
+ const response = await bridge.request({ type: "fill_form", fields, exact });
26632
+ const r = response;
26633
+ const lines = r.results.map((f) => `${f.success ? "\u2713" : "\u2717"} "${f.label}": ${f.message}`);
26634
+ return {
26635
+ content: [{
26636
+ type: "text",
26637
+ text: `Filled ${r.succeeded}/${r.total} fields:
26638
+ ${lines.join("\n")}`
26639
+ }]
26640
+ };
26641
+ }
26642
+ );
26643
+ }
26644
+
26645
+ // packages/mcp-server/src/tools/flow.ts
26646
+ function registerFlowTools(server, bridge, flowStore) {
26647
+ registerClickTools(server, bridge, flowStore);
26648
+ registerSaveFlowTools(server, flowStore);
26649
+ registerWaitTools(server, bridge);
26650
+ registerFindTools(server, bridge);
26651
+ registerFillFormTools(server, bridge);
26652
+ }
26653
+
26433
26654
  // packages/mcp-server/src/index.ts
26434
- var PACKAGE_VERSION = true ? "0.12.2" : "dev";
26655
+ var PACKAGE_VERSION = true ? "0.12.4" : "dev";
26435
26656
  main().catch((err) => {
26436
26657
  console.error("[chromeflow] Fatal error:", err);
26437
26658
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chromeflow",
3
- "version": "0.12.2",
3
+ "version": "0.12.4",
4
4
  "description": "MCP server for chromeflow \u2014 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",