iframer-toolkit 0.10.5 → 0.10.8

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.
@@ -659,18 +659,18 @@ var import_zod3 = require("zod");
659
659
  var import_zod2 = require("zod");
660
660
  var stepSchema = import_zod2.z.discriminatedUnion("type", [
661
661
  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.") }),
662
+ 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.") }),
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."), 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
664
  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
665
  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.") }),
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."), clear: import_zod2.z.boolean().optional().describe("Replace the field's existing text instead of appending to it.") }),
667
667
  import_zod2.z.object({ type: import_zod2.z.literal("evaluate"), expression: import_zod2.z.string() }),
668
668
  import_zod2.z.object({ type: import_zod2.z.literal("extract"), expression: import_zod2.z.string() }),
669
669
  import_zod2.z.object({ type: import_zod2.z.literal("wait"), ms: import_zod2.z.number() }),
670
670
  import_zod2.z.object({ type: import_zod2.z.literal("wait-for"), selector: import_zod2.z.string(), timeout: import_zod2.z.number().optional() }),
671
671
  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
672
  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() }),
673
+ 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
674
  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
675
  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
676
  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.") }),
@@ -783,6 +783,13 @@ ${api.domain} (${api.baseUrl})`);
783
783
  }
784
784
  }
785
785
  }
786
+ const serverErrors = (data.results || []).flatMap((r) => r.serverErrors || []);
787
+ if (serverErrors.length > 0) {
788
+ lines.push("\n--- Server errors (HTTP >= 400) ---");
789
+ for (const se of serverErrors) {
790
+ lines.push(` [step ${se.stepIndex}] ${se.method} ${se.url} \u2192 ${se.status}${se.body ? `: ${se.body}` : ""}`);
791
+ }
792
+ }
786
793
  if (data.error) {
787
794
  lines.push("\n--- Failure ---");
788
795
  if (typeof data.error === "string") {
@@ -793,6 +800,10 @@ ${api.domain} (${api.baseUrl})`);
793
800
  lines.push(`Message: ${data.error.message}`);
794
801
  lines.push(`Retryable: ${data.error.retryable}`);
795
802
  if (data.error.suggestion) lines.push(`Suggestion: ${data.error.suggestion}`);
803
+ const atFailure = serverErrors.filter((se) => se.stepIndex === data.error?.failedAtStep);
804
+ for (const se of atFailure) {
805
+ lines.push(`Server responded: ${se.method} ${se.url} \u2192 ${se.status}${se.body ? `: ${se.body}` : ""}`);
806
+ }
796
807
  if (data.error.pageState?.url) lines.push(`URL at failure: ${data.error.pageState.url}`);
797
808
  }
798
809
  }
@@ -828,7 +839,7 @@ Returns: ok, completedSteps, output for snapshot/find/read/extract steps, obstac
828
839
  screenshotAfterEach: import_zod3.z.boolean().optional().describe("Take a screenshot after every step (expensive)"),
829
840
  continueOnObstacle: import_zod3.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
830
841
  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."),
842
+ 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."),
832
843
  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)."),
833
844
  autoEscalate: import_zod3.z.boolean().optional().describe("Auto-retry with a stronger mode if blocked (default: true)"),
834
845
  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."),
@@ -1203,6 +1214,23 @@ stop: save cookies/localStorage and close the browser \u2014 ALWAYS call when br
1203
1214
  },
