chromeflow 0.10.24 → 0.10.25

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 +210 -22
  2. package/package.json +1 -1
@@ -24755,10 +24755,153 @@ var WsBridge = class {
24755
24755
  }
24756
24756
  };
24757
24757
 
24758
+ // packages/mcp-server/src/flow-store.ts
24759
+ import { homedir } from "node:os";
24760
+ import { join, dirname } from "node:path";
24761
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
24762
+ function originKey(url) {
24763
+ if (!url) return void 0;
24764
+ try {
24765
+ const u = new URL(url);
24766
+ if (u.protocol !== "http:" && u.protocol !== "https:") return void 0;
24767
+ const path2 = u.pathname && u.pathname !== "/" ? u.pathname.replace(/\/+$/, "") : "";
24768
+ return u.origin + path2;
24769
+ } catch {
24770
+ return void 0;
24771
+ }
24772
+ }
24773
+ var FRAGILE_RE = /:nth-(of-type|child)\(|>\s*\w+:nth/;
24774
+ var FlowStore = class {
24775
+ path;
24776
+ data;
24777
+ version;
24778
+ // In-memory, per-session state (never persisted):
24779
+ buffer = /* @__PURE__ */ new Map();
24780
+ // notable atoms not yet committed, by origin
24781
+ surfaced = /* @__PURE__ */ new Set();
24782
+ // origins whose recall hint already fired this session
24783
+ lastOrigin;
24784
+ constructor(version2, baseDir) {
24785
+ this.version = version2;
24786
+ this.path = join(baseDir ?? join(homedir(), ".chromeflow"), "flows.json");
24787
+ this.data = this.load();
24788
+ }
24789
+ load() {
24790
+ try {
24791
+ if (existsSync(this.path)) {
24792
+ const parsed = JSON.parse(readFileSync(this.path, "utf-8"));
24793
+ if (parsed && parsed.version === 1 && parsed.origins) return parsed;
24794
+ }
24795
+ } catch {
24796
+ try {
24797
+ renameSync(this.path, this.path + ".corrupt");
24798
+ } catch {
24799
+ }
24800
+ }
24801
+ return { version: 1, origins: {} };
24802
+ }
24803
+ persist() {
24804
+ try {
24805
+ mkdirSync(dirname(this.path), { recursive: true });
24806
+ const tmp = this.path + ".tmp";
24807
+ writeFileSync(tmp, JSON.stringify(this.data, null, 2), "utf-8");
24808
+ renameSync(tmp, this.path);
24809
+ } catch {
24810
+ }
24811
+ }
24812
+ /** Update the "current origin" from any URL chromeflow observed. */
24813
+ noteUrl(url) {
24814
+ const k = originKey(url);
24815
+ if (k) this.lastOrigin = k;
24816
+ }
24817
+ /** Buffer a notable atom against an origin (defaults to last-seen origin). */
24818
+ observe(atom, url) {
24819
+ if (!atom) return;
24820
+ const k = originKey(url) ?? this.lastOrigin;
24821
+ if (!k) return;
24822
+ const list = this.buffer.get(k) ?? [];
24823
+ const sig = `${atom.tool}|${atom.target}|${atom.selector ?? ""}`;
24824
+ if (list.some((a) => `${a.tool}|${a.target}|${a.selector ?? ""}` === sig)) return;
24825
+ list.push(atom);
24826
+ this.buffer.set(k, list);
24827
+ }
24828
+ /** Compact recall hint for an origin, at most once per origin per session. */
24829
+ recallHint(url) {
24830
+ const k = originKey(url);
24831
+ if (!k || this.surfaced.has(k)) return "";
24832
+ const flows = this.data.origins[k];
24833
+ if (!flows || flows.length === 0) return "";
24834
+ this.surfaced.add(k);
24835
+ const best = [...flows].sort((a, b) => b.success_count - a.success_count).slice(0, 3);
24836
+ const lines = best.map((f) => {
24837
+ const steps = f.steps.map((s, i) => {
24838
+ const via = s.recovered_via ? ` [via ${s.recovered_via}]` : "";
24839
+ const sig = s.signal ? ` (${s.signal})` : "";
24840
+ const frag = s.fragile ? " \u26A0fragile-selector" : "";
24841
+ return ` ${i + 1}. ${s.tool} ${s.target}${sig}${via}${frag}`;
24842
+ }).join("\n");
24843
+ const stale = f.chromeflow_version !== this.version ? ` recorded on v${f.chromeflow_version}, re-verify` : "";
24844
+ return ` "${f.task_label}" (${f.steps.length} steps, ${f.success_count}x ok${stale}):
24845
+ ${steps}`;
24846
+ });
24847
+ return `
24848
+
24849
+ \u2139 known_flow for ${k} \u2014 prefer these proven steps over rediscovery (verify each as usual):
24850
+ ${lines.join("\n")}`;
24851
+ }
24852
+ /** Nudge to save buffered hard-won steps, when there are uncommitted ones. */
24853
+ capturableHint(url) {
24854
+ const k = originKey(url) ?? this.lastOrigin;
24855
+ if (!k) return "";
24856
+ const buf = this.buffer.get(k);
24857
+ if (!buf || buf.length === 0) return "";
24858
+ const reasons = [...new Set(buf.map((a) => a.reason))].slice(0, 2).join("; ");
24859
+ return `
24860
+
24861
+ \u2139 flow_capturable: ${buf.length} hard-won step(s) on ${k} not yet saved (${reasons}). Call save_flow("<task label>") to persist them so future runs skip the trial-and-error.`;
24862
+ }
24863
+ /** Commit the buffered atoms for an origin as a named flow. */
24864
+ commit(taskLabel, url) {
24865
+ const k = originKey(url) ?? this.lastOrigin;
24866
+ if (!k) return { saved: 0, origin: null, message: "No origin known yet \u2014 navigate or interact with a page first." };
24867
+ const buf = this.buffer.get(k) ?? [];
24868
+ if (buf.length === 0) {
24869
+ return { saved: 0, origin: k, message: `Nothing notable buffered for ${k}. Flows capture hard-won steps (a fallback fired, a verified submit, a field needing real keystrokes) \u2014 an ordinary first-try click isn't recorded.` };
24870
+ }
24871
+ const now = (/* @__PURE__ */ new Date()).toISOString();
24872
+ const sig = JSON.stringify(buf.map((a) => [a.tool, a.target, a.selector ?? ""]));
24873
+ const flows = this.data.origins[k] ?? [];
24874
+ const existing = flows.find((f) => f.task_label === taskLabel && JSON.stringify(f.steps.map((a) => [a.tool, a.target, a.selector ?? ""])) === sig);
24875
+ if (existing) {
24876
+ existing.success_count += 1;
24877
+ existing.last_verified = now;
24878
+ existing.chromeflow_version = this.version;
24879
+ } else {
24880
+ flows.push({
24881
+ id: `${k}#${flows.length + 1}`,
24882
+ task_label: taskLabel,
24883
+ steps: buf,
24884
+ created_at: now,
24885
+ last_verified: now,
24886
+ success_count: 1,
24887
+ fail_count: 0,
24888
+ chromeflow_version: this.version
24889
+ });
24890
+ }
24891
+ this.data.origins[k] = flows;
24892
+ this.buffer.delete(k);
24893
+ this.persist();
24894
+ return { saved: buf.length, origin: k, message: `Saved flow "${taskLabel}" (${buf.length} steps) for ${k}.` };
24895
+ }
24896
+ };
24897
+ function isFragileSelector(selector) {
24898
+ return !!selector && FRAGILE_RE.test(selector);
24899
+ }
24900
+
24758
24901
  // packages/mcp-server/src/tools/browser.ts
24759
- import { writeFileSync, copyFileSync, readFileSync } from "fs";
24760
- import { tmpdir, homedir } from "os";
24761
- import { join } from "path";
24902
+ import { writeFileSync as writeFileSync2, copyFileSync, readFileSync as readFileSync2 } from "fs";
24903
+ import { tmpdir, homedir as homedir2 } from "os";
24904
+ import { join as join2 } from "path";
24762
24905
  import { execSync } from "child_process";
24763
24906
 
24764
24907
  // packages/mcp-server/src/policy.ts
@@ -24787,7 +24930,7 @@ function isBlockedUrl(rawUrl) {
24787
24930
  }
24788
24931
 
24789
24932
  // packages/mcp-server/src/tools/browser.ts
24790
- function registerBrowserTools(server, bridge) {
24933
+ function registerBrowserTools(server, bridge, flowStore) {
24791
24934
  server.tool(
24792
24935
  "open_page",
24793
24936
  `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.
@@ -24836,6 +24979,8 @@ After tabs.onUpdated fires status=complete, chromeflow also runs a 6s settle che
24836
24979
 
24837
24980
  \u2139 dismissed_beforeunload: true \u2014 the previous page had unsaved content (typed text in a composer, form draft, etc.) and Chrome's "Are you sure you want to leave?" dialog was auto-dismissed so navigation could proceed. If that draft was load-bearing, navigate back and re-capture before continuing.`;
24838
24981
  }
24982
+ flowStore.noteUrl(r.current_url ?? url);
24983
+ text += flowStore.recallHint(r.current_url ?? url);
24839
24984
  return { content: [{ type: "text", text }] };
24840
24985
  }
24841
24986
  );
@@ -24942,13 +25087,13 @@ Refuses fast on pages that are in fullscreen mode (captureVisibleTab hangs there
24942
25087
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
24943
25088
  const filename = `chromeflow-${timestamp}.png`;
24944
25089
  const imageBuffer = Buffer.from(response.image, "base64");
24945
- const tmpPath = join(tmpdir(), filename);
25090
+ const tmpPath = join2(tmpdir(), filename);
24946
25091
  const needTmp = !shouldInline || sharing;
24947
- if (needTmp) writeFileSync(tmpPath, imageBuffer);
25092
+ if (needTmp) writeFileSync2(tmpPath, imageBuffer);
24948
25093
  const notes = [];
24949
25094
  let landedPath = tmpPath;
24950
25095
  if (save_to !== "none") {
24951
- const savePath = save_to === "cwd" ? join(process.cwd(), filename) : join(homedir(), "Downloads", filename);
25096
+ const savePath = save_to === "cwd" ? join2(process.cwd(), filename) : join2(homedir2(), "Downloads", filename);
24952
25097
  copyFileSync(tmpPath, savePath);
24953
25098
  notes.push(`Saved to ${savePath}`);
24954
25099
  landedPath = savePath;
@@ -24990,7 +25135,7 @@ The saved file path can be passed directly to set_file_input(hint, file_path) to
24990
25135
  async ({ save_to = "downloads" }) => {
24991
25136
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
24992
25137
  const filename = `terminal-${timestamp}.png`;
24993
- const savePath = save_to === "cwd" ? join(process.cwd(), filename) : join(homedir(), "Downloads", filename);
25138
+ const savePath = save_to === "cwd" ? join2(process.cwd(), filename) : join2(homedir2(), "Downloads", filename);
24994
25139
  let captured = false;
24995
25140
  try {
24996
25141
  const bounds = execSync(`osascript -e '
@@ -25025,7 +25170,7 @@ The saved file path can be passed directly to set_file_input(hint, file_path) to
25025
25170
  content: [{ type: "text", text: "Failed to capture terminal. Ensure Screen Recording permission is granted to your terminal app in System Settings > Privacy & Security > Screen Recording." }]
25026
25171
  };
25027
25172
  }
25028
- const imageBuffer = readFileSync(savePath);
25173
+ const imageBuffer = readFileSync2(savePath);
25029
25174
  const base642 = imageBuffer.toString("base64");
25030
25175
  let clipboardNote = "";
25031
25176
  try {
@@ -25135,8 +25280,20 @@ ${lines.join("\n")}${r.warning ?? ""}${captchaLine}${oauthLine}` }] };
25135
25280
  timeoutMs
25136
25281
  );
25137
25282
  const r = response;
25283
+ let capturable = "";
25284
+ if (into_selector && r.success !== false) {
25285
+ flowStore.observe({
25286
+ tool: "type_text",
25287
+ target: into_selector,
25288
+ selector: into_selector,
25289
+ signal: clear_first ? "type_text(clear_first)" : "type_text",
25290
+ fragile: isFragileSelector(into_selector),
25291
+ reason: "field needs real keystrokes (type_text, not fill_input)"
25292
+ });
25293
+ capturable = flowStore.capturableHint(void 0);
25294
+ }
25138
25295
  return {
25139
- content: [{ type: "text", text: r.message ?? (r.success ? "Text typed successfully" : "Failed to type text") }]
25296
+ content: [{ type: "text", text: (r.message ?? (r.success ? "Text typed successfully" : "Failed to type text")) + capturable }]
25140
25297
  };
25141
25298
  }
25142
25299
  );
