chromeflow 0.10.24 → 0.11.1
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.
- package/bin/chromeflow.mjs +252 -29
- package/package.json +1 -1
package/bin/chromeflow.mjs
CHANGED
|
@@ -24670,7 +24670,8 @@ var WsBridge = class {
|
|
|
24670
24670
|
return;
|
|
24671
24671
|
}
|
|
24672
24672
|
if (msg.type === "ready") {
|
|
24673
|
-
|
|
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({
|
|
@@ -24755,10 +24756,153 @@ var WsBridge = class {
|
|
|
24755
24756
|
}
|
|
24756
24757
|
};
|
|
24757
24758
|
|
|
24759
|
+
// packages/mcp-server/src/flow-store.ts
|
|
24760
|
+
import { homedir } from "node:os";
|
|
24761
|
+
import { join, dirname } from "node:path";
|
|
24762
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
24763
|
+
function originKey(url) {
|
|
24764
|
+
if (!url) return void 0;
|
|
24765
|
+
try {
|
|
24766
|
+
const u = new URL(url);
|
|
24767
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") return void 0;
|
|
24768
|
+
const path2 = u.pathname && u.pathname !== "/" ? u.pathname.replace(/\/+$/, "") : "";
|
|
24769
|
+
return u.origin + path2;
|
|
24770
|
+
} catch {
|
|
24771
|
+
return void 0;
|
|
24772
|
+
}
|
|
24773
|
+
}
|
|
24774
|
+
var FRAGILE_RE = /:nth-(of-type|child)\(|>\s*\w+:nth/;
|
|
24775
|
+
var FlowStore = class {
|
|
24776
|
+
path;
|
|
24777
|
+
data;
|
|
24778
|
+
version;
|
|
24779
|
+
// In-memory, per-session state (never persisted):
|
|
24780
|
+
buffer = /* @__PURE__ */ new Map();
|
|
24781
|
+
// notable atoms not yet committed, by origin
|
|
24782
|
+
surfaced = /* @__PURE__ */ new Set();
|
|
24783
|
+
// origins whose recall hint already fired this session
|
|
24784
|
+
lastOrigin;
|
|
24785
|
+
constructor(version2, baseDir) {
|
|
24786
|
+
this.version = version2;
|
|
24787
|
+
this.path = join(baseDir ?? join(homedir(), ".chromeflow"), "flows.json");
|
|
24788
|
+
this.data = this.load();
|
|
24789
|
+
}
|
|
24790
|
+
load() {
|
|
24791
|
+
try {
|
|
24792
|
+
if (existsSync(this.path)) {
|
|
24793
|
+
const parsed = JSON.parse(readFileSync(this.path, "utf-8"));
|
|
24794
|
+
if (parsed && parsed.version === 1 && parsed.origins) return parsed;
|
|
24795
|
+
}
|
|
24796
|
+
} catch {
|
|
24797
|
+
try {
|
|
24798
|
+
renameSync(this.path, this.path + ".corrupt");
|
|
24799
|
+
} catch {
|
|
24800
|
+
}
|
|
24801
|
+
}
|
|
24802
|
+
return { version: 1, origins: {} };
|
|
24803
|
+
}
|
|
24804
|
+
persist() {
|
|
24805
|
+
try {
|
|
24806
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
24807
|
+
const tmp = this.path + ".tmp";
|
|
24808
|
+
writeFileSync(tmp, JSON.stringify(this.data, null, 2), "utf-8");
|
|
24809
|
+
renameSync(tmp, this.path);
|
|
24810
|
+
} catch {
|
|
24811
|
+
}
|
|
24812
|
+
}
|
|
24813
|
+
/** Update the "current origin" from any URL chromeflow observed. */
|
|
24814
|
+
noteUrl(url) {
|
|
24815
|
+
const k = originKey(url);
|
|
24816
|
+
if (k) this.lastOrigin = k;
|
|
24817
|
+
}
|
|
24818
|
+
/** Buffer a notable atom against an origin (defaults to last-seen origin). */
|
|
24819
|
+
observe(atom, url) {
|
|
24820
|
+
if (!atom) return;
|
|
24821
|
+
const k = originKey(url) ?? this.lastOrigin;
|
|
24822
|
+
if (!k) return;
|
|
24823
|
+
const list = this.buffer.get(k) ?? [];
|
|
24824
|
+
const sig = `${atom.tool}|${atom.target}|${atom.selector ?? ""}`;
|
|
24825
|
+
if (list.some((a) => `${a.tool}|${a.target}|${a.selector ?? ""}` === sig)) return;
|
|
24826
|
+
list.push(atom);
|
|
24827
|
+
this.buffer.set(k, list);
|
|
24828
|
+
}
|
|
24829
|
+
/** Compact recall hint for an origin, at most once per origin per session. */
|
|
24830
|
+
recallHint(url) {
|
|
24831
|
+
const k = originKey(url);
|
|
24832
|
+
if (!k || this.surfaced.has(k)) return "";
|
|
24833
|
+
const flows = this.data.origins[k];
|
|
24834
|
+
if (!flows || flows.length === 0) return "";
|
|
24835
|
+
this.surfaced.add(k);
|
|
24836
|
+
const best = [...flows].sort((a, b) => b.success_count - a.success_count).slice(0, 3);
|
|
24837
|
+
const lines = best.map((f) => {
|
|
24838
|
+
const steps = f.steps.map((s, i) => {
|
|
24839
|
+
const via = s.recovered_via ? ` [via ${s.recovered_via}]` : "";
|
|
24840
|
+
const sig = s.signal ? ` (${s.signal})` : "";
|
|
24841
|
+
const frag = s.fragile ? " \u26A0fragile-selector" : "";
|
|
24842
|
+
return ` ${i + 1}. ${s.tool} ${s.target}${sig}${via}${frag}`;
|
|
24843
|
+
}).join("\n");
|
|
24844
|
+
const stale = f.chromeflow_version !== this.version ? ` recorded on v${f.chromeflow_version}, re-verify` : "";
|
|
24845
|
+
return ` "${f.task_label}" (${f.steps.length} steps, ${f.success_count}x ok${stale}):
|
|
24846
|
+
${steps}`;
|
|
24847
|
+
});
|
|
24848
|
+
return `
|
|
24849
|
+
|
|
24850
|
+
\u2139 known_flow for ${k} \u2014 prefer these proven steps over rediscovery (verify each as usual):
|
|
24851
|
+
${lines.join("\n")}`;
|
|
24852
|
+
}
|
|
24853
|
+
/** Nudge to save buffered hard-won steps, when there are uncommitted ones. */
|
|
24854
|
+
capturableHint(url) {
|
|
24855
|
+
const k = originKey(url) ?? this.lastOrigin;
|
|
24856
|
+
if (!k) return "";
|
|
24857
|
+
const buf = this.buffer.get(k);
|
|
24858
|
+
if (!buf || buf.length === 0) return "";
|
|
24859
|
+
const reasons = [...new Set(buf.map((a) => a.reason))].slice(0, 2).join("; ");
|
|
24860
|
+
return `
|
|
24861
|
+
|
|
24862
|
+
\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.`;
|
|
24863
|
+
}
|
|
24864
|
+
/** Commit the buffered atoms for an origin as a named flow. */
|
|
24865
|
+
commit(taskLabel, url) {
|
|
24866
|
+
const k = originKey(url) ?? this.lastOrigin;
|
|
24867
|
+
if (!k) return { saved: 0, origin: null, message: "No origin known yet \u2014 navigate or interact with a page first." };
|
|
24868
|
+
const buf = this.buffer.get(k) ?? [];
|
|
24869
|
+
if (buf.length === 0) {
|
|
24870
|
+
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.` };
|
|
24871
|
+
}
|
|
24872
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
24873
|
+
const sig = JSON.stringify(buf.map((a) => [a.tool, a.target, a.selector ?? ""]));
|
|
24874
|
+
const flows = this.data.origins[k] ?? [];
|
|
24875
|
+
const existing = flows.find((f) => f.task_label === taskLabel && JSON.stringify(f.steps.map((a) => [a.tool, a.target, a.selector ?? ""])) === sig);
|
|
24876
|
+
if (existing) {
|
|
24877
|
+
existing.success_count += 1;
|
|
24878
|
+
existing.last_verified = now;
|
|
24879
|
+
existing.chromeflow_version = this.version;
|
|
24880
|
+
} else {
|
|
24881
|
+
flows.push({
|
|
24882
|
+
id: `${k}#${flows.length + 1}`,
|
|
24883
|
+
task_label: taskLabel,
|
|
24884
|
+
steps: buf,
|
|
24885
|
+
created_at: now,
|
|
24886
|
+
last_verified: now,
|
|
24887
|
+
success_count: 1,
|
|
24888
|
+
fail_count: 0,
|
|
24889
|
+
chromeflow_version: this.version
|
|
24890
|
+
});
|
|
24891
|
+
}
|
|
24892
|
+
this.data.origins[k] = flows;
|
|
24893
|
+
this.buffer.delete(k);
|
|
24894
|
+
this.persist();
|
|
24895
|
+
return { saved: buf.length, origin: k, message: `Saved flow "${taskLabel}" (${buf.length} steps) for ${k}.` };
|
|
24896
|
+
}
|
|
24897
|
+
};
|
|
24898
|
+
function isFragileSelector(selector) {
|
|
24899
|
+
return !!selector && FRAGILE_RE.test(selector);
|
|
24900
|
+
}
|
|
24901
|
+
|
|
24758
24902
|
// 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";
|
|
24903
|
+
import { writeFileSync as writeFileSync2, copyFileSync, readFileSync as readFileSync2 } from "fs";
|
|
24904
|
+
import { tmpdir, homedir as homedir2 } from "os";
|
|
24905
|
+
import { join as join2 } from "path";
|
|
24762
24906
|
import { execSync } from "child_process";
|
|
24763
24907
|
|
|
24764
24908
|
// packages/mcp-server/src/policy.ts
|
|
@@ -24787,7 +24931,7 @@ function isBlockedUrl(rawUrl) {
|
|
|
24787
24931
|
}
|
|
24788
24932
|
|
|
24789
24933
|
// packages/mcp-server/src/tools/browser.ts
|
|
24790
|
-
function registerBrowserTools(server, bridge) {
|
|
24934
|
+
function registerBrowserTools(server, bridge, flowStore) {
|
|
24791
24935
|
server.tool(
|
|
24792
24936
|
"open_page",
|
|
24793
24937
|
`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 +24980,8 @@ After tabs.onUpdated fires status=complete, chromeflow also runs a 6s settle che
|
|
|
24836
24980
|
|
|
24837
24981
|
\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
24982
|
}
|
|
24983
|
+
flowStore.noteUrl(r.current_url ?? url);
|
|
24984
|
+
text += flowStore.recallHint(r.current_url ?? url);
|
|
24839
24985
|
return { content: [{ type: "text", text }] };
|
|
24840
24986
|
}
|
|
24841
24987
|
);
|
|
@@ -24942,13 +25088,13 @@ Refuses fast on pages that are in fullscreen mode (captureVisibleTab hangs there
|
|
|
24942
25088
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
24943
25089
|
const filename = `chromeflow-${timestamp}.png`;
|
|
24944
25090
|
const imageBuffer = Buffer.from(response.image, "base64");
|
|
24945
|
-
const tmpPath =
|
|
25091
|
+
const tmpPath = join2(tmpdir(), filename);
|
|
24946
25092
|
const needTmp = !shouldInline || sharing;
|
|
24947
|
-
if (needTmp)
|
|
25093
|
+
if (needTmp) writeFileSync2(tmpPath, imageBuffer);
|
|
24948
25094
|
const notes = [];
|
|
24949
25095
|
let landedPath = tmpPath;
|
|
24950
25096
|
if (save_to !== "none") {
|
|
24951
|
-
const savePath = save_to === "cwd" ?
|
|
25097
|
+
const savePath = save_to === "cwd" ? join2(process.cwd(), filename) : join2(homedir2(), "Downloads", filename);
|
|
24952
25098
|
copyFileSync(tmpPath, savePath);
|
|
24953
25099
|
notes.push(`Saved to ${savePath}`);
|
|
24954
25100
|
landedPath = savePath;
|
|
@@ -24990,7 +25136,7 @@ The saved file path can be passed directly to set_file_input(hint, file_path) to
|
|
|
24990
25136
|
async ({ save_to = "downloads" }) => {
|
|
24991
25137
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
24992
25138
|
const filename = `terminal-${timestamp}.png`;
|
|
24993
|
-
const savePath = save_to === "cwd" ?
|
|
25139
|
+
const savePath = save_to === "cwd" ? join2(process.cwd(), filename) : join2(homedir2(), "Downloads", filename);
|
|
24994
25140
|
let captured = false;
|
|
24995
25141
|
try {
|
|
24996
25142
|
const bounds = execSync(`osascript -e '
|
|
@@ -25025,7 +25171,7 @@ The saved file path can be passed directly to set_file_input(hint, file_path) to
|
|
|
25025
25171
|
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
25172
|
};
|
|
25027
25173
|
}
|
|
25028
|
-
const imageBuffer =
|
|
25174
|
+
const imageBuffer = readFileSync2(savePath);
|
|
25029
25175
|
const base642 = imageBuffer.toString("base64");
|
|
25030
25176
|
let clipboardNote = "";
|
|
25031
25177
|
try {
|
|
@@ -25135,24 +25281,70 @@ ${lines.join("\n")}${r.warning ?? ""}${captchaLine}${oauthLine}` }] };
|
|
|
25135
25281
|
timeoutMs
|
|
25136
25282
|
);
|
|
25137
25283
|
const r = response;
|
|
25284
|
+
let capturable = "";
|
|
25285
|
+
if (into_selector && r.success !== false) {
|
|
25286
|
+
flowStore.observe({
|
|
25287
|
+
tool: "type_text",
|
|
25288
|
+
target: into_selector,
|
|
25289
|
+
selector: into_selector,
|
|
25290
|
+
signal: clear_first ? "type_text(clear_first)" : "type_text",
|
|
25291
|
+
fragile: isFragileSelector(into_selector),
|
|
25292
|
+
reason: "field needs real keystrokes (type_text, not fill_input)"
|
|
25293
|
+
});
|
|
25294
|
+
capturable = flowStore.capturableHint(void 0);
|
|
25295
|
+
}
|
|
25138
25296
|
return {
|
|
25139
|
-
content: [{ type: "text", text: r.message ?? (r.success ? "Text typed successfully" : "Failed to type text") }]
|
|
25297
|
+
content: [{ type: "text", text: (r.message ?? (r.success ? "Text typed successfully" : "Failed to type text")) + capturable }]
|
|
25140
25298
|
};
|
|
25141
25299
|
}
|
|
25142
25300
|
);
|
|
25143
25301
|
server.tool(
|
|
25144
25302
|
"set_file_input",
|
|
25145
|
-
|
|
25303
|
+
`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.
|
|
25304
|
+
|
|
25305
|
+
Two ways to supply the file:
|
|
25306
|
+
- file_path (CDP mode): an absolute path on the machine running this server. Reaches both open AND closed shadow roots.
|
|
25307
|
+
- 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.
|
|
25308
|
+
|
|
25309
|
+
Provide file_path OR file_content, not both.`,
|
|
25146
25310
|
{
|
|
25147
25311
|
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."),
|
|
25148
|
-
file_path: external_exports.string().describe("Absolute path to the file to upload (e.g. /Users/you/Downloads/task.zip)"),
|
|
25312
|
+
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."),
|
|
25313
|
+
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."),
|
|
25314
|
+
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.'),
|
|
25315
|
+
mime_type: external_exports.string().optional().describe('Optional MIME type for inline-content mode (e.g. "application/pdf"). Inferred from file_name when omitted.'),
|
|
25149
25316
|
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."),
|
|
25150
25317
|
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.')
|
|
25151
25318
|
},
|
|
25152
|
-
async ({ hint, file_path, wait_ms, verify_selector }) => {
|
|
25319
|
+
async ({ hint, file_path, file_content, file_name, mime_type, wait_ms, verify_selector }) => {
|
|
25320
|
+
if (!file_path && !file_content) {
|
|
25321
|
+
return {
|
|
25322
|
+
content: [{ type: "text", text: "Failed to set file: provide either file_path or file_content." }]
|
|
25323
|
+
};
|
|
25324
|
+
}
|
|
25325
|
+
if (file_path && file_content) {
|
|
25326
|
+
return {
|
|
25327
|
+
content: [{ type: "text", text: "Failed to set file: provide file_path OR file_content, not both." }]
|
|
25328
|
+
};
|
|
25329
|
+
}
|
|
25330
|
+
if (file_content && !file_name) {
|
|
25331
|
+
return {
|
|
25332
|
+
content: [{ type: "text", text: "Failed to set file: file_content requires file_name." }]
|
|
25333
|
+
};
|
|
25334
|
+
}
|
|
25153
25335
|
const wsTimeout = Math.max(3e4, (wait_ms ?? 3e3) + 1e4);
|
|
25154
25336
|
const response = await bridge.request(
|
|
25155
|
-
{
|
|
25337
|
+
{
|
|
25338
|
+
type: "set_file_input",
|
|
25339
|
+
hint,
|
|
25340
|
+
// snake -> camel, mirroring the existing file_path -> filePath mapping.
|
|
25341
|
+
filePath: file_path,
|
|
25342
|
+
fileContent: file_content,
|
|
25343
|
+
fileName: file_name,
|
|
25344
|
+
mimeType: mime_type,
|
|
25345
|
+
waitMs: wait_ms,
|
|
25346
|
+
verifySelector: verify_selector
|
|
25347
|
+
},
|
|
25156
25348
|
wsTimeout
|
|
25157
25349
|
);
|
|
25158
25350
|
const r = response;
|
|
@@ -25170,7 +25362,7 @@ ${lines.join("\n")}${r.warning ?? ""}${captchaLine}${oauthLine}` }] };
|
|
|
25170
25362
|
**Shadow-piercing helpers are pre-injected** into every script:
|
|
25171
25363
|
- \`$deep(selector, root?)\` \u2014 querySelector that walks open shadow roots
|
|
25172
25364
|
- \`$deepAll(selector, root?)\` \u2014 querySelectorAll equivalent, returns an array
|
|
25173
|
-
- \`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 (
|
|
25365
|
+
- \`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.
|
|
25174
25366
|
- \`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.
|
|
25175
25367
|
|
|
25176
25368
|
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.
|
|
@@ -25282,8 +25474,8 @@ Returns whether the element was found. Set valueToType only when the user must p
|
|
|
25282
25474
|
}
|
|
25283
25475
|
|
|
25284
25476
|
// packages/mcp-server/src/tools/capture.ts
|
|
25285
|
-
import { appendFileSync, mkdirSync, readFileSync as
|
|
25286
|
-
import { resolve, relative, isAbsolute, dirname } from "path";
|
|
25477
|
+
import { appendFileSync, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
25478
|
+
import { resolve, relative, isAbsolute, dirname as dirname2 } from "path";
|
|
25287
25479
|
function registerCaptureTools(server, bridge) {
|
|
25288
25480
|
server.tool(
|
|
25289
25481
|
"fill_input",
|
|
@@ -25471,7 +25663,7 @@ ${lines.join("\n")}` }] };
|
|
|
25471
25663
|
envPath = resolved;
|
|
25472
25664
|
let existing = "";
|
|
25473
25665
|
try {
|
|
25474
|
-
existing =
|
|
25666
|
+
existing = readFileSync3(envPath, "utf-8");
|
|
25475
25667
|
} catch {
|
|
25476
25668
|
}
|
|
25477
25669
|
const lines = existing.split("\n");
|
|
@@ -25479,7 +25671,7 @@ ${lines.join("\n")}` }] };
|
|
|
25479
25671
|
const existingIndex = lines.findIndex((l) => keyPattern.test(l));
|
|
25480
25672
|
if (existingIndex !== -1) {
|
|
25481
25673
|
lines[existingIndex] = `${key}=${value}`;
|
|
25482
|
-
|
|
25674
|
+
writeFileSync3(envPath, lines.join("\n"), "utf-8");
|
|
25483
25675
|
} else {
|
|
25484
25676
|
const toAppend = (existing && !existing.endsWith("\n") ? "\n" : "") + `${key}=${value}
|
|
25485
25677
|
`;
|
|
@@ -25613,9 +25805,9 @@ Set binary=true for non-text responses (PDFs, images, zips) \u2014 the body is r
|
|
|
25613
25805
|
`Refusing to write fetch_url body outside the project directory. Target "${resolved}" is not under "${cwd}".`
|
|
25614
25806
|
);
|
|
25615
25807
|
}
|
|
25616
|
-
|
|
25808
|
+
mkdirSync2(dirname2(resolved), { recursive: true });
|
|
25617
25809
|
const buf = r.body_base64 ? Buffer.from(r.body_base64, "base64") : Buffer.from(r.body_text ?? "", "utf-8");
|
|
25618
|
-
|
|
25810
|
+
writeFileSync3(resolved, buf);
|
|
25619
25811
|
const hdrLines = Object.keys(r.headers).sort().map((k) => ` ${k}: ${r.headers[k]}`).join("\n");
|
|
25620
25812
|
return {
|
|
25621
25813
|
content: [{
|
|
@@ -25644,7 +25836,7 @@ ${r.body_text}` : "";
|
|
|
25644
25836
|
}
|
|
25645
25837
|
|
|
25646
25838
|
// packages/mcp-server/src/tools/flow.ts
|
|
25647
|
-
function registerFlowTools(server, bridge) {
|
|
25839
|
+
function registerFlowTools(server, bridge, flowStore) {
|
|
25648
25840
|
server.tool(
|
|
25649
25841
|
"click_element",
|
|
25650
25842
|
`Click an interactive element by its visible text/aria-label (textHint) OR by direct CSS selector (selector). Pass exactly one.
|
|
@@ -25686,7 +25878,7 @@ ANTI-BOT SUBMIT CEILING \u2014 synthetic clicks on social/auth platforms (Reddit
|
|
|
25686
25878
|
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.`),
|
|
25687
25879
|
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.`),
|
|
25688
25880
|
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.`),
|
|
25689
|
-
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 (
|
|
25881
|
+
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.`),
|
|
25690
25882
|
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.`),
|
|
25691
25883
|
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.`),
|
|
25692
25884
|
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)".`)
|
|
@@ -25744,21 +25936,51 @@ Current URL: ${activeTab.url}`;
|
|
|
25744
25936
|
focusLine = `
|
|
25745
25937
|
\u2192 Focused: <${f.tag}${idBit}${nameBit}${aria}${valueBit}>`;
|
|
25746
25938
|
}
|
|
25939
|
+
const actionUrl = r.before_url ?? r.after_url;
|
|
25940
|
+
const nowUrl = r.after_url ?? r.before_url;
|
|
25941
|
+
const usedUntil = !!(until_selector || until_url_contains || until_text_contains || until_url_changes);
|
|
25942
|
+
if (r.success && (r.recovered_via || r.navigated || usedUntil)) {
|
|
25943
|
+
flowStore.observe({
|
|
25944
|
+
tool: "click_element",
|
|
25945
|
+
target: textHint ?? `selector=${selector}`,
|
|
25946
|
+
selector,
|
|
25947
|
+
recovered_via: r.recovered_via,
|
|
25948
|
+
signal: r.navigated ? "navigated" : until_url_changes ? "until_url_change" : usedUntil ? "until_*" : r.recovered_via,
|
|
25949
|
+
fragile: isFragileSelector(selector),
|
|
25950
|
+
reason: r.recovered_via ? `click recovered via ${r.recovered_via}` : r.navigated ? "navigating submit/link" : "verified terminal click"
|
|
25951
|
+
}, actionUrl);
|
|
25952
|
+
}
|
|
25953
|
+
flowStore.noteUrl(nowUrl);
|
|
25954
|
+
const recall = flowStore.recallHint(nowUrl);
|
|
25955
|
+
const capturable = flowStore.capturableHint(actionUrl);
|
|
25747
25956
|
if (!r.success) {
|
|
25748
25957
|
return {
|
|
25749
25958
|
content: [
|
|
25750
25959
|
{
|
|
25751
25960
|
type: "text",
|
|
25752
|
-
text: `Could not click "${targetLabel}": ${r.message}${navLine}${focusLine}`
|
|
25961
|
+
text: `Could not click "${targetLabel}": ${r.message}${navLine}${focusLine}${recall}`
|
|
25753
25962
|
}
|
|
25754
25963
|
]
|
|
25755
25964
|
};
|
|
25756
25965
|
}
|
|
25757
25966
|
return {
|
|
25758
|
-
content: [{ type: "text", text: `${r.message}${navLine}${focusLine}` }]
|
|
25967
|
+
content: [{ type: "text", text: `${r.message}${navLine}${focusLine}${recall}${capturable}` }]
|
|
25759
25968
|
};
|
|
25760
25969
|
}
|
|
25761
25970
|
);
|
|
25971
|
+
server.tool(
|
|
25972
|
+
"save_flow",
|
|
25973
|
+
`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.
|
|
25974
|
+
|
|
25975
|
+
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.`,
|
|
25976
|
+
{
|
|
25977
|
+
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".')
|
|
25978
|
+
},
|
|
25979
|
+
async ({ task_label }) => {
|
|
25980
|
+
const res = flowStore.commit(task_label);
|
|
25981
|
+
return { content: [{ type: "text", text: res.message }] };
|
|
25982
|
+
}
|
|
25983
|
+
);
|
|
25762
25984
|
server.tool(
|
|
25763
25985
|
"wait_for_click",
|
|
25764
25986
|
`Wait for the user to click (or interact with) the currently highlighted element, then return.
|
|
@@ -26072,21 +26294,22 @@ ${lines.join("\n")}${shadowSection}` }] };
|
|
|
26072
26294
|
}
|
|
26073
26295
|
|
|
26074
26296
|
// packages/mcp-server/src/index.ts
|
|
26075
|
-
var PACKAGE_VERSION = true ? "0.
|
|
26297
|
+
var PACKAGE_VERSION = true ? "0.11.1" : "dev";
|
|
26076
26298
|
main().catch((err) => {
|
|
26077
26299
|
console.error("[chromeflow] Fatal error:", err);
|
|
26078
26300
|
process.exit(1);
|
|
26079
26301
|
});
|
|
26080
26302
|
async function main() {
|
|
26081
26303
|
const bridge = new WsBridge();
|
|
26304
|
+
const flowStore = new FlowStore(PACKAGE_VERSION);
|
|
26082
26305
|
const server = new McpServer({
|
|
26083
26306
|
name: "chromeflow",
|
|
26084
26307
|
version: PACKAGE_VERSION
|
|
26085
26308
|
});
|
|
26086
|
-
registerBrowserTools(server, bridge);
|
|
26309
|
+
registerBrowserTools(server, bridge, flowStore);
|
|
26087
26310
|
registerHighlightTools(server, bridge);
|
|
26088
26311
|
registerCaptureTools(server, bridge);
|
|
26089
|
-
registerFlowTools(server, bridge);
|
|
26312
|
+
registerFlowTools(server, bridge, flowStore);
|
|
26090
26313
|
const registered = server._registeredTools ?? {};
|
|
26091
26314
|
const toolNames = Object.keys(registered).sort();
|
|
26092
26315
|
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.
|
|
3
|
+
"version": "0.11.1",
|
|
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",
|