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.
@@ -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 = 200;
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
- try {
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
- const serverTs = import_path4.default.join(__dirname, "..", "..", "index.ts");
278
- return { command: "node", args: ["--import", "tsx", serverTs] };
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.result !== undefined && r.result !== null) {
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
- const captureResult = mode === "docker-headful" && dockerRunning ? await apiPost("/execute", execParams) : await localApiPost("/execute", execParams);
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 > 80000) {
995
- text = text.slice(0, 80000) + `
1059
+ if (text.length > 30000) {
1060
+ text = text.slice(0, 30000) + `
996
1061
 
997
- [... response truncated — read captured-api.json for full data]`;
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
- lines.push(`
1040
- [${ep.protocol}/${ep.verb}] ${ep.functionName}`);
1041
- lines.push(` Action: ${ep.action}`);
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. If execute fails, read the FULL error. It tells you the step, error type, and suggestion.
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}`);