@@ -25282,8 +25439,8 @@ Returns whether the element was found. Set valueToType only when the user must p
25282
25439
  }
25283
25440
 
25284
25441
  // packages/mcp-server/src/tools/capture.ts
25285
- import { appendFileSync, mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
25286
- import { resolve, relative, isAbsolute, dirname } from "path";
25442
+ import { appendFileSync, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
25443
+ import { resolve, relative, isAbsolute, dirname as dirname2 } from "path";
25287
25444
  function registerCaptureTools(server, bridge) {
25288
25445
  server.tool(
25289
25446
  "fill_input",
@@ -25471,7 +25628,7 @@ ${lines.join("\n")}` }] };
25471
25628
  envPath = resolved;
25472
25629
  let existing = "";
25473
25630
  try {
25474
- existing = readFileSync2(envPath, "utf-8");
25631
+ existing = readFileSync3(envPath, "utf-8");
25475
25632
  } catch {
25476
25633
  }
25477
25634
  const lines = existing.split("\n");
@@ -25479,7 +25636,7 @@ ${lines.join("\n")}` }] };
25479
25636
  const existingIndex = lines.findIndex((l) => keyPattern.test(l));
25480
25637
  if (existingIndex !== -1) {
25481
25638
  lines[existingIndex] = `${key}=${value}`;
25482
- writeFileSync2(envPath, lines.join("\n"), "utf-8");
25639
+ writeFileSync3(envPath, lines.join("\n"), "utf-8");
25483
25640
  } else {
25484
25641
  const toAppend = (existing && !existing.endsWith("\n") ? "\n" : "") + `${key}=${value}
25485
25642
  `;
@@ -25613,9 +25770,9 @@ Set binary=true for non-text responses (PDFs, images, zips) \u2014 the body is r
25613
25770
  `Refusing to write fetch_url body outside the project directory. Target "${resolved}" is not under "${cwd}".`
25614
25771
  );
25615
25772
  }
