chromeflow 0.12.2 → 0.12.3
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 +366 -165
- 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,10 @@ 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 ATOM_KEYS = ["tool", "target", "selector", "recovered_via", "signal", "fragile", "reason"];
|
|
24767
|
+
var ATOM_KEYS = ["tool", "target", "selector", "recovered_via", "signal", "verification", "clear_first", "fragile", "reason"];
|
|
24767
24768
|
function originKey(url) {
|
|
24768
24769
|
if (!url) return void 0;
|
|
24769
24770
|
try {
|
|
@@ -24779,6 +24780,9 @@ var FRAGILE_RE = /:nth-(of-type|child)\(|>\s*\w+:nth/;
|
|
|
24779
24780
|
function signatureOf(steps) {
|
|
24780
24781
|
return JSON.stringify(steps.map((a) => [a.tool, a.target, a.selector ?? ""]));
|
|
24781
24782
|
}
|
|
24783
|
+
function flowCost(f) {
|
|
24784
|
+
return f.steps.reduce((c, s) => c + 1 + (s.fragile ? 1 : 0) + (s.recovered_via ? 0.5 : 0), 0);
|
|
24785
|
+
}
|
|
24782
24786
|
function sanitizeAtom(a) {
|
|
24783
24787
|
const out = {};
|
|
24784
24788
|
for (const k of ATOM_KEYS) {
|
|
@@ -24795,6 +24799,27 @@ function autoLabel(k, steps) {
|
|
|
24795
24799
|
}
|
|
24796
24800
|
return `auto: ${tools} @ ${path2}`;
|
|
24797
24801
|
}
|
|
24802
|
+
function actionableVia(recovered_via) {
|
|
24803
|
+
if (recovered_via && recovered_via.includes("fiber")) return "fiber";
|
|
24804
|
+
return null;
|
|
24805
|
+
}
|
|
24806
|
+
function renderStep(s, i) {
|
|
24807
|
+
const fragNote = s.fragile ? " \u26A0fragile \u2014 if it misses on the first try, do NOT retry it; rediscover" : "";
|
|
24808
|
+
if (s.tool === "type_text") {
|
|
24809
|
+
const sel = s.selector ?? s.target;
|
|
24810
|
+
const cf = s.clear_first ? ", clear_first=true" : "";
|
|
24811
|
+
return ` ${i + 1}. type_text(into_selector=${JSON.stringify(sel)}${cf})${fragNote}`;
|
|
24812
|
+
}
|
|
24813
|
+
if (s.tool === "click_element") {
|
|
24814
|
+
const isSel = s.target.startsWith("selector=");
|
|
24815
|
+
const targ = isSel ? `selector=${JSON.stringify(s.selector ?? s.target.replace(/^selector=/, ""))}` : `textHint=${JSON.stringify(s.target)}`;
|
|
24816
|
+
const via = actionableVia(s.recovered_via);
|
|
24817
|
+
const viaStr = via ? `, via=${JSON.stringify(via)}` : "";
|
|
24818
|
+
const ver = s.verification ? `, ${s.verification}` : s.signal === "navigated" || s.signal === "until_url_change" ? ", until_url_changes=true" : "";
|
|
24819
|
+
return ` ${i + 1}. click_element(${targ}${viaStr}${ver})${fragNote}`;
|
|
24820
|
+
}
|
|
24821
|
+
return ` ${i + 1}. ${s.tool} ${s.target}${s.fragile ? " \u26A0fragile" : ""}`;
|
|
24822
|
+
}
|
|
24798
24823
|
var FlowStore = class {
|
|
24799
24824
|
path;
|
|
24800
24825
|
data;
|
|
@@ -24807,6 +24832,10 @@ var FlowStore = class {
|
|
|
24807
24832
|
// origins whose recall hint already fired this session
|
|
24808
24833
|
recalled = /* @__PURE__ */ new Set();
|
|
24809
24834
|
// origins whose trusted flow was actually shown this session
|
|
24835
|
+
recalledFlows = /* @__PURE__ */ new Map();
|
|
24836
|
+
// the flows we showed, for mismatch/confirm attribution
|
|
24837
|
+
dinged = /* @__PURE__ */ new Set();
|
|
24838
|
+
// flow ids already failed this session (no double-count)
|
|
24810
24839
|
lastOrigin;
|
|
24811
24840
|
lastAutosaved = null;
|
|
24812
24841
|
// most recent autosave, for save_flow to vouch for
|
|
@@ -24861,6 +24890,21 @@ var FlowStore = class {
|
|
|
24861
24890
|
}
|
|
24862
24891
|
if (changed) this.persist();
|
|
24863
24892
|
}
|
|
24893
|
+
/**
|
|
24894
|
+
* Record a failed/mismatched replay against a flow: demote on first, prune on
|
|
24895
|
+
* second (via pruneExpired's PRUNE_AT_FAILS check). `dedupeKey` collapses the
|
|
24896
|
+
* repeated mismatch check (which runs on every observe) to one ding per flow
|
|
24897
|
+
* per session; explicit tool failures pass no key so each real failure counts.
|
|
24898
|
+
*/
|
|
24899
|
+
failFlow(f, dedupeKey) {
|
|
24900
|
+
if (dedupeKey) {
|
|
24901
|
+
if (this.dinged.has(dedupeKey)) return;
|
|
24902
|
+
this.dinged.add(dedupeKey);
|
|
24903
|
+
}
|
|
24904
|
+
f.fail_count += 1;
|
|
24905
|
+
f.last_replay_ok = false;
|
|
24906
|
+
if (f.tier === "trusted" && f.fail_count >= DEMOTE_AT_FAILS) f.tier = "provisional";
|
|
24907
|
+
}
|
|
24864
24908
|
/**
|
|
24865
24909
|
* Update the "current origin". Crossing to a DIFFERENT origin first autosaves
|
|
24866
24910
|
* the origin we are leaving — that boundary is our best server-side proxy for
|
|
@@ -24879,18 +24923,54 @@ var FlowStore = class {
|
|
|
24879
24923
|
if (!atom) return;
|
|
24880
24924
|
const k = originKey(url) ?? this.lastOrigin;
|
|
24881
24925
|
if (!k) return;
|
|
24926
|
+
this.reconcileAgainstRecalled(k, atom);
|
|
24882
24927
|
const list = this.buffer.get(k) ?? [];
|
|
24883
24928
|
const sig = `${atom.tool}|${atom.target}|${atom.selector ?? ""}`;
|
|
24884
24929
|
if (list.some((a) => `${a.tool}|${a.target}|${a.selector ?? ""}` === sig)) return;
|
|
24885
24930
|
list.push(sanitizeAtom(atom));
|
|
24886
24931
|
this.buffer.set(k, list);
|
|
24887
24932
|
}
|
|
24933
|
+
/**
|
|
24934
|
+
* When the agent performs a notable step on an origin we recalled a flow for,
|
|
24935
|
+
* compare it to the recalled steps of the same tool:
|
|
24936
|
+
* - same locator -> the recalled step worked: mark the flow's replay OK.
|
|
24937
|
+
* - different locator (and the recalled one was never used) -> the stored
|
|
24938
|
+
* selector was wrong; the agent silently rediscovered -> fail the flow.
|
|
24939
|
+
* This catches the "wrong element but technically succeeded" case that the
|
|
24940
|
+
* explicit failure signal misses, and is what neutralises a drifted Reddit-style
|
|
24941
|
+
* shadow-DOM selector before it costs another session.
|
|
24942
|
+
*/
|
|
24943
|
+
reconcileAgainstRecalled(k, atom) {
|
|
24944
|
+
const shown = this.recalledFlows.get(k);
|
|
24945
|
+
if (!shown) return;
|
|
24946
|
+
const atomLoc = atom.selector ?? atom.target;
|
|
24947
|
+
let changed = false;
|
|
24948
|
+
for (const f of shown) {
|
|
24949
|
+
const sameTool = f.steps.filter((s) => s.tool === atom.tool);
|
|
24950
|
+
if (sameTool.length === 0) continue;
|
|
24951
|
+
const usedRecalled = sameTool.some((s) => (s.selector ?? s.target) === atomLoc || s.target === atom.target);
|
|
24952
|
+
if (usedRecalled) {
|
|
24953
|
+
if (f.last_replay_ok !== true) {
|
|
24954
|
+
f.last_replay_ok = true;
|
|
24955
|
+
changed = true;
|
|
24956
|
+
}
|
|
24957
|
+
} else {
|
|
24958
|
+
const before = f.fail_count;
|
|
24959
|
+
this.failFlow(f, `mismatch:${f.id}`);
|
|
24960
|
+
if (f.fail_count !== before) changed = true;
|
|
24961
|
+
}
|
|
24962
|
+
}
|
|
24963
|
+
if (changed) this.persist();
|
|
24964
|
+
}
|
|
24888
24965
|
/** Autosave a single origin's buffer as a provisional flow (or promote a match). */
|
|
24889
24966
|
autoCommit(k) {
|
|
24890
24967
|
const buf = this.buffer.get(k);
|
|
24891
24968
|
if (!buf || buf.length === 0) return;
|
|
24892
24969
|
this.buffer.delete(k);
|
|
24893
24970
|
this.upsert(k, buf, null);
|
|
24971
|
+
if (buf.length > 1) {
|
|
24972
|
+
for (const atom of buf) this.upsert(k, [atom], null);
|
|
24973
|
+
}
|
|
24894
24974
|
this.lastAutosaved = { key: k, sig: signatureOf(buf) };
|
|
24895
24975
|
this.pruneExpired();
|
|
24896
24976
|
this.persist();
|
|
@@ -24912,11 +24992,12 @@ var FlowStore = class {
|
|
|
24912
24992
|
if (existing) {
|
|
24913
24993
|
existing.success_count += 1;
|
|
24914
24994
|
existing.last_verified = now;
|
|
24995
|
+
existing.last_replay_ok = true;
|
|
24915
24996
|
existing.chromeflow_version = this.version;
|
|
24916
24997
|
if (label !== null) {
|
|
24917
24998
|
existing.tier = "trusted";
|
|
24918
24999
|
existing.task_label = label;
|
|
24919
|
-
} else if (existing.success_count >= PROMOTE_AT_SUCCESS) {
|
|
25000
|
+
} else if (existing.success_count - existing.fail_count >= PROMOTE_AT_SUCCESS) {
|
|
24920
25001
|
existing.tier = "trusted";
|
|
24921
25002
|
}
|
|
24922
25003
|
} else {
|
|
@@ -24929,41 +25010,45 @@ var FlowStore = class {
|
|
|
24929
25010
|
last_verified: now,
|
|
24930
25011
|
success_count: 1,
|
|
24931
25012
|
fail_count: 0,
|
|
25013
|
+
last_replay_ok: true,
|
|
24932
25014
|
chromeflow_version: this.version
|
|
24933
25015
|
});
|
|
24934
25016
|
}
|
|
24935
25017
|
this.data.origins[k] = flows;
|
|
24936
25018
|
return { saved: steps.length };
|
|
24937
25019
|
}
|
|
24938
|
-
/** Compact recall hint for an origin (
|
|
25020
|
+
/** Compact recall hint for an origin (RELIABLE trusted flows only), once per origin per session. */
|
|
24939
25021
|
recallHint(url) {
|
|
24940
25022
|
const k = originKey(url);
|
|
24941
25023
|
if (!k || this.surfaced.has(k)) return "";
|
|
24942
|
-
const flows = (this.data.origins[k] ?? []).filter(
|
|
25024
|
+
const flows = (this.data.origins[k] ?? []).filter(
|
|
25025
|
+
(f) => f.tier === "trusted" && f.success_count > f.fail_count && f.last_replay_ok !== false
|
|
25026
|
+
);
|
|
24943
25027
|
if (flows.length === 0) return "";
|
|
24944
25028
|
this.surfaced.add(k);
|
|
24945
25029
|
this.recalled.add(k);
|
|
24946
|
-
const best = [...flows].sort((a, b) =>
|
|
25030
|
+
const best = [...flows].sort((a, b) => {
|
|
25031
|
+
const ca = flowCost(a), cb = flowCost(b);
|
|
25032
|
+
if (ca !== cb) return ca - cb;
|
|
25033
|
+
return b.success_count - a.success_count;
|
|
25034
|
+
}).slice(0, 3);
|
|
25035
|
+
this.recalledFlows.set(k, best);
|
|
24947
25036
|
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");
|
|
25037
|
+
const steps = f.steps.map((s, i) => renderStep(s, i)).join("\n");
|
|
24954
25038
|
const stale = f.chromeflow_version !== this.version ? ` recorded on v${f.chromeflow_version}, re-verify` : "";
|
|
24955
25039
|
return ` "${f.task_label}" (${f.steps.length} steps, ${f.success_count}x ok${stale}):
|
|
24956
25040
|
${steps}`;
|
|
24957
25041
|
});
|
|
24958
25042
|
return `
|
|
24959
25043
|
|
|
24960
|
-
\u2139 known_flow for ${k} \u2014
|
|
25044
|
+
\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
25045
|
${lines.join("\n")}`;
|
|
24962
25046
|
}
|
|
24963
25047
|
/**
|
|
24964
|
-
* A recalled
|
|
24965
|
-
*
|
|
24966
|
-
*
|
|
25048
|
+
* A recalled step failed on replay (a click that returned success:false, or a
|
|
25049
|
+
* type_text that did not land). Demote the matching flow on the first miss,
|
|
25050
|
+
* prune on the second. Gated on the flow having actually been recalled this
|
|
25051
|
+
* session, so an unrelated failure can't ding a flow the agent never used.
|
|
24967
25052
|
*/
|
|
24968
25053
|
observeFailure(url, selectorOrText) {
|
|
24969
25054
|
const k = originKey(url) ?? this.lastOrigin;
|
|
@@ -24972,10 +25057,11 @@ ${lines.join("\n")}`;
|
|
|
24972
25057
|
if (!flows) return;
|
|
24973
25058
|
let changed = false;
|
|
24974
25059
|
for (const f of flows) {
|
|
24975
|
-
|
|
24976
|
-
|
|
25060
|
+
const hit = f.steps.some(
|
|
25061
|
+
(s) => s.selector === selectorOrText || s.target === selectorOrText || s.target === `selector=${selectorOrText}`
|
|
25062
|
+
);
|
|
24977
25063
|
if (hit) {
|
|
24978
|
-
f
|
|
25064
|
+
this.failFlow(f);
|
|
24979
25065
|
changed = true;
|
|
24980
25066
|
}
|
|
24981
25067
|
}
|
|
@@ -25029,15 +25115,10 @@ ${lines.join("\n")}`;
|
|
|
25029
25115
|
}
|
|
25030
25116
|
};
|
|
25031
25117
|
function isFragileSelector(selector) {
|
|
25032
|
-
|
|
25118
|
+
if (!selector) return false;
|
|
25119
|
+
return FRAGILE_RE.test(selector) || selector.includes(",");
|
|
25033
25120
|
}
|
|
25034
25121
|
|
|
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
25122
|
// packages/mcp-server/src/policy.ts
|
|
25042
25123
|
function isBlockedUrl(rawUrl) {
|
|
25043
25124
|
let parsed;
|
|
@@ -25063,8 +25144,8 @@ function isBlockedUrl(rawUrl) {
|
|
|
25063
25144
|
return { blocked: false };
|
|
25064
25145
|
}
|
|
25065
25146
|
|
|
25066
|
-
// packages/mcp-server/src/tools/browser.ts
|
|
25067
|
-
function
|
|
25147
|
+
// packages/mcp-server/src/tools/browser/navigation.ts
|
|
25148
|
+
function registerNavigationTools(server, bridge, flowStore) {
|
|
25068
25149
|
server.tool(
|
|
25069
25150
|
"open_page",
|
|
25070
25151
|
`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 +25199,38 @@ After tabs.onUpdated fires status=complete, chromeflow also runs a 6s settle che
|
|
|
25118
25199
|
return { content: [{ type: "text", text }] };
|
|
25119
25200
|
}
|
|
25120
25201
|
);
|
|
25202
|
+
server.tool(
|
|
25203
|
+
"inspect_request_headers",
|
|
25204
|
+
`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.`,
|
|
25205
|
+
{
|
|
25206
|
+
url: external_exports.string().url().describe("URL to navigate to and capture headers for"),
|
|
25207
|
+
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."),
|
|
25208
|
+
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.")
|
|
25209
|
+
},
|
|
25210
|
+
async ({ url, redact_cookies = true, new_tab = true }) => {
|
|
25211
|
+
const block = isBlockedUrl(url);
|
|
25212
|
+
if (block.blocked) {
|
|
25213
|
+
return { content: [{ type: "text", text: `inspect_request_headers refused: ${block.reason}` }] };
|
|
25214
|
+
}
|
|
25215
|
+
const response = await bridge.request({ type: "inspect_request_headers", url, new_tab }, 3e4);
|
|
25216
|
+
const r = response;
|
|
25217
|
+
let text = r.message ?? "(no headers captured)";
|
|
25218
|
+
if (redact_cookies) {
|
|
25219
|
+
text = text.replace(/^(cookie:\s*)(.+)$/gim, (_m, prefix, body) => {
|
|
25220
|
+
const pairs = String(body).split(";").map((s) => s.trim()).filter(Boolean);
|
|
25221
|
+
const names = pairs.map((p) => p.split("=")[0]);
|
|
25222
|
+
return `${prefix}[REDACTED \u2014 ${pairs.length} cookies: ${names.join(", ")}]`;
|
|
25223
|
+
});
|
|
25224
|
+
}
|
|
25225
|
+
return {
|
|
25226
|
+
content: [{ type: "text", text }]
|
|
25227
|
+
};
|
|
25228
|
+
}
|
|
25229
|
+
);
|
|
25230
|
+
}
|
|
25231
|
+
|
|
25232
|
+
// packages/mcp-server/src/tools/browser/tabs.ts
|
|
25233
|
+
function registerTabTools(server, bridge, flowStore) {
|
|
25121
25234
|
server.tool(
|
|
25122
25235
|
"switch_to_tab",
|
|
25123
25236
|
`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 +25314,40 @@ ${keptList}` }]
|
|
|
25201
25314
|
};
|
|
25202
25315
|
}
|
|
25203
25316
|
);
|
|
25317
|
+
}
|
|
25318
|
+
|
|
25319
|
+
// packages/mcp-server/src/tools/browser/snapshot.ts
|
|
25320
|
+
function registerSnapshotTools(server, bridge) {
|
|
25321
|
+
server.tool(
|
|
25322
|
+
"interactive_snapshot",
|
|
25323
|
+
`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.`,
|
|
25324
|
+
{
|
|
25325
|
+
max: external_exports.number().int().min(1).optional().describe("Max elements to return (default 60).")
|
|
25326
|
+
},
|
|
25327
|
+
async ({ max }) => {
|
|
25328
|
+
let response;
|
|
25329
|
+
try {
|
|
25330
|
+
response = await bridge.request({ type: "interactive_snapshot", max });
|
|
25331
|
+
} catch {
|
|
25332
|
+
return { content: [{ type: "text", text: "interactive_snapshot is unavailable (reload/update the chromeflow extension). Fall back to get_page_text + find_text for now." }] };
|
|
25333
|
+
}
|
|
25334
|
+
const items = response.items ?? [];
|
|
25335
|
+
if (items.length === 0) {
|
|
25336
|
+
return { content: [{ type: "text", text: "No actionable elements found (page may render inside a cross-origin iframe, or content is non-interactive)." }] };
|
|
25337
|
+
}
|
|
25338
|
+
const lines = items.map((it, i) => `${i + 1}. [${it.role}]${it.name ? " " + it.name : ""} \u2014 ${it.selector}`);
|
|
25339
|
+
return { content: [{ type: "text", text: `Actionable elements (${items.length}):
|
|
25340
|
+
${lines.join("\n")}` }] };
|
|
25341
|
+
}
|
|
25342
|
+
);
|
|
25343
|
+
}
|
|
25344
|
+
|
|
25345
|
+
// packages/mcp-server/src/tools/browser/screenshot.ts
|
|
25346
|
+
import { writeFileSync as writeFileSync2, copyFileSync, readFileSync as readFileSync2 } from "fs";
|
|
25347
|
+
import { tmpdir, homedir as homedir2 } from "os";
|
|
25348
|
+
import { join as join2 } from "path";
|
|
25349
|
+
import { execSync } from "child_process";
|
|
25350
|
+
function registerScreenshotTools(server, bridge) {
|
|
25204
25351
|
server.tool(
|
|
25205
25352
|
"take_screenshot",
|
|
25206
25353
|
`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 +25481,10 @@ The saved file path can be passed directly to set_file_input(hint, file_path) to
|
|
|
25334
25481
|
};
|
|
25335
25482
|
}
|
|
25336
25483
|
);
|
|
25484
|
+
}
|
|
25485
|
+
|
|
25486
|
+
// packages/mcp-server/src/tools/browser/forms.ts
|
|
25487
|
+
function registerFormFieldTools(server, bridge) {
|
|
25337
25488
|
server.tool(
|
|
25338
25489
|
"get_form_fields",
|
|
25339
25490
|
`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 +25544,10 @@ To fill: fill_input("${r2.fields[0].label}", "<value>")` }] };
|
|
|
25393
25544
|
${lines.join("\n")}${r.warning ?? ""}${captchaLine}${oauthLine}` }] };
|
|
25394
25545
|
}
|
|
25395
25546
|
);
|
|
25547
|
+
}
|
|
25548
|
+
|
|
25549
|
+
// packages/mcp-server/src/tools/browser/typing.ts
|
|
25550
|
+
function registerTypingTools(server, bridge, flowStore) {
|
|
25396
25551
|
server.tool(
|
|
25397
25552
|
"type_text",
|
|
25398
25553
|
`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 +25572,32 @@ ${lines.join("\n")}${r.warning ?? ""}${captchaLine}${oauthLine}` }] };
|
|
|
25417
25572
|
timeoutMs
|
|
25418
25573
|
);
|
|
25419
25574
|
const r = response;
|
|
25575
|
+
const typeFailed = r.success === false || r.landed === false;
|
|
25576
|
+
const locator = r.resolved_selector || into_selector;
|
|
25420
25577
|
let capturable = "";
|
|
25421
|
-
if (into_selector &&
|
|
25578
|
+
if (into_selector && !typeFailed) {
|
|
25422
25579
|
flowStore.observe({
|
|
25423
25580
|
tool: "type_text",
|
|
25424
|
-
target:
|
|
25425
|
-
selector:
|
|
25581
|
+
target: locator,
|
|
25582
|
+
selector: locator,
|
|
25426
25583
|
signal: clear_first ? "type_text(clear_first)" : "type_text",
|
|
25427
|
-
|
|
25584
|
+
clear_first: clear_first || void 0,
|
|
25585
|
+
fragile: isFragileSelector(locator),
|
|
25428
25586
|
reason: "field needs real keystrokes (type_text, not fill_input)"
|
|
25429
25587
|
});
|
|
25430
25588
|
capturable = flowStore.capturableHint(void 0);
|
|
25589
|
+
} else if (into_selector && typeFailed) {
|
|
25590
|
+
flowStore.observeFailure(void 0, locator);
|
|
25431
25591
|
}
|
|
25432
25592
|
return {
|
|
25433
25593
|
content: [{ type: "text", text: (r.message ?? (r.success ? "Text typed successfully" : "Failed to type text")) + capturable }]
|
|
25434
25594
|
};
|
|
25435
25595
|
}
|
|
25436
25596
|
);
|
|
25597
|
+
}
|
|
25598
|
+
|
|
25599
|
+
// packages/mcp-server/src/tools/browser/files.ts
|
|
25600
|
+
function registerFileInputTools(server, bridge) {
|
|
25437
25601
|
server.tool(
|
|
25438
25602
|
"set_file_input",
|
|
25439
25603
|
`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 +25653,10 @@ Provide file_path OR file_content, not both.`,
|
|
|
25489
25653
|
};
|
|
25490
25654
|
}
|
|
25491
25655
|
);
|
|
25656
|
+
}
|
|
25657
|
+
|
|
25658
|
+
// packages/mcp-server/src/tools/browser/scripting.ts
|
|
25659
|
+
function registerScriptingTools(server, bridge) {
|
|
25492
25660
|
server.tool(
|
|
25493
25661
|
"execute_script",
|
|
25494
25662
|
`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 +25705,18 @@ PAGE ALERT: "${alert}" \u2014 the page showed a dialog with this message. Read i
|
|
|
25537
25705
|
};
|
|
25538
25706
|
}
|
|
25539
25707
|
);
|
|
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
|
-
);
|
|
25708
|
+
}
|
|
25709
|
+
|
|
25710
|
+
// packages/mcp-server/src/tools/browser.ts
|
|
25711
|
+
function registerBrowserTools(server, bridge, flowStore) {
|
|
25712
|
+
registerNavigationTools(server, bridge, flowStore);
|
|
25713
|
+
registerTabTools(server, bridge, flowStore);
|
|
25714
|
+
registerSnapshotTools(server, bridge);
|
|
25715
|
+
registerScreenshotTools(server, bridge);
|
|
25716
|
+
registerFormFieldTools(server, bridge);
|
|
25717
|
+
registerTypingTools(server, bridge, flowStore);
|
|
25718
|
+
registerFileInputTools(server, bridge);
|
|
25719
|
+
registerScriptingTools(server, bridge);
|
|
25568
25720
|
}
|
|
25569
25721
|
|
|
25570
25722
|
// packages/mcp-server/src/tools/highlight.ts
|
|
@@ -25609,10 +25761,8 @@ Returns whether the element was found. Set valueToType only when the user must p
|
|
|
25609
25761
|
);
|
|
25610
25762
|
}
|
|
25611
25763
|
|
|
25612
|
-
// packages/mcp-server/src/tools/capture.ts
|
|
25613
|
-
|
|
25614
|
-
import { resolve, relative, isAbsolute, dirname as dirname2 } from "path";
|
|
25615
|
-
function registerCaptureTools(server, bridge) {
|
|
25764
|
+
// packages/mcp-server/src/tools/capture/input.ts
|
|
25765
|
+
function registerInputTools(server, bridge) {
|
|
25616
25766
|
server.tool(
|
|
25617
25767
|
"fill_input",
|
|
25618
25768
|
`Fill a form input by visible label / placeholder / aria-label (\`textHint\`) OR by direct CSS selector (\`selector\`). Pass exactly one.
|
|
@@ -25668,6 +25818,10 @@ Or pass selector="<css>" instead of textHint to bypass fuzzy matching entirely.`
|
|
|
25668
25818
|
};
|
|
25669
25819
|
}
|
|
25670
25820
|
);
|
|
25821
|
+
}
|
|
25822
|
+
|
|
25823
|
+
// packages/mcp-server/src/tools/capture/extract.ts
|
|
25824
|
+
function registerExtractTools(server, bridge) {
|
|
25671
25825
|
server.tool(
|
|
25672
25826
|
"get_page_text",
|
|
25673
25827
|
`Get the visible text content of the current page without taking a screenshot.
|
|
@@ -25770,6 +25924,12 @@ Pass level="error" to see only errors, or omit to see all levels.`,
|
|
|
25770
25924
|
${lines.join("\n")}` }] };
|
|
25771
25925
|
}
|
|
25772
25926
|
);
|
|
25927
|
+
}
|
|
25928
|
+
|
|
25929
|
+
// packages/mcp-server/src/tools/capture/files.ts
|
|
25930
|
+
import { appendFileSync, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
25931
|
+
import { resolve, relative, isAbsolute } from "path";
|
|
25932
|
+
function registerFileTools(server, bridge) {
|
|
25773
25933
|
server.tool(
|
|
25774
25934
|
"write_to_env",
|
|
25775
25935
|
"Write a key=value pair to a .env file. Use this after capturing an API key or ID from the page.",
|
|
@@ -25884,6 +26044,12 @@ Size: ${r.size} bytes`
|
|
|
25884
26044
|
};
|
|
25885
26045
|
}
|
|
25886
26046
|
);
|
|
26047
|
+
}
|
|
26048
|
+
|
|
26049
|
+
// packages/mcp-server/src/tools/capture/fetch.ts
|
|
26050
|
+
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4 } from "fs";
|
|
26051
|
+
import { resolve as resolve2, relative as relative2, isAbsolute as isAbsolute2, dirname as dirname2 } from "path";
|
|
26052
|
+
function registerFetchTools(server, bridge) {
|
|
25887
26053
|
server.tool(
|
|
25888
26054
|
"fetch_url",
|
|
25889
26055
|
`Make an HTTP request to a URL from the extension's privileged context, bypassing the page's Content-Security-Policy.
|
|
@@ -25934,16 +26100,16 @@ Set binary=true for non-text responses (PDFs, images, zips) \u2014 the body is r
|
|
|
25934
26100
|
\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
26101
|
if (to_file) {
|
|
25936
26102
|
const cwd = process.cwd();
|
|
25937
|
-
const resolved =
|
|
25938
|
-
const rel =
|
|
25939
|
-
if (rel.startsWith("..") ||
|
|
26103
|
+
const resolved = isAbsolute2(to_file) ? to_file : resolve2(cwd, to_file);
|
|
26104
|
+
const rel = relative2(cwd, resolved);
|
|
26105
|
+
if (rel.startsWith("..") || isAbsolute2(rel)) {
|
|
25940
26106
|
throw new Error(
|
|
25941
26107
|
`Refusing to write fetch_url body outside the project directory. Target "${resolved}" is not under "${cwd}".`
|
|
25942
26108
|
);
|
|
25943
26109
|
}
|
|
25944
26110
|
mkdirSync2(dirname2(resolved), { recursive: true });
|
|
25945
26111
|
const buf = r.body_base64 ? Buffer.from(r.body_base64, "base64") : Buffer.from(r.body_text ?? "", "utf-8");
|
|
25946
|
-
|
|
26112
|
+
writeFileSync4(resolved, buf);
|
|
25947
26113
|
const hdrLines = Object.keys(r.headers).sort().map((k) => ` ${k}: ${r.headers[k]}`).join("\n");
|
|
25948
26114
|
return {
|
|
25949
26115
|
content: [{
|
|
@@ -25971,8 +26137,16 @@ ${r.body_text}` : "";
|
|
|
25971
26137
|
);
|
|
25972
26138
|
}
|
|
25973
26139
|
|
|
25974
|
-
// packages/mcp-server/src/tools/
|
|
25975
|
-
function
|
|
26140
|
+
// packages/mcp-server/src/tools/capture.ts
|
|
26141
|
+
function registerCaptureTools(server, bridge) {
|
|
26142
|
+
registerInputTools(server, bridge);
|
|
26143
|
+
registerExtractTools(server, bridge);
|
|
26144
|
+
registerFileTools(server, bridge);
|
|
26145
|
+
registerFetchTools(server, bridge);
|
|
26146
|
+
}
|
|
26147
|
+
|
|
26148
|
+
// packages/mcp-server/src/tools/flow/click.ts
|
|
26149
|
+
function registerClickTools(server, bridge, flowStore) {
|
|
25976
26150
|
server.tool(
|
|
25977
26151
|
"click_element",
|
|
25978
26152
|
`Click an interactive element by its visible text/aria-label (textHint) OR by direct CSS selector (selector). Pass exactly one.
|
|
@@ -26075,6 +26249,7 @@ Current URL: ${activeTab.url}`;
|
|
|
26075
26249
|
const actionUrl = r.before_url ?? r.after_url;
|
|
26076
26250
|
const nowUrl = r.after_url ?? r.before_url;
|
|
26077
26251
|
const usedUntil = !!(until_selector || until_url_contains || until_text_contains || until_url_changes);
|
|
26252
|
+
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;
|
|
26078
26253
|
if (r.success && (r.recovered_via || r.navigated || usedUntil)) {
|
|
26079
26254
|
flowStore.observe({
|
|
26080
26255
|
tool: "click_element",
|
|
@@ -26082,6 +26257,7 @@ Current URL: ${activeTab.url}`;
|
|
|
26082
26257
|
selector,
|
|
26083
26258
|
recovered_via: r.recovered_via,
|
|
26084
26259
|
signal: r.navigated ? "navigated" : until_url_changes ? "until_url_change" : usedUntil ? "until_*" : r.recovered_via,
|
|
26260
|
+
verification,
|
|
26085
26261
|
fragile: isFragileSelector(selector),
|
|
26086
26262
|
reason: r.recovered_via ? `click recovered via ${r.recovered_via}` : r.navigated ? "navigating submit/link" : "verified terminal click"
|
|
26087
26263
|
}, actionUrl);
|
|
@@ -26105,6 +26281,33 @@ Current URL: ${activeTab.url}`;
|
|
|
26105
26281
|
};
|
|
26106
26282
|
}
|
|
26107
26283
|
);
|
|
26284
|
+
server.tool(
|
|
26285
|
+
"click_at_coordinates",
|
|
26286
|
+
`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.
|
|
26287
|
+
|
|
26288
|
+
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)\`.
|
|
26289
|
+
|
|
26290
|
+
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.
|
|
26291
|
+
|
|
26292
|
+
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.`,
|
|
26293
|
+
{
|
|
26294
|
+
x: external_exports.number().describe("Viewport CSS X coordinate (left=0). Get from list_frames or a screenshot grid."),
|
|
26295
|
+
y: external_exports.number().describe("Viewport CSS Y coordinate (top=0). Get from list_frames or a screenshot grid."),
|
|
26296
|
+
button: external_exports.enum(["left", "right", "middle"]).optional().describe('Mouse button (default "left").'),
|
|
26297
|
+
double: external_exports.boolean().optional().describe("Fire a double-click instead of a single click. Default false.")
|
|
26298
|
+
},
|
|
26299
|
+
async ({ x, y, button, double }) => {
|
|
26300
|
+
const response = await bridge.request({ type: "click_at_coordinates", x, y, button, double });
|
|
26301
|
+
const r = response;
|
|
26302
|
+
const navLine = r.navigated && r.after_url ? `
|
|
26303
|
+
\u2192 Navigated: ${r.after_url}` : "";
|
|
26304
|
+
return { content: [{ type: "text", text: `${r.message}${navLine}` }] };
|
|
26305
|
+
}
|
|
26306
|
+
);
|
|
26307
|
+
}
|
|
26308
|
+
|
|
26309
|
+
// packages/mcp-server/src/tools/flow/save.ts
|
|
26310
|
+
function registerSaveFlowTools(server, flowStore) {
|
|
26108
26311
|
server.tool(
|
|
26109
26312
|
"save_flow",
|
|
26110
26313
|
`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 +26321,10 @@ Call this when a response shows \`flow_capturable\` and you are confident the ta
|
|
|
26118
26321
|
return { content: [{ type: "text", text: res.message }] };
|
|
26119
26322
|
}
|
|
26120
26323
|
);
|
|
26324
|
+
}
|
|
26325
|
+
|
|
26326
|
+
// packages/mcp-server/src/tools/flow/wait.ts
|
|
26327
|
+
function registerWaitTools(server, bridge) {
|
|
26121
26328
|
server.tool(
|
|
26122
26329
|
"wait_for_click",
|
|
26123
26330
|
`Wait for the user to click (or interact with) the currently highlighted element, then return.
|
|
@@ -26160,29 +26367,6 @@ CDP re-dispatched: isTrusted=true click at (${r.target?.x ?? 0}, ${r.target?.y ?
|
|
|
26160
26367
|
};
|
|
26161
26368
|
}
|
|
26162
26369
|
);
|
|
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
26370
|
server.tool(
|
|
26187
26371
|
"wait_for",
|
|
26188
26372
|
`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 +26456,10 @@ Examples: scroll_to_element("#submit-btn"), scroll_to_element("Billing address")
|
|
|
26272
26456
|
return { content: [{ type: "text", text: msg }] };
|
|
26273
26457
|
}
|
|
26274
26458
|
);
|
|
26459
|
+
}
|
|
26460
|
+
|
|
26461
|
+
// packages/mcp-server/src/tools/flow/find.ts
|
|
26462
|
+
function registerFindTools(server, bridge) {
|
|
26275
26463
|
server.tool(
|
|
26276
26464
|
"find_text",
|
|
26277
26465
|
`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 +26528,6 @@ ${lines.join("\n")}` }]
|
|
|
26340
26528
|
};
|
|
26341
26529
|
}
|
|
26342
26530
|
);
|
|
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
26531
|
server.tool(
|
|
26376
26532
|
"list_frames",
|
|
26377
26533
|
`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 +26586,53 @@ ${lines.join("\n")}${shadowSection}` }] };
|
|
|
26430
26586
|
);
|
|
26431
26587
|
}
|
|
26432
26588
|
|
|
26589
|
+
// packages/mcp-server/src/tools/flow/forms.ts
|
|
26590
|
+
function registerFillFormTools(server, bridge) {
|
|
26591
|
+
server.tool(
|
|
26592
|
+
"fill_form",
|
|
26593
|
+
`Fill multiple form fields in a single call by targeting each field by its label text.
|
|
26594
|
+
Use this instead of calling fill_input repeatedly \u2014 it fills all fields in one round trip and returns a per-field success report.
|
|
26595
|
+
Ideal for forms with many textareas or inputs where each fill would otherwise require a separate tool call.
|
|
26596
|
+
fields is an array of {label, value} pairs. label should match the field's visible label, placeholder, or aria-label.
|
|
26597
|
+
|
|
26598
|
+
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.
|
|
26599
|
+
|
|
26600
|
+
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.`,
|
|
26601
|
+
{
|
|
26602
|
+
fields: external_exports.array(
|
|
26603
|
+
external_exports.object({
|
|
26604
|
+
label: external_exports.string().describe("Visible label, placeholder, or aria-label of the field"),
|
|
26605
|
+
value: external_exports.string().describe("Value to fill in")
|
|
26606
|
+
})
|
|
26607
|
+
).describe("List of fields to fill"),
|
|
26608
|
+
exact: external_exports.boolean().optional().describe("If true, refuse fuzzy text-walk matches for every field. Default false.")
|
|
26609
|
+
},
|
|
26610
|
+
async ({ fields, exact }) => {
|
|
26611
|
+
const response = await bridge.request({ type: "fill_form", fields, exact });
|
|
26612
|
+
const r = response;
|
|
26613
|
+
const lines = r.results.map((f) => `${f.success ? "\u2713" : "\u2717"} "${f.label}": ${f.message}`);
|
|
26614
|
+
return {
|
|
26615
|
+
content: [{
|
|
26616
|
+
type: "text",
|
|
26617
|
+
text: `Filled ${r.succeeded}/${r.total} fields:
|
|
26618
|
+
${lines.join("\n")}`
|
|
26619
|
+
}]
|
|
26620
|
+
};
|
|
26621
|
+
}
|
|
26622
|
+
);
|
|
26623
|
+
}
|
|
26624
|
+
|
|
26625
|
+
// packages/mcp-server/src/tools/flow.ts
|
|
26626
|
+
function registerFlowTools(server, bridge, flowStore) {
|
|
26627
|
+
registerClickTools(server, bridge, flowStore);
|
|
26628
|
+
registerSaveFlowTools(server, flowStore);
|
|
26629
|
+
registerWaitTools(server, bridge);
|
|
26630
|
+
registerFindTools(server, bridge);
|
|
26631
|
+
registerFillFormTools(server, bridge);
|
|
26632
|
+
}
|
|
26633
|
+
|
|
26433
26634
|
// packages/mcp-server/src/index.ts
|
|
26434
|
-
var PACKAGE_VERSION = true ? "0.12.
|
|
26635
|
+
var PACKAGE_VERSION = true ? "0.12.3" : "dev";
|
|
26435
26636
|
main().catch((err) => {
|
|
26436
26637
|
console.error("[chromeflow] Fatal error:", err);
|
|
26437
26638
|
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.3",
|
|
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",
|