iframer-toolkit 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +4651 -307
- package/dist/local-server.cjs +1244 -128
- package/dist/mcp-server.cjs +579 -37
- package/extension/README.md +134 -0
- package/extension/background.js +639 -0
- package/extension/manifest.json +32 -0
- package/extension/native-host.cjs +91 -0
- package/extension/popup.html +72 -0
- package/extension/popup.js +108 -0
- package/package.json +12 -4
package/dist/mcp-server.cjs
CHANGED
|
@@ -139,7 +139,7 @@ function readServerInfo() {
|
|
|
139
139
|
// src/mcp/local-server.ts
|
|
140
140
|
var __dirname = "/Users/eduardoverona/tools/iframer-toolkit/src/mcp";
|
|
141
141
|
var BASE_PORT = parseInt(process.env.IFRAMER_LOCAL_PORT || "3022", 10);
|
|
142
|
-
var PORT_SCAN_ATTEMPTS =
|
|
142
|
+
var PORT_SCAN_ATTEMPTS = 21;
|
|
143
143
|
var STARTUP_TIMEOUT_MS = 15000;
|
|
144
144
|
var HEALTH_POLL_MS = 300;
|
|
145
145
|
var SPAWN_LOCK_STALE_MS = 20000;
|
|
@@ -263,19 +263,25 @@ ${this.readLogTail()}`);
|
|
|
263
263
|
}
|
|
264
264
|
}
|
|
265
265
|
resolveRuntime() {
|
|
266
|
-
|
|
267
|
-
const bunPath = require("child_process").execSync("which bun", { encoding: "utf8" }).trim();
|
|
268
|
-
const serverTs2 = import_path4.default.join(__dirname, "..", "..", "index.ts");
|
|
269
|
-
if (import_fs3.default.existsSync(serverTs2)) {
|
|
270
|
-
return { command: bunPath, args: ["run", serverTs2] };
|
|
271
|
-
}
|
|
272
|
-
} catch {}
|
|
266
|
+
const serverTs = import_path4.default.join(__dirname, "..", "..", "index.ts");
|
|
273
267
|
const serverCjs = import_path4.default.join(__dirname, "..", "..", "dist", "local-server.cjs");
|
|
274
268
|
if (import_fs3.default.existsSync(serverCjs)) {
|
|
275
269
|
return { command: "node", args: [serverCjs] };
|
|
276
270
|
}
|
|
277
|
-
|
|
278
|
-
|
|
271
|
+
if (import_fs3.default.existsSync(serverTs)) {
|
|
272
|
+
try {
|
|
273
|
+
require.resolve("/Users/eduardoverona/tools/iframer-toolkit/node_modules/tsx/dist/loader.mjs");
|
|
274
|
+
return { command: "node", args: ["--import", "tsx", serverTs] };
|
|
275
|
+
} catch {}
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
const bunPath = require("child_process").execSync("which bun", { encoding: "utf8" }).trim();
|
|
279
|
+
if (bunPath && import_fs3.default.existsSync(serverTs)) {
|
|
280
|
+
console.error("[iframer] running local server under bun — extension mode (connectOverCDP) will not work; run `bun run build` to get the node bundle");
|
|
281
|
+
return { command: bunPath, args: ["run", serverTs] };
|
|
282
|
+
}
|
|
283
|
+
} catch {}
|
|
284
|
+
throw new Error("No runnable iframer server entry found: dist/local-server.cjs is missing, tsx is not " + "installed, and bun is unavailable. Run `bun install && bun run build` in the iframer repo.");
|
|
279
285
|
}
|
|
280
286
|
async restart() {
|
|
281
287
|
const info = readServerInfo();
|
|
@@ -374,7 +380,7 @@ async function apiPost(endpoint, body) {
|
|
|
374
380
|
return res.json();
|
|
375
381
|
}
|
|
376
382
|
async function apiGet(endpoint) {
|
|
377
|
-
const res = await fetch(`${BASE_URL}${endpoint}`, { headers: authHeaders() });
|
|
383
|
+
const res = await fetch(`${BASE_URL}${endpoint}`, { headers: authHeaders(), signal: AbortSignal.timeout(30000) });
|
|
378
384
|
return res.json();
|
|
379
385
|
}
|
|
380
386
|
async function localApiPost(endpoint, body) {
|
|
@@ -391,13 +397,13 @@ async function localApiPost(endpoint, body) {
|
|
|
391
397
|
async function localApiGet(endpoint) {
|
|
392
398
|
await ensureLocalServer();
|
|
393
399
|
const url = localServer.getBaseUrl();
|
|
394
|
-
const res = await fetch(`${url}${endpoint}`, { headers: authHeaders(LOCAL_TOKEN) });
|
|
400
|
+
const res = await fetch(`${url}${endpoint}`, { headers: authHeaders(LOCAL_TOKEN), signal: AbortSignal.timeout(30000) });
|
|
395
401
|
return res.json();
|
|
396
402
|
}
|
|
397
403
|
async function localApiDelete(endpoint) {
|
|
398
404
|
await ensureLocalServer();
|
|
399
405
|
const url = localServer.getBaseUrl();
|
|
400
|
-
const res = await fetch(`${url}${endpoint}`, { method: "DELETE", headers: authHeaders(LOCAL_TOKEN) });
|
|
406
|
+
const res = await fetch(`${url}${endpoint}`, { method: "DELETE", headers: authHeaders(LOCAL_TOKEN), signal: AbortSignal.timeout(30000) });
|
|
401
407
|
return res.json();
|
|
402
408
|
}
|
|
403
409
|
async function isDockerRunning() {
|
|
@@ -623,7 +629,7 @@ var import_zod2 = require("zod");
|
|
|
623
629
|
var stepSchema = import_zod2.z.discriminatedUnion("type", [
|
|
624
630
|
import_zod2.z.object({ type: import_zod2.z.literal("navigate"), url: import_zod2.z.string(), waitUntil: import_zod2.z.string().optional() }),
|
|
625
631
|
import_zod2.z.object({ type: import_zod2.z.literal("click"), selector: import_zod2.z.string() }),
|
|
626
|
-
import_zod2.z.object({ type: import_zod2.z.literal("fill"), selector: import_zod2.z.string(), value: import_zod2.z.string() }),
|
|
632
|
+
import_zod2.z.object({ type: import_zod2.z.literal("fill"), selector: import_zod2.z.string(), value: import_zod2.z.string().describe("Sets an input/textarea's value. Framework-aware: fires the React-safe native setter + input/change/blur, so controlled forms (React, react-hook-form, Formik, Vue) register the value AND mark the field touched. This is the fix for 'I filled the field but submit says it's still empty' — always use fill for form fields, not evaluate.") }),
|
|
627
633
|
import_zod2.z.object({ type: import_zod2.z.literal("human-click"), selector: import_zod2.z.string().optional(), x: import_zod2.z.number().optional(), y: import_zod2.z.number().optional() }),
|
|
628
634
|
import_zod2.z.object({ type: import_zod2.z.literal("right-click"), selector: import_zod2.z.string().optional(), x: import_zod2.z.number().optional(), y: import_zod2.z.number().optional() }),
|
|
629
635
|
import_zod2.z.object({ type: import_zod2.z.literal("human-type"), selector: import_zod2.z.string(), value: import_zod2.z.string() }),
|
|
@@ -631,8 +637,12 @@ var stepSchema = import_zod2.z.discriminatedUnion("type", [
|
|
|
631
637
|
import_zod2.z.object({ type: import_zod2.z.literal("extract"), expression: import_zod2.z.string() }),
|
|
632
638
|
import_zod2.z.object({ type: import_zod2.z.literal("wait"), ms: import_zod2.z.number() }),
|
|
633
639
|
import_zod2.z.object({ type: import_zod2.z.literal("wait-for"), selector: import_zod2.z.string(), timeout: import_zod2.z.number().optional() }),
|
|
634
|
-
import_zod2.z.object({ type: import_zod2.z.literal("scroll"), deltaY: import_zod2.z.number().optional() }),
|
|
635
|
-
import_zod2.z.object({ type: import_zod2.z.literal("keyboard"), key: import_zod2.z.string() }),
|
|
640
|
+
import_zod2.z.object({ type: import_zod2.z.literal("scroll"), deltaY: import_zod2.z.number().optional(), selector: import_zod2.z.string().optional().describe("Scroll within this element instead of the window") }),
|
|
641
|
+
import_zod2.z.object({ type: import_zod2.z.literal("keyboard"), key: import_zod2.z.string(), meta: import_zod2.z.boolean().optional(), ctrl: import_zod2.z.boolean().optional(), shift: import_zod2.z.boolean().optional(), alt: import_zod2.z.boolean().optional() }),
|
|
642
|
+
import_zod2.z.object({ type: import_zod2.z.literal("read"), selector: import_zod2.z.string().optional().describe("Element to read visible text from (CSS or @e ref). Omit for the whole page body."), maxChars: import_zod2.z.number().optional().describe("Cap the returned text length (default 6000). Raise it only when you actually need more — larger reads cost proportionally more context.") }),
|
|
643
|
+
import_zod2.z.object({ type: import_zod2.z.literal("upload"), selector: import_zod2.z.string().describe("The <input type=file> to set (CSS, @e ref, or @a anchor)."), files: import_zod2.z.array(import_zod2.z.string()).describe("Absolute local file path(s) to upload. Paths must exist on this machine (same machine as the browser).") }),
|
|
644
|
+
import_zod2.z.object({ type: import_zod2.z.literal("paste"), selector: import_zod2.z.string().optional().describe("Field to paste the OS clipboard into (CSS, @e ref, or @a anchor). Omit to insert at the currently focused element. Reliable — inserts via CDP Input.insertText, unlike a ⌘V keyboard step (the extension relay ignores modifier keys).") }),
|
|
645
|
+
import_zod2.z.object({ type: import_zod2.z.literal("download"), url: import_zod2.z.string().describe("URL of the file to download. Fetched through the browser's session (cookies included, so auth'd downloads work) and written to disk server-side — no Save-As dialog."), path: import_zod2.z.string().optional().describe("Absolute local path to save to. Omit to save into ~/.iframer/downloads/ with the URL's filename. Read the returned path to open the file.") }),
|
|
636
646
|
import_zod2.z.object({ type: import_zod2.z.literal("type-code"), value: import_zod2.z.string(), selector: import_zod2.z.string().optional() }),
|
|
637
647
|
import_zod2.z.object({ type: import_zod2.z.literal("login"), domain: import_zod2.z.string(), usernameSelector: import_zod2.z.string().optional(), passwordSelector: import_zod2.z.string().optional(), submitSelector: import_zod2.z.string().optional(), totpSelector: import_zod2.z.string().optional() }),
|
|
638
648
|
import_zod2.z.object({ type: import_zod2.z.literal("solve-captcha") }),
|
|
@@ -669,6 +679,10 @@ Key step types:
|
|
|
669
679
|
|
|
670
680
|
IMPORTANT — Element refs (@e1, @e2...): All selector fields accept @e refs from snapshot, find, or annotated screenshot. PREFER refs over CSS selectors.
|
|
671
681
|
|
|
682
|
+
IMPORTANT — Saved anchors (@a:<name>): Selector fields also accept persisted, per-domain anchors from the \`remember\` tool. Call \`remember get <domain>\` before a UI task; if an anchor exists, target it directly (e.g. selector="@a:composer") — no snapshot needed. After finding a new element that works, \`remember save\` it. Unlike @e refs (which reset every snapshot), @a: anchors persist across runs.
|
|
683
|
+
|
|
684
|
+
FILLING FORMS: Always use the \`fill\` step for text inputs/textareas — NOT \`evaluate\` to set .value. \`fill\` is framework-aware: it fires the React-safe native setter plus input/change/blur, so controlled forms (React, react-hook-form, Formik, Vue) actually register the value and mark the field "touched". This prevents the common "I filled every field but submit still says they're required/empty" failure — which is a form-framework state issue, not a real empty field. If a submit is still rejected as incomplete after filling, re-run \`fill\` on the flagged field (it re-triggers the blur/validation) rather than assuming the value didn't land.
|
|
685
|
+
|
|
672
686
|
Returns: ok, completedSteps, results, obstacles, capturedApi, and on failure: errorContext with screenshot path, URL, errorType, suggestion, retryable.`, {
|
|
673
687
|
steps: import_zod3.z.array(stepSchema).describe("Pipeline steps to execute sequentially"),
|
|
674
688
|
options: import_zod3.z.object({
|
|
@@ -677,12 +691,33 @@ Returns: ok, completedSteps, results, obstacles, capturedApi, and on failure: er
|
|
|
677
691
|
continueOnObstacle: import_zod3.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
|
|
678
692
|
continueOnError: import_zod3.z.boolean().optional().describe("Continue past failing steps (default: false)"),
|
|
679
693
|
captureApi: import_zod3.z.boolean().optional().describe("Record all API calls (XHR/fetch) the page makes."),
|
|
680
|
-
mode: import_zod3.z.enum(["headless", "binary-headful", "docker-headful"]).optional().describe("DO NOT SET THIS unless user explicitly requests a mode. iframer auto-selects and auto-escalates."),
|
|
694
|
+
mode: import_zod3.z.enum(["headless", "binary-headful", "docker-headful", "extension"]).optional().describe("DO NOT SET THIS unless user explicitly requests a mode. iframer auto-selects and auto-escalates. Use 'extension' ONLY to drive a tab in the user's real Chrome via the iframer extension — requires options.tabId (get it from the `tabs` tool)."),
|
|
681
695
|
autoEscalate: import_zod3.z.boolean().optional().describe("Auto-retry with a stronger mode if blocked (default: true)"),
|
|
682
|
-
instanceId: import_zod3.z.string().optional().describe("Run in a named parallel browser within this session (default: 'default'). Use distinct ids to drive several browsers at once, e.g. one per account — each keeps its own login/session state.")
|
|
696
|
+
instanceId: import_zod3.z.string().optional().describe("Run in a named parallel browser within this session (default: 'default'). Use distinct ids to drive several browsers at once, e.g. one per account — each keeps its own login/session state."),
|
|
697
|
+
tabId: import_zod3.z.number().optional().describe("Only with mode='extension': the id of the real Chrome tab to drive (from the `tabs` tool). Input is trusted OS-level (chrome.debugger); Chrome shows its 'is being debugged' bar while the run is in progress."),
|
|
698
|
+
clientId: import_zod3.z.string().optional().describe("Only with mode='extension', and only needed when several profiles/browsers are connected and a tab is ambiguous: the clientId of the profile that owns the tab (from the `tabs` tool)."),
|
|
699
|
+
focus: import_zod3.z.boolean().optional().describe("Only with mode='extension': bring the tab's window to the OS foreground while driving. Default false — the tab is driven in the background (activated in its window, focus-emulated) without interrupting the user. Set true only if a site ignores background input.")
|
|
683
700
|
}).optional()
|
|
684
701
|
}, async (params) => {
|
|
685
702
|
try {
|
|
703
|
+
if (params.options?.mode === "extension") {
|
|
704
|
+
const tabId = params.options?.tabId;
|
|
705
|
+
if (typeof tabId !== "number") {
|
|
706
|
+
return err("mode='extension' requires options.tabId. Call the `tabs` tool first to " + "find the id of the tab the user wants to drive.");
|
|
707
|
+
}
|
|
708
|
+
const extResult = await localApiPost("/extension/execute", {
|
|
709
|
+
tabId,
|
|
710
|
+
clientId: params.options?.clientId,
|
|
711
|
+
steps: params.steps,
|
|
712
|
+
options: params.options
|
|
713
|
+
});
|
|
714
|
+
const extLines = formatExecuteResult(extResult);
|
|
715
|
+
const content2 = [{ type: "text", text: extLines.join(`
|
|
716
|
+
`) }];
|
|
717
|
+
if (!extResult.ok)
|
|
718
|
+
return { content: content2, isError: true };
|
|
719
|
+
return { content: content2 };
|
|
720
|
+
}
|
|
686
721
|
const dockerRunning = await isDockerRunning();
|
|
687
722
|
async function runWithMode(mode) {
|
|
688
723
|
if (mode === "docker-headful") {
|
|
@@ -800,6 +835,12 @@ Final page: ${data.finalState.title}`);
|
|
|
800
835
|
Knowledge cache updated for ${domain}. Before the next browser run on this domain, call \`knowledge get ${domain}\` — you may be able to skip the browser entirely.`);
|
|
801
836
|
} catch {}
|
|
802
837
|
}
|
|
838
|
+
for (const r of data.results || []) {
|
|
839
|
+
if (r.tabSwitchedTo) {
|
|
840
|
+
lines.push(`
|
|
841
|
+
↳ step ${r.stepIndex} opened a new tab — pipeline is now on: ${r.tabSwitchedTo}`);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
803
844
|
const meaningful = (data.results || []).filter((r) => r.ok && r.result !== undefined && r.result !== null);
|
|
804
845
|
for (const r of meaningful) {
|
|
805
846
|
if (r.step.type === "snapshot") {
|
|
@@ -822,9 +863,16 @@ Found: ${res.ref} ${res.role} "${res.name}" (${res.matchCount} match${res.matchC
|
|
|
822
863
|
--- Annotated screenshot refs ---`);
|
|
823
864
|
lines.push(res.refs);
|
|
824
865
|
}
|
|
825
|
-
} else if (r.
|
|
866
|
+
} else if (r.step.type === "read") {
|
|
867
|
+
const res = r.result;
|
|
868
|
+
if (res?.text !== undefined) {
|
|
869
|
+
lines.push(`
|
|
870
|
+
--- Read (step ${r.stepIndex}${res.truncated ? ", truncated" : ""}) ---`);
|
|
871
|
+
lines.push(res.text);
|
|
872
|
+
}
|
|
873
|
+
} else if (r.step.type === "extract" || r.step.type === "evaluate") {
|
|
826
874
|
lines.push(`
|
|
827
|
-
step ${r.stepIndex}: ${JSON.stringify(r.result)}`);
|
|
875
|
+
step ${r.stepIndex} (${r.step.type}): ${JSON.stringify(r.result)}`);
|
|
828
876
|
}
|
|
829
877
|
}
|
|
830
878
|
if (data.obstacles && data.obstacles.length > 0) {
|
|
@@ -923,7 +971,9 @@ The outputDir defaults to ./<domain>/. Ask the user where to save if unclear.`,
|
|
|
923
971
|
staleTimeoutMs: import_zod4.z.number().optional().describe("Override the 20s stale-state timeout per step"),
|
|
924
972
|
continueOnObstacle: import_zod4.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
|
|
925
973
|
continueOnError: import_zod4.z.boolean().optional().describe("Continue past failing steps (default: false)"),
|
|
926
|
-
mode: import_zod4.z.enum(["headless", "binary-headful", "docker-headful"]).optional().describe("Browser mode override")
|
|
974
|
+
mode: import_zod4.z.enum(["headless", "binary-headful", "docker-headful", "extension"]).optional().describe("Browser mode override. Use 'extension' to capture the API of a tab already open in the user's real Chrome — requires options.tabId from the `tabs` tool. Chrome shows its 'is being debugged' bar while the capture runs."),
|
|
975
|
+
tabId: import_zod4.z.number().optional().describe("Only with mode='extension': the real Chrome tab to reverse-engineer (from the `tabs` tool)."),
|
|
976
|
+
clientId: import_zod4.z.string().optional().describe("Only with mode='extension', when multiple profiles are connected and the tab is ambiguous: the owning profile's clientId (from the `tabs` tool).")
|
|
927
977
|
}).optional()
|
|
928
978
|
}, async (params) => {
|
|
929
979
|
try {
|
|
@@ -933,7 +983,22 @@ The outputDir defaults to ./<domain>/. Ask the user where to save if unclear.`,
|
|
|
933
983
|
};
|
|
934
984
|
const mode = params.options?.mode;
|
|
935
985
|
const dockerRunning = await isDockerRunning();
|
|
936
|
-
|
|
986
|
+
let captureResult;
|
|
987
|
+
if (mode === "extension") {
|
|
988
|
+
if (typeof params.options?.tabId !== "number") {
|
|
989
|
+
return err("mode='extension' requires options.tabId. Call the `tabs` tool first to find the tab to reverse-engineer.");
|
|
990
|
+
}
|
|
991
|
+
captureResult = await localApiPost("/extension/execute", {
|
|
992
|
+
tabId: params.options.tabId,
|
|
993
|
+
clientId: params.options.clientId,
|
|
994
|
+
steps: params.steps,
|
|
995
|
+
options: { ...params.options, captureApi: true }
|
|
996
|
+
});
|
|
997
|
+
} else if (mode === "docker-headful" && dockerRunning) {
|
|
998
|
+
captureResult = await apiPost("/execute", execParams);
|
|
999
|
+
} else {
|
|
1000
|
+
captureResult = await localApiPost("/execute", execParams);
|
|
1001
|
+
}
|
|
937
1002
|
const lines = [];
|
|
938
1003
|
lines.push(`ok: ${captureResult.ok}`);
|
|
939
1004
|
lines.push(`steps: ${captureResult.completedSteps}/${captureResult.totalSteps}`);
|
|
@@ -991,10 +1056,10 @@ Full captured data saved to: ${jsonPath}`);
|
|
|
991
1056
|
}
|
|
992
1057
|
let text = lines.join(`
|
|
993
1058
|
`);
|
|
994
|
-
if (text.length >
|
|
995
|
-
text = text.slice(0,
|
|
1059
|
+
if (text.length > 30000) {
|
|
1060
|
+
text = text.slice(0, 30000) + `
|
|
996
1061
|
|
|
997
|
-
[...
|
|
1062
|
+
[... index truncated — read captured-api.json for the full endpoint list and all detail]`;
|
|
998
1063
|
}
|
|
999
1064
|
const content = [{ type: "text", text }];
|
|
1000
1065
|
if (!captureResult.ok)
|
|
@@ -1034,18 +1099,11 @@ Auth:`);
|
|
|
1034
1099
|
}
|
|
1035
1100
|
const protocolSummary = Array.from(byProtocol.entries()).map(([p, eps]) => `${p}=${eps.length}`).join(", ");
|
|
1036
1101
|
lines.push(`
|
|
1037
|
-
Endpoints (${api.endpoints.length}) [${protocolSummary}]:`);
|
|
1102
|
+
Endpoints (${api.endpoints.length}) [${protocolSummary}] — full detail in captured-api.json:`);
|
|
1038
1103
|
for (const ep of api.endpoints) {
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
lines.push(`
|
|
1042
|
-
lines.push(` ${ep.method} ${ep.path} → ${ep.responseStatus}`);
|
|
1043
|
-
if (ep.requestBody) {
|
|
1044
|
-
const body = ep.requestBody;
|
|
1045
|
-
const signalKeys = extractSignalKeys(body);
|
|
1046
|
-
if (signalKeys)
|
|
1047
|
-
lines.push(` Params: ${signalKeys}`);
|
|
1048
|
-
}
|
|
1104
|
+
const params2 = ep.requestBody ? extractSignalKeys(ep.requestBody) : null;
|
|
1105
|
+
const tail = params2 ? ` {${params2}}` : "";
|
|
1106
|
+
lines.push(` [${ep.protocol}/${ep.verb}] ${ep.method} ${ep.path} → ${ep.functionName}${tail}`);
|
|
1049
1107
|
}
|
|
1050
1108
|
}
|
|
1051
1109
|
const mainDomain = capturedApi[0]?.domain || "api";
|
|
@@ -1122,6 +1180,9 @@ function extractSignalKeys(body) {
|
|
|
1122
1180
|
continue;
|
|
1123
1181
|
if (typeof v === "string" && v.length > 80) {
|
|
1124
1182
|
signalEntries.push(`${k}=${v.slice(0, 40)}...`);
|
|
1183
|
+
} else if (Array.isArray(v)) {
|
|
1184
|
+
const items = v.slice(0, 8).map((x) => String(x)).join(",");
|
|
1185
|
+
signalEntries.push(`${k}=[${items}${v.length > 8 ? ",…" : ""}]`);
|
|
1125
1186
|
} else if (typeof v === "object" && v !== null) {
|
|
1126
1187
|
const keys = Object.keys(v).slice(0, 5).join(",");
|
|
1127
1188
|
signalEntries.push(`${k}={${keys}${Object.keys(v).length > 5 ? ",..." : ""}}`);
|
|
@@ -1633,6 +1694,452 @@ Actions:
|
|
|
1633
1694
|
});
|
|
1634
1695
|
}
|
|
1635
1696
|
|
|
1697
|
+
// src/mcp/tools/tabs.ts
|
|
1698
|
+
var import_zod8 = require("zod");
|
|
1699
|
+
function registerTabsTool(server) {
|
|
1700
|
+
server.tool("tabs", `List the tabs currently open in the user's REAL Chrome browser, through the iframer browser extension.
|
|
1701
|
+
|
|
1702
|
+
Use this when the user says something like "use my open tab", "the tab I have here", "my Gmail tab", or references a site/screenshot they already have open. Match their reference against the returned tabs (by url or title), pick the tab id, then call \`execute\` with options.mode="extension" and options.tabId=<id> to drive that exact tab on their real logged-in session (Chrome shows its "is being debugged" bar while a run is in progress).
|
|
1703
|
+
|
|
1704
|
+
Requires the iframer Chrome extension to be installed and connected (paired once with the token). Once connected, iframer can see and drive any open tab. If nothing is connected, this returns a clear message telling the user how to connect.
|
|
1705
|
+
|
|
1706
|
+
Returns: connected (bool), and tabs: [{ id, title, url, active, windowId }]. If several tabs match the user's reference, ask them which one rather than guessing.
|
|
1707
|
+
|
|
1708
|
+
action="open" opens a NEW tab in the user's real Chrome (native — not a page popup) at options.url, and returns its tab id so you can immediately drive it with \`execute\` mode="extension".
|
|
1709
|
+
|
|
1710
|
+
Tab GROUP management (native Chrome tab groups):
|
|
1711
|
+
- action="group": put tabIds into a group (new, or add to an existing groupId); optionally title/color/collapsed.
|
|
1712
|
+
- action="ungroup": remove tabIds from their group.
|
|
1713
|
+
- action="update-group": rename/recolor/collapse an EXISTING group by groupId (no tabIds needed).
|
|
1714
|
+
- action="groups": list all current groups (id, title, color, collapsed) — use to find a groupId to update.`, {
|
|
1715
|
+
action: import_zod8.z.enum(["list", "open", "group", "ungroup", "update-group", "groups"]).optional().describe("'list' (default) lists open tabs; 'open' opens a new tab; 'group'/'ungroup' add/remove tabIds to a group; 'update-group' renames/recolors an existing group by groupId; 'groups' lists all groups."),
|
|
1716
|
+
url: import_zod8.z.string().optional().describe("With action='open': the URL to open (omit for a blank tab)."),
|
|
1717
|
+
active: import_zod8.z.boolean().optional().describe("With action='open': focus the new tab (default true)."),
|
|
1718
|
+
tabIds: import_zod8.z.array(import_zod8.z.number()).optional().describe("With action='group': the tab ids to group (must be in the same window)."),
|
|
1719
|
+
title: import_zod8.z.string().optional().describe("With action='group': the group's label."),
|
|
1720
|
+
color: import_zod8.z.enum(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]).optional().describe("With action='group': the group's color."),
|
|
1721
|
+
collapsed: import_zod8.z.boolean().optional().describe("With action='group': collapse the group."),
|
|
1722
|
+
groupId: import_zod8.z.number().optional().describe("With action='group': add tabs to this existing group instead of creating a new one."),
|
|
1723
|
+
clientId: import_zod8.z.string().optional().describe("With action='open'/'group' and multiple profiles connected: which profile to act in."),
|
|
1724
|
+
filter: import_zod8.z.string().optional().describe("With action='list': case-insensitive substring to match against tab url or title (e.g. 'gmail', 'github.com'). Omit to list every open tab.")
|
|
1725
|
+
}, async ({ action, url, active, tabIds, title, color, collapsed, groupId, clientId, filter }) => {
|
|
1726
|
+
try {
|
|
1727
|
+
if (action === "open") {
|
|
1728
|
+
const res = await localApiPost("/extension/tab/create", { url, active, clientId });
|
|
1729
|
+
if (!res.ok || !res.tab)
|
|
1730
|
+
return err(res.error || "Failed to open tab.");
|
|
1731
|
+
const t = res.tab;
|
|
1732
|
+
return {
|
|
1733
|
+
content: [
|
|
1734
|
+
{
|
|
1735
|
+
type: "text",
|
|
1736
|
+
text: `Opened tab [id ${t.id}] ${t.title || t.url}
|
|
1737
|
+
${t.url}
|
|
1738
|
+
Drive it with: execute mode="extension", tabId=${t.id}.`
|
|
1739
|
+
}
|
|
1740
|
+
]
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
if (action === "group") {
|
|
1744
|
+
if (!tabIds || tabIds.length === 0)
|
|
1745
|
+
return err("action='group' requires tabIds (array of tab ids).");
|
|
1746
|
+
const res = await localApiPost("/extension/tab/group", { tabIds, title, color, collapsed, groupId, clientId });
|
|
1747
|
+
if (!res.ok || !res.group)
|
|
1748
|
+
return err(res.error || "Failed to group tabs.");
|
|
1749
|
+
const g = res.group;
|
|
1750
|
+
return {
|
|
1751
|
+
content: [
|
|
1752
|
+
{
|
|
1753
|
+
type: "text",
|
|
1754
|
+
text: `Grouped ${tabIds.length} tab(s) into group ${g.groupId}${g.title ? ` "${g.title}"` : ""}${g.color ? ` (${g.color})` : ""}${g.collapsed ? ", collapsed" : ""}.`
|
|
1755
|
+
}
|
|
1756
|
+
]
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
if (action === "ungroup") {
|
|
1760
|
+
if (!tabIds || tabIds.length === 0)
|
|
1761
|
+
return err("action='ungroup' requires tabIds.");
|
|
1762
|
+
const res = await localApiPost("/extension/tab/ungroup", { tabIds, clientId });
|
|
1763
|
+
if (!res.ok)
|
|
1764
|
+
return err(res.error || "Failed to ungroup tabs.");
|
|
1765
|
+
return { content: [{ type: "text", text: `Ungrouped ${tabIds.length} tab(s).` }] };
|
|
1766
|
+
}
|
|
1767
|
+
if (action === "update-group") {
|
|
1768
|
+
if (typeof groupId !== "number")
|
|
1769
|
+
return err("action='update-group' requires groupId (from action='groups'). Pass any of title, color, collapsed.");
|
|
1770
|
+
const res = await localApiPost("/extension/group/update", { groupId, title, color, collapsed, clientId });
|
|
1771
|
+
if (!res.ok || !res.group)
|
|
1772
|
+
return err(res.error || "Failed to update group.");
|
|
1773
|
+
const g = res.group;
|
|
1774
|
+
return { content: [{ type: "text", text: `Updated group ${g.groupId} → title "${g.title}", color ${g.color}${g.collapsed ? ", collapsed" : ""}.` }] };
|
|
1775
|
+
}
|
|
1776
|
+
if (action === "groups") {
|
|
1777
|
+
const res = await localApiPost("/extension/groups", { clientId });
|
|
1778
|
+
if (!res.ok)
|
|
1779
|
+
return err(res.error || "Failed to list groups.");
|
|
1780
|
+
const groups = res.groups || [];
|
|
1781
|
+
if (groups.length === 0)
|
|
1782
|
+
return { content: [{ type: "text", text: "No tab groups open." }] };
|
|
1783
|
+
const lines2 = [`${groups.length} tab group(s):`, ""];
|
|
1784
|
+
for (const g of groups)
|
|
1785
|
+
lines2.push(` [group ${g.groupId}] "${g.title || "(untitled)"}" — ${g.color}${g.collapsed ? ", collapsed" : ""} (window ${g.windowId})`);
|
|
1786
|
+
return { content: [{ type: "text", text: lines2.join(`
|
|
1787
|
+
`) }] };
|
|
1788
|
+
}
|
|
1789
|
+
const status = await localApiGet("/extension/status");
|
|
1790
|
+
if (!status.connected) {
|
|
1791
|
+
return err(`No iframer extension is connected.
|
|
1792
|
+
|
|
1793
|
+
` + `To use your real Chrome tabs:
|
|
1794
|
+
` + "1. Install the iframer extension (chrome://extensions → Load unpacked → the `extension/` folder).\n" + "2. Run `iframer install extension` in a terminal, then restart the browser — the extension pairs itself.\n" + " (Manual fallback: click the iframer icon and paste the token from `cat ~/.iframer/secret`.)\n" + "Once the dot is green, run `tabs` again — iframer can then see and drive any open tab.");
|
|
1795
|
+
}
|
|
1796
|
+
const data = await localApiPost("/extension/tabs", {});
|
|
1797
|
+
let tabs = data.tabs || [];
|
|
1798
|
+
const clients = data.clients || [];
|
|
1799
|
+
const multiProfile = clients.length > 1;
|
|
1800
|
+
if (filter) {
|
|
1801
|
+
const f = filter.toLowerCase();
|
|
1802
|
+
tabs = tabs.filter((t) => t.url.toLowerCase().includes(f) || (t.title || "").toLowerCase().includes(f));
|
|
1803
|
+
}
|
|
1804
|
+
if (tabs.length === 0) {
|
|
1805
|
+
return {
|
|
1806
|
+
content: [
|
|
1807
|
+
{
|
|
1808
|
+
type: "text",
|
|
1809
|
+
text: filter ? `Extension connected, but no open tab matches "${filter}".` : "Extension connected, but no tabs were reported."
|
|
1810
|
+
}
|
|
1811
|
+
]
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1814
|
+
const profiles = clients.map((c) => c.profileName || c.clientId.slice(0, 8)).join(", ");
|
|
1815
|
+
const ver = clients[0]?.extVersion ? ` [ext v${clients[0].extVersion}]` : "";
|
|
1816
|
+
const lines = [
|
|
1817
|
+
`Connected: ${clients.length} profile${clients.length > 1 ? "s" : ""}${profiles ? ` (${profiles})` : ""}${ver}.`,
|
|
1818
|
+
`${tabs.length} tab${tabs.length > 1 ? "s" : ""}${filter ? ` matching "${filter}"` : ""}:`,
|
|
1819
|
+
""
|
|
1820
|
+
];
|
|
1821
|
+
for (const t of tabs) {
|
|
1822
|
+
const prof = multiProfile ? ` «${t.profileName || t.clientId.slice(0, 8)}»` : "";
|
|
1823
|
+
lines.push(` [id ${t.id}]${t.active ? " (active)" : ""}${prof} ${t.title}`);
|
|
1824
|
+
lines.push(` ${t.url}`);
|
|
1825
|
+
if (multiProfile)
|
|
1826
|
+
lines.push(` clientId: ${t.clientId}`);
|
|
1827
|
+
}
|
|
1828
|
+
lines.push("");
|
|
1829
|
+
lines.push('To drive one: execute with options.mode="extension", options.tabId=<id>.');
|
|
1830
|
+
if (multiProfile) {
|
|
1831
|
+
lines.push("Multiple profiles are connected — if two tabs share a title, also pass options.clientId " + "(shown as «profile» above maps to a clientId) so the right profile is driven.");
|
|
1832
|
+
}
|
|
1833
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
1834
|
+
`) }] };
|
|
1835
|
+
} catch (e) {
|
|
1836
|
+
return err(`Error listing tabs: ${getErrorMessage(e)}`);
|
|
1837
|
+
}
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// src/mcp/tools/anchors.ts
|
|
1842
|
+
var import_zod9 = require("zod");
|
|
1843
|
+
|
|
1844
|
+
// src/lib/knowledge/component-map.ts
|
|
1845
|
+
var import_fs8 = __toESM(require("fs"));
|
|
1846
|
+
var import_path10 = __toESM(require("path"));
|
|
1847
|
+
function anchorsPath(domain) {
|
|
1848
|
+
return import_path10.default.join(getKnowledgeDir(), `${sanitizeDomain(domain)}.anchors.json`);
|
|
1849
|
+
}
|
|
1850
|
+
function loadComponentMap(domain) {
|
|
1851
|
+
const norm = normalizeDomain(domain);
|
|
1852
|
+
try {
|
|
1853
|
+
const raw = import_fs8.default.readFileSync(anchorsPath(norm), "utf8");
|
|
1854
|
+
const parsed = JSON.parse(raw);
|
|
1855
|
+
return {
|
|
1856
|
+
domain: parsed.domain || norm,
|
|
1857
|
+
anchors: parsed.anchors || {},
|
|
1858
|
+
quirks: Array.isArray(parsed.quirks) ? parsed.quirks : []
|
|
1859
|
+
};
|
|
1860
|
+
} catch {
|
|
1861
|
+
return { domain: norm, anchors: {}, quirks: [] };
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
function write(cm) {
|
|
1865
|
+
import_fs8.default.mkdirSync(getKnowledgeDir(), { recursive: true });
|
|
1866
|
+
import_fs8.default.writeFileSync(anchorsPath(cm.domain), JSON.stringify(cm, null, 2), "utf8");
|
|
1867
|
+
}
|
|
1868
|
+
function saveAnchor(domain, input, now) {
|
|
1869
|
+
const cm = loadComponentMap(domain);
|
|
1870
|
+
cm.anchors[input.name] = {
|
|
1871
|
+
name: input.name,
|
|
1872
|
+
selector: input.selector,
|
|
1873
|
+
role: input.role,
|
|
1874
|
+
description: input.description,
|
|
1875
|
+
quirks: input.quirks && input.quirks.length ? input.quirks : undefined,
|
|
1876
|
+
uses: 0,
|
|
1877
|
+
fails: 0,
|
|
1878
|
+
lastVerified: now
|
|
1879
|
+
};
|
|
1880
|
+
write(cm);
|
|
1881
|
+
}
|
|
1882
|
+
function removeAnchor(domain, name) {
|
|
1883
|
+
const cm = loadComponentMap(domain);
|
|
1884
|
+
if (!(name in cm.anchors))
|
|
1885
|
+
return false;
|
|
1886
|
+
delete cm.anchors[name];
|
|
1887
|
+
write(cm);
|
|
1888
|
+
return true;
|
|
1889
|
+
}
|
|
1890
|
+
function setDomainQuirks(domain, quirks) {
|
|
1891
|
+
const cm = loadComponentMap(domain);
|
|
1892
|
+
cm.quirks = Array.from(new Set([...cm.quirks, ...quirks]));
|
|
1893
|
+
write(cm);
|
|
1894
|
+
}
|
|
1895
|
+
function listAnchorDomains() {
|
|
1896
|
+
try {
|
|
1897
|
+
return import_fs8.default.readdirSync(getKnowledgeDir()).filter((f) => f.endsWith(".anchors.json")).map((f) => f.replace(/\.anchors\.json$/, ""));
|
|
1898
|
+
} catch {
|
|
1899
|
+
return [];
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
// src/mcp/tools/anchors.ts
|
|
1904
|
+
function registerRememberTool(server) {
|
|
1905
|
+
server.tool("remember", `Persisted per-domain map of a website's UI elements ("anchors"), so the agent recalls where things are instead of re-exploring the DOM every run.
|
|
1906
|
+
|
|
1907
|
+
WORKFLOW:
|
|
1908
|
+
1. Before a UI task on a site, call \`remember get <domain>\`. If an anchor exists (e.g. "composer", "send-button"), use it directly: put \`@a:<name>\` in any selector field of an \`execute\` step (click/fill/etc.). It resolves to the saved selector — no snapshot needed.
|
|
1909
|
+
2. If the site is new or the anchor you need is missing, discover the element with a \`snapshot\` or \`find\` step, act on it, then \`remember save\` it (name + the selector that worked, plus any quirks) so future runs are instant.
|
|
1910
|
+
3. SELF-HEAL: if a step using \`@a:<name>\` FAILS, the page changed — do NOT keep retrying the same anchor. Re-discover with snapshot/find, then \`remember save\` the new selector under the same name (overwrites, resets the health counters). Prefer stable selectors (aria-label, data-qa, role+name) over brittle generated CSS.
|
|
1911
|
+
4. Record site quirks with \`remember quirk\` (e.g. "synthetic clicks ignored — use trusted/coordinate clicks", "@here triggers a confirmation modal — click Send"). \`get\` surfaces them so the agent isn't surprised.
|
|
1912
|
+
|
|
1913
|
+
Anchors live at ~/.iframer/knowledge/<domain>.anchors.json, alongside the API knowledge cache.`, {
|
|
1914
|
+
action: import_zod9.z.enum(["get", "save", "forget", "list", "quirk"]).describe("get: show a domain's anchors+quirks | save: create/overwrite an anchor | forget: delete an anchor | list: all domains with anchors | quirk: add site-wide quirk note(s)"),
|
|
1915
|
+
domain: import_zod9.z.string().optional().describe("Site domain, e.g. 'slack.com' or 'app.slack.com' (required for all actions except list)."),
|
|
1916
|
+
name: import_zod9.z.string().optional().describe("Anchor name for save/forget, e.g. 'composer', 'send-button', 'search'. Short, stable, kebab-case."),
|
|
1917
|
+
selector: import_zod9.z.string().optional().describe("With save: the CSS selector that locates the element. Prefer stable attributes: [aria-label=...], [data-qa=...], role+name. Avoid brittle generated class chains."),
|
|
1918
|
+
role: import_zod9.z.string().optional().describe("With save (optional): the element role, e.g. textbox, button, link."),
|
|
1919
|
+
description: import_zod9.z.string().optional().describe("With save (optional): a short human note about the element."),
|
|
1920
|
+
quirks: import_zod9.z.array(import_zod9.z.string()).optional().describe("With save: element-specific gotchas. With quirk: site-wide gotchas to append.")
|
|
1921
|
+
}, async ({ action, domain, name, selector, role, description, quirks }) => {
|
|
1922
|
+
try {
|
|
1923
|
+
if (action === "list") {
|
|
1924
|
+
const domains = listAnchorDomains();
|
|
1925
|
+
if (domains.length === 0) {
|
|
1926
|
+
return { content: [{ type: "text", text: "No anchors saved yet. Use `remember save` after locating an element with snapshot/find." }] };
|
|
1927
|
+
}
|
|
1928
|
+
const lines = [`Domains with saved anchors:
|
|
1929
|
+
`];
|
|
1930
|
+
for (const d of domains) {
|
|
1931
|
+
const cm = loadComponentMap(d);
|
|
1932
|
+
lines.push(` ${d} — ${Object.keys(cm.anchors).length} anchor(s)${cm.quirks.length ? `, ${cm.quirks.length} quirk(s)` : ""}`);
|
|
1933
|
+
}
|
|
1934
|
+
lines.push("\nCall `remember get <domain>` for details.");
|
|
1935
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
1936
|
+
`) }] };
|
|
1937
|
+
}
|
|
1938
|
+
if (!domain)
|
|
1939
|
+
return err(`\`${action}\` requires a domain (e.g. 'app.slack.com').`);
|
|
1940
|
+
if (action === "get") {
|
|
1941
|
+
const cm = loadComponentMap(domain);
|
|
1942
|
+
const names = Object.keys(cm.anchors);
|
|
1943
|
+
if (names.length === 0 && cm.quirks.length === 0) {
|
|
1944
|
+
return { content: [{ type: "text", text: `No anchors saved for ${domain} yet. Discover elements with snapshot/find, then \`remember save\` them.` }] };
|
|
1945
|
+
}
|
|
1946
|
+
const lines = [`Component map for ${domain}:
|
|
1947
|
+
`];
|
|
1948
|
+
if (cm.quirks.length) {
|
|
1949
|
+
lines.push("Site quirks:");
|
|
1950
|
+
for (const q of cm.quirks)
|
|
1951
|
+
lines.push(` - ${q}`);
|
|
1952
|
+
lines.push("");
|
|
1953
|
+
}
|
|
1954
|
+
lines.push(`Anchors (use as @a:<name> in any selector):`);
|
|
1955
|
+
for (const a of Object.values(cm.anchors)) {
|
|
1956
|
+
const health = a.fails > 0 ? ` [${a.uses}✓/${a.fails}✗${a.fails >= a.uses && a.fails >= 2 ? " — likely STALE, re-verify" : ""}]` : a.uses > 0 ? ` [${a.uses}✓]` : "";
|
|
1957
|
+
lines.push(` @a:${a.name}${a.role ? ` (${a.role})` : ""} → ${a.selector}${health}`);
|
|
1958
|
+
if (a.description)
|
|
1959
|
+
lines.push(` ${a.description}`);
|
|
1960
|
+
if (a.quirks?.length)
|
|
1961
|
+
for (const q of a.quirks)
|
|
1962
|
+
lines.push(` • ${q}`);
|
|
1963
|
+
}
|
|
1964
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
1965
|
+
`) }] };
|
|
1966
|
+
}
|
|
1967
|
+
if (action === "quirk") {
|
|
1968
|
+
if (!quirks || quirks.length === 0)
|
|
1969
|
+
return err("`quirk` requires a non-empty `quirks` array.");
|
|
1970
|
+
setDomainQuirks(domain, quirks);
|
|
1971
|
+
return { content: [{ type: "text", text: `Added ${quirks.length} quirk(s) to ${domain}.` }] };
|
|
1972
|
+
}
|
|
1973
|
+
if (action === "forget") {
|
|
1974
|
+
if (!name)
|
|
1975
|
+
return err("`forget` requires the anchor `name`.");
|
|
1976
|
+
const removed = removeAnchor(domain, name);
|
|
1977
|
+
return { content: [{ type: "text", text: removed ? `Removed anchor @a:${name} from ${domain}.` : `No anchor named '${name}' for ${domain}.` }] };
|
|
1978
|
+
}
|
|
1979
|
+
if (!name)
|
|
1980
|
+
return err("`save` requires an anchor `name`.");
|
|
1981
|
+
if (!selector)
|
|
1982
|
+
return err("`save` requires a `selector` (the CSS that locates the element).");
|
|
1983
|
+
saveAnchor(domain, { name, selector, role, description, quirks }, new Date().toISOString());
|
|
1984
|
+
return {
|
|
1985
|
+
content: [
|
|
1986
|
+
{
|
|
1987
|
+
type: "text",
|
|
1988
|
+
text: `Saved anchor @a:${name} for ${domain} → ${selector}
|
|
1989
|
+
Use it in any selector field: e.g. {"type":"click","selector":"@a:${name}"}.`
|
|
1990
|
+
}
|
|
1991
|
+
]
|
|
1992
|
+
};
|
|
1993
|
+
} catch (e) {
|
|
1994
|
+
return err(`remember failed: ${getErrorMessage(e)}`);
|
|
1995
|
+
}
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
// src/mcp/tools/clipboard.ts
|
|
2000
|
+
var import_zod10 = require("zod");
|
|
2001
|
+
|
|
2002
|
+
// src/lib/clipboard.ts
|
|
2003
|
+
var import_child_process2 = require("child_process");
|
|
2004
|
+
function platformTools(mode) {
|
|
2005
|
+
if (process.platform === "darwin")
|
|
2006
|
+
return [[mode === "read" ? "pbpaste" : "pbcopy"]];
|
|
2007
|
+
if (mode === "read")
|
|
2008
|
+
return [["wl-paste", "-n"], ["xclip", "-selection", "clipboard", "-o"], ["xsel", "-b", "-o"]];
|
|
2009
|
+
return [["wl-copy"], ["xclip", "-selection", "clipboard", "-i"], ["xsel", "-b", "-i"]];
|
|
2010
|
+
}
|
|
2011
|
+
function run(cmd, input) {
|
|
2012
|
+
return new Promise((resolve) => {
|
|
2013
|
+
let child;
|
|
2014
|
+
try {
|
|
2015
|
+
child = import_child_process2.spawn(cmd[0], cmd.slice(1));
|
|
2016
|
+
} catch {
|
|
2017
|
+
resolve({ ok: false, out: "", err: `spawn ${cmd[0]} failed` });
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
let out = "";
|
|
2021
|
+
let errOut = "";
|
|
2022
|
+
child.stdout?.on("data", (d) => out += d);
|
|
2023
|
+
child.stderr?.on("data", (d) => errOut += d);
|
|
2024
|
+
child.on("error", (e) => resolve({ ok: false, out: "", err: e.message }));
|
|
2025
|
+
child.on("close", (code) => resolve({ ok: code === 0, out, err: errOut }));
|
|
2026
|
+
if (input !== undefined) {
|
|
2027
|
+
child.stdin?.write(input);
|
|
2028
|
+
child.stdin?.end();
|
|
2029
|
+
}
|
|
2030
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
async function clipboardWrite(text) {
|
|
2033
|
+
const tools = platformTools("write");
|
|
2034
|
+
for (const cmd of tools) {
|
|
2035
|
+
if ((await run(cmd, text)).ok)
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
throw new Error(`No working clipboard tool found (${tools.map((t) => t[0]).join(", ")}).`);
|
|
2039
|
+
}
|
|
2040
|
+
async function clipboardRead() {
|
|
2041
|
+
const tools = platformTools("read");
|
|
2042
|
+
let lastErr = "";
|
|
2043
|
+
for (const cmd of tools) {
|
|
2044
|
+
const r = await run(cmd);
|
|
2045
|
+
if (r.ok)
|
|
2046
|
+
return r.out;
|
|
2047
|
+
lastErr = r.err;
|
|
2048
|
+
}
|
|
2049
|
+
throw new Error(`No working clipboard tool found (${tools.map((t) => t[0]).join(", ")}). ${lastErr}`);
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
// src/mcp/tools/clipboard.ts
|
|
2053
|
+
function registerClipboardTool(server) {
|
|
2054
|
+
server.tool("clipboard", `Read or write the machine's clipboard (the same one the user's Chrome pastes from — iframer runs locally).
|
|
2055
|
+
|
|
2056
|
+
Use this instead of trying to grant a page clipboard permission (not possible via the extension):
|
|
2057
|
+
- To READ something a site copied (a code, a link): clipboard get.
|
|
2058
|
+
- To WRITE text to the clipboard: clipboard set <text>.
|
|
2059
|
+
- To PASTE the clipboard into a page field: use the \`paste\` execute step (do NOT rely on a ⌘V keyboard step — the extension relay ignores modifier keys).`, {
|
|
2060
|
+
action: import_zod10.z.enum(["get", "set"]).describe("get: read clipboard text | set: write text to clipboard"),
|
|
2061
|
+
text: import_zod10.z.string().optional().describe("With action='set': the text to put on the clipboard.")
|
|
2062
|
+
}, async ({ action, text }) => {
|
|
2063
|
+
try {
|
|
2064
|
+
if (action === "set") {
|
|
2065
|
+
if (text === undefined)
|
|
2066
|
+
return err("action='set' requires `text`.");
|
|
2067
|
+
await clipboardWrite(text);
|
|
2068
|
+
return { content: [{ type: "text", text: `Clipboard set (${text.length} chars). To paste into a field, use the \`paste\` execute step.` }] };
|
|
2069
|
+
}
|
|
2070
|
+
const value = await clipboardRead();
|
|
2071
|
+
return { content: [{ type: "text", text: value.length ? value : "(clipboard is empty)" }] };
|
|
2072
|
+
} catch (e) {
|
|
2073
|
+
return err(`clipboard failed: ${getErrorMessage(e)}`);
|
|
2074
|
+
}
|
|
2075
|
+
});
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
// src/mcp/telemetry.ts
|
|
2079
|
+
var import_fs9 = __toESM(require("fs"));
|
|
2080
|
+
var import_path11 = __toESM(require("path"));
|
|
2081
|
+
var CHARS_PER_TOKEN = 4;
|
|
2082
|
+
var est = (chars) => Math.round(chars / CHARS_PER_TOKEN);
|
|
2083
|
+
var enabled = process.env.IFRAMER_TELEMETRY !== "0";
|
|
2084
|
+
var sessionId = `${new Date().toISOString().slice(0, 10)}-${process.pid}`;
|
|
2085
|
+
function telemetryPath() {
|
|
2086
|
+
return import_path11.default.join(getDataDir(), "telemetry.jsonl");
|
|
2087
|
+
}
|
|
2088
|
+
var sessionCalls = 0;
|
|
2089
|
+
var sessionChars = 0;
|
|
2090
|
+
function write2(rec) {
|
|
2091
|
+
if (!enabled)
|
|
2092
|
+
return;
|
|
2093
|
+
try {
|
|
2094
|
+
import_fs9.default.appendFileSync(telemetryPath(), JSON.stringify({ ts: new Date().toISOString(), session: sessionId, ...rec }) + `
|
|
2095
|
+
`);
|
|
2096
|
+
} catch {}
|
|
2097
|
+
}
|
|
2098
|
+
function recordDefinitions(toolCount, defChars, instructionChars) {
|
|
2099
|
+
write2({
|
|
2100
|
+
kind: "definitions",
|
|
2101
|
+
toolCount,
|
|
2102
|
+
defChars,
|
|
2103
|
+
instructionChars,
|
|
2104
|
+
estTokens: est(defChars + instructionChars)
|
|
2105
|
+
});
|
|
2106
|
+
}
|
|
2107
|
+
function recordCall(tool, inChars, outChars, ms, isError) {
|
|
2108
|
+
sessionCalls++;
|
|
2109
|
+
sessionChars += inChars + outChars;
|
|
2110
|
+
write2({
|
|
2111
|
+
kind: "call",
|
|
2112
|
+
tool,
|
|
2113
|
+
inChars,
|
|
2114
|
+
outChars,
|
|
2115
|
+
estTokens: est(inChars + outChars),
|
|
2116
|
+
ms,
|
|
2117
|
+
isError,
|
|
2118
|
+
sessionCalls,
|
|
2119
|
+
sessionEstTokens: est(sessionChars)
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
2122
|
+
function contentChars(res) {
|
|
2123
|
+
const r = res;
|
|
2124
|
+
if (!r || !Array.isArray(r.content))
|
|
2125
|
+
return 0;
|
|
2126
|
+
let n = 0;
|
|
2127
|
+
for (const c of r.content) {
|
|
2128
|
+
if (typeof c?.text === "string")
|
|
2129
|
+
n += c.text.length;
|
|
2130
|
+
else if (typeof c?.data === "string")
|
|
2131
|
+
n += c.data.length;
|
|
2132
|
+
}
|
|
2133
|
+
return n;
|
|
2134
|
+
}
|
|
2135
|
+
function safeJsonLen(v) {
|
|
2136
|
+
try {
|
|
2137
|
+
return v === undefined ? 0 : JSON.stringify(v).length;
|
|
2138
|
+
} catch {
|
|
2139
|
+
return 0;
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
|
|
1636
2143
|
// src/mcp/server.ts
|
|
1637
2144
|
var IS_DEV = process.env.IFRAMER_URL?.includes("localhost") || process.env.IFRAMER_URL?.includes("127.0.0.1");
|
|
1638
2145
|
var INSTRUCTIONS = IS_DEV ? `iframer-dev — local development instance of iframer (connects to ${BASE_URL}).
|
|
@@ -1653,7 +2160,8 @@ CRITICAL RULES:
|
|
|
1653
2160
|
3. DO NOT present options when you can just act. Check status, credentials, execute.
|
|
1654
2161
|
4. NEVER re-store credentials when login fails. Login failures are browser-mode problems, not credential problems.
|
|
1655
2162
|
5. ALWAYS check "knowledge get <domain>" before launching a browser. Skip the browser if the cache has what you need.
|
|
1656
|
-
6.
|
|
2163
|
+
6. For UI tasks on a site, check "remember get <domain>" first — saved anchors let you target elements as @a:<name> (in any selector) instead of re-finding them. After you locate a new element with snapshot/find and it works, "remember save" it. If an @a: anchor fails, the page changed: re-discover and "remember save" the new selector (don't retry the stale one).
|
|
2164
|
+
7. If execute fails, read the FULL error. It tells you the step, error type, and suggestion.
|
|
1657
2165
|
7. If the browser crashes, call "session restart" and retry. Don't panic.
|
|
1658
2166
|
8. ALWAYS call "session" action=stop when you are done with browser work. It saves session state and frees the browser. Idle browsers are auto-reclaimed, but don't rely on that.
|
|
1659
2167
|
|
|
@@ -1663,6 +2171,28 @@ CAPTCHA: In binary-headful mode, ask the user to solve it in the visible window.
|
|
|
1663
2171
|
|
|
1664
2172
|
REVERSE ENGINEERING: Use the "reverse-engineer" tool when the user asks to capture/map/save API endpoints.`;
|
|
1665
2173
|
var server = new import_mcp.McpServer({ name: "iframer", version: "3.0.0" }, { instructions: INSTRUCTIONS });
|
|
2174
|
+
var defChars = 0;
|
|
2175
|
+
var defCount = 0;
|
|
2176
|
+
{
|
|
2177
|
+
const origTool = server.tool.bind(server);
|
|
2178
|
+
server.tool = (...a) => {
|
|
2179
|
+
const name = String(a[0]);
|
|
2180
|
+
defCount++;
|
|
2181
|
+
defChars += name.length + (typeof a[1] === "string" ? a[1].length : 0);
|
|
2182
|
+
const hIdx = a.length - 1;
|
|
2183
|
+
const handler = a[hIdx];
|
|
2184
|
+
if (typeof handler === "function") {
|
|
2185
|
+
a[hIdx] = async (...ha) => {
|
|
2186
|
+
const started = Date.now();
|
|
2187
|
+
const res = await handler(...ha);
|
|
2188
|
+
const r = res;
|
|
2189
|
+
recordCall(name, safeJsonLen(ha[0]), contentChars(res), Date.now() - started, !!r?.isError);
|
|
2190
|
+
return res;
|
|
2191
|
+
};
|
|
2192
|
+
}
|
|
2193
|
+
return origTool(...a);
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
1666
2196
|
registerStatusTool(server);
|
|
1667
2197
|
registerBrowseTool(server);
|
|
1668
2198
|
registerExecuteTool(server);
|
|
@@ -1670,8 +2200,20 @@ registerSessionTool(server);
|
|
|
1670
2200
|
registerCredentialsTool(server);
|
|
1671
2201
|
registerReverseEngineerTool(server);
|
|
1672
2202
|
registerKnowledgeTool(server);
|
|
2203
|
+
registerTabsTool(server);
|
|
2204
|
+
registerRememberTool(server);
|
|
2205
|
+
registerClipboardTool(server);
|
|
2206
|
+
recordDefinitions(defCount, defChars, INSTRUCTIONS.length);
|
|
1673
2207
|
process.on("SIGTERM", () => process.exit(0));
|
|
1674
2208
|
process.on("SIGINT", () => process.exit(0));
|
|
2209
|
+
process.stdin.on("end", () => process.exit(0));
|
|
2210
|
+
process.stdin.on("close", () => process.exit(0));
|
|
2211
|
+
var originalPpid = process.ppid;
|
|
2212
|
+
var orphanCheck = setInterval(() => {
|
|
2213
|
+
if (process.ppid !== originalPpid || process.ppid === 1)
|
|
2214
|
+
process.exit(0);
|
|
2215
|
+
}, 30000);
|
|
2216
|
+
orphanCheck.unref?.();
|
|
1675
2217
|
process.on("uncaughtException", (err2) => {
|
|
1676
2218
|
try {
|
|
1677
2219
|
console.error(`[mcp] uncaughtException: ${err2?.message}`);
|