25616
- mkdirSync(dirname(resolved), { recursive: true });
25773
+ mkdirSync2(dirname2(resolved), { recursive: true });
25617
25774
  const buf = r.body_base64 ? Buffer.from(r.body_base64, "base64") : Buffer.from(r.body_text ?? "", "utf-8");
25618
- writeFileSync2(resolved, buf);
25775
+ writeFileSync3(resolved, buf);
25619
25776
  const hdrLines = Object.keys(r.headers).sort().map((k) => ` ${k}: ${r.headers[k]}`).join("\n");
25620
25777
  return {
25621
25778
  content: [{
@@ -25644,7 +25801,7 @@ ${r.body_text}` : "";
25644
25801
  }
25645
25802
 
25646
25803
  // packages/mcp-server/src/tools/flow.ts
25647
- function registerFlowTools(server, bridge) {
25804
+ function registerFlowTools(server, bridge, flowStore) {
25648
25805
  server.tool(
25649
25806
  "click_element",
25650
25807
  `Click an interactive element by its visible text/aria-label (textHint) OR by direct CSS selector (selector). Pass exactly one.
@@ -25744,21 +25901,51 @@ Current URL: ${activeTab.url}`;
25744
25901
  focusLine = `
25745
25902
  \u2192 Focused: <${f.tag}${idBit}${nameBit}${aria}${valueBit}>`;
25746
25903
  }
25904
+ const actionUrl = r.before_url ?? r.after_url;
25905
+ const nowUrl = r.after_url ?? r.before_url;
25906
+ const usedUntil = !!(until_selector || until_url_contains || until_text_contains || until_url_changes);
25907
+ if (r.success && (r.recovered_via || r.navigated || usedUntil)) {
25908
+ flowStore.observe({
25909
+ tool: "click_element",
25910
+ target: textHint ?? `selector=${selector}`,
25911
+ selector,
25912
+ recovered_via: r.recovered_via,
25913
+ signal: r.navigated ? "navigated" : until_url_changes ? "until_url_change" : usedUntil ? "until_*" : r.recovered_via,
25914
+ fragile: isFragileSelector(selector),
25915
+ reason: r.recovered_via ? `click recovered via ${r.recovered_via}` : r.navigated ? "navigating submit/link" : "verified terminal click"
25916
+ }, actionUrl);
25917
+ }
25918
+ flowStore.noteUrl(nowUrl);
25919
+ const recall = flowStore.recallHint(nowUrl);
25920
+ const capturable = flowStore.capturableHint(actionUrl);
25747
25921
  if (!r.success) {
25748
25922
  return {
25749
25923
  content: [
25750
25924
  {
25751
25925
  type: "text",
25752
- text: `Could not click "${targetLabel}": ${r.message}${navLine}${focusLine}`
25926
+ text: `Could not click "${targetLabel}": ${r.message}${navLine}${focusLine}${recall}`
25753
25927
  }
25754
25928
  ]
25755
25929
  };
25756
25930
  }
25757
25931
  return {
25758
- content: [{ type: "text", text: `${r.message}${navLine}${focusLine}` }]
25932
+ content: [{ type: "text", text: `${r.message}${navLine}${focusLine}${recall}${capturable}` }]
25759
25933
  };
25760
25934
  }
25761
25935
  );
25936
+ server.tool(
25937
+ "save_flow",
25938
+ `Persist the hard-won interaction steps chromeflow buffered for the current site as a reusable, named flow. chromeflow auto-buffers only NOTABLE resolutions (a click that needed a fallback, a verified submit, a field that needed real keystrokes) \u2014 so you just give the task a label and it commits whatever is buffered for the current origin. Next session, those steps are surfaced back as a known_flow hint so you skip the trial-and-error.
25939
+
25940
+ Call this when a response shows \`flow_capturable\`. Stored locally only (~/.chromeflow/flows.json), selectors/signals only \u2014 never typed text. Guidance, not autopilot: recalled steps are still verified on replay.`,
25941
+ {
25942
+ task_label: external_exports.string().describe('Short human label for what this flow accomplishes, e.g. "submit text post", "set flair and submit", "log report time".')
25943
+ },
25944
+ async ({ task_label }) => {
25945
+ const res = flowStore.commit(task_label);
25946
+ return { content: [{ type: "text", text: res.message }] };
25947
+ }
25948
+ );
25762
25949
  server.tool(
25763
25950
  "wait_for_click",
25764
25951
  `Wait for the user to click (or interact with) the currently highlighted element, then return.
@@ -26072,21 +26259,22 @@ ${lines.join("\n")}${shadowSection}` }] };
26072
26259
  }
