chromeflow 0.12.0 → 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 +399 -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,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,7 +24832,13 @@ 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;
|
|
24840
|
+
lastAutosaved = null;
|
|
24841
|
+
// most recent autosave, for save_flow to vouch for
|
|
24811
24842
|
constructor(version2, baseDir, now = Date.now) {
|
|
24812
24843
|
this.version = version2;
|
|
24813
24844
|
this.now = now;
|
|
@@ -24859,6 +24890,21 @@ var FlowStore = class {
|
|
|
24859
24890
|
}
|
|
24860
24891
|
if (changed) this.persist();
|
|
24861
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
|
+
}
|
|
24862
24908
|
/**
|
|
24863
24909
|
* Update the "current origin". Crossing to a DIFFERENT origin first autosaves
|
|
24864
24910
|
* the origin we are leaving — that boundary is our best server-side proxy for
|
|
@@ -24877,18 +24923,55 @@ var FlowStore = class {
|
|
|
24877
24923
|
if (!atom) return;
|
|
24878
24924
|
const k = originKey(url) ?? this.lastOrigin;
|
|
24879
24925
|
if (!k) return;
|
|
24926
|
+
this.reconcileAgainstRecalled(k, atom);
|
|
24880
24927
|
const list = this.buffer.get(k) ?? [];
|
|
24881
24928
|
const sig = `${atom.tool}|${atom.target}|${atom.selector ?? ""}`;
|
|
24882
24929
|
if (list.some((a) => `${a.tool}|${a.target}|${a.selector ?? ""}` === sig)) return;
|
|
24883
24930
|
list.push(sanitizeAtom(atom));
|
|
24884
24931
|
this.buffer.set(k, list);
|
|
24885
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
|
+
}
|
|
24886
24965
|
/** Autosave a single origin's buffer as a provisional flow (or promote a match). */
|
|
24887
24966
|
autoCommit(k) {
|
|
24888
24967
|
const buf = this.buffer.get(k);
|
|
24889
24968
|
if (!buf || buf.length === 0) return;
|
|
24890
24969
|
this.buffer.delete(k);
|
|
24891
24970
|
this.upsert(k, buf, null);
|
|
24971
|
+
if (buf.length > 1) {
|
|
24972
|
+
for (const atom of buf) this.upsert(k, [atom], null);
|
|
24973
|
+
}
|
|
24974
|
+
this.lastAutosaved = { key: k, sig: signatureOf(buf) };
|
|
24892
24975
|
this.pruneExpired();
|
|
24893
24976
|
this.persist();
|
|
24894
24977
|
}
|
|
@@ -24909,11 +24992,12 @@ var FlowStore = class {
|
|
|
24909
24992
|
if (existing) {
|
|
24910
24993
|
existing.success_count += 1;
|
|
24911
24994
|
existing.last_verified = now;
|
|
24995
|
+
existing.last_replay_ok = true;
|
|
24912
24996
|
existing.chromeflow_version = this.version;
|
|
24913
24997
|
if (label !== null) {
|
|
24914
24998
|
existing.tier = "trusted";
|
|
24915
24999
|
existing.task_label = label;
|
|
24916
|
-
} else if (existing.success_count >= PROMOTE_AT_SUCCESS) {
|
|
25000
|
+
} else if (existing.success_count - existing.fail_count >= PROMOTE_AT_SUCCESS) {
|
|
24917
25001
|
existing.tier = "trusted";
|
|
24918
25002
|
}
|
|
24919
25003
|
} else {
|
|
@@ -24926,41 +25010,45 @@ var FlowStore = class {
|
|
|
24926
25010
|
last_verified: now,
|
|
24927
25011
|
success_count: 1,
|
|
24928
25012
|
fail_count: 0,
|
|
25013
|
+
last_replay_ok: true,
|
|
24929
25014
|
chromeflow_version: this.version
|
|
24930
25015
|
});
|
|
24931
25016
|
}
|
|
24932
25017
|
this.data.origins[k] = flows;
|
|
24933
25018
|
return { saved: steps.length };
|
|
24934
25019
|
}
|
|
24935
|
-
/** Compact recall hint for an origin (
|
|
25020
|
+
/** Compact recall hint for an origin (RELIABLE trusted flows only), once per origin per session. */
|
|
24936
25021
|
recallHint(url) {
|
|
24937
25022
|
const k = originKey(url);
|
|
24938
25023
|
if (!k || this.surfaced.has(k)) return "";
|
|
24939
|
-
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
|
+
);
|
|
24940
25027
|
if (flows.length === 0) return "";
|
|
24941
25028
|
this.surfaced.add(k);
|
|
24942
25029
|
this.recalled.add(k);
|
|
24943
|
-
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);
|
|
24944
25036
|
const lines = best.map((f) => {
|
|
24945
|
-
const steps = f.steps.map((s, i) =>
|
|
24946
|
-
const via = s.recovered_via ? ` [via ${s.recovered_via}]` : "";
|
|
24947
|
-
const sig = s.signal ? ` (${s.signal})` : "";
|
|
24948
|
-
const frag = s.fragile ? " \u26A0fragile-selector" : "";
|
|
24949
|
-
return ` ${i + 1}. ${s.tool} ${s.target}${sig}${via}${frag}`;
|
|
24950
|
-
}).join("\n");
|
|
25037
|
+
const steps = f.steps.map((s, i) => renderStep(s, i)).join("\n");
|
|
24951
25038
|
const stale = f.chromeflow_version !== this.version ? ` recorded on v${f.chromeflow_version}, re-verify` : "";
|
|
24952
25039
|
return ` "${f.task_label}" (${f.steps.length} steps, ${f.success_count}x ok${stale}):
|
|
24953
25040
|
${steps}`;
|
|
24954
25041
|
});
|
|
24955
25042
|
return `
|
|
24956
25043
|
|
|
24957
|
-
\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.
|
|
24958
25045
|
${lines.join("\n")}`;
|
|
24959
25046
|
}
|
|
24960
25047
|
/**
|
|
24961
|
-
* A recalled
|
|
24962
|
-
*
|
|
24963
|
-
*
|
|
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.
|
|
24964
25052
|
*/
|
|
24965
25053
|
observeFailure(url, selectorOrText) {
|
|
24966
25054
|
const k = originKey(url) ?? this.lastOrigin;
|
|
@@ -24969,10 +25057,11 @@ ${lines.join("\n")}`;
|
|
|
24969
25057
|
if (!flows) return;
|
|
24970
25058
|
let changed = false;
|
|
24971
25059
|
for (const f of flows) {
|
|
24972
|
-
|
|
24973
|
-
|
|
25060
|
+
const hit = f.steps.some(
|
|
25061
|
+
(s) => s.selector === selectorOrText || s.target === selectorOrText || s.target === `selector=${selectorOrText}`
|
|
25062
|
+
);
|
|
24974
25063
|
if (hit) {
|
|
24975
|
-
f
|
|
25064
|
+
this.failFlow(f);
|
|
24976
25065
|
changed = true;
|
|
24977
25066
|
}
|
|
24978
25067
|
}
|
|
@@ -24998,6 +25087,19 @@ ${lines.join("\n")}`;
|
|
|
24998
25087
|
if (!k) return { saved: 0, origin: null, message: "No origin known yet \u2014 navigate or interact with a page first." };
|
|
24999
25088
|
const buf = this.buffer.get(k) ?? [];
|
|
25000
25089
|
if (buf.length === 0) {
|
|
25090
|
+
if (this.lastAutosaved) {
|
|
25091
|
+
const flows = this.data.origins[this.lastAutosaved.key] ?? [];
|
|
25092
|
+
const f = flows.find((x) => signatureOf(x.steps) === this.lastAutosaved.sig);
|
|
25093
|
+
if (f) {
|
|
25094
|
+
f.tier = "trusted";
|
|
25095
|
+
f.task_label = taskLabel;
|
|
25096
|
+
f.last_verified = this.nowIso();
|
|
25097
|
+
const promotedKey = this.lastAutosaved.key;
|
|
25098
|
+
this.lastAutosaved = null;
|
|
25099
|
+
this.persist();
|
|
25100
|
+
return { saved: f.steps.length, origin: promotedKey, message: `Promoted the just-autosaved flow to trusted: "${taskLabel}" (${f.steps.length} steps) for ${promotedKey}.` };
|
|
25101
|
+
}
|
|
25102
|
+
}
|
|
25001
25103
|
return { saved: 0, origin: k, message: `Nothing notable buffered for ${k}. Flows capture hard-won steps (a fallback fired, a verified submit, a field needing real keystrokes) \u2014 an ordinary first-try click isn't recorded.` };
|
|
25002
25104
|
}
|
|
25003
25105
|
this.buffer.delete(k);
|
|
@@ -25013,15 +25115,10 @@ ${lines.join("\n")}`;
|
|
|
25013
25115
|
}
|
|
25014
25116
|
};
|
|
25015
25117
|
function isFragileSelector(selector) {
|
|
25016
|
-
|
|
25118
|
+
if (!selector) return false;
|
|
25119
|
+
return FRAGILE_RE.test(selector) || selector.includes(",");
|
|
25017
25120
|
}
|
|
25018
25121
|
|
|
25019
|
-
// packages/mcp-server/src/tools/browser.ts
|
|
25020
|
-
import { writeFileSync as writeFileSync2, copyFileSync, readFileSync as readFileSync2 } from "fs";
|
|
25021
|
-
import { tmpdir, homedir as homedir2 } from "os";
|
|
25022
|
-
import { join as join2 } from "path";
|
|
25023
|
-
import { execSync } from "child_process";
|
|
25024
|
-
|
|
25025
25122
|
// packages/mcp-server/src/policy.ts
|
|
25026
25123
|
function isBlockedUrl(rawUrl) {
|
|
25027
25124
|
let parsed;
|
|
@@ -25047,8 +25144,8 @@ function isBlockedUrl(rawUrl) {
|
|
|
25047
25144
|
return { blocked: false };
|
|
25048
25145
|
}
|
|
25049
25146
|
|
|
25050
|
-
// packages/mcp-server/src/tools/browser.ts
|
|
25051
|
-
function
|
|
25147
|
+
// packages/mcp-server/src/tools/browser/navigation.ts
|
|
25148
|
+
function registerNavigationTools(server, bridge, flowStore) {
|
|
25052
25149
|
server.tool(
|
|
25053
25150
|
"open_page",
|
|
25054
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.
|
|
@@ -25102,6 +25199,38 @@ After tabs.onUpdated fires status=complete, chromeflow also runs a 6s settle che
|
|
|
25102
25199
|
return { content: [{ type: "text", text }] };
|
|
25103
25200
|
}
|
|
25104
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) {
|
|
25105
25234
|
server.tool(
|
|
25106
25235
|
"switch_to_tab",
|
|
25107
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.
|
|
@@ -25139,9 +25268,12 @@ Examples: switch_to_tab({tab: 1}) for the first tab, switch_to_tab({tab: "form"}
|
|
|
25139
25268
|
if (response.type !== "tabs_response") throw new Error("Unexpected response");
|
|
25140
25269
|
const tabs = response.tabs;
|
|
25141
25270
|
const lines = tabs.map((t) => `${t.index}. ${t.active ? "[active] " : ""}${t.title} \u2014 ${t.url}`);
|
|
25271
|
+
const activeUrl = tabs.find((t) => t.active)?.url;
|
|
25272
|
+
flowStore.noteUrl(activeUrl);
|
|
25273
|
+
const recall = flowStore.recallHint(activeUrl);
|
|
25142
25274
|
return {
|
|
25143
25275
|
content: [{ type: "text", text: `Open tabs:
|
|
25144
|
-
${lines.join("\n")}` }]
|
|
25276
|
+
${lines.join("\n")}${recall}` }]
|
|
25145
25277
|
};
|
|
25146
25278
|
}
|
|
25147
25279
|
);
|
|
@@ -25182,6 +25314,40 @@ ${keptList}` }]
|
|
|
25182
25314
|
};
|
|
25183
25315
|
}
|
|
25184
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) {
|
|
25185
25351
|
server.tool(
|
|
25186
25352
|
"take_screenshot",
|
|
25187
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.
|
|
@@ -25315,6 +25481,10 @@ The saved file path can be passed directly to set_file_input(hint, file_path) to
|
|
|
25315
25481
|
};
|
|
25316
25482
|
}
|
|
25317
25483
|
);
|
|
25484
|
+
}
|
|
25485
|
+
|
|
25486
|
+
// packages/mcp-server/src/tools/browser/forms.ts
|
|
25487
|
+
function registerFormFieldTools(server, bridge) {
|
|
25318
25488
|
server.tool(
|
|
25319
25489
|
"get_form_fields",
|
|
25320
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.
|
|
@@ -25374,6 +25544,10 @@ To fill: fill_input("${r2.fields[0].label}", "<value>")` }] };
|
|
|
25374
25544
|
${lines.join("\n")}${r.warning ?? ""}${captchaLine}${oauthLine}` }] };
|
|
25375
25545
|
}
|
|
25376
25546
|
);
|
|
25547
|
+
}
|
|
25548
|
+
|
|
25549
|
+
// packages/mcp-server/src/tools/browser/typing.ts
|
|
25550
|
+
function registerTypingTools(server, bridge, flowStore) {
|
|
25377
25551
|
server.tool(
|
|
25378
25552
|
"type_text",
|
|
25379
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.
|
|
@@ -25398,23 +25572,32 @@ ${lines.join("\n")}${r.warning ?? ""}${captchaLine}${oauthLine}` }] };
|
|
|
25398
25572
|
timeoutMs
|
|
25399
25573
|
);
|
|
25400
25574
|
const r = response;
|
|
25575
|
+
const typeFailed = r.success === false || r.landed === false;
|
|
25576
|
+
const locator = r.resolved_selector || into_selector;
|
|
25401
25577
|
let capturable = "";
|
|
25402
|
-
if (into_selector &&
|
|
25578
|
+
if (into_selector && !typeFailed) {
|
|
25403
25579
|
flowStore.observe({
|
|
25404
25580
|
tool: "type_text",
|
|
25405
|
-
target:
|
|
25406
|
-
selector:
|
|
25581
|
+
target: locator,
|
|
25582
|
+
selector: locator,
|
|
25407
25583
|
signal: clear_first ? "type_text(clear_first)" : "type_text",
|
|
25408
|
-
|
|
25584
|
+
clear_first: clear_first || void 0,
|
|
25585
|
+
fragile: isFragileSelector(locator),
|
|
25409
25586
|
reason: "field needs real keystrokes (type_text, not fill_input)"
|
|
25410
25587
|
});
|
|
25411
25588
|
capturable = flowStore.capturableHint(void 0);
|
|
25589
|
+
} else if (into_selector && typeFailed) {
|
|
25590
|
+
flowStore.observeFailure(void 0, locator);
|
|
25412
25591
|
}
|
|
25413
25592
|
return {
|
|
25414
25593
|
content: [{ type: "text", text: (r.message ?? (r.success ? "Text typed successfully" : "Failed to type text")) + capturable }]
|
|
25415
25594
|
};
|
|
25416
25595
|
}
|
|
25417
25596
|
);
|
|
25597
|
+
}
|
|
25598
|
+
|
|
25599
|
+
// packages/mcp-server/src/tools/browser/files.ts
|
|
25600
|
+
function registerFileInputTools(server, bridge) {
|
|
25418
25601
|
server.tool(
|
|
25419
25602
|
"set_file_input",
|
|
25420
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.
|
|
@@ -25470,6 +25653,10 @@ Provide file_path OR file_content, not both.`,
|
|
|
25470
25653
|
};
|
|
25471
25654
|
}
|
|
25472
25655
|
);
|
|
25656
|
+
}
|
|
25657
|
+
|
|
25658
|
+
// packages/mcp-server/src/tools/browser/scripting.ts
|
|
25659
|
+
function registerScriptingTools(server, bridge) {
|
|
25473
25660
|
server.tool(
|
|
25474
25661
|
"execute_script",
|
|
25475
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.
|
|
@@ -25518,34 +25705,18 @@ PAGE ALERT: "${alert}" \u2014 the page showed a dialog with this message. Read i
|
|
|
25518
25705
|
};
|
|
25519
25706
|
}
|
|
25520
25707
|
);
|
|
25521
|
-
|
|
25522
|
-
|
|
25523
|
-
|
|
25524
|
-
|
|
25525
|
-
|
|
25526
|
-
|
|
25527
|
-
|
|
25528
|
-
|
|
25529
|
-
|
|
25530
|
-
|
|
25531
|
-
|
|
25532
|
-
|
|
25533
|
-
}
|
|
25534
|
-
const response = await bridge.request({ type: "inspect_request_headers", url, new_tab }, 3e4);
|
|
25535
|
-
const r = response;
|
|
25536
|
-
let text = r.message ?? "(no headers captured)";
|
|
25537
|
-
if (redact_cookies) {
|
|
25538
|
-
text = text.replace(/^(cookie:\s*)(.+)$/gim, (_m, prefix, body) => {
|
|
25539
|
-
const pairs = String(body).split(";").map((s) => s.trim()).filter(Boolean);
|
|
25540
|
-
const names = pairs.map((p) => p.split("=")[0]);
|
|
25541
|
-
return `${prefix}[REDACTED \u2014 ${pairs.length} cookies: ${names.join(", ")}]`;
|
|
25542
|
-
});
|
|
25543
|
-
}
|
|
25544
|
-
return {
|
|
25545
|
-
content: [{ type: "text", text }]
|
|
25546
|
-
};
|
|
25547
|
-
}
|
|
25548
|
-
);
|
|
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);
|
|
25549
25720
|
}
|
|
25550
25721
|
|
|
25551
25722
|
// packages/mcp-server/src/tools/highlight.ts
|
|
@@ -25590,10 +25761,8 @@ Returns whether the element was found. Set valueToType only when the user must p
|
|
|
25590
25761
|
);
|
|
25591
25762
|
}
|
|
25592
25763
|
|
|
25593
|
-
// packages/mcp-server/src/tools/capture.ts
|
|
25594
|
-
|
|
25595
|
-
import { resolve, relative, isAbsolute, dirname as dirname2 } from "path";
|
|
25596
|
-
function registerCaptureTools(server, bridge) {
|
|
25764
|
+
// packages/mcp-server/src/tools/capture/input.ts
|
|
25765
|
+
function registerInputTools(server, bridge) {
|
|
25597
25766
|
server.tool(
|
|
25598
25767
|
"fill_input",
|
|
25599
25768
|
`Fill a form input by visible label / placeholder / aria-label (\`textHint\`) OR by direct CSS selector (\`selector\`). Pass exactly one.
|
|
@@ -25649,6 +25818,10 @@ Or pass selector="<css>" instead of textHint to bypass fuzzy matching entirely.`
|
|
|
25649
25818
|
};
|
|
25650
25819
|
}
|
|
25651
25820
|
);
|
|
25821
|
+
}
|
|
25822
|
+
|
|
25823
|
+
// packages/mcp-server/src/tools/capture/extract.ts
|
|
25824
|
+
function registerExtractTools(server, bridge) {
|
|
25652
25825
|
server.tool(
|
|
25653
25826
|
"get_page_text",
|
|
25654
25827
|
`Get the visible text content of the current page without taking a screenshot.
|
|
@@ -25751,6 +25924,12 @@ Pass level="error" to see only errors, or omit to see all levels.`,
|
|
|
25751
25924
|
${lines.join("\n")}` }] };
|
|
25752
25925
|
}
|
|
25753
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) {
|
|
25754
25933
|
server.tool(
|
|
25755
25934
|
"write_to_env",
|
|
25756
25935
|
"Write a key=value pair to a .env file. Use this after capturing an API key or ID from the page.",
|
|
@@ -25865,6 +26044,12 @@ Size: ${r.size} bytes`
|
|
|
25865
26044
|
};
|
|
25866
26045
|
}
|
|
25867
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) {
|
|
25868
26053
|
server.tool(
|
|
25869
26054
|
"fetch_url",
|
|
25870
26055
|
`Make an HTTP request to a URL from the extension's privileged context, bypassing the page's Content-Security-Policy.
|
|
@@ -25915,16 +26100,16 @@ Set binary=true for non-text responses (PDFs, images, zips) \u2014 the body is r
|
|
|
25915
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.` : "";
|
|
25916
26101
|
if (to_file) {
|
|
25917
26102
|
const cwd = process.cwd();
|
|
25918
|
-
const resolved =
|
|
25919
|
-
const rel =
|
|
25920
|
-
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)) {
|
|
25921
26106
|
throw new Error(
|
|
25922
26107
|
`Refusing to write fetch_url body outside the project directory. Target "${resolved}" is not under "${cwd}".`
|
|
25923
26108
|
);
|
|
25924
26109
|
}
|
|
25925
26110
|
mkdirSync2(dirname2(resolved), { recursive: true });
|
|
25926
26111
|
const buf = r.body_base64 ? Buffer.from(r.body_base64, "base64") : Buffer.from(r.body_text ?? "", "utf-8");
|
|
25927
|
-
|
|
26112
|
+
writeFileSync4(resolved, buf);
|
|
25928
26113
|
const hdrLines = Object.keys(r.headers).sort().map((k) => ` ${k}: ${r.headers[k]}`).join("\n");
|
|
25929
26114
|
return {
|
|
25930
26115
|
content: [{
|
|
@@ -25952,8 +26137,16 @@ ${r.body_text}` : "";
|
|
|
25952
26137
|
);
|
|
25953
26138
|
}
|
|
25954
26139
|
|
|
25955
|
-
// packages/mcp-server/src/tools/
|
|
25956
|
-
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) {
|
|
25957
26150
|
server.tool(
|
|
25958
26151
|
"click_element",
|
|
25959
26152
|
`Click an interactive element by its visible text/aria-label (textHint) OR by direct CSS selector (selector). Pass exactly one.
|
|
@@ -26056,6 +26249,7 @@ Current URL: ${activeTab.url}`;
|
|
|
26056
26249
|
const actionUrl = r.before_url ?? r.after_url;
|
|
26057
26250
|
const nowUrl = r.after_url ?? r.before_url;
|
|
26058
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;
|
|
26059
26253
|
if (r.success && (r.recovered_via || r.navigated || usedUntil)) {
|
|
26060
26254
|
flowStore.observe({
|
|
26061
26255
|
tool: "click_element",
|
|
@@ -26063,12 +26257,13 @@ Current URL: ${activeTab.url}`;
|
|
|
26063
26257
|
selector,
|
|
26064
26258
|
recovered_via: r.recovered_via,
|
|
26065
26259
|
signal: r.navigated ? "navigated" : until_url_changes ? "until_url_change" : usedUntil ? "until_*" : r.recovered_via,
|
|
26260
|
+
verification,
|
|
26066
26261
|
fragile: isFragileSelector(selector),
|
|
26067
26262
|
reason: r.recovered_via ? `click recovered via ${r.recovered_via}` : r.navigated ? "navigating submit/link" : "verified terminal click"
|
|
26068
26263
|
}, actionUrl);
|
|
26069
26264
|
}
|
|
26070
26265
|
flowStore.noteUrl(nowUrl);
|
|
26071
|
-
const recall = flowStore.recallHint(nowUrl);
|
|
26266
|
+
const recall = flowStore.recallHint(actionUrl) || flowStore.recallHint(nowUrl);
|
|
26072
26267
|
const capturable = flowStore.capturableHint(actionUrl);
|
|
26073
26268
|
if (!r.success) {
|
|
26074
26269
|
flowStore.observeFailure(actionUrl, selector ?? textHint);
|
|
@@ -26086,6 +26281,33 @@ Current URL: ${activeTab.url}`;
|
|
|
26086
26281
|
};
|
|
26087
26282
|
}
|
|
26088
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) {
|
|
26089
26311
|
server.tool(
|
|
26090
26312
|
"save_flow",
|
|
26091
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.
|
|
@@ -26099,6 +26321,10 @@ Call this when a response shows \`flow_capturable\` and you are confident the ta
|
|
|
26099
26321
|
return { content: [{ type: "text", text: res.message }] };
|
|
26100
26322
|
}
|
|
26101
26323
|
);
|
|
26324
|
+
}
|
|
26325
|
+
|
|
26326
|
+
// packages/mcp-server/src/tools/flow/wait.ts
|
|
26327
|
+
function registerWaitTools(server, bridge) {
|
|
26102
26328
|
server.tool(
|
|
26103
26329
|
"wait_for_click",
|
|
26104
26330
|
`Wait for the user to click (or interact with) the currently highlighted element, then return.
|
|
@@ -26141,29 +26367,6 @@ CDP re-dispatched: isTrusted=true click at (${r.target?.x ?? 0}, ${r.target?.y ?
|
|
|
26141
26367
|
};
|
|
26142
26368
|
}
|
|
26143
26369
|
);
|
|
26144
|
-
server.tool(
|
|
26145
|
-
"click_at_coordinates",
|
|
26146
|
-
`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.
|
|
26147
|
-
|
|
26148
|
-
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)\`.
|
|
26149
|
-
|
|
26150
|
-
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.
|
|
26151
|
-
|
|
26152
|
-
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.`,
|
|
26153
|
-
{
|
|
26154
|
-
x: external_exports.number().describe("Viewport CSS X coordinate (left=0). Get from list_frames or a screenshot grid."),
|
|
26155
|
-
y: external_exports.number().describe("Viewport CSS Y coordinate (top=0). Get from list_frames or a screenshot grid."),
|
|
26156
|
-
button: external_exports.enum(["left", "right", "middle"]).optional().describe('Mouse button (default "left").'),
|
|
26157
|
-
double: external_exports.boolean().optional().describe("Fire a double-click instead of a single click. Default false.")
|
|
26158
|
-
},
|
|
26159
|
-
async ({ x, y, button, double }) => {
|
|
26160
|
-
const response = await bridge.request({ type: "click_at_coordinates", x, y, button, double });
|
|
26161
|
-
const r = response;
|
|
26162
|
-
const navLine = r.navigated && r.after_url ? `
|
|
26163
|
-
\u2192 Navigated: ${r.after_url}` : "";
|
|
26164
|
-
return { content: [{ type: "text", text: `${r.message}${navLine}` }] };
|
|
26165
|
-
}
|
|
26166
|
-
);
|
|
26167
26370
|
server.tool(
|
|
26168
26371
|
"wait_for",
|
|
26169
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).
|
|
@@ -26253,6 +26456,10 @@ Examples: scroll_to_element("#submit-btn"), scroll_to_element("Billing address")
|
|
|
26253
26456
|
return { content: [{ type: "text", text: msg }] };
|
|
26254
26457
|
}
|
|
26255
26458
|
);
|
|
26459
|
+
}
|
|
26460
|
+
|
|
26461
|
+
// packages/mcp-server/src/tools/flow/find.ts
|
|
26462
|
+
function registerFindTools(server, bridge) {
|
|
26256
26463
|
server.tool(
|
|
26257
26464
|
"find_text",
|
|
26258
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.
|
|
@@ -26321,38 +26528,6 @@ ${lines.join("\n")}` }]
|
|
|
26321
26528
|
};
|
|
26322
26529
|
}
|
|
26323
26530
|
);
|
|
26324
|
-
server.tool(
|
|
26325
|
-
"fill_form",
|
|
26326
|
-
`Fill multiple form fields in a single call by targeting each field by its label text.
|
|
26327
|
-
Use this instead of calling fill_input repeatedly \u2014 it fills all fields in one round trip and returns a per-field success report.
|
|
26328
|
-
Ideal for forms with many textareas or inputs where each fill would otherwise require a separate tool call.
|
|
26329
|
-
fields is an array of {label, value} pairs. label should match the field's visible label, placeholder, or aria-label.
|
|
26330
|
-
|
|
26331
|
-
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.
|
|
26332
|
-
|
|
26333
|
-
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.`,
|
|
26334
|
-
{
|
|
26335
|
-
fields: external_exports.array(
|
|
26336
|
-
external_exports.object({
|
|
26337
|
-
label: external_exports.string().describe("Visible label, placeholder, or aria-label of the field"),
|
|
26338
|
-
value: external_exports.string().describe("Value to fill in")
|
|
26339
|
-
})
|
|
26340
|
-
).describe("List of fields to fill"),
|
|
26341
|
-
exact: external_exports.boolean().optional().describe("If true, refuse fuzzy text-walk matches for every field. Default false.")
|
|
26342
|
-
},
|
|
26343
|
-
async ({ fields, exact }) => {
|
|
26344
|
-
const response = await bridge.request({ type: "fill_form", fields, exact });
|
|
26345
|
-
const r = response;
|
|
26346
|
-
const lines = r.results.map((f) => `${f.success ? "\u2713" : "\u2717"} "${f.label}": ${f.message}`);
|
|
26347
|
-
return {
|
|
26348
|
-
content: [{
|
|
26349
|
-
type: "text",
|
|
26350
|
-
text: `Filled ${r.succeeded}/${r.total} fields:
|
|
26351
|
-
${lines.join("\n")}`
|
|
26352
|
-
}]
|
|
26353
|
-
};
|
|
26354
|
-
}
|
|
26355
|
-
);
|
|
26356
26531
|
server.tool(
|
|
26357
26532
|
"list_frames",
|
|
26358
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).
|
|
@@ -26411,8 +26586,53 @@ ${lines.join("\n")}${shadowSection}` }] };
|
|
|
26411
26586
|
);
|
|
26412
26587
|
}
|
|
26413
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
|
+
|
|
26414
26634
|
// packages/mcp-server/src/index.ts
|
|
26415
|
-
var PACKAGE_VERSION = true ? "0.12.
|
|
26635
|
+
var PACKAGE_VERSION = true ? "0.12.3" : "dev";
|
|
26416
26636
|
main().catch((err) => {
|
|
26417
26637
|
console.error("[chromeflow] Fatal error:", err);
|
|
26418
26638
|
process.exit(1);
|
|
@@ -26486,7 +26706,10 @@ ${tabList}`
|
|
|
26486
26706
|
);
|
|
26487
26707
|
const transport = new StdioServerTransport();
|
|
26488
26708
|
await server.connect(transport);
|
|
26709
|
+
let exited = false;
|
|
26489
26710
|
const exitClean = (reason) => {
|
|
26711
|
+
if (exited) return;
|
|
26712
|
+
exited = true;
|
|
26490
26713
|
console.error(`[chromeflow] host disconnected (${reason}), exiting.`);
|
|
26491
26714
|
try {
|
|
26492
26715
|
flowStore.flushAll();
|
|
@@ -26496,6 +26719,15 @@ ${tabList}`
|
|
|
26496
26719
|
};
|
|
26497
26720
|
process.stdin.on("end", () => exitClean("stdin end"));
|
|
26498
26721
|
process.stdin.on("close", () => exitClean("stdin close"));
|
|
26722
|
+
process.on("SIGTERM", () => exitClean("SIGTERM"));
|
|
26723
|
+
process.on("SIGINT", () => exitClean("SIGINT"));
|
|
26724
|
+
process.on("SIGHUP", () => exitClean("SIGHUP"));
|
|
26725
|
+
process.on("beforeExit", () => {
|
|
26726
|
+
try {
|
|
26727
|
+
flowStore.flushAll();
|
|
26728
|
+
} catch {
|
|
26729
|
+
}
|
|
26730
|
+
});
|
|
26499
26731
|
const originalPpid = process.ppid;
|
|
26500
26732
|
setInterval(() => {
|
|
26501
26733
|
const ppid = process.ppid;
|
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",
|