iframer-toolkit 0.10.8 → 0.11.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.
@@ -12,9 +12,16 @@
12
12
  // stdlib) and must read the same secret locations as src/lib/auth/crypto.ts.
13
13
 
14
14
  const fs = require("fs");
15
+ const http = require("http");
15
16
  const os = require("os");
16
17
  const path = require("path");
17
18
 
19
+ // Same window the local server allocates its port from
20
+ // (src/mcp/local-server.ts, extension/background.js).
21
+ const PORT_START = 3022;
22
+ const PORT_END = 3042;
23
+ const PROBE_TIMEOUT_MS = 1500;
24
+
18
25
  function readToken() {
19
26
  if (process.env.IFRAMER_SECRET) return process.env.IFRAMER_SECRET;
20
27
  const dataDir = process.env.IFRAMER_DATA_DIR || path.join(os.homedir(), ".iframer");
@@ -33,34 +40,107 @@ function readToken() {
33
40
  return "";
34
41
  }
35
42
 
36
- // Where is the iframer server right now? Reads the server registry the local
37
- // server writes on startup (src/lib/browser/registry.ts) and checks the pid is
38
- // actually alive — so the extension can connect straight to the right port
39
- // instead of blind-scanning 21 ports (each dead dial logs a console error).
40
- function readServerInfo() {
43
+ function readRegisteredPort() {
41
44
  const dataDir = process.env.IFRAMER_DATA_DIR || path.join(os.homedir(), ".iframer");
42
45
  try {
43
46
  const info = JSON.parse(fs.readFileSync(path.join(dataDir, "server.json"), "utf8"));
44
- let alive = false;
45
- if (info && typeof info.pid === "number") {
46
- try {
47
- process.kill(info.pid, 0);
48
- alive = true;
49
- } catch {
50
- /* pid gone — stale registry */
51
- }
52
- }
53
- return { port: info && typeof info.port === "number" ? info.port : null, alive };
47
+ return info && typeof info.port === "number" ? info.port : null;
54
48
  } catch {
55
- return { port: null, alive: false };
49
+ return null;
56
50
  }
57
51
  }
58
52
 
59
- function send(msg) {
53
+ function probe(port) {
54
+ return new Promise((resolve) => {
55
+ let settled = false;
56
+ const finish = (ok) => {
57
+ if (settled) return;
58
+ settled = true;
59
+ clearTimeout(timer);
60
+ resolve(ok);
61
+ };
62
+ const req = http.get({ host: "127.0.0.1", port, path: "/health", agent: false }, (res) => {
63
+ let body = "";
64
+ res.setEncoding("utf8");
65
+ res.on("data", (d) => {
66
+ if (body.length < 4096) body += d;
67
+ });
68
+ res.on("end", () => {
69
+ try {
70
+ finish(JSON.parse(body).ok === true);
71
+ } catch {
72
+ finish(false);
73
+ }
74
+ });
75
+ res.on("error", () => finish(false));
76
+ });
77
+ req.on("error", () => finish(false));
78
+ const timer = setTimeout(() => {
79
+ req.destroy();
80
+ finish(false);
81
+ }, PROBE_TIMEOUT_MS);
82
+ });
83
+ }
84
+
85
+ // Where is the iframer server right now? Only a server that actually answers
86
+ // counts. A pid check can't decide this: on Windows a reused pid looks alive,
87
+ // and a server running as another user looks dead. Probing from here also lets
88
+ // the extension find the port without dialing dead ones (each dead dial logs a
89
+ // console error in the extension).
90
+ async function locateServer() {
91
+ const registered = readRegisteredPort();
92
+ if (registered && (await probe(registered))) return { port: registered, alive: true };
93
+
94
+ const ports = [];
95
+ for (let p = PORT_START; p <= PORT_END; p++) if (p !== registered) ports.push(p);
96
+ // All at once: Windows takes about 2s to refuse a closed port, one by one
97
+ // would take most of a minute.
98
+ const found = await new Promise((resolve) => {
99
+ let left = ports.length;
100
+ for (const p of ports) {
101
+ probe(p).then((ok) => {
102
+ if (ok) resolve(p);
103
+ else if (--left === 0) resolve(null);
104
+ });
105
+ }
106
+ });
107
+ return found ? { port: found, alive: true } : { port: registered, alive: false };
108
+ }
109
+
110
+ function send(msg, done) {
60
111
  const payload = Buffer.from(JSON.stringify(msg), "utf8");
61
112
  const header = Buffer.alloc(4);
62
113
  header.writeUInt32LE(payload.length, 0);
63
- process.stdout.write(Buffer.concat([header, payload]));
114
+ process.stdout.write(Buffer.concat([header, payload]), done);
115
+ }
116
+
117
+ // Chrome may close stdin while a reply is still being worked out, so exit only
118
+ // once stdin has ended AND every reply has been written.
119
+ let pending = 0;
120
+ let stdinEnded = false;
121
+ function exitIfDone() {
122
+ if (stdinEnded && pending === 0) process.exit(0);
123
+ }
124
+
125
+ async function reply(msg) {
126
+ let out;
127
+ try {
128
+ if (msg && msg.cmd === "get-token") {
129
+ const token = readToken();
130
+ out = token ? { ok: true, token } : { ok: false, error: "no secret file found — run any iframer command once to create it" };
131
+ } else if (msg && msg.cmd === "get-info") {
132
+ // Token + live server location in one call.
133
+ out = { ok: true, token: readToken() || null, ...(await locateServer()) };
134
+ } else {
135
+ out = { ok: false, error: "unknown command" };
136
+ }
137
+ } catch (e) {
138
+ out = { ok: false, error: String((e && e.message) || e) };
139
+ }
140
+ send(out, () => {
141
+ pending--;
142
+ exitIfDone();
143
+ });
64
144
  }
65
145
 
66
146
  let buf = Buffer.alloc(0);
@@ -75,17 +155,13 @@ process.stdin.on("data", (chunk) => {
75
155
  try {
76
156
  msg = JSON.parse(body.toString("utf8"));
77
157
  } catch {
78
- /* respond with error below */
79
- }
80
- if (msg && msg.cmd === "get-token") {
81
- const token = readToken();
82
- send(token ? { ok: true, token } : { ok: false, error: "no secret file found — run any iframer command once to create it" });
83
- } else if (msg && msg.cmd === "get-info") {
84
- // Token + live server location in one call.
85
- send({ ok: true, token: readToken() || null, ...readServerInfo() });
86
- } else {
87
- send({ ok: false, error: "unknown command" });
158
+ /* answered as an unknown command */
88
159
  }
160
+ pending++;
161
+ reply(msg);
89
162
  }
90
163
  });
91
- process.stdin.on("end", () => process.exit(0));
164
+ process.stdin.on("end", () => {
165
+ stdinEnded = true;
166
+ exitIfDone();
167
+ });
@@ -15,10 +15,10 @@ function renderStatus(status) {
15
15
  const connected = status && status.connected;
16
16
  $("dot").className = "dot" + (connected ? " on" : "");
17
17
  const reasonText = {
18
- "no-token": "No token set — try Pair automatically, or paste it below.",
19
- "server-not-found": "iframer server not found. Is Claude Code / iframer running?",
18
+ "no-token": "No token yet. Run `iframer install extension chrome` in a terminal and this pairs by itself, or paste the output of `iframer extension token` below.",
19
+ "server-not-found": "iframer server not found. It starts with your Claude/iframer session. Will connect by itself.",
20
20
  "server-not-running": "iframer server not running — it starts with your Claude/iframer session. Will connect by itself.",
21
- "bad-token": "Token rejected. Check it matches ~/.iframer/secret.",
21
+ "bad-token": "Token rejected by the server. Paste the output of `iframer extension token` below.",
22
22
  disconnected: "Disconnected — retrying…",
23
23
  };
24
24
  if (connected) {
@@ -86,7 +86,7 @@ $("autopair").addEventListener("click", async () => {
86
86
  const res = await send("auto-pair");
87
87
  if (res.ok) {
88
88
  if (res.token) $("token").value = res.token;
89
- $("pairmsg").textContent = "✓ Paired.";
89
+ $("pairmsg").textContent = res.status && res.status.connected ? "✓ Paired." : "Token saved. See the status above.";
90
90
  renderStatus(res.status);
91
91
  } else {
92
92
  $("pairmsg").textContent = res.error || "Auto-pair failed — paste the token manually below.";
@@ -105,4 +105,13 @@ $("save").addEventListener("click", async () => {
105
105
  $("save").textContent = "Save & connect";
106
106
  });
107
107
 
108
+ // Keep the popup live: pairing usually finishes while it's open.
109
+ chrome.storage.onChanged.addListener((changes, area) => {
110
+ if (area !== "local") return;
111
+ if (changes.status) renderStatus(changes.status.newValue);
112
+ if (changes.token && changes.token.newValue && document.activeElement !== $("token")) {
113
+ $("token").value = changes.token.newValue;
114
+ }
115
+ });
116
+
108
117
  init();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iframer-toolkit",
3
- "version": "0.10.8",
3
+ "version": "0.11.1",
4
4
  "description": "Browser access toolkit for AI agents — CLI and MCP server for headless/headful browsing, login flows, and pipeline execution.",
5
5
  "keywords": [
6
6
  "iframer",
@@ -39,9 +39,6 @@
39
39
  },
40
40
  "scripts": {
41
41
  "start": "bun run index.ts",
42
- "start:docker": "docker compose up --build -d",
43
- "stop:docker": "docker compose down",
44
- "logs:docker": "docker compose logs -f api",
45
42
  "test:smoke": "node tests/smoke.js",
46
43
  "mcp:install": "node bin/cli.js install-mcp",
47
44
  "mcp:remove": "node bin/cli.js remove-mcp",
@@ -50,13 +47,13 @@
50
47
  "test": "bun test tests/unit",
51
48
  "test:extension": "node tests/integration/extension-bridge.mjs",
52
49
  "prepublishOnly": "bun run build",
53
- "test:cdp-relay": "bun tests/integration/cdp-relay.mjs"
50
+ "test:cdp-relay": "bun tests/integration/cdp-relay.mjs",
51
+ "test:kit": "bun tests/integration/kit-bridge.mjs"
54
52
  },
55
53
  "dependencies": {
56
54
  "@anthropic-ai/sdk": "^0.80.0",
57
55
  "@modelcontextprotocol/sdk": "^1.29.0",
58
56
  "better-sqlite3": "^12.8.0",
59
- "fingerprint-generator": "^2.1.81",
60
57
  "patchright": "^1.58.2",
61
58
  "playwright-core": "^1.58.0",
62
59
  "ws": "^8.18.0",
package/skills/iframer.md CHANGED
@@ -10,10 +10,12 @@ You are an expert at using the iframer browser automation toolkit. The user want
10
10
  - `iframer.credentials` — list or store login credentials (NEVER re-store as recovery)
11
11
  - `iframer.execute` — run a browser pipeline (navigate, login, screenshot, snapshot, etc.)
12
12
  - `iframer.browse` — fast headless page fetch (no pipeline needed)
13
- - `iframer.session` — stop (save state) or clear session data
13
+ - `iframer.session` — stop (save state, close the browser), clear (wipe session data), restart (recover from a freeze or crash), capture-start / capture-stop (record XHR/fetch), get-cookies, get-auth
14
14
  - `iframer.status` — system health, modes, stored credentials
15
15
  - `iframer.reverse-engineer` — capture API calls a site makes
16
16
  - `iframer.tabs` — list/open tabs in the user's REAL Chrome (needs the extension)
17
+ - `iframer.remember` — per-domain element anchors, targeted as `@a:<name>` in any selector
18
+ - `iframer.clipboard` — read/write the machine's clipboard (same one the user's Chrome uses)
17
19
 
18
20
  ## Core rules
19
21
 
@@ -39,7 +41,8 @@ The `execute` tool takes a `steps` array. Each step is an object with a `type` f
39
41
  | `snapshot` | — | Get interactive elements as structured list with @e refs. **Do this BEFORE interacting.** Fields no human can reach (honeypots pushed offscreen/clipped/transparent by an ancestor) are omitted; bait-looking ones (`website`, `fax`, tabindex=-1) are marked `[honeypot?]` — leave those empty unless the task clearly needs them. |
40
42
  | `find` | `role?`, `name?`, `text?`, `placeholder?`, `label?` | Find a specific element, returns a ref |
41
43
  | `screenshot` | `annotate?` | Take a screenshot. `annotate: true` overlays numbered badges. |
42
- | `click` | `selector` | Click element (use @e refs from snapshot/find). Text selectors (`text=`, `:has-text()`, `:text-is()`) are rejected: `find` by text, then click the ref. |
44
+ | `click` | `selector` | Click element (use @e refs from snapshot/find). Text selectors (`text=`, `:has-text()`, `:text-is()`, `>>`) are rejected: `find` by text, then click the ref. |
45
+ | `right-click` | `selector` or `x`, `y` | Right-click an element or coordinates |
43
46
  | `fill` | `selector`, `value`, `force?` | Fill input (use @e refs). Refuses honeypot fields (unreachable by a human) with an explanatory error — skip the field. `force: true` bypasses, only when you are certain it is real. |
44
47
  | `human-click` | `selector` or `x`, `y` | Human-like click with random offset |
45
48
  | `human-type` | `selector`, `value`, `clear?` | Human-like typing with variable delays. Appends by default; `clear: true` replaces the existing text. |
@@ -49,13 +52,19 @@ The `execute` tool takes a `steps` array. Each step is an object with a `type` f
49
52
  | `evaluate` | `expression` | Run JS, return result |
50
53
  | `extract` | `expression` | Run JS, include result in response |
51
54
  | `keyboard` | `key`, `meta?`, `ctrl?` | Press a key (Enter, Escape, Tab, etc.). Cmd/Ctrl+A selects all everywhere; other Cmd/Ctrl shortcuts are refused in extension mode. |
52
- | `solve-captcha` | — | Auto-detect and solve reCAPTCHA or hCaptcha |
55
+ | `select` | `selector`, `value` | Pick an option in a native `<select>`, with real change events. Never set `.value` via `evaluate`. |
56
+ | `type-code` | `value`, `selector?` | Type a verification code digit by digit. Default target: `input[type="tel"]`. |
57
+ | `upload` | `selector`, `files` | Set absolute local file paths on an `<input type=file>` |
58
+ | `paste` | `selector?` | Paste the OS clipboard into a field, or the focused element if omitted. More reliable than a ⌘V `keyboard` step. |
59
+ | `download` | `url`, `path?` | Fetch a file with the browser's cookies and save it server-side, no Save-As dialog. Default path: `~/.iframer/downloads/<name>`. |
60
+ | `solve-captcha` | — | Best-effort reCAPTCHA or hCaptcha solve with vision AI. Works in any mode; needs `ANTHROPIC_API_KEY`. Fails the run with `captcha-unsolvable` if it can't solve it. |
61
+ | `recaptcha` | `action`, `tiles?` | Manual captcha control: `info`, `click`, `answer`, `select`, `verify`, `solve`. The full workflow arrives in the result when a captcha blocks a run. |
53
62
 
54
63
  ### Pipeline options
55
64
 
56
65
  | Option | Default | Description |
57
66
  |--------|---------|-------------|
58
- | `mode` | auto | `headless`, `binary-headful`, `docker-headful`. Omit for auto-select + auto-escalation. |
67
+ | `mode` | auto | `headless`, `binary-headful`. Omit for auto-select + auto-escalation. |
59
68
  | `captureApi` | false | Record all XHR/fetch requests. Use for reverse-engineering, and when a form submit fails with only a vague on-page message: every HTTP >= 400 response is reported per step, so you read the server's real answer (e.g. `POST /api/apply → 422: {"error":"..."}`) instead of guessing. |
60
69
  | `staleTimeoutMs` | 20000 | Per-step stale-state timeout |
61
70
  | `continueOnError` | false | Don't abort pipeline on step failure |
@@ -157,6 +166,25 @@ execute: steps: [{ type: "click", selector: "@e3" }, { type: "screenshot" }]
157
166
 
158
167
  The more interactions (scroll, click tabs, navigate sub-pages), the more API calls get captured. Results feed into the knowledge cache automatically.
159
168
 
169
+ ### Reverse-engineer inside a browser iframer doesn't control (T3 Code, Playwright MCP, ...)
170
+
171
+ When you already drive a browser through your own tools (a T3 Code preview tab, Playwright MCP, Chrome DevTools MCP), don't launch iframer's browser: load the in-page kit into that tab. It records the page's own calls on the user's real session and exports shapes, never values. Use the CLI for this, not the MCP tools.
172
+
173
+ ```
174
+ 1. iframer kit --for t3 # or --for playwright-mcp / --for any. Prints the exact tool calls.
175
+ 2. Follow the printed install steps (upload the kit file, stash it, top-level eval). Never paste the kit's source.
176
+ 3. Drive the site with your normal tools. After EVERY full page load, re-run the printed eval step (step 4).
177
+ 4. window.__ifk.dump(), then: iframer ingest --latest --typed
178
+ -> ./<host>-api/api.json, client.js, client.d.ts, plus the knowledge cache. The capture file is deleted.
179
+ ```
180
+
181
+ - Replays run IN the page: `window.__ifk.call('GET','/path')`, or load the generated `client.js` (`iframer kit --for t3 --client ./<host>-api/client.js` prints the steps). HttpOnly cookies never leave the browser, so curl replays fail.
182
+ - Writes need `{approved:true}`: ask the user first. Replays stop for good on 403, 429 or a captcha page. Do not retry or work around it.
183
+ - Sites that detect tampering: `iframer kit --mode passive` (no patching; URLs and form posts only).
184
+ - Report coverage as the kit states it. It is never complete: workers, redirect hops and cross-origin iframes are invisible.
185
+ - Private values are separated from the API, not a reason to stop. `ingest` swaps anything token-, email- or phone-like for a placeholder (`{id}` in paths, `{key}` for keys) and lists what it replaced. Endpoints that had one carry `privateValues` in api.json: pass that value per call.
186
+ - Identity: api.json `identity` (and the knowledge file) says which auth headers the site sends and where the page keeps each value, e.g. `localStorage["session -> user.token"]`. `__ifk.call` attaches them on its own, cookies included. If a call returns 401 with a `hint`, use the site once so it sends its token, then retry.
187
+
160
188
  ### Handle captchas
161
189
 
162
190
  Captcha handling depends on the browser mode:
@@ -171,16 +199,13 @@ Captcha handling depends on the browser mode:
171
199
  execute: steps: [{ type: "screenshot" }] // verify captcha is gone, then continue
172
200
  ```
173
201
 
174
- **Docker-headful** (remote browser via noVNC):
175
- - Use the `solve-captcha` step — it auto-detects and solves reCAPTCHA/hCaptcha via vision AI.
202
+ **Headless, or nobody at the screen**:
203
+ - If you hit a captcha in headless, iframer auto-escalates to binary-headful.
204
+ - If nobody can solve it by hand, try the `solve-captcha` step. It's best effort: it uses vision AI, needs `ANTHROPIC_API_KEY`, and fails on many challenges.
176
205
  ```
177
206
  execute: steps: [{ type: "solve-captcha" }, { type: "screenshot" }]
178
207
  ```
179
208
 
180
- **Headless**:
181
- - Captchas can't be solved in headless. If you hit one, iframer auto-escalates to binary-headful.
182
- - If it still fails, switch to docker-headful and use `solve-captcha`.
183
-
184
209
  ### Handle OTP / 2FA codes
185
210
 
186
211
  If the login step encounters a code field and no TOTP secret is stored:
@@ -201,7 +226,7 @@ When the browser crashes, freezes, or you get connection errors:
201
226
  ```
202
227
  session: { action: "restart" }
203
228
  ```
204
- This kills ALL running browser instances (local + Docker) and resets state. Credentials and knowledge cache are NOT affected. The next `execute` call launches a fresh browser automatically.
229
+ This kills ALL running browser instances and resets state. Credentials and knowledge cache are NOT affected. The next `execute` call launches a fresh browser automatically.
205
230
 
206
231
  **The execute tool also auto-recovers:** if it detects a *connection-level* crash (connection closed, ECONNREFUSED/ECONNRESET/EPIPE, socket hang up), it restarts the browser server and retries once before reporting failure. Timeouts are deliberately NOT auto-retried — a slow-but-healthy pipeline that hit the client timeout must not be re-run, or non-idempotent steps (form submits, purchases) would double-execute. If auto-recovery fails, or a step timed out, call `session restart` manually and continue from where you left off (don't blindly re-run the whole pipeline).
207
232
 
@@ -232,7 +257,7 @@ knowledge: { action: "clear", domain: "example.com" }
232
257
 
233
258
  If an `execute` call takes a very long time (30s+), it's probably stuck on one of:
234
259
 
235
- 1. **Captcha blocking progress** — the login/navigate step can't proceed because a captcha appeared. In binary-headful mode, tell the user to solve it in the visible window. In docker-headful mode, add a `solve-captcha` step.
260
+ 1. **Captcha blocking progress** — the login/navigate step can't proceed because a captcha appeared. In binary-headful mode, tell the user to solve it in the visible window. If nobody can, add a `solve-captcha` step.
236
261
 
237
262
  2. **Waiting for a page transition that never happens** — the site didn't redirect after form submission. Take a screenshot to see the current state, then decide what to do (wrong button clicked, error message on page, etc.).
238
263
 
@@ -245,7 +270,7 @@ If an `execute` call takes a very long time (30s+), it's probably stuck on one o
245
270
 
246
271
  2. If captcha visible:
247
272
  - binary-headful → ask user to solve it in the visible window
248
- - docker-headful → execute: steps: [{ type: "solve-captcha" }]
273
+ - nobody at the screen → execute: steps: [{ type: "solve-captcha" }]
249
274
 
250
275
  3. If the page shows an error or unexpected state:
251
276
  - Take a snapshot to get element refs
@@ -256,11 +281,9 @@ If an `execute` call takes a very long time (30s+), it's probably stuck on one o
256
281
 
257
282
  **CRITICAL: Don't restart from scratch.** The browser session persists between execute calls (same daemon, same page context). If you were mid-login and hit a captcha, you don't need to navigate back to the login page — you're still there. Just solve the captcha and continue.
258
283
 
259
- **Timeouts:** Docker-headful is slower than local modes — Xvfb + VNC + Chrome share one container. Heavy SPAs (Slack, Figma) can take 30+ seconds to load. If an execute call times out:
284
+ **Timeouts:** Heavy SPAs (Slack, Figma) can take 30+ seconds to load. If an execute call times out:
260
285
  1. Use `staleTimeoutMs: 60000` or higher for heavy sites
261
- 2. Check `status` to confirm Docker is still running
262
- 3. Take a screenshot to see where the browser is — the session is likely still alive
263
- 4. If the container crashed, `docker compose up -d` restarts it; stored sessions/credentials survive since they're in the host's SQLite
286
+ 2. Take a screenshot to see where the browser is — the session is likely still alive
264
287
 
265
288
  ## Multi-step flows (email → code, OAuth, etc.)
266
289
 
@@ -295,7 +318,6 @@ Instead:
295
318
  **Your progress is NOT lost.** Credentials, sessions, and knowledge cache are in the SQLite database on disk — they survive MCP server restarts. You don't need to re-login or re-store anything.
296
319
 
297
320
  If the MCP disconnects repeatedly on the same operation, the site is probably too heavy for the current mode. Switch to a lighter approach:
298
- - If using `docker-headful` → switch to `binary-headful` (less overhead)
299
321
  - If doing a complex multi-step pipeline → break it into smaller execute calls
300
322
  - If just trying to capture APIs → you probably already have enough from previous runs. Check `knowledge get <domain>` before retrying.
301
323
 
@@ -305,7 +327,7 @@ If the MCP disconnects repeatedly on the same operation, the site is probably to
305
327
  |---------|-----------------|
306
328
  | Guessing CSS selectors like `button[data-qa='submit']` | Use `snapshot` → read refs → use `@e` refs |
307
329
  | Passing `usernameSelector`/`passwordSelector` to login step | Omit them — auto-detect handles it |
308
- | Re-storing credentials when login fails | Retry with stronger mode (`binary-headful` or `docker-headful`) |
330
+ | Re-storing credentials when login fails | Retry with stronger mode (`binary-headful`) |
309
331
  | Launching browser without checking knowledge cache | Always call `knowledge get <domain>` first |
310
332
  | Using `browse` for a page that needs interaction | Use `execute` with a pipeline |
311
333
  | Running a huge pipeline without snapshots | Split into navigate+snapshot, then interact based on refs |
@@ -374,16 +396,13 @@ job to close it, not theirs.
374
396
  | Simple data extraction, no login | Omit mode (auto-select, starts headless) |
375
397
  | Site with login | Omit mode (auto-escalation if headless is blocked) |
376
398
  | Known bot-detection (Cloudflare, etc.) | `binary-headful` |
377
- | Heavy SPA (Slack, Figma, Linear, etc.) | `binary-headful` — Docker often stalls on heavy pages |
399
+ | Heavy SPA (Slack, Figma, Linear, etc.) | `binary-headful` |
378
400
  | Interactive session (browsing, clicking around) | `binary-headful` — most stable for long flows |
379
- | Need automated captcha solving | `docker-headful` (uses vision AI to solve) |
380
- | Need to watch the browser remotely / headless server | `docker-headful` |
401
+ | Nobody available to solve a captcha | Any mode, with a best-effort `solve-captcha` step (needs `ANTHROPIC_API_KEY`) |
381
402
  | Reverse-engineering after login | Same mode that login succeeded in |
382
403
  | "Use my browser" / user's own logins / their open tab | `extension` + `tabId` from the `tabs` tool |
383
404
 
384
- **Prefer `binary-headful` over `docker-headful` for most tasks.** Binary-headful runs Chrome directly on the user's machine — it's faster, more stable for long sessions, and the user can solve captchas manually. Docker-headful adds overhead (Xvfb, VNC, container networking) and heavy SPAs frequently stall or timeout inside the container.
385
-
386
- Use `docker-headful` only when: (a) automated captcha solving is needed, (b) the user asks for it explicitly, or (c) it's a remote/headless server with no display.
405
+ **Prefer `binary-headful` for sites that block headless.** It runs Chrome directly on the user's machine, is stable for long sessions, and the user can solve captchas manually.
387
406
 
388
407
  ## Response handling
389
408