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