1204
1215
  async ({ action, mode, urls, instanceId, outputDir }) => {
1205
1216
  try {
1217
+ const SPAWNS_BROWSER = ["capture-start", "capture-stop", "get-cookies", "get-auth"];
1218
+ let resolvedMode = mode;
1219
+ if (SPAWNS_BROWSER.includes(action) && !mode) {
1220
+ const live = await localApiGet("/instances").catch(() => ({ instances: [] }));
1221
+ const match = (live.instances || []).find((i) => i.instanceId === (instanceId || "default"));
1222
+ if (match) {
1223
+ if (match.mode === "headless" || match.mode === "binary-headful") resolvedMode = match.mode;
1224
+ } else {
1225
+ const ext = await localApiGet("/extension/status").catch(() => ({ connected: false }));
1226
+ if (ext.connected) {
1227
+ return err(
1228
+ `'${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.`
1229
+ );
1230
+ }
1231
+ }
1232
+ }
1233
+ mode = resolvedMode;
1206
1234
  if (action === "stop") {
1207
1235
  const result = await localApiPost("/interactive/stop").catch(() => ({ ok: true, sessionSaved: false }));
1208
1236
  return { content: [{ type: "text", text: `Session stopped. State saved: ${result.sessionSaved ?? false}` }] };
@@ -2087,6 +2115,8 @@ CRITICAL RULES:
2087
2115
  7. If the browser crashes, call "session restart" and retry. Don't panic.
2088
2116
  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.
2089
2117
 
2118
+ If headless detects Imperva or Akamai, immediately stop headless and pivot to headed in the same run; do not wait for a block, retry headless, or defer the switch to a future request. Use headed from the outset when the provider is already known in the current task. On detection, switch to headed and use respectful pacing (at least 3 seconds between top-level actions; no parallel bursts or repeated blocked retries). Do not announce the provider or explain the switch unless the user asks why. Only when asked, explain that the site is covered by Imperva or Akamai and iframer adjusts its browser mode and pacing to respect it.
2119
+
2090
2120
  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.
2091
2121
 
2092
2122
  CAPTCHA: In binary-headful mode, ask the user to solve it in the visible window. In docker-headful mode, use the "solve-captcha" step.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "iframer",
4
- "version": "0.10.5",
4
+ "version": "0.10.8",
5
5
  "description": "Let iframer drive a Chrome tab you choose, on your real logged-in session.",
6
6
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp2wbce98k+xDnEgtuzLyNXYBLEDY0nqChuvolb94B3VV1HsHFUulUyNe92eN6MjQuadDohQ2j5mj7zWs4yQoixgTQPe7Gm/+GHWdgxwM2s9t5opnLHwF90LPiJm6q1NGWLM4aK6JT+2I6FnUGmj2bzB4WIlf55C3aMEBoXTqIzG2U8s2cxkXGebWWqbPlMK/Phsmsvo4POWnidbGHPhBOq0iyPTu7RdSDlNdJdJ+dtdNXkMmltKUS881MQI9T6GcCCKhIKcZrJGGnk0xphlWyl+ublTAO0tbQSVB/K7D3JmCxMv690TPtYUPPhGi+C6VCv3IeutLmNo60mvux3wbGQIDAQAB",
7
7
  "permissions": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iframer-toolkit",
3
- "version": "0.10.5",
3
+ "version": "0.10.8",
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",
package/skills/iframer.md CHANGED
@@ -13,6 +13,7 @@ You are an expert at using the iframer browser automation toolkit. The user want
13
13
  - `iframer.session` — stop (save state) or clear session data
14
14
  - `iframer.status` — system health, modes, stored credentials
15
15
  - `iframer.reverse-engineer` — capture API calls a site makes
16
+ - `iframer.tabs` — list/open tabs in the user's REAL Chrome (needs the extension)
16
17
 
17
18
  ## Core rules
18
19
 
@@ -20,7 +21,10 @@ You are an expert at using the iframer browser automation toolkit. The user want
20
21
  2. **NEVER guess CSS selectors.** Use `snapshot` to see what's on the page, then use `@e` refs from the snapshot for click/fill/etc.
21
22
  3. **NEVER re-store credentials when login fails.** If credentials exist in `credentials list`, they are valid. Login failures are browser-mode or bot-detection problems, not credential problems. Retry with a stronger mode.
22
23
  4. **NEVER pass explicit selectors to the login step** unless the auto-detect failed AND you took a snapshot to find the right ones. The login step auto-detects email, password, submit, and OTP fields.
23
- 5. **Let iframer handle mode escalation.** Don't hardcode modes. If you must pick one, prefer no mode (auto-select) or `binary-headful` for sites known to block headless.
24
+ 5. If headless detects Imperva or Akamai, immediately stop headless and pivot to headed in the same run; do not wait for a block, retry headless, or defer the switch to a future request. Use headed from the outset when the provider is already known in the current task. On detection, switch to headed and use respectful pacing (at least 3 seconds between top-level actions; no parallel bursts or repeated blocked retries). Do not announce the provider or explain the switch unless the user asks why. Only when asked, explain that the site is covered by Imperva or Akamai and iframer adjusts its browser mode and pacing to respect it.
25
+ 6. **Let iframer handle mode escalation.** Don't hardcode modes. If you must pick one, prefer no mode (auto-select) or `binary-headful` for sites known to block headless.
26
+ 7. **"My browser" / "my tabs" / "I'm already logged in there" means extension mode, never a launched browser.** See the section below. A launched browser has none of the user's logins and opening one is a visible, unwanted window on their desktop.
27
+ 8. **Every browser you open is yours to close.** Call `session stop` when the work is done, and if a call opened a browser by mistake, close it immediately instead of leaving it. `status` lists live windows.
24
28
 
25
29
  ## How to build pipelines
26
30
 
@@ -32,19 +36,19 @@ The `execute` tool takes a `steps` array. Each step is an object with a `type` f
32
36
  |------|--------|-------------|
33
37
  | `navigate` | `url`, `waitUntil?` | Go to URL |
34
38
  | `login` | `domain` | Auto-detect form, fill stored credentials, handle 2FA. Handles email-first flows (Slack, Microsoft, Google) and standard email+password flows. |
35
- | `snapshot` | — | Get interactive elements as structured list with @e refs. **Do this BEFORE interacting.** |
39
+ | `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. |
36
40
  | `find` | `role?`, `name?`, `text?`, `placeholder?`, `label?` | Find a specific element, returns a ref |
37
41
  | `screenshot` | `annotate?` | Take a screenshot. `annotate: true` overlays numbered badges. |
38
- | `click` | `selector` | Click element (use @e refs from snapshot) |
39
- | `fill` | `selector`, `value` | Fill input (use @e refs) |
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. |
43
+ | `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. |
40
44
  | `human-click` | `selector` or `x`, `y` | Human-like click with random offset |
41
- | `human-type` | `selector`, `value` | Human-like typing with variable delays |
45
+ | `human-type` | `selector`, `value`, `clear?` | Human-like typing with variable delays. Appends by default; `clear: true` replaces the existing text. |
42
46
  | `scroll` | `deltaY?` | Scroll the page |
43
47
  | `wait` | `ms` | Wait N milliseconds |
44
48
  | `wait-for` | `selector`, `timeout?` | Wait for element to appear |
45
49
  | `evaluate` | `expression` | Run JS, return result |
46
50
  | `extract` | `expression` | Run JS, include result in response |
47
- | `keyboard` | `key` | Press a key (Enter, Escape, Tab, etc.) |
51
+ | `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. |
48
52
  | `solve-captcha` | — | Auto-detect and solve reCAPTCHA or hCaptcha |
49
53
 
50
54
  ### Pipeline options
@@ -52,7 +56,7 @@ The `execute` tool takes a `steps` array. Each step is an object with a `type` f
52
56
  | Option | Default | Description |
53
57
  |--------|---------|-------------|
54
58
  | `mode` | auto | `headless`, `binary-headful`, `docker-headful`. Omit for auto-select + auto-escalation. |
55
- | `captureApi` | false | Record all XHR/fetch requests. Use for reverse-engineering. |
59
+ | `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. |
56
60
  | `staleTimeoutMs` | 20000 | Per-step stale-state timeout |
57
61
  | `continueOnError` | false | Don't abort pipeline on step failure |
58
62
  | `screenshotAfterEach` | false | Screenshot after every step (expensive) |
@@ -199,7 +203,7 @@ session: { action: "restart" }
199
203
  ```
200
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.
201
205
 
202
- **The execute tool also auto-recovers:** if it detects a crash (connection closed, timeout, ECONNREFUSED), it automatically restarts the browser and retries once before reporting failure. If auto-recovery also fails, the agent should call `session restart` manually and try again.
206
+ **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).
203
207
 
204
208
  ### Manage credentials
205
209
 
@@ -232,7 +236,7 @@ If an `execute` call takes a very long time (30s+), it's probably stuck on one o
232
236
 
233
237
  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.).
234
238
 
235
- 3. **Stale-state timeout** — iframer's stale monitor will abort after `staleTimeoutMs` (default 20s). The error will say "stale-state" and include a screenshot. Read the screenshot to understand what the page looks like.
239
+ 3. **Stale-state timeout** — iframer's stale monitor will abort after `staleTimeoutMs` (default 20s). The error will say "stale-state" and include a screenshot. Read the screenshot to understand what the page looks like. If the step targeted a selector that no longer exists, the error is reported as `element-not-found` instead ("No element matches …"): the page re-rendered it away. Re-snapshot right before acting and retry — do not change styles or theorise about visibility.
236
240
 
237
241
  **Recovery pattern for all stalls:**
238
242
  ```
@@ -274,7 +278,7 @@ Many modern sites don't have email+password on one page. The login step handles
274
278
 
275
279
  ## Recovering from MCP disconnect
276
280
 
277
- The iframer MCP server runs Chrome in-process. If Chrome crashes hard enough (native segfault, out of memory), the entire MCP server process dies and Claude Code shows "MCP disconnected."
281
+ The iframer MCP server does NOT run Chrome — Chrome lives in a separate, shared local background server (the daemon on 127.0.0.1). The MCP process is just a thin HTTP/stdio client. So a browser crash can't take the MCP down with it: if Chrome dies, the daemon's own recovery handles it and the MCP keeps running. A real "MCP disconnected" in Claude Code means the MCP client process itself dropped (session killed, pipe closed), not a browser crash — the daemon and its warm browsers survive that too.
278
282
 
279
283
  **Recovery is simple — Claude Code auto-reconnects MCP servers.** Just wait 2-3 seconds and retry the tool call. Don't:
280
284
  - Try to run `claude mcp` commands
@@ -313,6 +317,56 @@ If the MCP disconnects repeatedly on the same operation, the site is probably to
313
317
  | Not passing `mode` consistently across execute calls in the same flow | Always pass the same `options.mode` for all steps in a multi-call flow |
314
318
  | Panicking when MCP disconnects (running `claude mcp`, inspecting source, giving up) | Just wait 2-3 seconds, call `status` to verify reconnect, then `session restart` and retry |
315
319
 
320
+ ## Driving the user's real Chrome (extension mode)
321
+
322
+ Use this whenever the user says "my browser", "my tabs", "the page I have open", or the task
323
+ needs accounts they are already signed into. A launched browser has a fresh, logged-out profile.
324
+
325
+ 1. `tabs` with `action: "list"` — returns open tabs as `[id <n>] title / url`. If it reports the
326
+ extension is not connected, tell the user to open Chrome / check the extension; do NOT fall
327
+ back to launching a browser.
328
+ 2. `execute` with `options.mode: "extension"` AND `options.tabId: <n>` from that list. Both are
329
+ required: `mode` alone is refused and launches nothing.
330
+ 3. Need a different page? `tabs` with `action: "open"` returns the new tab id. Do NOT use a
331
+ navigate-only pipeline in a launched browser to "get there".
332
+
333
+ Rules while in extension mode:
334
+
335
+ - **Do not call `session get-cookies` / `get-auth` / `capture-start` without a mode.** They default
336
+ to launching a separate window. The tab you are driving already has the auth; read it there.
337
+ - **Do not call `session stop`.** There is nothing to stop, and it is the user's own browser.
338
+ - `execute` never escalates in extension mode. If it fails, report why (extension disconnected,
339
+ tab closed) instead of retrying in a launched mode, which would silently use a logged-out profile.
340
+
341
+ Driving a live single-page app (most dashboards) in extension mode:
342
+
343
+ - **Act only on `@e` refs** from a `find`/`snapshot` in the same execute call. Never CSS you
344
+ guessed, never text selectors.
345
+ - **Give steps that can wedge the tab their own execute call:** `navigate`, anything that opens or
346
+ closes a modal, and `evaluate`. If one hangs, the steps around it are not lost.
347
+ - **If a step reports the tab stopped responding,** run a lone `read` or `snapshot` to confirm it
348
+ recovered, then continue. Don't resend the same step unchanged.
349
+ - **Route changes:** `navigate` first. If the page never leaves, use `evaluate` with
350
+ `location.assign("<url>")`, or click the site's own link.
351
+ - **Custom dropdowns (MUI, react-select, comboboxes):** click the field or its arrow button, `find`
352
+ the option (`role: "option", name: "Texas"`), click that `@e` ref. To filter first, `fill` the
353
+ field (it stays focused so the list stays open), then `find` and click the option. Don't type
354
+ with Cmd+A/Backspace tricks.
355
+ - **Don't scroll with `evaluate` (`scrollIntoView`).** `click` scrolls its target into view itself.
356
+
357
+ ## Cleaning up after yourself
358
+
359
+ A browser opened by mistake is a real, visible window sitting on the user's desktop. It is your
360
+ job to close it, not theirs.
361
+
362
+ - Any pipeline that opens a browser must end with `session stop` once the task is done.
363
+ - If you opened a browser you did not need (wrong mode, wrong call, task turned out not to need
364
+ one), call `session stop` right away, in the same turn you noticed.
365
+ - `status` lists live windows with their `instanceId`. If you see one you do not recognise from
366
+ your own work in this conversation, say so rather than silently leaving it.
367
+ - A pipeline with no `navigate` step is refused unless a live browser already holds that
368
+ `instanceId`. If you get that error, you probably meant extension mode — go get a `tabId`.
369
+
316
370
  ## Mode selection guide
317
371
 
318
372
  | Scenario | Recommended mode |
@@ -325,6 +379,7 @@ If the MCP disconnects repeatedly on the same operation, the site is probably to
325
379
  | Need automated captcha solving | `docker-headful` (uses vision AI to solve) |
326
380
  | Need to watch the browser remotely / headless server | `docker-headful` |
327
381
  | Reverse-engineering after login | Same mode that login succeeded in |
382
+ | "Use my browser" / user's own logins / their open tab | `extension` + `tabId` from the `tabs` tool |
328
383
 
329
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.
330
385