iframer-toolkit 0.10.6 → 0.11.0

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.
@@ -112,8 +112,8 @@ function isPidAlive(pid) {
112
112
  try {
113
113
  process.kill(pid, 0);
114
114
  return true;
115
- } catch {
116
- return false;
115
+ } catch (e) {
116
+ return e.code === "EPERM";
117
117
  }
118
118
  }
119
119
  function readServerInfo() {
@@ -208,7 +208,6 @@ var LocalServerManager = class {
208
208
  const env = {
209
209
  ...process.env,
210
210
  PORT: String(port),
211
- IFRAMER_MODE: "local",
212
211
  IFRAMER_DATA_DIR: dataDir
213
212
  };
214
213
  if (process.env.IFRAMER_SECRET) {
@@ -223,8 +222,12 @@ var LocalServerManager = class {
223
222
  const child = (0, import_child_process.spawn)(command, args, {
224
223
  env,
225
224
  stdio: ["ignore", logFd, logFd],
226
- detached: true
225
+ detached: true,
226
+ // On Windows a detached child otherwise gets its own console window,
227
+ // and closing that window kills the server.
228
+ windowsHide: true
227
229
  });
230
+ child.on("error", (e) => console.error(`[iframer] could not start the local server: ${e.message}`));
228
231
  child.unref();
229
232
  import_fs3.default.closeSync(logFd);
230
233
  const deadline = Date.now() + STARTUP_TIMEOUT_MS;
@@ -374,9 +377,7 @@ function getErrorMessage(err2) {
374
377
 
375
378
  // src/mcp/helpers.ts
376
379
  var log2 = createLogger("mcp");
377
- var BASE_URL = process.env.IFRAMER_URL || "http://localhost:3021";
378
380
  var IFRAMER_SECRET = process.env.IFRAMER_SECRET;
379
- var IFRAMER_MODE = process.env.IFRAMER_MODE;
380
381
  var LOCAL_TOKEN = getLocalToken();
381
382
  var localServer = new LocalServerManager();
382
383
  async function ensureLocalServer() {
@@ -388,19 +389,6 @@ function authHeaders(secret) {
388
389
  if (key) headers["x-api-key"] = key;
389
390
  return headers;
390
391
  }
391
- async function apiPost(endpoint, body) {
392
- const res = await fetch(`${BASE_URL}${endpoint}`, {
393
- method: "POST",
394
- headers: authHeaders(),
395
- body: body ? JSON.stringify(body) : void 0,
396
- signal: AbortSignal.timeout(18e4)
397
- });
398
- return res.json();
399
- }
400
- async function apiGet(endpoint) {
401
- const res = await fetch(`${BASE_URL}${endpoint}`, { headers: authHeaders(), signal: AbortSignal.timeout(3e4) });
402
- return res.json();
403
- }
404
392
  async function localApiPost(endpoint, body, timeoutMs = 18e4) {
405
393
  await ensureLocalServer();
406
394
  const url = localServer.getBaseUrl();
@@ -424,15 +412,6 @@ async function localApiDelete(endpoint) {
424
412
  const res = await fetch(`${url}${endpoint}`, { method: "DELETE", headers: authHeaders(LOCAL_TOKEN), signal: AbortSignal.timeout(3e4) });
425
413
  return res.json();
426
414
  }
427
- async function isDockerRunning() {
428
- try {
429
- const res = await fetch(`${BASE_URL}/health`, { signal: AbortSignal.timeout(3e3) });
430
- const data = await res.json();
431
- return data.ok === true;
432
- } catch {
433
- return false;
434
- }
435
- }
436
415
  async function resolveScreenshotPath(url) {
437
416
  try {
438
417
  if (url.startsWith("file://")) {
@@ -448,7 +427,7 @@ async function resolveScreenshotPath(url) {
448
427
  const fs10 = await import("fs");
449
428
  const dir = import_path5.default.join(import_os3.default.tmpdir(), "iframer-screenshots", "screenshots");
450
429
  fs10.mkdirSync(dir, { recursive: true });
451
- const filePath = import_path5.default.join(dir, `docker-${Date.now()}.jpg`);
430
+ const filePath = import_path5.default.join(dir, `screenshot-${Date.now()}.jpg`);
452
431
  fs10.writeFileSync(filePath, buf);
453
432
  return filePath;
454
433
  } catch {
@@ -467,7 +446,7 @@ function defaultFile() {
467
446
  return import_path6.default.join(getDataDir(), "domain-modes.json");
468
447
  }
469
448
  var TTL_DAYS = 14;
470
- var ESCALATION_LADDER = ["headless", "docker-headful", "binary-headful"];
449
+ var ESCALATION_LADDER = ["headless", "binary-headful"];
471
450
  var DomainModeStore = class {
472
451
  data = {};
473
452
  filePath;
@@ -572,8 +551,6 @@ function registerStatusTool(server2) {
572
551
  async () => {
573
552
  try {
574
553
  const status = {};
575
- const dockerRunning = await isDockerRunning();
576
- status.dockerApi = dockerRunning;
577
554
  try {
578
555
  const localHealth = await localApiGet("/health");
579
556
  status.localServer = localHealth.ok;
@@ -591,13 +568,6 @@ function registerStatusTool(server2) {
591
568
  if (inst.instances && inst.instances.length) status.liveInstances = inst.instances;
592
569
  } catch {
593
570
  }
594
- if (dockerRunning) {
595
- try {
596
- const sessionData = await apiGet("/interactive/status");
597
- status.dockerSession = sessionData.active ? { active: true, noVncUrl: sessionData.noVncUrl } : { active: false };
598
- } catch {
599
- }
600
- }
601
571
  try {
602
572
  const credData = await localApiGet("/credentials");
603
573
  status.credentials = credData.domains || [];
@@ -659,18 +629,18 @@ var import_zod3 = require("zod");
659
629
  var import_zod2 = require("zod");
660
630
  var stepSchema = import_zod2.z.discriminatedUnion("type", [
661
631
  import_zod2.z.object({ type: import_zod2.z.literal("navigate"), url: import_zod2.z.string(), waitUntil: import_zod2.z.string().optional() }),
662
- import_zod2.z.object({ type: import_zod2.z.literal("click"), selector: import_zod2.z.string() }),
663
- 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' \u2014 always use fill for form fields, not evaluate.") }),
632
+ import_zod2.z.object({ type: import_zod2.z.literal("click"), selector: import_zod2.z.string().describe("An @e ref from snapshot/find, an @a: anchor, or plain CSS. Text selectors (text=, :has-text(), :text-is()) are rejected: use a find step for text, then click its @e ref.") }),
633
+ 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' \u2014 always use fill for form fields, not evaluate."), force: import_zod2.z.boolean().optional().describe("Bypass the honeypot guard. fill refuses fields no human can reach (offscreen/clipped/transparent) \u2014 only set this when you are certain the field is real.") }),
664
634
  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() }),
665
635
  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() }),
666
- import_zod2.z.object({ type: import_zod2.z.literal("human-type"), selector: import_zod2.z.string(), value: import_zod2.z.string(), skipClick: import_zod2.z.boolean().optional().describe("Skip the click-to-focus and type into the already-focused element. Use for editors that blur on a synthetic click (e.g. Draft.js). Either way, typing aborts safely if the target isn't actually focused."), speed: import_zod2.z.enum(["slow", "normal", "fast"]).optional().describe("Typing speed. 'normal' (~130ms/char, realistic, default), 'fast' (~45ms/char) for long non-sensitive text, 'slow' for extra realism.") }),
636
+ import_zod2.z.object({ type: import_zod2.z.literal("human-type"), selector: import_zod2.z.string(), value: import_zod2.z.string(), skipClick: import_zod2.z.boolean().optional().describe("Skip the click-to-focus and type into the already-focused element. Use for editors that blur on a synthetic click (e.g. Draft.js). Either way, typing aborts safely if the target isn't actually focused."), speed: import_zod2.z.enum(["slow", "normal", "fast"]).optional().describe("Typing speed. 'normal' (~130ms/char, realistic, default), 'fast' (~45ms/char) for long non-sensitive text, 'slow' for extra realism."), clear: import_zod2.z.boolean().optional().describe("Replace the field's existing text instead of appending to it.") }),
667
637
  import_zod2.z.object({ type: import_zod2.z.literal("evaluate"), expression: import_zod2.z.string() }),
668
638
  import_zod2.z.object({ type: import_zod2.z.literal("extract"), expression: import_zod2.z.string() }),
669
639
  import_zod2.z.object({ type: import_zod2.z.literal("wait"), ms: import_zod2.z.number() }),
670
640
  import_zod2.z.object({ type: import_zod2.z.literal("wait-for"), selector: import_zod2.z.string(), timeout: import_zod2.z.number().optional() }),
671
641
  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"), human: import_zod2.z.boolean().optional().describe("Real eased wheel events instead of an instant jump (slower, less bot-obvious)") }),
672
642
  import_zod2.z.object({ type: import_zod2.z.literal("select"), selector: import_zod2.z.string(), value: import_zod2.z.string().describe("Option value to pick in a <select> \u2014 native selection, real change events (never set .value via evaluate)") }),
673
- 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() }),
643
+ import_zod2.z.object({ type: import_zod2.z.literal("keyboard"), key: import_zod2.z.string(), meta: import_zod2.z.boolean().optional().describe("Cmd/Ctrl+A selects all in every mode. Other Cmd/Ctrl shortcuts are rejected in extension mode (the relay drops them)."), ctrl: import_zod2.z.boolean().optional(), shift: import_zod2.z.boolean().optional(), alt: import_zod2.z.boolean().optional() }),
674
644
  import_zod2.z.object({ type: import_zod2.z.literal("read"), selector: import_zod2.z.string().optional().describe("Element to read text from (CSS or @e ref); omit for the whole body"), maxChars: import_zod2.z.number().optional().describe("Cap returned text length (default 6000)") }),
675
645
  import_zod2.z.object({ type: import_zod2.z.literal("upload"), selector: import_zod2.z.string().describe("The <input type=file> (CSS, @e ref, or @a anchor)"), files: import_zod2.z.array(import_zod2.z.string()).describe("Absolute local file path(s) on this machine") }),
676
646
  import_zod2.z.object({ type: import_zod2.z.literal("paste"), selector: import_zod2.z.string().optional().describe("Field to paste the OS clipboard into; omit for the focused element. Reliable via CDP insertText where a \u2318V keyboard step isn't.") }),
@@ -706,8 +676,8 @@ A captcha is blocking this run. Use the "recaptcha" step with an action:
706
676
  {type:"recaptcha", action:"click"} \u2192 click the "I'm not a robot" checkbox
707
677
  {type:"recaptcha", action:"answer", tiles:[...]} \u2192 select tiles + verify + re-check (handles refreshing grids)
708
678
  {type:"recaptcha", action:"select"|"verify"} \u2192 manual tile-select / submit, if you need finer control
709
- {type:"recaptcha", action:"solve"} \u2192 automatic vision solve (docker-headful)
710
- Or {type:"solve-captcha"} for one-shot auto-detect + solve.
679
+ {type:"recaptcha", action:"solve"} \u2192 automatic vision solve (needs ANTHROPIC_API_KEY)
680
+ Or {type:"solve-captcha"} for one-shot auto-detect + solve (needs ANTHROPIC_API_KEY).
711
681
  In binary-headful mode, prefer asking the user to solve it in the visible window.`;
712
682
  function captchaBlocked(data) {
713
683
  if (data.error?.errorType === "captcha-unsolvable") return true;
@@ -783,6 +753,13 @@ ${api.domain} (${api.baseUrl})`);
783
753
  }
784
754
  }
785
755
  }
756
+ const serverErrors = (data.results || []).flatMap((r) => r.serverErrors || []);
757
+ if (serverErrors.length > 0) {
758
+ lines.push("\n--- Server errors (HTTP >= 400) ---");
759
+ for (const se of serverErrors) {
760
+ lines.push(` [step ${se.stepIndex}] ${se.method} ${se.url} \u2192 ${se.status}${se.body ? `: ${se.body}` : ""}`);
761
+ }
762
+ }
786
763
  if (data.error) {
787
764
  lines.push("\n--- Failure ---");
788
765
  if (typeof data.error === "string") {
@@ -793,6 +770,10 @@ ${api.domain} (${api.baseUrl})`);
793
770
  lines.push(`Message: ${data.error.message}`);
794
771
  lines.push(`Retryable: ${data.error.retryable}`);
795
772
  if (data.error.suggestion) lines.push(`Suggestion: ${data.error.suggestion}`);
773
+ const atFailure = serverErrors.filter((se) => se.stepIndex === data.error?.failedAtStep);
774
+ for (const se of atFailure) {
775
+ lines.push(`Server responded: ${se.method} ${se.url} \u2192 ${se.status}${se.body ? `: ${se.body}` : ""}`);
776
+ }
796
777
  if (data.error.pageState?.url) lines.push(`URL at failure: ${data.error.pageState.url}`);
797
778
  }
798
779
  }
@@ -828,8 +809,8 @@ Returns: ok, completedSteps, output for snapshot/find/read/extract steps, obstac
828
809
  screenshotAfterEach: import_zod3.z.boolean().optional().describe("Take a screenshot after every step (expensive)"),
829
810
  continueOnObstacle: import_zod3.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
830
811
  continueOnError: import_zod3.z.boolean().optional().describe("Continue past failing steps (default: false)"),
831
- captureApi: import_zod3.z.boolean().optional().describe("Record all API calls (XHR/fetch) the page makes."),
832
- mode: import_zod3.z.enum(["headless", "binary-headful", "docker-headful", "extension"]).optional().describe("DO NOT SET unless the user explicitly requests a mode \u2014 iframer auto-selects and auto-escalates. 'extension' drives a real-Chrome tab (requires options.tabId from the `tabs` tool)."),
812
+ captureApi: import_zod3.z.boolean().optional().describe("Record all API calls (XHR/fetch) the page makes. Also surfaces every HTTP >= 400 response per step \u2014 turn it on when a form submit fails with only a vague on-page message, to read the server's real answer."),
813
+ mode: import_zod3.z.enum(["headless", "binary-headful", "extension"]).optional().describe("DO NOT SET unless the user explicitly requests a mode \u2014 iframer auto-selects and auto-escalates. 'extension' drives a real-Chrome tab (requires options.tabId from the `tabs` tool)."),
833
814
  autoEscalate: import_zod3.z.boolean().optional().describe("Auto-retry with a stronger mode if blocked (default: true)"),
834
815
  instanceId: import_zod3.z.string().optional().describe("Named parallel browser (default 'default') \u2014 distinct ids drive several browsers at once, each with its own session state."),
835
816
  tabId: import_zod3.z.number().optional().describe("mode='extension': the real-Chrome tab id to drive (from the `tabs` tool)."),
@@ -839,6 +820,13 @@ Returns: ok, completedSteps, output for snapshot/find/read/extract steps, obstac
839
820
  },
840
821
  async (params) => {
841
822
  try {
823
+ let runWithMode2 = function(mode) {
824
+ return localApiPost("/execute", {
825
+ steps,
826
+ options: { ...params.options, mode: mode || void 0 }
827
+ });
828
+ };
829
+ var runWithMode = runWithMode2;
842
830
  const steps = normalizeSteps(params.steps);
843
831
  if (params.options?.mode === "extension") {
844
832
  const tabId = params.options?.tabId;
@@ -862,43 +850,9 @@ Returns: ok, completedSteps, output for snapshot/find/read/extract steps, obstac
862
850
  if (!extResult.ok) return { content: content2, isError: true };
863
851
  return { content: content2 };
864
852
  }
865
- const dockerRunning = await isDockerRunning();
866
- async function runWithMode(mode) {
867
- if (mode === "docker-headful") {
868
- if (!dockerRunning) {
869
- return {
870
- ok: false,
871
- completedSteps: 0,
872
- totalSteps: steps.length,
873
- results: [],
874
- finalState: { url: "", title: "" },
875
- obstacles: [],
876
- durationMs: 0,
877
- modeUsed: "docker-headful",
878
- error: {
879
- failedAtStep: 0,
880
- failedStep: steps[0],
881
- errorType: "action-failed",
882
- message: "docker-headful mode was requested but the Docker API is not reachable.",
883
- pageState: { url: "", title: "" },
884
- suggestion: "Start Docker with `bun run start:docker`, or omit options.mode.",
885
- retryable: false
886
- }
887
- };
888
- }
889
- return apiPost("/execute", {
890
- steps,
891
- options: { ...params.options, mode: "docker-headful", autoEscalate: false }
892
- });
893
- }
894
- return localApiPost("/execute", {
895
- steps,
896
- options: { ...params.options, mode: mode || void 0 }
897
- });
898
- }
899
853
  let execResult;
900
854
  try {
901
- execResult = await runWithMode(params.options?.mode);
855
+ execResult = await runWithMode2(params.options?.mode);
902
856
  } catch (execErr) {
903
857
  const msg = execErr instanceof Error ? execErr.message : String(execErr);
904
858
  const isCrash = /ECONNREFUSED|ECONNRESET|EPIPE|socket hang up|fetch failed/i.test(msg);
@@ -909,7 +863,7 @@ Returns: ok, completedSteps, output for snapshot/find/read/extract steps, obstac
909
863
  } catch {
910
864
  }
911
865
  try {
912
- execResult = await runWithMode(params.options?.mode);
866
+ execResult = await runWithMode2(params.options?.mode);
913
867
  } catch (retryErr) {
914
868
  return err(
915
869
  `Browser server crashed and retry also failed.
@@ -925,14 +879,8 @@ Call \`session restart\` to reset, then retry.`
925
879
  }
926
880
  const requestedMode = params.options?.mode;
927
881
  if (!execResult.ok && execResult.error?.errorType === "bot-blocked" && params.options?.autoEscalate !== false && !requestedMode) {
928
- const escalation = ["docker-headful", "binary-headful"];
929
- for (const nextMode of escalation) {
930
- if (nextMode === "docker-headful" && !dockerRunning) continue;
931
- log2.info(`Auto-escalating to ${nextMode}`);
932
- execResult = await runWithMode(nextMode);
933
- if (execResult.ok) break;
934
- if (execResult.error?.errorType !== "bot-blocked") break;
935
- }
882
+ log2.info("Auto-escalating to binary-headful");
883
+ execResult = await runWithMode2("binary-headful");
936
884
  }
937
885
  const lines = formatExecuteResult(execResult);
938
886
  let screenshotUrl = null;
@@ -980,7 +928,7 @@ During capture, drive the site like a user (open the inbox, send a message\u2026
980
928
  staleTimeoutMs: import_zod4.z.number().optional().describe("Override the 20s stale-state timeout per step"),
981
929
  continueOnObstacle: import_zod4.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
982
930
  continueOnError: import_zod4.z.boolean().optional().describe("Continue past failing steps (default: false)"),
983
- mode: import_zod4.z.enum(["headless", "binary-headful", "docker-headful", "extension"]).optional().describe("Mode override. 'extension' captures a tab already open in the user's real Chrome (requires options.tabId from `tabs`)."),
931
+ mode: import_zod4.z.enum(["headless", "binary-headful", "extension"]).optional().describe("Mode override. 'extension' captures a tab already open in the user's real Chrome (requires options.tabId from `tabs`)."),
984
932
  tabId: import_zod4.z.number().optional().describe("mode='extension': the real-Chrome tab id (from `tabs`)."),
985
933
  clientId: import_zod4.z.string().optional().describe("mode='extension': owning profile's clientId when the tab is ambiguous.")
986
934
  }).optional()
@@ -993,7 +941,6 @@ During capture, drive the site like a user (open the inbox, send a message\u2026
993
941
  options: { ...params.options, captureApi: true }
994
942
  };
995
943
  const mode = params.options?.mode;
996
- const dockerRunning = await isDockerRunning();
997
944
  let captureResult;
998
945
  if (mode === "extension") {
999
946
  if (typeof params.options?.tabId !== "number") {
@@ -1005,8 +952,6 @@ During capture, drive the site like a user (open the inbox, send a message\u2026
1005
952
  steps,
1006
953
  options: { ...params.options, captureApi: true }
1007
954
  });
1008
- } else if (mode === "docker-headful" && dockerRunning) {
1009
- captureResult = await apiPost("/execute", execParams);
1010
955
  } else {
1011
956
  captureResult = await localApiPost("/execute", execParams);
1012
957
  }
@@ -1203,6 +1148,23 @@ stop: save cookies/localStorage and close the browser \u2014 ALWAYS call when br
1203
1148
  },
1204
1149
  async ({ action, mode, urls, instanceId, outputDir }) => {
1205
1150
  try {
1151
+ const SPAWNS_BROWSER = ["capture-start", "capture-stop", "get-cookies", "get-auth"];
1152
+ let resolvedMode = mode;
1153
+ if (SPAWNS_BROWSER.includes(action) && !mode) {
1154
+ const live = await localApiGet("/instances").catch(() => ({ instances: [] }));
1155
+ const match = (live.instances || []).find((i) => i.instanceId === (instanceId || "default"));
1156
+ if (match) {
1157
+ if (match.mode === "headless" || match.mode === "binary-headful") resolvedMode = match.mode;
1158
+ } else {
1159
+ const ext = await localApiGet("/extension/status").catch(() => ({ connected: false }));
1160
+ if (ext.connected) {
1161
+ return err(
1162
+ `'${action}' would launch a new browser window, but the iframer extension is connected \u2014 you are probably driving a real Chrome tab. Read that tab instead: execute with options.mode="extension" and options.tabId (from the \`tabs\` tool). If you really want a separate browser, pass mode explicitly. Nothing was launched.`
1163
+ );
1164
+ }
1165
+ }
1166
+ }
1167
+ mode = resolvedMode;
1206
1168
  if (action === "stop") {
1207
1169
  const result = await localApiPost("/interactive/stop").catch(() => ({ ok: true, sessionSaved: false }));
1208
1170
  return { content: [{ type: "text", text: `Session stopped. State saved: ${result.sessionSaved ?? false}` }] };
@@ -1225,13 +1187,6 @@ stop: save cookies/localStorage and close the browser \u2014 ALWAYS call when br
1225
1187
  parts.push(`Local restart failed: ${e instanceof Error ? e.message : String(e)}`);
1226
1188
  }
1227
1189
  }
1228
- try {
1229
- if (await isDockerRunning()) {
1230
- await apiPost("/browser/restart");
1231
- parts.push("Docker browser restarted.");
1232
- }
1233
- } catch {
1234
- }
1235
1190
  parts.push("Credentials and knowledge cache are untouched. Next execute launches a fresh browser.");
1236
1191
  return { content: [{ type: "text", text: parts.join(" ") }] };
1237
1192
  }
@@ -2063,18 +2018,7 @@ function safeJsonLen(v) {
2063
2018
  }
2064
2019
 
2065
2020
  // src/mcp/server.ts
2066
- var IS_DEV = process.env.IFRAMER_URL?.includes("localhost") || process.env.IFRAMER_URL?.includes("127.0.0.1");
2067
- var INSTRUCTIONS = IS_DEV ? `iframer-dev \u2014 local development instance of iframer (connects to ${BASE_URL}).
2068
-
2069
- CRITICAL RULES:
2070
- 1. NEVER ask the user "do you have credentials?". Call credentials action=list and read the response.
2071
- 2. NEVER ask the user for passwords in chat. Use credentials action=store (secure form) or the CLI fallback.
2072
- 3. DO NOT present options when you can just act. Check credentials, execute, show results.
2073
- 4. If execute fails, read the error. It tells you exactly what happened and where.
2074
- 5. Call "session" action=stop when done to save session state.
2075
-
2076
- CAPTCHA: Use the "solve-captcha" step \u2014 auto-detects reCAPTCHA/hCaptcha and solves with vision AI.
2077
- REVERSE ENGINEERING: Use the "reverse-engineer" tool when the user asks to capture/map APIs.` : `iframer \u2014 browser access for AI agents when normal methods fail.
2021
+ var INSTRUCTIONS = `iframer \u2014 browser access for AI agents when normal methods fail.
2078
2022
 
2079
2023
  CRITICAL RULES:
2080
2024
  1. NEVER ask "do you have credentials?". Call credentials action=list and read the response.
@@ -2091,7 +2035,7 @@ If headless detects Imperva or Akamai, immediately stop headless and pivot to he
2091
2035
 
2092
2036
  BROWSER MODES: Don't specify options.mode \u2014 iframer auto-selects and auto-escalates (headless \u2192 binary-headful). Only set a mode if the user explicitly asks.
2093
2037
 
2094
- CAPTCHA: In binary-headful mode, ask the user to solve it in the visible window. In docker-headful mode, use the "solve-captcha" step.
2038
+ CAPTCHA: In binary-headful mode, ask the user to solve it in the visible window. Otherwise try the "solve-captcha" step (best effort, needs ANTHROPIC_API_KEY).
2095
2039
 
2096
2040
  REVERSE ENGINEERING: Use the "reverse-engineer" tool when the user asks to capture/map/save API endpoints.`;
2097
2041
  var server = new import_mcp.McpServer(
@@ -81,22 +81,24 @@ build` instead; `iframer update` detects that and says so.)
81
81
  ## Pair it (one time, zero copying)
82
82
 
83
83
  ```sh
84
- iframer install extension
84
+ iframer install extension chrome
85
85
  ```
86
86
 
87
- then restart the browser. That installs a Chrome **native-messaging host**
87
+ on macOS, Linux or Windows. That installs a Chrome **native-messaging host**
88
88
  (locked to this extension's pinned ID) that hands the extension the pairing
89
89
  token straight from `~/.iframer/secret` — every profile and every Chromium-family
90
90
  browser (Chrome, Brave, Edge, …) pairs itself, and re-pairs automatically after
91
- a reinstall or a rotated secret. No copying, ever.
91
+ a reinstall or a rotated secret. No copying, ever. The command also tests the
92
+ host and starts the local server, and tells you if either one fails.
92
93
 
93
94
  Manual fallback (host not installed): click the iframer toolbar icon → paste the
94
- token from `cat ~/.iframer/secret` → **Save & connect**.
95
+ output of `iframer extension token` → **Save & connect**.
95
96
 
96
- Either way the dot turns green when it finds the running iframer server (it
97
- scans ports 3022–3042 on `127.0.0.1`; the server allocates its port from the
98
- same window). The token is the same machine-local secret the iframer CLI/MCP
99
- already use. It is only ever sent to `127.0.0.1`.
97
+ Either way the dot turns green only once the running iframer server has accepted
98
+ the token (the host locates the server; without a host the extension scans ports
99
+ 3022–3042 on `127.0.0.1`, the window the server allocates its port from). The
100
+ token is the same machine-local secret the iframer CLI/MCP already use. It is
101
+ only ever sent to `127.0.0.1`.
100
102
 
101
103
  ## Use it
102
104