chromeflow 0.10.23 → 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 +211 -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;
@@ -24958,6 +25103,7 @@ Refuses fast on pages that are in fullscreen mode (captureVisibleTab hangs there
24958
25103
  execSync(`osascript -e 'set the clipboard to (read (POSIX file "${tmpPath}") as \xABclass PNGf\xBB)'`);
24959
25104
  notes.push("Copied to clipboard");
24960
25105
  } catch {
25106
+ notes.push("Clipboard copy failed (macOS-only feature)");
24961
25107
  }
24962
25108
  }
24963
25109
  const r = response;
@@ -24989,7 +25135,7 @@ The saved file path can be passed directly to set_file_input(hint, file_path) to
24989
25135
  async ({ save_to = "downloads" }) => {
24990
25136
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
24991
25137
  const filename = `terminal-${timestamp}.png`;
24992
- 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);
24993
25139
  let captured = false;
24994
25140
  try {
24995
25141
  const bounds = execSync(`osascript -e '
@@ -25024,7 +25170,7 @@ The saved file path can be passed directly to set_file_input(hint, file_path) to
25024
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." }]
25025
25171
  };
25026
25172
  }
25027
- const imageBuffer = readFileSync(savePath);
25173
+ const imageBuffer = readFileSync2(savePath);
25028
25174
  const base642 = imageBuffer.toString("base64");
25029
25175
  let clipboardNote = "";
25030
25176
  try {
@@ -25134,8 +25280,20 @@ ${lines.join("\n")}${r.warning ?? ""}${captchaLine}${oauthLine}` }] };
25134
25280
  timeoutMs
25135
25281
  );
25136
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
+ }
25137
25295
  return {
25138
- 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 }]
25139
25297
  };
25140
25298
  }
25141
25299
  );
@@ -25281,8 +25439,8 @@ Returns whether the element was found. Set valueToType only when the user must p
25281
25439
  }
25282
25440
 
25283
25441
  // packages/mcp-server/src/tools/capture.ts
25284
- import { appendFileSync, mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
25285
- 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";
25286
25444
  function registerCaptureTools(server, bridge) {
25287
25445
  server.tool(
25288
25446
  "fill_input",
@@ -25470,7 +25628,7 @@ ${lines.join("\n")}` }] };
25470
25628
  envPath = resolved;
25471
25629
  let existing = "";
25472
25630
  try {
25473
- existing = readFileSync2(envPath, "utf-8");
25631
+ existing = readFileSync3(envPath, "utf-8");
25474
25632
  } catch {
25475
25633
  }
25476
25634
  const lines = existing.split("\n");
@@ -25478,7 +25636,7 @@ ${lines.join("\n")}` }] };
25478
25636
  const existingIndex = lines.findIndex((l) => keyPattern.test(l));
25479
25637
  if (existingIndex !== -1) {
25480
25638
  lines[existingIndex] = `${key}=${value}`;
25481
- writeFileSync2(envPath, lines.join("\n"), "utf-8");
25639
+ writeFileSync3(envPath, lines.join("\n"), "utf-8");
25482
25640
  } else {
25483
25641
  const toAppend = (existing && !existing.endsWith("\n") ? "\n" : "") + `${key}=${value}
25484
25642
  `;
@@ -25612,9 +25770,9 @@ Set binary=true for non-text responses (PDFs, images, zips) \u2014 the body is r
25612
25770
  `Refusing to write fetch_url body outside the project directory. Target "${resolved}" is not under "${cwd}".`
25613
25771
  );
25614
25772
  }
25615
- mkdirSync(dirname(resolved), { recursive: true });
25773
+ mkdirSync2(dirname2(resolved), { recursive: true });
25616
25774
  const buf = r.body_base64 ? Buffer.from(r.body_base64, "base64") : Buffer.from(r.body_text ?? "", "utf-8");
25617
- writeFileSync2(resolved, buf);
25775
+ writeFileSync3(resolved, buf);
25618
25776
  const hdrLines = Object.keys(r.headers).sort().map((k) => ` ${k}: ${r.headers[k]}`).join("\n");
25619
25777
  return {
25620
25778
  content: [{
@@ -25643,7 +25801,7 @@ ${r.body_text}` : "";
25643
25801
  }
25644
25802
 
25645
25803
  // packages/mcp-server/src/tools/flow.ts
25646
- function registerFlowTools(server, bridge) {
25804
+ function registerFlowTools(server, bridge, flowStore) {
25647
25805
  server.tool(
25648
25806
  "click_element",
25649
25807
  `Click an interactive element by its visible text/aria-label (textHint) OR by direct CSS selector (selector). Pass exactly one.
@@ -25743,21 +25901,51 @@ Current URL: ${activeTab.url}`;
25743
25901
  focusLine = `
25744
25902
  \u2192 Focused: <${f.tag}${idBit}${nameBit}${aria}${valueBit}>`;
25745
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);
25746
25921
  if (!r.success) {
25747
25922
  return {
25748
25923
  content: [
25749
25924
  {
25750
25925
  type: "text",
25751
- text: `Could not click "${targetLabel}": ${r.message}${navLine}${focusLine}`
25926
+ text: `Could not click "${targetLabel}": ${r.message}${navLine}${focusLine}${recall}`
25752
25927
  }
25753
25928
  ]
25754
25929
  };
25755
25930
  }
25756
25931
  return {
25757
- content: [{ type: "text", text: `${r.message}${navLine}${focusLine}` }]
25932
+ content: [{ type: "text", text: `${r.message}${navLine}${focusLine}${recall}${capturable}` }]
25758
25933
  };
25759
25934
  }
25760
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
+ );
25761
25949
  server.tool(
25762
25950
  "wait_for_click",
25763
25951
  `Wait for the user to click (or interact with) the currently highlighted element, then return.
@@ -26071,21 +26259,22 @@ ${lines.join("\n")}${shadowSection}` }] };
26071
26259
  }
26072
26260
 
26073
26261
  // packages/mcp-server/src/index.ts
26074
- var PACKAGE_VERSION = true ? "0.10.23" : "dev";
26262
+ var PACKAGE_VERSION = true ? "0.10.25" : "dev";
26075
26263
  main().catch((err) => {
26076
26264
  console.error("[chromeflow] Fatal error:", err);
26077
26265
  process.exit(1);
26078
26266
  });
26079
26267
  async function main() {
26080
26268
  const bridge = new WsBridge();
26269
+ const flowStore = new FlowStore(PACKAGE_VERSION);
26081
26270
  const server = new McpServer({
26082
26271
  name: "chromeflow",
26083
26272
  version: PACKAGE_VERSION
26084
26273
  });
26085
- registerBrowserTools(server, bridge);
26274
+ registerBrowserTools(server, bridge, flowStore);
26086
26275
  registerHighlightTools(server, bridge);
26087
26276
  registerCaptureTools(server, bridge);
26088
- registerFlowTools(server, bridge);
26277
+ registerFlowTools(server, bridge, flowStore);
26089
26278
  const registered = server._registeredTools ?? {};
26090
26279
  const toolNames = Object.keys(registered).sort();
26091
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.23",
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",