26073
26260
 
26074
26261
  // packages/mcp-server/src/index.ts
26075
- var PACKAGE_VERSION = true ? "0.10.24" : "dev";
26262
+ var PACKAGE_VERSION = true ? "0.10.25" : "dev";
26076
26263
  main().catch((err) => {
26077
26264
  console.error("[chromeflow] Fatal error:", err);
26078
26265
  process.exit(1);
26079
26266
  });
26080
26267
  async function main() {
26081
26268
  const bridge = new WsBridge();
26269
+ const flowStore = new FlowStore(PACKAGE_VERSION);
26082
26270
  const server = new McpServer({
26083
26271
  name: "chromeflow",
26084
26272
  version: PACKAGE_VERSION
26085
26273
  });
26086
- registerBrowserTools(server, bridge);
26274
+ registerBrowserTools(server, bridge, flowStore);
26087
26275
  registerHighlightTools(server, bridge);
26088
26276
  registerCaptureTools(server, bridge);
26089
- registerFlowTools(server, bridge);
26277
+ registerFlowTools(server, bridge, flowStore);
26090
26278
  const registered = server._registeredTools ?? {};
26091
26279
  const toolNames = Object.keys(registered).sort();
26092
26280
  console.error(`[chromeflow] v${PACKAGE_VERSION} \u2014 registered ${toolNames.length} tools`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chromeflow",
3
- "version": "0.10.24",
3
+ "version": "0.10.25",
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",