chromeflow 0.10.25 → 0.12.0

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 +199 -42
  2. package/package.json +2 -2
@@ -24670,7 +24670,8 @@ var WsBridge = class {
24670
24670
  return;
24671
24671
  }
24672
24672
  if (msg.type === "ready") {
24673
- console.error("[chromeflow] Extension ready");
24673
+ const token = typeof msg.token === "string" ? msg.token : void 0;
24674
+ console.error(`[chromeflow] Extension ready${token ? " (token presented)" : ""}`);
24674
24675
  const cwd = process.cwd();
24675
24676
  const host = process.env.CHROMEFLOW_HOST ?? (process.env.CLAUDE_PLUGIN_ROOT ? "claude" : void 0);
24676
24677
  ws.send(JSON.stringify({
@@ -24759,6 +24760,10 @@ var WsBridge = class {
24759
24760
  import { homedir } from "node:os";
24760
24761
  import { join, dirname } from "node:path";
24761
24762
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
24763
+ var PROMOTE_AT_SUCCESS = 2;
24764
+ var PRUNE_AT_FAILS = 2;
24765
+ var PROVISIONAL_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
24766
+ var ATOM_KEYS = ["tool", "target", "selector", "recovered_via", "signal", "fragile", "reason"];
24762
24767
  function originKey(url) {
24763
24768
  if (!url) return void 0;
24764
24769
  try {
@@ -24771,20 +24776,47 @@ function originKey(url) {
24771
24776
  }
24772
24777
  }
24773
24778
  var FRAGILE_RE = /:nth-(of-type|child)\(|>\s*\w+:nth/;
24779
+ function signatureOf(steps) {
24780
+ return JSON.stringify(steps.map((a) => [a.tool, a.target, a.selector ?? ""]));
24781
+ }
24782
+ function sanitizeAtom(a) {
24783
+ const out = {};
24784
+ for (const k of ATOM_KEYS) {
24785
+ if (a[k] !== void 0) out[k] = a[k];
24786
+ }
24787
+ return out;
24788
+ }
24789
+ function autoLabel(k, steps) {
24790
+ const tools = [...new Set(steps.map((s) => s.tool.replace("_element", "")))].join("+");
24791
+ let path2 = "/";
24792
+ try {
24793
+ path2 = new URL(k).pathname || "/";
24794
+ } catch {
24795
+ }
24796
+ return `auto: ${tools} @ ${path2}`;
24797
+ }
24774
24798
  var FlowStore = class {
24775
24799
  path;
24776
24800
  data;
24777
24801
  version;
24802
+ now;
24778
24803
  // In-memory, per-session state (never persisted):
24779
24804
  buffer = /* @__PURE__ */ new Map();
24780
24805
  // notable atoms not yet committed, by origin
24781
24806
  surfaced = /* @__PURE__ */ new Set();
24782
24807
  // origins whose recall hint already fired this session
24808
+ recalled = /* @__PURE__ */ new Set();
24809
+ // origins whose trusted flow was actually shown this session
24783
24810
  lastOrigin;
24784
- constructor(version2, baseDir) {
24811
+ constructor(version2, baseDir, now = Date.now) {
24785
24812
  this.version = version2;
24813
+ this.now = now;
24786
24814
  this.path = join(baseDir ?? join(homedir(), ".chromeflow"), "flows.json");
24787
24815
  this.data = this.load();
24816
+ this.pruneExpired();
24817
+ }
24818
+ nowIso() {
24819
+ return new Date(this.now()).toISOString();
24788
24820
  }
24789
24821
  load() {
24790
24822
  try {
@@ -24809,10 +24841,36 @@ var FlowStore = class {
24809
24841
  } catch {
24810
24842
  }
24811
24843
  }
24812
- /** Update the "current origin" from any URL chromeflow observed. */
24844
+ /** Drop expired provisional flows and any flow already past the fail ceiling. */
24845
+ pruneExpired() {
24846
+ const cutoff = this.now() - PROVISIONAL_TTL_MS;
24847
+ let changed = false;
24848
+ for (const [k, flows] of Object.entries(this.data.origins)) {
24849
+ const kept = flows.filter((f) => {
24850
+ if (f.fail_count >= PRUNE_AT_FAILS) return false;
24851
+ if (f.tier === "provisional" && Date.parse(f.last_verified) < cutoff) return false;
24852
+ return true;
24853
+ });
24854
+ if (kept.length !== flows.length) {
24855
+ changed = true;
24856
+ if (kept.length === 0) delete this.data.origins[k];
24857
+ else this.data.origins[k] = kept;
24858
+ }
24859
+ }
24860
+ if (changed) this.persist();
24861
+ }
24862
+ /**
24863
+ * Update the "current origin". Crossing to a DIFFERENT origin first autosaves
24864
+ * the origin we are leaving — that boundary is our best server-side proxy for
24865
+ * "a task on that site just finished".
24866
+ */
24813
24867
  noteUrl(url) {
24814
24868
  const k = originKey(url);
24815
- if (k) this.lastOrigin = k;
24869
+ if (!k) return;
24870
+ if (this.lastOrigin && this.lastOrigin !== k && (this.buffer.get(this.lastOrigin)?.length ?? 0) > 0) {
24871
+ this.autoCommit(this.lastOrigin);
24872
+ }
24873
+ this.lastOrigin = k;
24816
24874
  }
24817
24875
  /** Buffer a notable atom against an origin (defaults to last-seen origin). */
24818
24876
  observe(atom, url) {
@@ -24822,16 +24880,66 @@ var FlowStore = class {
24822
24880
  const list = this.buffer.get(k) ?? [];
24823
24881
  const sig = `${atom.tool}|${atom.target}|${atom.selector ?? ""}`;
24824
24882
  if (list.some((a) => `${a.tool}|${a.target}|${a.selector ?? ""}` === sig)) return;
24825
- list.push(atom);
24883
+ list.push(sanitizeAtom(atom));
24826
24884
  this.buffer.set(k, list);
24827
24885
  }
24828
- /** Compact recall hint for an origin, at most once per origin per session. */
24886
+ /** Autosave a single origin's buffer as a provisional flow (or promote a match). */
24887
+ autoCommit(k) {
24888
+ const buf = this.buffer.get(k);
24889
+ if (!buf || buf.length === 0) return;
24890
+ this.buffer.delete(k);
24891
+ this.upsert(k, buf, null);
24892
+ this.pruneExpired();
24893
+ this.persist();
24894
+ }
24895
+ /** Flush every buffered origin. Call on shutdown and for single-origin sessions. */
24896
+ flushAll() {
24897
+ for (const k of [...this.buffer.keys()]) this.autoCommit(k);
24898
+ }
24899
+ /**
24900
+ * Insert or reinforce a flow for an origin.
24901
+ * label === null → autosave: create provisional, or bump+maybe-promote a match.
24902
+ * label is string → manual save_flow: create trusted, or promote+relabel a match.
24903
+ */
24904
+ upsert(k, steps, label) {
24905
+ const now = this.nowIso();
24906
+ const sig = signatureOf(steps);
24907
+ const flows = this.data.origins[k] ?? [];
24908
+ const existing = flows.find((f) => signatureOf(f.steps) === sig);
24909
+ if (existing) {
24910
+ existing.success_count += 1;
24911
+ existing.last_verified = now;
24912
+ existing.chromeflow_version = this.version;
24913
+ if (label !== null) {
24914
+ existing.tier = "trusted";
24915
+ existing.task_label = label;
24916
+ } else if (existing.success_count >= PROMOTE_AT_SUCCESS) {
24917
+ existing.tier = "trusted";
24918
+ }
24919
+ } else {
24920
+ flows.push({
24921
+ id: `${k}#${flows.length + 1}`,
24922
+ task_label: label ?? autoLabel(k, steps),
24923
+ steps,
24924
+ tier: label !== null ? "trusted" : "provisional",
24925
+ created_at: now,
24926
+ last_verified: now,
24927
+ success_count: 1,
24928
+ fail_count: 0,
24929
+ chromeflow_version: this.version
24930
+ });
24931
+ }
24932
+ this.data.origins[k] = flows;
24933
+ return { saved: steps.length };
24934
+ }
24935
+ /** Compact recall hint for an origin (TRUSTED flows only), at most once per origin per session. */
24829
24936
  recallHint(url) {
24830
24937
  const k = originKey(url);
24831
24938
  if (!k || this.surfaced.has(k)) return "";
24832
- const flows = this.data.origins[k];
24833
- if (!flows || flows.length === 0) return "";
24939
+ const flows = (this.data.origins[k] ?? []).filter((f) => f.tier === "trusted");
24940
+ if (flows.length === 0) return "";
24834
24941
  this.surfaced.add(k);
24942
+ this.recalled.add(k);
24835
24943
  const best = [...flows].sort((a, b) => b.success_count - a.success_count).slice(0, 3);
24836
24944
  const lines = best.map((f) => {
24837
24945
  const steps = f.steps.map((s, i) => {
@@ -24849,7 +24957,31 @@ ${steps}`;
24849
24957
  \u2139 known_flow for ${k} \u2014 prefer these proven steps over rediscovery (verify each as usual):
24850
24958
  ${lines.join("\n")}`;
24851
24959
  }
24852
- /** Nudge to save buffered hard-won steps, when there are uncommitted ones. */
24960
+ /**
24961
+ * A recalled trusted step failed on replay. Raise its fail_count; drop the flow
24962
+ * once it crosses PRUNE_AT_FAILS. Gated on the flow having actually been recalled
24963
+ * this session, so an unrelated failure can't ding a flow the agent never used.
24964
+ */
24965
+ observeFailure(url, selectorOrText) {
24966
+ const k = originKey(url) ?? this.lastOrigin;
24967
+ if (!k || !selectorOrText || !this.recalled.has(k)) return;
24968
+ const flows = this.data.origins[k];
24969
+ if (!flows) return;
24970
+ let changed = false;
24971
+ for (const f of flows) {
24972
+ if (f.tier !== "trusted") continue;
24973
+ const hit = f.steps.some((s) => s.selector === selectorOrText || s.target === selectorOrText || s.target === `selector=${selectorOrText}`);
24974
+ if (hit) {
24975
+ f.fail_count += 1;
24976
+ changed = true;
24977
+ }
24978
+ }
24979
+ if (changed) {
24980
+ this.pruneExpired();
24981
+ this.persist();
24982
+ }
24983
+ }
24984
+ /** Nudge to save buffered hard-won steps — kept as the manual instant-vouch path. */
24853
24985
  capturableHint(url) {
24854
24986
  const k = originKey(url) ?? this.lastOrigin;
24855
24987
  if (!k) return "";
@@ -24858,9 +24990,9 @@ ${lines.join("\n")}`;
24858
24990
  const reasons = [...new Set(buf.map((a) => a.reason))].slice(0, 2).join("; ");
24859
24991
  return `
24860
24992
 
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.`;
24993
+ \u2139 flow_capturable: ${buf.length} hard-won step(s) on ${k} buffered (${reasons}). They autosave on leaving the site; call save_flow("<task label>") to trust them immediately.`;
24862
24994
  }
24863
- /** Commit the buffered atoms for an origin as a named flow. */
24995
+ /** Manual save_flow: commit the buffered atoms for an origin as a TRUSTED flow. */
24864
24996
  commit(taskLabel, url) {
24865
24997
  const k = originKey(url) ?? this.lastOrigin;
24866
24998
  if (!k) return { saved: 0, origin: null, message: "No origin known yet \u2014 navigate or interact with a page first." };
@@ -24868,30 +25000,16 @@ ${lines.join("\n")}`;
24868
25000
  if (buf.length === 0) {
24869
25001
  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
25002
  }
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
25003
  this.buffer.delete(k);
25004
+ const { saved } = this.upsert(k, buf, taskLabel);
25005
+ this.pruneExpired();
24893
25006
  this.persist();
24894
- return { saved: buf.length, origin: k, message: `Saved flow "${taskLabel}" (${buf.length} steps) for ${k}.` };
25007
+ return { saved, origin: k, message: `Saved flow "${taskLabel}" (${saved} steps) for ${k} \u2014 trusted.` };
25008
+ }
25009
+ /** Test-only accessor: the persisted flows for an origin. */
25010
+ _flowsFor(url) {
25011
+ const k = originKey(url) ?? url;
25012
+ return this.data.origins[k] ?? [];
24895
25013
  }
24896
25014
  };
24897
25015
  function isFragileSelector(selector) {
@@ -25299,17 +25417,51 @@ ${lines.join("\n")}${r.warning ?? ""}${captchaLine}${oauthLine}` }] };
25299
25417
  );
25300
25418
  server.tool(
25301
25419
  "set_file_input",
25302
- "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.",
25420
+ `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.
25421
+
25422
+ Two ways to supply the file:
25423
+ - file_path (CDP mode): an absolute path on the machine running this server. Reaches both open AND closed shadow roots.
25424
+ - file_content + file_name (inline-content mode): base64 file bytes plus a filename, materialized into the input directly. Use this when the server has no local disk access (e.g. a remote endpoint that can't see your filesystem). Caveat: inline-content mode reaches OPEN shadow roots only \u2014 if the input lives in a closed shadow root, use file_path instead.
25425
+
25426
+ Provide file_path OR file_content, not both.`,
25303
25427
  {
25304
25428
  hint: external_exports.string().describe("Label text, name, or surrounding text of the file input. Use empty string to target the first file input on the page."),
25305
- file_path: external_exports.string().describe("Absolute path to the file to upload (e.g. /Users/you/Downloads/task.zip)"),
25429
+ file_path: external_exports.string().optional().describe("Absolute path to the file to upload (e.g. /Users/you/Downloads/task.zip). CDP mode \u2014 reaches closed shadow roots. Provide this OR file_content."),
25430
+ file_content: external_exports.string().optional().describe("Base64-encoded file bytes (no data: prefix) for inline-content mode. Use when the server has no local disk access. Requires file_name. Reaches OPEN shadow roots only. Provide this OR file_path."),
25431
+ file_name: external_exports.string().optional().describe('Filename to present to the page in inline-content mode (e.g. "report.pdf"). Required when file_content is set.'),
25432
+ mime_type: external_exports.string().optional().describe('Optional MIME type for inline-content mode (e.g. "application/pdf"). Inferred from file_name when omitted.'),
25306
25433
  wait_ms: external_exports.number().int().min(0).optional().describe("How long to wait for an observable change after setting the file (default 3000). Increase for slow uploaders that take a moment to render thumbnails."),
25307
25434
  verify_selector: external_exports.string().optional().describe('Optional CSS selector that should appear after a successful upload (e.g. ".photo-thumbnail", "[data-uploaded=true]"). When matched, set_file_input returns success immediately.')
25308
25435
  },
25309
- async ({ hint, file_path, wait_ms, verify_selector }) => {
25436
+ async ({ hint, file_path, file_content, file_name, mime_type, wait_ms, verify_selector }) => {
25437
+ if (!file_path && !file_content) {
25438
+ return {
25439
+ content: [{ type: "text", text: "Failed to set file: provide either file_path or file_content." }]
25440
+ };
25441
+ }
25442
+ if (file_path && file_content) {
25443
+ return {
25444
+ content: [{ type: "text", text: "Failed to set file: provide file_path OR file_content, not both." }]
25445
+ };
25446
+ }
25447
+ if (file_content && !file_name) {
25448
+ return {
25449
+ content: [{ type: "text", text: "Failed to set file: file_content requires file_name." }]
25450
+ };
25451
+ }
25310
25452
  const wsTimeout = Math.max(3e4, (wait_ms ?? 3e3) + 1e4);
25311
25453
  const response = await bridge.request(
25312
- { type: "set_file_input", hint, filePath: file_path, waitMs: wait_ms, verifySelector: verify_selector },
25454
+ {
25455
+ type: "set_file_input",
25456
+ hint,
25457
+ // snake -> camel, mirroring the existing file_path -> filePath mapping.
25458
+ filePath: file_path,
25459
+ fileContent: file_content,
25460
+ fileName: file_name,
25461
+ mimeType: mime_type,
25462
+ waitMs: wait_ms,
25463
+ verifySelector: verify_selector
25464
+ },
25313
25465
  wsTimeout
25314
25466
  );
25315
25467
  const r = response;
@@ -25327,7 +25479,7 @@ ${lines.join("\n")}${r.warning ?? ""}${captchaLine}${oauthLine}` }] };
25327
25479
  **Shadow-piercing helpers are pre-injected** into every script:
25328
25480
  - \`$deep(selector, root?)\` \u2014 querySelector that walks open shadow roots
25329
25481
  - \`$deepAll(selector, root?)\` \u2014 querySelectorAll equivalent, returns an array
25330
- - \`shadowDocument\` \u2014 the open shadow root with the most interactive elements (buttons, inputs, links), or \`document\` if none. Useful when an SPA mounts ALL of its UI inside a single root shadow host (Outlier-style annotation dashboards): replace every \`document.querySelector*\` call with \`shadowDocument.querySelector*\` and the same code now reaches the SPA's content. On pages with multiple shadow roots (e.g. one for CSS theme vars, one for content), this picks the content root automatically.
25482
+ - \`shadowDocument\` \u2014 the open shadow root with the most interactive elements (buttons, inputs, links), or \`document\` if none. Useful when an SPA mounts ALL of its UI inside a single root shadow host (annotation-style dashboards that wrap the whole app in one web component): replace every \`document.querySelector*\` call with \`shadowDocument.querySelector*\` and the same code now reaches the SPA's content. On pages with multiple shadow roots (e.g. one for CSS theme vars, one for content), this picks the content root automatically.
25331
25483
  - \`shadowDocuments\` \u2014 array of ALL open shadow roots on the page (in DOM order). Use when you need to search across multiple shadow roots or when the automatic pick is wrong.
25332
25484
 
25333
25485
  The helpers pierce OPEN shadow roots only \u2014 MAIN world can't reach closed roots. For closed roots, use find_text / get_page_text / click_element / fill_input which pierce both kinds via chrome.dom.openOrClosedShadowRoot.
@@ -25843,7 +25995,7 @@ ANTI-BOT SUBMIT CEILING \u2014 synthetic clicks on social/auth platforms (Reddit
25843
25995
  try_fiber: external_exports.boolean().optional().describe(`Opt-in last-resort fallback when silently_rejected fires. After the activity probe reports zero activity, chromeflow walks the React fiber tree from the matched element (up to 12 levels), finds the nearest \`__reactProps$.onClick\` prop, and invokes it with a minimal synthetic event. Now shadow-DOM-aware: when the element is inside a shadow root, the fiber walk searches for React root containers inside that shadow root instead of walking the light DOM. Returns fiber_attempted=true in the response when the path was taken. Do NOT default to this; reserve for repeat silently_rejected on a known-safe React site.`),
25844
25996
  activity_timeout_ms: external_exports.number().int().min(500).optional().describe(`How long the activity probe watches for DOM mutations, focus changes, URL changes, or alert/toast/modal appearance after the click (default 1500ms). The probe returns early as soon as activity is detected, so the default adds only ~100ms on successful clicks. Increase to 2500-4000ms for buttons that trigger async API calls before producing visible DOM changes. The probe reports "silently_rejected" when zero activity is seen within this window; a higher value reduces false negatives but slows genuine rejection detection. For buttons where the click triggers a 3-5s API call with no immediate DOM change, use skip_activity_probe instead.`),
25845
25997
  skip_activity_probe: external_exports.boolean().optional().describe(`Skip the 1500ms activity probe AND all automatic fallbacks (tap gesture, pointer chain, DOM .click(), fiber walk) after the CDP click. The click is dispatched and success is returned immediately without verifying page activity. Use for buttons that trigger slow async API calls (3-5s+) before producing visible DOM changes, where the activity probe would falsely report "silently_rejected" and the fallback chain would double-fire. Verify state yourself via find_text or execute_script after an appropriate delay. NOTE: this is set IMPLICITLY when any until_* clause is passed, since the until-clause already provides verification. WARNING: no automatic silent-rejection detection when this is set without an until-clause.`),
25846
- via: external_exports.enum(["auto", "cdp", "fiber"]).optional().describe(`Click dispatch mode. "auto" (default): CDP click, then fiber fallback when try_fiber=true and the activity probe failed. "cdp": CDP click only, no fiber fallback ever. "fiber": skip the CDP bezier + activity probe entirely and invoke __reactProps$.onClick directly. Use "fiber" on React-heavy SPAs (Outlier-style dashboards) where you already know the site is fiber-only \u2014 cuts ~3 seconds of ceremony off the round trip. The fiber path is undocumented React internal access, prefer "auto" until you've confirmed the site needs it.`),
25998
+ via: external_exports.enum(["auto", "cdp", "fiber"]).optional().describe(`Click dispatch mode. "auto" (default): CDP click, then fiber fallback when try_fiber=true and the activity probe failed. "cdp": CDP click only, no fiber fallback ever. "fiber": skip the CDP bezier + activity probe entirely and invoke __reactProps$.onClick directly. Use "fiber" on React-heavy SPAs (fiber-only annotation dashboards) where you already know the site is fiber-only \u2014 cuts ~3 seconds of ceremony off the round trip. The fiber path is undocumented React internal access, prefer "auto" until you've confirmed the site needs it.`),
25847
25999
  in_dialog: external_exports.boolean().optional().describe(`Scope candidate matches to the topmost open dialog (\`[role=dialog]\`, \`[role=alertdialog]\`, or \`<dialog open>\`), highest z-index wins. Use when Radix/Headless UI dialogs portal to document.body and a generic textHint like "Cancel" would otherwise match the wrong button. Returns scope_missed=true when no dialog is open.`),
25848
26000
  dialog_query: external_exports.string().optional().describe(`Scope candidate matches to a specific dialog by heading or aria-label substring. Use when multiple dialogs are open and in_dialog (topmost) would pick the wrong one \u2014 e.g. click_element("Confirm", dialog_query="Delete account"). Mutually exclusive with in_dialog; dialog_query wins when both are set.`),
25849
26001
  wait_until_enabled_ms: external_exports.number().int().min(0).optional().describe(`When the matched target is currently disabled (native disabled OR aria-disabled=true), poll for up to this many ms waiting for it to become enabled before clicking. Default 0 (do not wait \u2014 return target_disabled immediately). Use 2000-5000 for Submit-style buttons that briefly disable while an async copilot/validator/save is in flight. On timeout the response carries target_disabled=true plus a structured disabled_state snapshot (disabled, aria_disabled, pointer_events, opacity, visible) so the caller can decide between "wait more" or "field is genuinely missing \u2014 run get_form_fields(only_empty:true)".`)
@@ -25919,6 +26071,7 @@ Current URL: ${activeTab.url}`;
25919
26071
  const recall = flowStore.recallHint(nowUrl);
25920
26072
  const capturable = flowStore.capturableHint(actionUrl);
25921
26073
  if (!r.success) {
26074
+ flowStore.observeFailure(actionUrl, selector ?? textHint);
25922
26075
  return {
25923
26076
  content: [
25924
26077
  {
@@ -25935,9 +26088,9 @@ Current URL: ${activeTab.url}`;
25935
26088
  );
25936
26089
  server.tool(
25937
26090
  "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.
26091
+ `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.
25939
26092
 
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.`,
26093
+ Call this when a response shows \`flow_capturable\` and you are confident the task genuinely succeeded. Stored locally only (~/.chromeflow/flows.json), selectors/signals only \u2014 never typed text. Guidance, not autopilot: recalled steps are still verified on replay.`,
25941
26094
  {
25942
26095
  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
26096
  },
@@ -26259,7 +26412,7 @@ ${lines.join("\n")}${shadowSection}` }] };
26259
26412
  }
26260
26413
 
26261
26414
  // packages/mcp-server/src/index.ts
26262
- var PACKAGE_VERSION = true ? "0.10.25" : "dev";
26415
+ var PACKAGE_VERSION = true ? "0.12.0" : "dev";
26263
26416
  main().catch((err) => {
26264
26417
  console.error("[chromeflow] Fatal error:", err);
26265
26418
  process.exit(1);
@@ -26335,6 +26488,10 @@ ${tabList}`
26335
26488
  await server.connect(transport);
26336
26489
  const exitClean = (reason) => {
26337
26490
  console.error(`[chromeflow] host disconnected (${reason}), exiting.`);
26491
+ try {
26492
+ flowStore.flushAll();
26493
+ } catch {
26494
+ }
26338
26495
  process.exit(0);
26339
26496
  };
26340
26497
  process.stdin.on("end", () => exitClean("stdin end"));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "chromeflow",
3
- "version": "0.10.25",
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.",
3
+ "version": "0.12.0",
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",
7
7
  "bin": {