iframer-toolkit 0.10.1 → 0.10.5

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.
@@ -1,34 +1,28 @@
1
1
  #!/usr/bin/env node
2
+ const importMetaUrl = require('node:url').pathToFileURL(__filename).href;
3
+ "use strict";
2
4
  var __create = Object.create;
3
- var __getProtoOf = Object.getPrototypeOf;
4
5
  var __defProp = Object.defineProperty;
6
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
7
  var __getOwnPropNames = Object.getOwnPropertyNames;
8
+ var __getProtoOf = Object.getPrototypeOf;
6
9
  var __hasOwnProp = Object.prototype.hasOwnProperty;
7
- function __accessProp(key) {
8
- return this[key];
9
- }
10
- var __toESMCache_node;
11
- var __toESMCache_esm;
12
- var __toESM = (mod, isNodeMode, target) => {
13
- var canCache = mod != null && typeof mod === "object";
14
- if (canCache) {
15
- var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
16
- var cached = cache.get(mod);
17
- if (cached)
18
- return cached;
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
19
15
  }
20
- target = mod != null ? __create(__getProtoOf(mod)) : {};
21
- const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
22
- for (let key of __getOwnPropNames(mod))
23
- if (!__hasOwnProp.call(to, key))
24
- __defProp(to, key, {
25
- get: __accessProp.bind(mod, key),
26
- enumerable: true
27
- });
28
- if (canCache)
29
- cache.set(mod, to);
30
16
  return to;
31
17
  };
18
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
19
+ // If the importer is in node compatibility mode or this is not an ESM
20
+ // file that has been converted to a CommonJS file using a Babel-
21
+ // compatible transform (i.e. "__esModule" has not been set), then set
22
+ // "default" to the CommonJS "module.exports" for node compatibility.
23
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
24
+ mod
25
+ ));
32
26
 
33
27
  // src/mcp/server.ts
34
28
  var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
@@ -45,20 +39,16 @@ function createLogger(tag) {
45
39
  const prefix = `[${tag}]`;
46
40
  return {
47
41
  debug: (...args) => {
48
- if (LEVELS[currentLevel] <= 0)
49
- console.log(prefix, ...args);
42
+ if (LEVELS[currentLevel] <= 0) console.log(prefix, ...args);
50
43
  },
51
44
  info: (...args) => {
52
- if (LEVELS[currentLevel] <= 1)
53
- console.log(prefix, ...args);
45
+ if (LEVELS[currentLevel] <= 1) console.log(prefix, ...args);
54
46
  },
55
47
  warn: (...args) => {
56
- if (LEVELS[currentLevel] <= 2)
57
- console.warn(prefix, ...args);
48
+ if (LEVELS[currentLevel] <= 2) console.warn(prefix, ...args);
58
49
  },
59
50
  error: (...args) => {
60
- if (LEVELS[currentLevel] <= 3)
61
- console.error(prefix, ...args);
51
+ if (LEVELS[currentLevel] <= 3) console.error(prefix, ...args);
62
52
  }
63
53
  };
64
54
  }
@@ -78,8 +68,7 @@ function getDataDir() {
78
68
 
79
69
  // src/lib/auth/crypto.ts
80
70
  function getLocalToken() {
81
- if (process.env.IFRAMER_SECRET)
82
- return process.env.IFRAMER_SECRET;
71
+ if (process.env.IFRAMER_SECRET) return process.env.IFRAMER_SECRET;
83
72
  const candidates = [
84
73
  import_path2.default.join(getDataDir(), "secret"),
85
74
  import_path2.default.join(process.env.XDG_RUNTIME_DIR || import_os2.default.tmpdir(), "iframer-secret")
@@ -87,9 +76,9 @@ function getLocalToken() {
87
76
  for (const file of candidates) {
88
77
  try {
89
78
  const existing = import_fs.default.readFileSync(file, "utf8").trim();
90
- if (existing)
91
- return existing;
92
- } catch {}
79
+ if (existing) return existing;
80
+ } catch {
81
+ }
93
82
  }
94
83
  for (const file of candidates) {
95
84
  try {
@@ -97,9 +86,12 @@ function getLocalToken() {
97
86
  const secret = import_crypto.default.randomBytes(32).toString("hex");
98
87
  import_fs.default.writeFileSync(file, secret, { mode: 384 });
99
88
  return secret;
100
- } catch {}
89
+ } catch {
90
+ }
101
91
  }
102
- throw new Error("iframer: could not read or create a persistent encryption secret in any " + `writable location (${candidates.join(", ")}). Set IFRAMER_SECRET to a ` + "stable value shared between the MCP server and CLI (openssl rand -hex 32).");
92
+ throw new Error(
93
+ `iframer: could not read or create a persistent encryption secret in any writable location (${candidates.join(", ")}). Set IFRAMER_SECRET to a stable value shared between the MCP server and CLI (openssl rand -hex 32).`
94
+ );
103
95
  }
104
96
 
105
97
  // src/mcp/local-server.ts
@@ -116,8 +108,7 @@ function serverInfoPath() {
116
108
  return import_path3.default.join(getDataDir(), "server.json");
117
109
  }
118
110
  function isPidAlive(pid) {
119
- if (!Number.isInteger(pid) || pid <= 1)
120
- return false;
111
+ if (!Number.isInteger(pid) || pid <= 1) return false;
121
112
  try {
122
113
  process.kill(pid, 0);
123
114
  return true;
@@ -128,8 +119,7 @@ function isPidAlive(pid) {
128
119
  function readServerInfo() {
129
120
  try {
130
121
  const info = JSON.parse(import_fs2.default.readFileSync(serverInfoPath(), "utf8"));
131
- if (!Number.isInteger(info.pid) || !Number.isInteger(info.port))
132
- return null;
122
+ if (!Number.isInteger(info.pid) || !Number.isInteger(info.port)) return null;
133
123
  return info;
134
124
  } catch {
135
125
  return null;
@@ -137,14 +127,12 @@ function readServerInfo() {
137
127
  }
138
128
 
139
129
  // src/mcp/local-server.ts
140
- var __dirname = "/Users/eduardoverona/tools/iframer-toolkit/src/mcp";
141
130
  var BASE_PORT = parseInt(process.env.IFRAMER_LOCAL_PORT || "3022", 10);
142
131
  var PORT_SCAN_ATTEMPTS = 21;
143
- var STARTUP_TIMEOUT_MS = 15000;
132
+ var STARTUP_TIMEOUT_MS = 15e3;
144
133
  var HEALTH_POLL_MS = 300;
145
- var SPAWN_LOCK_STALE_MS = 20000;
146
-
147
- class LocalServerManager {
134
+ var SPAWN_LOCK_STALE_MS = 2e4;
135
+ var LocalServerManager = class {
148
136
  startingPromise = null;
149
137
  baseUrl = "";
150
138
  logPath;
@@ -153,24 +141,24 @@ class LocalServerManager {
153
141
  }
154
142
  getBaseUrl() {
155
143
  if (!this.baseUrl) {
156
- throw new Error("Local server not started yet — call ensureRunning() first.");
144
+ throw new Error("Local server not started yet \u2014 call ensureRunning() first.");
157
145
  }
158
146
  return this.baseUrl;
159
147
  }
148
+ /** Ensure a shared local server is reachable. Idempotent — concurrent
149
+ * callers await the same startup promise. */
160
150
  async ensureRunning() {
161
- if (this.startingPromise)
162
- return this.startingPromise;
163
- if (await this.adoptExisting())
164
- return;
151
+ if (this.startingPromise) return this.startingPromise;
152
+ if (await this.adoptExisting()) return;
165
153
  this.startingPromise = this.startShared().finally(() => {
166
154
  this.startingPromise = null;
167
155
  });
168
156
  return this.startingPromise;
169
157
  }
158
+ /** Adopt the advertised shared server if its process is alive and healthy. */
170
159
  async adoptExisting() {
171
160
  const info = readServerInfo();
172
- if (!info || !isPidAlive(info.pid))
173
- return false;
161
+ if (!info || !isPidAlive(info.pid)) return false;
174
162
  const url = `http://127.0.0.1:${info.port}`;
175
163
  if (await healthCheck(url)) {
176
164
  this.baseUrl = url;
@@ -199,21 +187,20 @@ class LocalServerManager {
199
187
  import_fs3.default.rmdirSync(lockDir);
200
188
  import_fs3.default.mkdirSync(lockDir);
201
189
  holdingLock = true;
202
- } catch {}
190
+ } catch {
191
+ }
203
192
  }
204
193
  }
205
194
  try {
206
195
  if (!holdingLock) {
207
196
  const deadline2 = Date.now() + STARTUP_TIMEOUT_MS;
208
197
  while (Date.now() < deadline2) {
209
- if (await this.adoptExisting())
210
- return;
198
+ if (await this.adoptExisting()) return;
211
199
  await sleep(HEALTH_POLL_MS);
212
200
  }
213
201
  throw new Error("Timed out waiting for another session to start the shared iframer server.");
214
202
  }
215
- if (await this.adoptExisting())
216
- return;
203
+ if (await this.adoptExisting()) return;
217
204
  const port = await findFreePort(BASE_PORT, PORT_SCAN_ATTEMPTS);
218
205
  const url = `http://127.0.0.1:${port}`;
219
206
  const { command, args } = this.resolveRuntime();
@@ -229,11 +216,11 @@ class LocalServerManager {
229
216
  } else {
230
217
  try {
231
218
  const secret = import_fs3.default.readFileSync(import_path4.default.join(dataDir, "secret"), "utf8").trim();
232
- if (secret)
233
- env.IFRAMER_SECRET = secret;
234
- } catch {}
219
+ if (secret) env.IFRAMER_SECRET = secret;
220
+ } catch {
221
+ }
235
222
  }
236
- const child = import_child_process.spawn(command, args, {
223
+ const child = (0, import_child_process.spawn)(command, args, {
237
224
  env,
238
225
  stdio: ["ignore", logFd, logFd],
239
226
  detached: true
@@ -250,77 +237,109 @@ class LocalServerManager {
250
237
  }
251
238
  try {
252
239
  child.kill("SIGKILL");
253
- } catch {}
254
- throw new Error(`Local iframer server failed to start on port ${port} within ${STARTUP_TIMEOUT_MS}ms.
255
- ` + `Last log lines:
256
- ${this.readLogTail()}`);
240
+ } catch {
241
+ }
242
+ throw new Error(
243
+ `Local iframer server failed to start on port ${port} within ${STARTUP_TIMEOUT_MS}ms.
244
+ Last log lines:
245
+ ${this.readLogTail()}`
246
+ );
257
247
  } finally {
258
248
  if (holdingLock) {
259
249
  try {
260
250
  import_fs3.default.rmdirSync(lockDir);
261
- } catch {}
251
+ } catch {
252
+ }
253
+ }
254
+ }
255
+ }
256
+ /** Package root, found by walking up for iframer-toolkit's package.json.
257
+ * Robust to WHERE this code runs from: src/mcp/ (source), dist/ (bundled
258
+ * CLI under node), or a global npm install — a fixed __dirname/../.. guess
259
+ * breaks in the bundle (that's why the warm CLI silently ran in-process). */
260
+ packageRoot() {
261
+ let dir = __dirname;
262
+ for (let i = 0; i < 8; i++) {
263
+ try {
264
+ const pkg = JSON.parse(import_fs3.default.readFileSync(import_path4.default.join(dir, "package.json"), "utf8"));
265
+ if (pkg.name === "iframer-toolkit") return dir;
266
+ } catch {
262
267
  }
268
+ const parent = import_path4.default.dirname(dir);
269
+ if (parent === dir) break;
270
+ dir = parent;
263
271
  }
272
+ return import_path4.default.join(__dirname, "..", "..");
264
273
  }
265
274
  resolveRuntime() {
266
- const serverTs = import_path4.default.join(__dirname, "..", "..", "index.ts");
267
- const serverCjs = import_path4.default.join(__dirname, "..", "..", "dist", "local-server.cjs");
275
+ const root = this.packageRoot();
276
+ const serverTs = import_path4.default.join(root, "index.ts");
277
+ const serverCjs = import_path4.default.join(root, "dist", "local-server.cjs");
268
278
  if (import_fs3.default.existsSync(serverCjs)) {
269
279
  return { command: "node", args: [serverCjs] };
270
280
  }
271
281
  if (import_fs3.default.existsSync(serverTs)) {
272
282
  try {
273
- require.resolve("/Users/eduardoverona/tools/iframer-toolkit/node_modules/tsx/dist/loader.mjs");
283
+ require.resolve("tsx");
274
284
  return { command: "node", args: ["--import", "tsx", serverTs] };
275
- } catch {}
285
+ } catch {
286
+ }
276
287
  }
277
288
  try {
278
289
  const bunPath = require("child_process").execSync("which bun", { encoding: "utf8" }).trim();
279
290
  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");
291
+ console.error("[iframer] running local server under bun \u2014 extension mode (connectOverCDP) will not work; run `bun run build` to get the node bundle");
281
292
  return { command: bunPath, args: ["run", serverTs] };
282
293
  }
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.");
294
+ } catch {
295
+ }
296
+ throw new Error(
297
+ "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."
298
+ );
285
299
  }
300
+ /** Ask the shared server to exit (it kills its browsers with a hard
301
+ * deadline), then start a fresh one. Used for crash recovery. */
286
302
  async restart() {
287
303
  const info = readServerInfo();
288
304
  if (info && this.baseUrl) {
289
305
  try {
290
- await fetch(`${this.baseUrl}/shutdown`, { method: "POST", signal: AbortSignal.timeout(3000) });
291
- } catch {}
292
- const deadline = Date.now() + 12000;
306
+ await fetch(`${this.baseUrl}/shutdown`, { method: "POST", signal: AbortSignal.timeout(3e3) });
307
+ } catch {
308
+ }
309
+ const deadline = Date.now() + 12e3;
293
310
  while (Date.now() < deadline && isPidAlive(info.pid)) {
294
311
  await sleep(200);
295
312
  }
296
313
  if (isPidAlive(info.pid)) {
297
314
  try {
298
315
  process.kill(info.pid, "SIGKILL");
299
- } catch {}
316
+ } catch {
317
+ }
300
318
  }
301
319
  }
302
320
  this.baseUrl = "";
303
321
  await this.ensureRunning();
304
322
  }
305
- shutdown() {}
323
+ /** MCP process exiting. The shared server intentionally stays up —
324
+ * other sessions may be using it; idle-exit + reaper govern its life. */
325
+ shutdown() {
326
+ }
306
327
  readLogTail() {
307
328
  try {
308
329
  const content = import_fs3.default.readFileSync(this.logPath, "utf8");
309
- const lines = content.trim().split(`
310
- `);
311
- return lines.slice(-10).join(`
312
- `);
330
+ const lines = content.trim().split("\n");
331
+ return lines.slice(-10).join("\n");
313
332
  } catch {
314
333
  return "(no log file)";
315
334
  }
316
335
  }
317
- }
336
+ };
318
337
  function sleep(ms) {
319
338
  return new Promise((r) => setTimeout(r, ms));
320
339
  }
321
340
  async function healthCheck(baseUrl) {
322
341
  try {
323
- const res = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(2000) });
342
+ const res = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(2e3) });
324
343
  const data = await res.json();
325
344
  return data.ok === true;
326
345
  } catch {
@@ -349,8 +368,8 @@ function findFreePort(start, attempts) {
349
368
  }
350
369
 
351
370
  // src/lib/errors.ts
352
- function getErrorMessage(err) {
353
- return err instanceof Error ? err.message : String(err);
371
+ function getErrorMessage(err2) {
372
+ return err2 instanceof Error ? err2.message : String(err2);
354
373
  }
355
374
 
356
375
  // src/mcp/helpers.ts
@@ -359,56 +378,55 @@ var BASE_URL = process.env.IFRAMER_URL || "http://localhost:3021";
359
378
  var IFRAMER_SECRET = process.env.IFRAMER_SECRET;
360
379
  var IFRAMER_MODE = process.env.IFRAMER_MODE;
361
380
  var LOCAL_TOKEN = getLocalToken();
362
- var localServer = new LocalServerManager;
381
+ var localServer = new LocalServerManager();
363
382
  async function ensureLocalServer() {
364
383
  await localServer.ensureRunning();
365
384
  }
366
385
  function authHeaders(secret) {
367
386
  const headers = { "Content-Type": "application/json" };
368
387
  const key = secret || IFRAMER_SECRET;
369
- if (key)
370
- headers["x-api-key"] = key;
388
+ if (key) headers["x-api-key"] = key;
371
389
  return headers;
372
390
  }
373
391
  async function apiPost(endpoint, body) {
374
392
  const res = await fetch(`${BASE_URL}${endpoint}`, {
375
393
  method: "POST",
376
394
  headers: authHeaders(),
377
- body: body ? JSON.stringify(body) : undefined,
378
- signal: AbortSignal.timeout(180000)
395
+ body: body ? JSON.stringify(body) : void 0,
396
+ signal: AbortSignal.timeout(18e4)
379
397
  });
380
398
  return res.json();
381
399
  }
382
400
  async function apiGet(endpoint) {
383
- const res = await fetch(`${BASE_URL}${endpoint}`, { headers: authHeaders(), signal: AbortSignal.timeout(30000) });
401
+ const res = await fetch(`${BASE_URL}${endpoint}`, { headers: authHeaders(), signal: AbortSignal.timeout(3e4) });
384
402
  return res.json();
385
403
  }
386
- async function localApiPost(endpoint, body) {
404
+ async function localApiPost(endpoint, body, timeoutMs = 18e4) {
387
405
  await ensureLocalServer();
388
406
  const url = localServer.getBaseUrl();
389
407
  const res = await fetch(`${url}${endpoint}`, {
390
408
  method: "POST",
391
409
  headers: authHeaders(LOCAL_TOKEN),
392
- body: body ? JSON.stringify(body) : undefined,
393
- signal: AbortSignal.timeout(180000)
410
+ body: body ? JSON.stringify(body) : void 0,
411
+ signal: AbortSignal.timeout(timeoutMs)
394
412
  });
395
413
  return res.json();
396
414
  }
397
415
  async function localApiGet(endpoint) {
398
416
  await ensureLocalServer();
399
417
  const url = localServer.getBaseUrl();
400
- const res = await fetch(`${url}${endpoint}`, { headers: authHeaders(LOCAL_TOKEN), signal: AbortSignal.timeout(30000) });
418
+ const res = await fetch(`${url}${endpoint}`, { headers: authHeaders(LOCAL_TOKEN), signal: AbortSignal.timeout(3e4) });
401
419
  return res.json();
402
420
  }
403
421
  async function localApiDelete(endpoint) {
404
422
  await ensureLocalServer();
405
423
  const url = localServer.getBaseUrl();
406
- const res = await fetch(`${url}${endpoint}`, { method: "DELETE", headers: authHeaders(LOCAL_TOKEN), signal: AbortSignal.timeout(30000) });
424
+ const res = await fetch(`${url}${endpoint}`, { method: "DELETE", headers: authHeaders(LOCAL_TOKEN), signal: AbortSignal.timeout(3e4) });
407
425
  return res.json();
408
426
  }
409
427
  async function isDockerRunning() {
410
428
  try {
411
- const res = await fetch(`${BASE_URL}/health`, { signal: AbortSignal.timeout(3000) });
429
+ const res = await fetch(`${BASE_URL}/health`, { signal: AbortSignal.timeout(3e3) });
412
430
  const data = await res.json();
413
431
  return data.ok === true;
414
432
  } catch {
@@ -420,20 +438,18 @@ async function resolveScreenshotPath(url) {
420
438
  if (url.startsWith("file://")) {
421
439
  const { fileURLToPath } = await import("url");
422
440
  const filePath2 = fileURLToPath(url);
423
- const fs5 = await import("fs");
424
- if (fs5.existsSync(filePath2))
425
- return filePath2;
441
+ const fs11 = await import("fs");
442
+ if (fs11.existsSync(filePath2)) return filePath2;
426
443
  return null;
427
444
  }
428
445
  const res = await fetch(url);
429
- if (!res.ok)
430
- return null;
446
+ if (!res.ok) return null;
431
447
  const buf = Buffer.from(await res.arrayBuffer());
432
- const fs4 = await import("fs");
448
+ const fs10 = await import("fs");
433
449
  const dir = import_path5.default.join(import_os3.default.tmpdir(), "iframer-screenshots", "screenshots");
434
- fs4.mkdirSync(dir, { recursive: true });
450
+ fs10.mkdirSync(dir, { recursive: true });
435
451
  const filePath = import_path5.default.join(dir, `docker-${Date.now()}.jpg`);
436
- fs4.writeFileSync(filePath, buf);
452
+ fs10.writeFileSync(filePath, buf);
437
453
  return filePath;
438
454
  } catch {
439
455
  return null;
@@ -452,24 +468,23 @@ function defaultFile() {
452
468
  }
453
469
  var TTL_DAYS = 14;
454
470
  var ESCALATION_LADDER = ["headless", "docker-headful", "binary-headful"];
455
-
456
- class DomainModeStore {
471
+ var DomainModeStore = class {
457
472
  data = {};
458
473
  filePath;
459
474
  constructor(filePath = defaultFile()) {
460
475
  this.filePath = filePath;
461
476
  this.load();
462
477
  }
478
+ /** Get the recommended mode for a domain, or null if unknown/expired */
463
479
  getMode(domain) {
464
480
  const entry = this.data[domain];
465
- if (!entry)
466
- return null;
467
- if (this.isExpired(entry))
468
- return null;
481
+ if (!entry) return null;
482
+ if (this.isExpired(entry)) return null;
469
483
  return entry.mode;
470
484
  }
485
+ /** Record a successful access */
471
486
  recordSuccess(domain, mode) {
472
- const now = new Date().toISOString();
487
+ const now = (/* @__PURE__ */ new Date()).toISOString();
473
488
  const existing = this.data[domain];
474
489
  this.data[domain] = {
475
490
  mode,
@@ -481,8 +496,9 @@ class DomainModeStore {
481
496
  };
482
497
  this.save();
483
498
  }
499
+ /** Record a blocked attempt */
484
500
  recordFailure(domain, mode, reason) {
485
- const now = new Date().toISOString();
501
+ const now = (/* @__PURE__ */ new Date()).toISOString();
486
502
  const existing = this.data[domain];
487
503
  this.data[domain] = {
488
504
  mode: existing?.mode || mode,
@@ -494,26 +510,28 @@ class DomainModeStore {
494
510
  };
495
511
  this.save();
496
512
  }
513
+ /** Get the next mode to try after a failure, or null if exhausted */
497
514
  getNextMode(failedMode, availableModes) {
498
515
  const idx = ESCALATION_LADDER.indexOf(failedMode);
499
- for (let i = idx + 1;i < ESCALATION_LADDER.length; i++) {
516
+ for (let i = idx + 1; i < ESCALATION_LADDER.length; i++) {
500
517
  if (availableModes.includes(ESCALATION_LADDER[i])) {
501
518
  return ESCALATION_LADDER[i];
502
519
  }
503
520
  }
504
521
  return null;
505
522
  }
523
+ /** Get the best starting mode: domain memory > first available in ladder */
506
524
  getBestMode(domain, availableModes) {
507
525
  const remembered = this.getMode(domain);
508
526
  if (remembered && availableModes.includes(remembered)) {
509
527
  return remembered;
510
528
  }
511
529
  for (const mode of ESCALATION_LADDER) {
512
- if (availableModes.includes(mode))
513
- return mode;
530
+ if (availableModes.includes(mode)) return mode;
514
531
  }
515
532
  return "headless";
516
533
  }
534
+ /** Get summary stats for the status tool */
517
535
  getSummary() {
518
536
  const entries = Object.entries(this.data).filter(([, e]) => !this.isExpired(e)).sort(([, a], [, b]) => b.lastSuccess.localeCompare(a.lastSuccess));
519
537
  return {
@@ -522,10 +540,9 @@ class DomainModeStore {
522
540
  };
523
541
  }
524
542
  isExpired(entry) {
525
- if (!entry.lastSuccess)
526
- return true;
543
+ if (!entry.lastSuccess) return true;
527
544
  const age = Date.now() - new Date(entry.lastSuccess).getTime();
528
- return age > TTL_DAYS * 24 * 60 * 60 * 1000;
545
+ return age > TTL_DAYS * 24 * 60 * 60 * 1e3;
529
546
  }
530
547
  load() {
531
548
  try {
@@ -544,81 +561,95 @@ class DomainModeStore {
544
561
  log3.error("Failed to save:", err2);
545
562
  }
546
563
  }
547
- }
564
+ };
548
565
 
549
566
  // src/mcp/tools/status.ts
550
- function registerStatusTool(server) {
551
- server.tool("status", `Get the full state of iframer in one call. Call this first. Returns: available browser modes, API health, active session, stored credentials, and domain memory.`, {}, async () => {
552
- try {
553
- const status = {};
554
- const dockerRunning = await isDockerRunning();
555
- status.dockerApi = dockerRunning;
567
+ function registerStatusTool(server2) {
568
+ server2.tool(
569
+ "status",
570
+ `Get the full state of iframer in one call. Call this first. Returns: available browser modes, API health, active session, stored credentials, and domain memory.`,
571
+ {},
572
+ async () => {
556
573
  try {
557
- const localHealth = await localApiGet("/health");
558
- status.localServer = localHealth.ok;
559
- } catch {
560
- status.localServer = false;
561
- }
562
- try {
563
- const browserHealth = await localApiGet("/browser/health");
564
- status.browserAlive = browserHealth.alive;
565
- status.runningModes = browserHealth.modes;
566
- } catch {}
567
- if (dockerRunning) {
574
+ const status = {};
575
+ const dockerRunning = await isDockerRunning();
576
+ status.dockerApi = dockerRunning;
568
577
  try {
569
- const sessionData = await apiGet("/interactive/status");
570
- status.dockerSession = sessionData.active ? { active: true, noVncUrl: sessionData.noVncUrl } : { active: false };
571
- } catch {}
572
- }
573
- try {
574
- const credData = await localApiGet("/credentials");
575
- status.credentials = credData.domains || [];
576
- } catch {
577
- status.credentials = [];
578
+ const localHealth = await localApiGet("/health");
579
+ status.localServer = localHealth.ok;
580
+ } catch {
581
+ status.localServer = false;
582
+ }
583
+ try {
584
+ const browserHealth = await localApiGet("/browser/health");
585
+ status.browserAlive = browserHealth.alive;
586
+ status.runningModes = browserHealth.modes;
587
+ } catch {
588
+ }
589
+ try {
590
+ const inst = await localApiGet("/instances");
591
+ if (inst.instances && inst.instances.length) status.liveInstances = inst.instances;
592
+ } catch {
593
+ }
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
+ try {
602
+ const credData = await localApiGet("/credentials");
603
+ status.credentials = credData.domains || [];
604
+ } catch {
605
+ status.credentials = [];
606
+ }
607
+ try {
608
+ const domainModes = new DomainModeStore();
609
+ status.domainMemory = domainModes.getSummary();
610
+ } catch {
611
+ }
612
+ return { content: [{ type: "text", text: JSON.stringify(status, null, 2) }] };
613
+ } catch (e) {
614
+ return err(`Error: ${getErrorMessage(e)}`);
578
615
  }
579
- try {
580
- const domainModes = new DomainModeStore;
581
- status.domainMemory = domainModes.getSummary();
582
- } catch {}
583
- return { content: [{ type: "text", text: JSON.stringify(status, null, 2) }] };
584
- } catch (e) {
585
- return err(`Error: ${getErrorMessage(e)}`);
586
616
  }
587
- });
617
+ );
588
618
  }
589
619
 
590
620
  // src/mcp/tools/browse.ts
591
621
  var import_zod = require("zod");
592
- function registerBrowseTool(server) {
593
- server.tool("browse", `Fetch a web page with a headless browser. Use for pages that need JavaScript rendering but don't have bot detection walls. Session cookies and stored credentials persist across calls via the single local SQLite store.
594
-
595
- PRE-FLIGHT: Call \`knowledge get <domain>\` first. If the cache shows a direct-API path for the data you need, skip this tool and hit the endpoints directly — it's orders of magnitude faster.`, {
596
- url: import_zod.z.string().describe("URL to navigate to"),
597
- extract: import_zod.z.string().optional().describe("JavaScript expression to evaluate (e.g. 'document.title')"),
598
- actions: import_zod.z.array(import_zod.z.object({
599
- type: import_zod.z.enum(["click", "fill", "wait", "scroll", "human-click", "human-type"]),
600
- selector: import_zod.z.string().optional(),
601
- value: import_zod.z.string().optional(),
602
- ms: import_zod.z.number().optional()
603
- })).optional().describe("Actions to execute before extracting"),
604
- returnHtml: import_zod.z.boolean().optional().describe("Return full page HTML"),
605
- waitForSelector: import_zod.z.string().optional().describe("Wait for this CSS selector before proceeding"),
606
- sessionless: import_zod.z.boolean().optional().describe("Skip session persistence")
607
- }, async (params) => {
608
- try {
609
- const fetchResult = await localApiPost("/fetch", params);
610
- if (!fetchResult.ok)
611
- return err(`Error: ${fetchResult.error}`);
612
- const { html, ...rest } = fetchResult;
613
- const text = html ? JSON.stringify(rest, null, 2) + `
614
-
615
- --- HTML ---
616
- ` + html : JSON.stringify(rest, null, 2);
617
- return { content: [{ type: "text", text }] };
618
- } catch (e) {
619
- return err(`Error: ${getErrorMessage(e)}`);
622
+ function registerBrowseTool(server2) {
623
+ server2.tool(
624
+ "browse",
625
+ `Fetch a web page with a headless browser. Use for pages that need JavaScript rendering but don't have bot detection walls. Session cookies and stored credentials persist across calls via the single local SQLite store.
626
+
627
+ PRE-FLIGHT: Call \`knowledge get <domain>\` first. If the cache shows a direct-API path for the data you need, skip this tool and hit the endpoints directly \u2014 it's orders of magnitude faster.`,
628
+ {
629
+ url: import_zod.z.string().describe("URL to navigate to"),
630
+ extract: import_zod.z.string().optional().describe("JavaScript expression to evaluate (e.g. 'document.title')"),
631
+ actions: import_zod.z.array(import_zod.z.object({
632
+ type: import_zod.z.enum(["click", "fill", "wait", "scroll", "human-click", "human-type"]),
633
+ selector: import_zod.z.string().optional(),
634
+ value: import_zod.z.string().optional(),
635
+ ms: import_zod.z.number().optional()
636
+ })).optional().describe("Actions to execute before extracting"),
637
+ returnHtml: import_zod.z.boolean().optional().describe("Return full page HTML"),
638
+ waitForSelector: import_zod.z.string().optional().describe("Wait for this CSS selector before proceeding"),
639
+ sessionless: import_zod.z.boolean().optional().describe("Skip session persistence")
640
+ },
641
+ async (params) => {
642
+ try {
643
+ const fetchResult = await localApiPost("/fetch", params);
644
+ if (!fetchResult.ok) return err(`Error: ${fetchResult.error}`);
645
+ const { html, ...rest } = fetchResult;
646
+ const text = html ? JSON.stringify(rest, null, 2) + "\n\n--- HTML ---\n" + html : JSON.stringify(rest, null, 2);
647
+ return { content: [{ type: "text", text }] };
648
+ } catch (e) {
649
+ return err(`Error: ${getErrorMessage(e)}`);
650
+ }
620
651
  }
621
- });
652
+ );
622
653
  }
623
654
 
624
655
  // src/mcp/tools/execute.ts
@@ -629,219 +660,79 @@ var import_zod2 = require("zod");
629
660
  var stepSchema = import_zod2.z.discriminatedUnion("type", [
630
661
  import_zod2.z.object({ type: import_zod2.z.literal("navigate"), url: import_zod2.z.string(), waitUntil: import_zod2.z.string().optional() }),
631
662
  import_zod2.z.object({ type: import_zod2.z.literal("click"), selector: 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.") }),
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.") }),
633
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() }),
634
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() }),
635
- import_zod2.z.object({ type: import_zod2.z.literal("human-type"), selector: import_zod2.z.string(), value: import_zod2.z.string() }),
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
667
  import_zod2.z.object({ type: import_zod2.z.literal("evaluate"), expression: import_zod2.z.string() }),
637
668
  import_zod2.z.object({ type: import_zod2.z.literal("extract"), expression: import_zod2.z.string() }),
638
669
  import_zod2.z.object({ type: import_zod2.z.literal("wait"), ms: import_zod2.z.number() }),
639
670
  import_zod2.z.object({ type: import_zod2.z.literal("wait-for"), selector: import_zod2.z.string(), timeout: import_zod2.z.number().optional() }),
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") }),
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
+ 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)") }),
641
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() }),
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.") }),
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
+ 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
+ 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.") }),
677
+ import_zod2.z.object({ type: import_zod2.z.literal("download"), url: import_zod2.z.string().describe("File URL \u2014 fetched with the browser's cookies (auth'd downloads work), written to disk server-side, no Save-As dialog"), path: import_zod2.z.string().optional().describe("Absolute save path (default: ~/.iframer/downloads/<name>)") }),
646
678
  import_zod2.z.object({ type: import_zod2.z.literal("type-code"), value: import_zod2.z.string(), selector: import_zod2.z.string().optional() }),
647
679
  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() }),
648
680
  import_zod2.z.object({ type: import_zod2.z.literal("solve-captcha") }),
649
- import_zod2.z.object({ type: import_zod2.z.literal("screenshot"), annotate: import_zod2.z.boolean().optional().describe("Overlay numbered badges on interactive elements. Returns refs (@e1, @e2...) you can use in subsequent steps.") }),
650
- import_zod2.z.object({ type: import_zod2.z.literal("snapshot"), interactiveOnly: import_zod2.z.boolean().optional().describe("Only include interactive elements (default: true)"), maxElements: import_zod2.z.number().optional().describe("Max elements to return (default: 80)") }),
651
- import_zod2.z.object({ type: import_zod2.z.literal("find"), role: import_zod2.z.string().optional().describe("ARIA role: button, link, textbox, checkbox, etc."), name: import_zod2.z.string().optional().describe("Accessible name — button text, aria-label"), text: import_zod2.z.string().optional().describe("Visible text content"), placeholder: import_zod2.z.string().optional().describe("Input placeholder text"), label: import_zod2.z.string().optional().describe("Associated label text"), exact: import_zod2.z.boolean().optional().describe("Exact match vs substring (default: substring)") }),
652
- import_zod2.z.object({ type: import_zod2.z.literal("recaptcha-click") }),
653
- import_zod2.z.object({ type: import_zod2.z.literal("recaptcha-select"), tiles: import_zod2.z.array(import_zod2.z.number()) }),
654
- import_zod2.z.object({ type: import_zod2.z.literal("recaptcha-verify") }),
655
- import_zod2.z.object({ type: import_zod2.z.literal("recaptcha-info") }),
656
- import_zod2.z.object({ type: import_zod2.z.literal("recaptcha-solve") }),
657
- import_zod2.z.object({ type: import_zod2.z.literal("recaptcha-answer"), tiles: import_zod2.z.array(import_zod2.z.number()) })
681
+ import_zod2.z.object({ type: import_zod2.z.literal("screenshot"), annotate: import_zod2.z.boolean().optional().describe("Overlay numbered badges on interactive elements; returns @e refs") }),
682
+ import_zod2.z.object({ type: import_zod2.z.literal("snapshot"), interactiveOnly: import_zod2.z.boolean().optional().describe("Only interactive elements (default: true)"), maxElements: import_zod2.z.number().optional().describe("Max elements (default: 80)") }),
683
+ import_zod2.z.object({ type: import_zod2.z.literal("find"), role: import_zod2.z.string().optional().describe("ARIA role: button, link, textbox\u2026"), name: import_zod2.z.string().optional().describe("Accessible name / aria-label"), text: import_zod2.z.string().optional().describe("Visible text content"), placeholder: import_zod2.z.string().optional(), label: import_zod2.z.string().optional(), exact: import_zod2.z.boolean().optional().describe("Exact match vs substring (default: substring)") }),
684
+ // Captcha interaction, collapsed to one step. The full workflow manual is
685
+ // injected into the execute RESULT whenever a captcha blocks a run.
686
+ import_zod2.z.object({ type: import_zod2.z.literal("recaptcha"), action: import_zod2.z.enum(["info", "click", "select", "verify", "solve", "answer"]).describe("Captcha interaction (manual arrives in the result when a captcha blocks a run): info=state+grid screenshot, click=checkbox, answer=select tiles+verify+recheck, select/verify=manual control, solve=auto vision solve"), tiles: import_zod2.z.array(import_zod2.z.number()).optional().describe("Tile numbers for select/answer") })
658
687
  ]);
688
+ function normalizeSteps(steps) {
689
+ return (steps || []).map((s) => {
690
+ if (s && s.type === "recaptcha") {
691
+ const { action, ...rest } = s;
692
+ return { ...rest, type: `recaptcha-${action || "info"}` };
693
+ }
694
+ return s;
695
+ });
696
+ }
659
697
 
660
- // src/mcp/tools/execute.ts
698
+ // src/lib/format-result.ts
661
699
  function resultOf(r, _type) {
662
700
  return r.result;
663
701
  }
664
- function registerExecuteTool(server) {
665
- server.tool("execute", `Execute a pipeline of browser steps. Auto-starts a session if needed. Handles obstacles (captcha, cookie banners) automatically.
666
-
667
- MANDATORY PRE-FLIGHT: Before calling this tool for any website, call \`knowledge get <domain>\` first. If the cache shows a direct-API path that satisfies the user's request, skip this tool entirely — direct API calls are orders of magnitude faster. Only fall through to \`execute\` when the cache is missing, stale, or insufficient. Every successful run here updates the knowledge cache automatically.
668
-
669
- Steps run sequentially. Each step has a 20-second stale-state timeout — if nothing changes on the page for 20s, execution stops and returns a detailed error so you can decide what to do.
670
-
671
- Key step types:
672
- - navigate: go to a URL (obstacle detection runs after this)
673
- - snapshot: get the page's interactive elements as a structured list with refs (@e1, @e2...). Use this BEFORE interacting to see what's on the page. Then use refs in click/fill/human-click/human-type steps instead of CSS selectors.
674
- - find: locate a specific element by role, name, text, placeholder, or label. Returns a ref.
675
- - screenshot: take a screenshot. Add annotate=true to overlay numbered badges on interactive elements.
676
- - extract: evaluate JS and include the result in the response
677
- - solve-captcha: auto-detect and solve reCAPTCHA/hCaptcha with vision AI
678
- - login: fill login form with stored credentials (never exposes passwords). Handles email-first flows (Slack, Microsoft, Google) and standard forms.
679
-
680
- IMPORTANT — Element refs (@e1, @e2...): All selector fields accept @e refs from snapshot, find, or annotated screenshot. PREFER refs over CSS selectors.
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
-
686
- Returns: ok, completedSteps, results, obstacles, capturedApi, and on failure: errorContext with screenshot path, URL, errorType, suggestion, retryable.`, {
687
- steps: import_zod3.z.array(stepSchema).describe("Pipeline steps to execute sequentially"),
688
- options: import_zod3.z.object({
689
- staleTimeoutMs: import_zod3.z.number().optional().describe("Override the 20s stale-state timeout per step"),
690
- screenshotAfterEach: import_zod3.z.boolean().optional().describe("Take a screenshot after every step (expensive)"),
691
- continueOnObstacle: import_zod3.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
692
- continueOnError: import_zod3.z.boolean().optional().describe("Continue past failing steps (default: false)"),
693
- captureApi: import_zod3.z.boolean().optional().describe("Record all API calls (XHR/fetch) the page makes."),
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)."),
695
- autoEscalate: import_zod3.z.boolean().optional().describe("Auto-retry with a stronger mode if blocked (default: true)"),
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.")
700
- }).optional()
701
- }, async (params) => {
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
- }
721
- const dockerRunning = await isDockerRunning();
722
- async function runWithMode(mode) {
723
- if (mode === "docker-headful") {
724
- if (!dockerRunning) {
725
- return {
726
- ok: false,
727
- completedSteps: 0,
728
- totalSteps: params.steps.length,
729
- results: [],
730
- finalState: { url: "", title: "" },
731
- obstacles: [],
732
- durationMs: 0,
733
- modeUsed: "docker-headful",
734
- error: {
735
- failedAtStep: 0,
736
- failedStep: params.steps[0],
737
- errorType: "action-failed",
738
- message: "docker-headful mode was requested but the Docker API is not reachable.",
739
- pageState: { url: "", title: "" },
740
- suggestion: "Start Docker with `bun run start:docker`, or omit options.mode.",
741
- retryable: false
742
- }
743
- };
744
- }
745
- return apiPost("/execute", {
746
- steps: params.steps,
747
- options: { ...params.options, mode: "docker-headful", autoEscalate: false }
748
- });
749
- }
750
- return localApiPost("/execute", {
751
- steps: params.steps,
752
- options: { ...params.options, mode: mode || undefined }
753
- });
754
- }
755
- let execResult;
756
- try {
757
- execResult = await runWithMode(params.options?.mode);
758
- } catch (execErr) {
759
- const msg = execErr instanceof Error ? execErr.message : String(execErr);
760
- const isCrash = /ECONNREFUSED|ECONNRESET|EPIPE|socket hang up|fetch failed/i.test(msg);
761
- if (isCrash) {
762
- log2.info(`Execute crashed (${msg.slice(0, 80)}), restarting local server and retrying...`);
763
- try {
764
- await localServer.restart();
765
- } catch {}
766
- try {
767
- execResult = await runWithMode(params.options?.mode);
768
- } catch (retryErr) {
769
- return err(`Browser server crashed and retry also failed.
770
- ` + `Original: ${msg}
771
- Retry: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}
772
-
773
- ` + `Call \`session restart\` to reset, then retry.`);
774
- }
775
- } else {
776
- throw execErr;
777
- }
778
- }
779
- const requestedMode = params.options?.mode;
780
- if (!execResult.ok && execResult.error?.errorType === "bot-blocked" && params.options?.autoEscalate !== false && !requestedMode) {
781
- const escalation = ["docker-headful", "binary-headful"];
782
- for (const nextMode of escalation) {
783
- if (nextMode === "docker-headful" && !dockerRunning)
784
- continue;
785
- log2.info(`Auto-escalating to ${nextMode}`);
786
- execResult = await runWithMode(nextMode);
787
- if (execResult.ok)
788
- break;
789
- if (execResult.error?.errorType !== "bot-blocked")
790
- break;
791
- }
792
- }
793
- const lines = formatExecuteResult(execResult);
794
- let screenshotUrl = null;
795
- if (execResult.error) {
796
- screenshotUrl = execResult.error.pageState?.screenshotUrl ?? null;
797
- } else {
798
- screenshotUrl = execResult.finalState?.screenshotUrl ?? null;
799
- }
800
- if (screenshotUrl) {
801
- const filePath = await resolveScreenshotPath(screenshotUrl);
802
- if (filePath) {
803
- lines.push(`
804
- Screenshot saved: ${filePath}`);
805
- lines.push("Use the Read tool on the path above to view the screenshot.");
806
- }
807
- }
808
- const content = [{ type: "text", text: lines.join(`
809
- `) }];
810
- if (!execResult.ok)
811
- return { content, isError: true };
812
- return { content };
813
- } catch (e) {
814
- return err(`Error: ${getErrorMessage(e)}`);
815
- }
816
- });
702
+ var RECAPTCHA_MANUAL = `
703
+ --- Captcha workflow ---
704
+ A captcha is blocking this run. Use the "recaptcha" step with an action:
705
+ {type:"recaptcha", action:"info"} \u2192 state + instruction + tile-grid screenshot
706
+ {type:"recaptcha", action:"click"} \u2192 click the "I'm not a robot" checkbox
707
+ {type:"recaptcha", action:"answer", tiles:[...]} \u2192 select tiles + verify + re-check (handles refreshing grids)
708
+ {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.
711
+ In binary-headful mode, prefer asking the user to solve it in the visible window.`;
712
+ function captchaBlocked(data) {
713
+ if (data.error?.errorType === "captcha-unsolvable") return true;
714
+ return (data.obstacles || []).some(
715
+ (o) => (o.type === "captcha" || o.type === "hcaptcha") && !o.resolved
716
+ );
817
717
  }
818
718
  function formatExecuteResult(data) {
819
719
  const lines = [];
820
720
  lines.push(`ok: ${data.ok}`);
821
721
  lines.push(`steps: ${data.completedSteps}/${data.totalSteps}`);
822
- if (data.durationMs)
823
- lines.push(`duration: ${data.durationMs}ms`);
824
- if (data.modeUsed)
825
- lines.push(`mode: ${data.modeUsed}${data.modeEscalated ? " (auto-escalated)" : ""}`);
722
+ if (data.durationMs) lines.push(`duration: ${data.durationMs}ms`);
723
+ if (data.modeUsed) lines.push(`mode: ${data.modeUsed}${data.modeEscalated ? " (auto-escalated)" : ""}`);
826
724
  if (data.finalState) {
827
725
  lines.push(`
828
726
  Final page: ${data.finalState.title}`);
829
727
  lines.push(`URL: ${data.finalState.url}`);
830
728
  }
831
- if (data.ok && data.finalState?.url) {
832
- try {
833
- const domain = new URL(data.finalState.url).hostname.replace(/^www\./, "");
834
- lines.push(`
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.`);
836
- } catch {}
837
- }
838
729
  for (const r of data.results || []) {
839
730
  if (r.tabSwitchedTo) {
840
731
  lines.push(`
841
- ↳ step ${r.stepIndex} opened a new tab — pipeline is now on: ${r.tabSwitchedTo}`);
732
+ \u21B3 step ${r.stepIndex} opened a new tab \u2014 pipeline is now on: ${r.tabSwitchedTo}`);
842
733
  }
843
734
  }
844
- const meaningful = (data.results || []).filter((r) => r.ok && r.result !== undefined && r.result !== null);
735
+ const meaningful = (data.results || []).filter((r) => r.ok && r.result !== void 0 && r.result !== null);
845
736
  for (const r of meaningful) {
846
737
  if (r.step.type === "snapshot") {
847
738
  const res = resultOf(r, "snapshot");
@@ -865,7 +756,7 @@ Found: ${res.ref} ${res.role} "${res.name}" (${res.matchCount} match${res.matchC
865
756
  }
866
757
  } else if (r.step.type === "read") {
867
758
  const res = r.result;
868
- if (res?.text !== undefined) {
759
+ if (res?.text !== void 0) {
869
760
  lines.push(`
870
761
  --- Read (step ${r.stepIndex}${res.truncated ? ", truncated" : ""}) ---`);
871
762
  lines.push(res.text);
@@ -876,15 +767,13 @@ step ${r.stepIndex} (${r.step.type}): ${JSON.stringify(r.result)}`);
876
767
  }
877
768
  }
878
769
  if (data.obstacles && data.obstacles.length > 0) {
879
- lines.push(`
880
- Obstacles handled:`);
770
+ lines.push("\nObstacles handled:");
881
771
  for (const o of data.obstacles) {
882
772
  lines.push(` [step ${o.detectedAtStep}] ${o.type}: ${o.resolved ? o.resolution : "UNRESOLVED - " + (o.resolution || "unknown")}`);
883
773
  }
884
774
  }
885
775
  if (data.capturedApi && data.capturedApi.length > 0) {
886
- lines.push(`
887
- --- Captured API ---`);
776
+ lines.push("\n--- Captured API ---");
888
777
  for (const api of data.capturedApi) {
889
778
  lines.push(`
890
779
  ${api.domain} (${api.baseUrl})`);
@@ -895,8 +784,7 @@ ${api.domain} (${api.baseUrl})`);
895
784
  }
896
785
  }
897
786
  if (data.error) {
898
- lines.push(`
899
- --- Failure ---`);
787
+ lines.push("\n--- Failure ---");
900
788
  if (typeof data.error === "string") {
901
789
  lines.push(`Error: ${data.error}`);
902
790
  } else {
@@ -904,15 +792,173 @@ ${api.domain} (${api.baseUrl})`);
904
792
  lines.push(`Error type: ${data.error.errorType}`);
905
793
  lines.push(`Message: ${data.error.message}`);
906
794
  lines.push(`Retryable: ${data.error.retryable}`);
907
- if (data.error.suggestion)
908
- lines.push(`Suggestion: ${data.error.suggestion}`);
909
- if (data.error.pageState?.url)
910
- lines.push(`URL at failure: ${data.error.pageState.url}`);
795
+ if (data.error.suggestion) lines.push(`Suggestion: ${data.error.suggestion}`);
796
+ if (data.error.pageState?.url) lines.push(`URL at failure: ${data.error.pageState.url}`);
911
797
  }
912
798
  }
799
+ if (captchaBlocked(data)) {
800
+ lines.push(RECAPTCHA_MANUAL);
801
+ }
913
802
  return lines;
914
803
  }
915
804
 
805
+ // src/mcp/tools/execute.ts
806
+ function registerExecuteTool(server2) {
807
+ server2.tool(
808
+ "execute",
809
+ `Execute a pipeline of browser steps. Auto-starts a session; handles obstacles (captcha, cookie banners) automatically.
810
+
811
+ MANDATORY PRE-FLIGHT: call \`knowledge get <domain>\` first. If the cache shows a direct-API path that satisfies the request, skip the browser entirely \u2014 direct API calls are orders of magnitude cheaper. Every successful run updates the cache silently.
812
+
813
+ Steps run sequentially (20s stale-state timeout per step; failures return errorType + suggestion).
814
+
815
+ Seeing the page: \`snapshot\` lists interactive elements with refs (@e1\u2026), \`find\` locates one element by role/name/text, \`screenshot\` with annotate=true overlays numbered badges, \`extract\` evaluates JS and returns the result. \`login\` fills login forms from stored credentials (never exposes passwords; handles email-first flows).
816
+
817
+ Selectors: every selector field accepts @e refs (PREFER them over CSS) and persisted per-domain @a:<name> anchors from the \`remember\` tool. \`remember get <domain>\` before a UI task \u2014 an existing anchor can be targeted directly with no snapshot. When a newly found selector works, \`remember save\` it (@e refs reset each snapshot; @a: anchors persist across runs).
818
+
819
+ FORMS: use \`fill\` for text inputs \u2014 never evaluate-set .value (fill fires the framework-aware events; see its description). If a submit still claims fields are empty/required, re-run fill on the flagged field rather than assuming the value didn't land.
820
+
821
+ RESUMING A TASK: name a multi-step or human-in-the-loop task's browser with a stable \`options.instanceId\` (e.g. "otp-login") \u2014 the browser and its page PERSIST between calls and survive an interrupt. To resume (after a stop, an interrupt, or a wait for the user to enter a code), call execute again with the SAME instanceId and act on the CURRENT page (snapshot/read/find) \u2014 do NOT navigate again, which reloads and discards the state (e.g. an OTP screen). \`status\` lists live windows (instanceId \u2192 current url) so you can find the right one.
822
+
823
+ Returns: ok, completedSteps, output for snapshot/find/read/extract steps, obstacles, capturedApi, and on failure a screenshot path + errorType + suggestion + retryable.`,
824
+ {
825
+ steps: import_zod3.z.array(stepSchema).describe("Pipeline steps to execute sequentially"),
826
+ options: import_zod3.z.object({
827
+ staleTimeoutMs: import_zod3.z.number().optional().describe("Override the 20s stale-state timeout per step"),
828
+ screenshotAfterEach: import_zod3.z.boolean().optional().describe("Take a screenshot after every step (expensive)"),
829
+ continueOnObstacle: import_zod3.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
830
+ 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)."),
833
+ autoEscalate: import_zod3.z.boolean().optional().describe("Auto-retry with a stronger mode if blocked (default: true)"),
834
+ 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
+ tabId: import_zod3.z.number().optional().describe("mode='extension': the real-Chrome tab id to drive (from the `tabs` tool)."),
836
+ clientId: import_zod3.z.string().optional().describe("mode='extension': owning profile's clientId, only when several browsers are connected and the tab is ambiguous."),
837
+ focus: import_zod3.z.boolean().optional().describe("mode='extension': raise the window to the foreground while driving (default false \u2014 background drive with focus emulation). Only if a site ignores background input.")
838
+ }).optional()
839
+ },
840
+ async (params) => {
841
+ try {
842
+ const steps = normalizeSteps(params.steps);
843
+ if (params.options?.mode === "extension") {
844
+ const tabId = params.options?.tabId;
845
+ if (typeof tabId !== "number") {
846
+ return err(
847
+ "mode='extension' requires options.tabId. Call the `tabs` tool first to find the id of the tab the user wants to drive."
848
+ );
849
+ }
850
+ const typeChars = (steps || []).reduce((n, s) => {
851
+ return (s.type === "human-type" || s.type === "type-code") && typeof s.value === "string" ? n + s.value.length : n;
852
+ }, 0);
853
+ const timeoutMs = Math.min(6e4 + (steps.length || 0) * 15e3 + typeChars * 250, 12e5) + 3e4;
854
+ const extResult = await localApiPost("/extension/execute", {
855
+ tabId,
856
+ clientId: params.options?.clientId,
857
+ steps,
858
+ options: params.options
859
+ }, timeoutMs);
860
+ const extLines = formatExecuteResult(extResult);
861
+ const content2 = [{ type: "text", text: extLines.join("\n") }];
862
+ if (!extResult.ok) return { content: content2, isError: true };
863
+ return { content: content2 };
864
+ }
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
+ let execResult;
900
+ try {
901
+ execResult = await runWithMode(params.options?.mode);
902
+ } catch (execErr) {
903
+ const msg = execErr instanceof Error ? execErr.message : String(execErr);
904
+ const isCrash = /ECONNREFUSED|ECONNRESET|EPIPE|socket hang up|fetch failed/i.test(msg);
905
+ if (isCrash) {
906
+ log2.info(`Execute crashed (${msg.slice(0, 80)}), restarting local server and retrying...`);
907
+ try {
908
+ await localServer.restart();
909
+ } catch {
910
+ }
911
+ try {
912
+ execResult = await runWithMode(params.options?.mode);
913
+ } catch (retryErr) {
914
+ return err(
915
+ `Browser server crashed and retry also failed.
916
+ Original: ${msg}
917
+ Retry: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}
918
+
919
+ Call \`session restart\` to reset, then retry.`
920
+ );
921
+ }
922
+ } else {
923
+ throw execErr;
924
+ }
925
+ }
926
+ const requestedMode = params.options?.mode;
927
+ 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
+ }
936
+ }
937
+ const lines = formatExecuteResult(execResult);
938
+ let screenshotUrl = null;
939
+ if (execResult.error) {
940
+ screenshotUrl = execResult.error.pageState?.screenshotUrl ?? null;
941
+ } else {
942
+ screenshotUrl = execResult.finalState?.screenshotUrl ?? null;
943
+ }
944
+ if (screenshotUrl) {
945
+ const filePath = await resolveScreenshotPath(screenshotUrl);
946
+ if (filePath) {
947
+ lines.push(`
948
+ Screenshot saved: ${filePath}`);
949
+ lines.push("Use the Read tool on the path above to view the screenshot.");
950
+ }
951
+ }
952
+ const content = [{ type: "text", text: lines.join("\n") }];
953
+ if (!execResult.ok) return { content, isError: true };
954
+ return { content };
955
+ } catch (e) {
956
+ return err(`Error: ${getErrorMessage(e)}`);
957
+ }
958
+ }
959
+ );
960
+ }
961
+
916
962
  // src/mcp/tools/session.ts
917
963
  var import_zod5 = require("zod");
918
964
  var import_path7 = __toESM(require("path"));
@@ -920,165 +966,121 @@ var import_fs5 = __toESM(require("fs"));
920
966
 
921
967
  // src/mcp/tools/reverse-engineer.ts
922
968
  var import_zod4 = require("zod");
923
- function registerReverseEngineerTool(server) {
924
- server.tool("reverse-engineer", `Reverse-engineer a website's API. Navigates to a URL, performs the steps you specify, and captures every XHR/fetch request the page makes — including auth tokens, cookies, headers, request/response bodies, and ready-to-use curl commands.
925
-
926
- Use this when the user asks to:
927
- - "reverse engineer" or "map" a site's API
928
- - "capture the endpoints" or "save the API"
929
- - "figure out how this site works under the hood"
930
- - "record the API calls" so they can be replayed later
931
-
932
- How it works:
933
- 1. You provide steps (same as execute) — navigate, click, fill, extract, etc.
934
- 2. iframer runs them while recording all API calls the page makes
935
- 3. Returns structured data per domain: shared auth + endpoints classified by protocol (rest, graphql, json-rpc, grpc-web, form-rpc, soap) with method, path, action, verb, headers, body, response, curl
936
-
937
- IMPORTANT — one request ≠ one endpoint. Each endpoint has (protocol, action):
938
- - REST: action = "METHOD /parameterized/path". Verb from HTTP method.
939
- - GraphQL: action = operationName (or doc_id for persisted queries). Many ops share the single /graphql URL — EACH operation is its own endpoint.
940
- - JSON-RPC: action = body.method (e.g. eth_getBalance, user.list).
941
- - gRPC-web: action = request path.
942
- - Form-RPC (FB-style urlencoded): action = fb_api_req_friendly_name / doc_id.
943
- - SOAP: action = SOAPAction header.
944
-
945
- Generate ONE function per (protocol, action). Do NOT merge different GraphQL operations into one function just because they share the URL.
946
-
947
- Output layout — save as RUNNABLE CODE to <outputDir>/:
948
- auth.{js,ts} — shared cookies, tokens, authorization
949
- transport/
950
- rest.{js,ts} — shared REST helper (only if any rest endpoints)
951
- graphql.{js,ts} — shared GraphQL client: post(operationName|docId, variables)
952
- jsonRpc.{js,ts} — shared JSON-RPC client (if any)
953
- grpc.{js,ts} — shared gRPC-web client (if any)
954
- <protocol>/<verb>/<functionName>.{js,ts}
955
- e.g. graphql/queries/getTimelineFeed.ts
956
- graphql/mutations/reactToPost.ts
957
- rest/read/getChannelMessages.ts
958
- rest/create/createMessage.ts
959
- jsonRpc/ethGetBalance.ts
960
- index.{js,ts} — re-exports all endpoint functions
961
- types.ts — (typed mode only) inferred interfaces from responses
962
- README.md — endpoints grouped by protocol + verb, dependency chain, auth expiry warning
963
-
964
- Use capturedApi[i].endpoints[j].protocol, .action, .verb, .functionName directly — iframer already classified them. Put queries (verb=read|list) under queries/, mutations (verb=create|update|delete|action) under mutations/ for GraphQL. For REST, group by verb dir.
965
-
966
- The outputDir defaults to ./<domain>/. Ask the user where to save if unclear.`, {
967
- steps: import_zod4.z.array(stepSchema).describe("Pipeline steps to execute while capturing API calls"),
968
- outputDir: import_zod4.z.string().optional().describe("Directory to save the captured API files. If not provided, ask the user or default to ./<domain>/"),
969
- typed: import_zod4.z.boolean().optional().describe("Save as .ts with inferred types instead of .js. Set to true when the user asks for types, typescript, or type inference."),
970
- options: import_zod4.z.object({
971
- staleTimeoutMs: import_zod4.z.number().optional().describe("Override the 20s stale-state timeout per step"),
972
- continueOnObstacle: import_zod4.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
973
- continueOnError: import_zod4.z.boolean().optional().describe("Continue past failing steps (default: false)"),
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).")
977
- }).optional()
978
- }, async (params) => {
979
- try {
980
- const execParams = {
981
- steps: params.steps,
982
- options: { ...params.options, captureApi: true }
983
- };
984
- const mode = params.options?.mode;
985
- const dockerRunning = await isDockerRunning();
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,
969
+ function registerReverseEngineerTool(server2) {
970
+ server2.tool(
971
+ "reverse-engineer",
972
+ `Reverse-engineer a website's API: runs the steps you provide (SAME format as the \`execute\` tool's steps) while recording every XHR/fetch the page makes \u2014 auth tokens, cookies, headers, request/response bodies, ready-to-use curl. Use when the user asks to "reverse engineer", "map", or "capture" a site's API so it can be replayed later.
973
+
974
+ During capture, drive the site like a user (open the inbox, send a message\u2026) \u2014 the actions are what trigger the calls worth recording. Endpoint classification, output layout, and code-generation rules arrive WITH the captured results.`,
975
+ {
976
+ steps: import_zod4.z.array(import_zod4.z.record(import_zod4.z.string(), import_zod4.z.unknown())).describe("Pipeline steps to run while capturing \u2014 same step format as the execute tool"),
977
+ outputDir: import_zod4.z.string().optional().describe("Directory to save the captured API files. If not provided, ask the user or default to ./<domain>/"),
978
+ typed: import_zod4.z.boolean().optional().describe("Save as .ts with inferred types instead of .js. Set to true when the user asks for types, typescript, or type inference."),
979
+ options: import_zod4.z.object({
980
+ staleTimeoutMs: import_zod4.z.number().optional().describe("Override the 20s stale-state timeout per step"),
981
+ continueOnObstacle: import_zod4.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
982
+ 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`)."),
984
+ tabId: import_zod4.z.number().optional().describe("mode='extension': the real-Chrome tab id (from `tabs`)."),
985
+ clientId: import_zod4.z.string().optional().describe("mode='extension': owning profile's clientId when the tab is ambiguous.")
986
+ }).optional()
987
+ },
988
+ async (params) => {
989
+ try {
990
+ const steps = normalizeSteps(params.steps);
991
+ const execParams = {
992
+ steps,
995
993
  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
- }
1002
- const lines = [];
1003
- lines.push(`ok: ${captureResult.ok}`);
1004
- lines.push(`steps: ${captureResult.completedSteps}/${captureResult.totalSteps}`);
1005
- if (captureResult.durationMs)
1006
- lines.push(`duration: ${captureResult.durationMs}ms`);
1007
- if (captureResult.finalState) {
1008
- lines.push(`
1009
- Final page: ${captureResult.finalState.title}`);
1010
- lines.push(`URL: ${captureResult.finalState.url}`);
1011
- }
1012
- if (captureResult.capturedApi && captureResult.capturedApi.length > 0) {
1013
- formatCapturedApi(lines, captureResult.capturedApi, params);
1014
- } else {
1015
- lines.push(`
1016
- No API calls were captured. The page may not have made any XHR/fetch requests during the steps, or the steps may not have triggered the expected behavior.`);
1017
- }
1018
- if (captureResult.error) {
1019
- lines.push(`
1020
- --- Failure ---`);
1021
- if (typeof captureResult.error === "string") {
1022
- lines.push(`Error: ${captureResult.error}`);
994
+ };
995
+ const mode = params.options?.mode;
996
+ const dockerRunning = await isDockerRunning();
997
+ let captureResult;
998
+ if (mode === "extension") {
999
+ if (typeof params.options?.tabId !== "number") {
1000
+ return err("mode='extension' requires options.tabId. Call the `tabs` tool first to find the tab to reverse-engineer.");
1001
+ }
1002
+ captureResult = await localApiPost("/extension/execute", {
1003
+ tabId: params.options.tabId,
1004
+ clientId: params.options.clientId,
1005
+ steps,
1006
+ options: { ...params.options, captureApi: true }
1007
+ });
1008
+ } else if (mode === "docker-headful" && dockerRunning) {
1009
+ captureResult = await apiPost("/execute", execParams);
1023
1010
  } else {
1024
- lines.push(`Failed at step ${captureResult.error.failedAtStep}: ${JSON.stringify(captureResult.error.failedStep)}`);
1025
- lines.push(`Error type: ${captureResult.error.errorType}`);
1026
- lines.push(`Message: ${captureResult.error.message}`);
1027
- if (captureResult.error.suggestion)
1028
- lines.push(`Suggestion: ${captureResult.error.suggestion}`);
1011
+ captureResult = await localApiPost("/execute", execParams);
1029
1012
  }
1030
- }
1031
- const screenshotUrl = captureResult.error?.pageState?.screenshotUrl ?? captureResult.finalState?.screenshotUrl;
1032
- if (screenshotUrl) {
1033
- const filePath = await resolveScreenshotPath(screenshotUrl);
1034
- if (filePath) {
1013
+ const lines = [];
1014
+ lines.push(`ok: ${captureResult.ok}`);
1015
+ lines.push(`steps: ${captureResult.completedSteps}/${captureResult.totalSteps}`);
1016
+ if (captureResult.durationMs) lines.push(`duration: ${captureResult.durationMs}ms`);
1017
+ if (captureResult.finalState) {
1035
1018
  lines.push(`
1019
+ Final page: ${captureResult.finalState.title}`);
1020
+ lines.push(`URL: ${captureResult.finalState.url}`);
1021
+ }
1022
+ if (captureResult.capturedApi && captureResult.capturedApi.length > 0) {
1023
+ formatCapturedApi(lines, captureResult.capturedApi, params);
1024
+ } else {
1025
+ lines.push("\nNo API calls were captured. The page may not have made any XHR/fetch requests during the steps, or the steps may not have triggered the expected behavior.");
1026
+ }
1027
+ if (captureResult.error) {
1028
+ lines.push("\n--- Failure ---");
1029
+ if (typeof captureResult.error === "string") {
1030
+ lines.push(`Error: ${captureResult.error}`);
1031
+ } else {
1032
+ lines.push(`Failed at step ${captureResult.error.failedAtStep}: ${JSON.stringify(captureResult.error.failedStep)}`);
1033
+ lines.push(`Error type: ${captureResult.error.errorType}`);
1034
+ lines.push(`Message: ${captureResult.error.message}`);
1035
+ if (captureResult.error.suggestion) lines.push(`Suggestion: ${captureResult.error.suggestion}`);
1036
+ }
1037
+ }
1038
+ const screenshotUrl = captureResult.error?.pageState?.screenshotUrl ?? captureResult.finalState?.screenshotUrl;
1039
+ if (screenshotUrl) {
1040
+ const filePath = await resolveScreenshotPath(screenshotUrl);
1041
+ if (filePath) {
1042
+ lines.push(`
1036
1043
  Screenshot saved: ${filePath}`);
1037
- lines.push("Use the Read tool on the path above to view the screenshot.");
1044
+ lines.push("Use the Read tool on the path above to view the screenshot.");
1045
+ }
1038
1046
  }
1039
- }
1040
- if (captureResult.capturedApi && captureResult.capturedApi.length > 0) {
1041
- const mainDomain = captureResult.capturedApi[0]?.domain || "api";
1042
- const outDir = params.outputDir || `./${mainDomain}`;
1043
- try {
1044
- const fs5 = await import("fs");
1045
- const path7 = await import("path");
1046
- fs5.mkdirSync(outDir, { recursive: true });
1047
- const jsonPath = path7.join(outDir, "captured-api.json");
1048
- fs5.writeFileSync(jsonPath, JSON.stringify(captureResult.capturedApi, null, 2));
1049
- lines.push(`
1047
+ if (captureResult.capturedApi && captureResult.capturedApi.length > 0) {
1048
+ const mainDomain = captureResult.capturedApi[0]?.domain || "api";
1049
+ const outDir = params.outputDir || `./${mainDomain}`;
1050
+ try {
1051
+ const fs10 = await import("fs");
1052
+ const path12 = await import("path");
1053
+ fs10.mkdirSync(outDir, { recursive: true });
1054
+ const jsonPath = path12.join(outDir, "captured-api.json");
1055
+ fs10.writeFileSync(jsonPath, JSON.stringify(captureResult.capturedApi, null, 2));
1056
+ lines.push(`
1050
1057
  Full captured data saved to: ${jsonPath}`);
1051
- lines.push("Read this file for complete curl commands, request/response bodies, and auth data.");
1052
- } catch (writeErr) {
1053
- lines.push(`
1058
+ lines.push("Read this file for complete curl commands, request/response bodies, and auth data.");
1059
+ } catch (writeErr) {
1060
+ lines.push(`
1054
1061
  (Could not save captured JSON: ${writeErr instanceof Error ? writeErr.message : String(writeErr)})`);
1062
+ }
1055
1063
  }
1064
+ let text = lines.join("\n");
1065
+ if (text.length > 3e4) {
1066
+ text = text.slice(0, 3e4) + "\n\n[... index truncated \u2014 read captured-api.json for the full endpoint list and all detail]";
1067
+ }
1068
+ const content = [{ type: "text", text }];
1069
+ if (!captureResult.ok) return { content, isError: true };
1070
+ return { content };
1071
+ } catch (e) {
1072
+ return err(`Connection error: ${getErrorMessage(e)}. Try \`session restart\` and retry.`);
1056
1073
  }
1057
- let text = lines.join(`
1058
- `);
1059
- if (text.length > 30000) {
1060
- text = text.slice(0, 30000) + `
1061
-
1062
- [... index truncated — read captured-api.json for the full endpoint list and all detail]`;
1063
- }
1064
- const content = [{ type: "text", text }];
1065
- if (!captureResult.ok)
1066
- return { content, isError: true };
1067
- return { content };
1068
- } catch (e) {
1069
- return err(`Connection error: ${getErrorMessage(e)}. Try \`session restart\` and retry.`);
1070
1074
  }
1071
- });
1075
+ );
1072
1076
  }
1073
1077
  function formatCapturedApi(lines, capturedApi, params) {
1074
1078
  for (const api of capturedApi) {
1075
1079
  lines.push(`
1076
- ━━━ ${api.domain} (${api.baseUrl}) ━━━`);
1080
+ \u2501\u2501\u2501 ${api.domain} (${api.baseUrl}) \u2501\u2501\u2501`);
1077
1081
  const authParts = [];
1078
- if (api.auth?.authorization)
1079
- authParts.push(`Authorization: ${api.auth.authorization.slice(0, 30)}...`);
1080
- if (api.auth?.cookies && Object.keys(api.auth.cookies).length > 0)
1081
- authParts.push(`${Object.keys(api.auth.cookies).length} cookies`);
1082
+ if (api.auth?.authorization) authParts.push(`Authorization: ${api.auth.authorization.slice(0, 30)}...`);
1083
+ if (api.auth?.cookies && Object.keys(api.auth.cookies).length > 0) authParts.push(`${Object.keys(api.auth.cookies).length} cookies`);
1082
1084
  if (api.auth?.tokens && Object.keys(api.auth.tokens).length > 0) {
1083
1085
  for (const [k, v] of Object.entries(api.auth.tokens)) {
1084
1086
  authParts.push(`${k}: ${String(v).slice(0, 30)}...`);
@@ -1087,69 +1089,62 @@ function formatCapturedApi(lines, capturedApi, params) {
1087
1089
  if (authParts.length > 0) {
1088
1090
  lines.push(`
1089
1091
  Auth:`);
1090
- for (const part of authParts)
1091
- lines.push(` ${part}`);
1092
+ for (const part of authParts) lines.push(` ${part}`);
1092
1093
  }
1093
- const byProtocol = new Map;
1094
+ const byProtocol = /* @__PURE__ */ new Map();
1094
1095
  for (const ep of api.endpoints) {
1095
1096
  const p = ep.protocol || "rest";
1096
- if (!byProtocol.has(p))
1097
- byProtocol.set(p, []);
1097
+ if (!byProtocol.has(p)) byProtocol.set(p, []);
1098
1098
  byProtocol.get(p).push(ep);
1099
1099
  }
1100
1100
  const protocolSummary = Array.from(byProtocol.entries()).map(([p, eps]) => `${p}=${eps.length}`).join(", ");
1101
1101
  lines.push(`
1102
- Endpoints (${api.endpoints.length}) [${protocolSummary}] — full detail in captured-api.json:`);
1102
+ Endpoints (${api.endpoints.length}) [${protocolSummary}] \u2014 full detail in captured-api.json:`);
1103
1103
  for (const ep of api.endpoints) {
1104
1104
  const params2 = ep.requestBody ? extractSignalKeys(ep.requestBody) : null;
1105
1105
  const tail = params2 ? ` {${params2}}` : "";
1106
- lines.push(` [${ep.protocol}/${ep.verb}] ${ep.method} ${ep.path} → ${ep.functionName}${tail}`);
1106
+ lines.push(` [${ep.protocol}/${ep.verb}] ${ep.method} ${ep.path} \u2192 ${ep.functionName}${tail}`);
1107
1107
  }
1108
1108
  }
1109
1109
  const mainDomain = capturedApi[0]?.domain || "api";
1110
1110
  const dir = params.outputDir || `./${mainDomain}`;
1111
1111
  const ext = params.typed ? ".ts" : ".js";
1112
1112
  lines.push(`
1113
- ━━━ Save instructions ━━━`);
1113
+ \u2501\u2501\u2501 Save instructions \u2501\u2501\u2501`);
1114
1114
  lines.push(`Save to: ${dir}`);
1115
1115
  lines.push(`Format: ${ext} files`);
1116
- const protocolsSeen = new Set;
1117
- for (const api of capturedApi)
1118
- for (const ep of api.endpoints)
1119
- protocolsSeen.add(ep.protocol);
1116
+ const protocolsSeen = /* @__PURE__ */ new Set();
1117
+ for (const api of capturedApi) for (const ep of api.endpoints) protocolsSeen.add(ep.protocol);
1120
1118
  lines.push(`
1121
1119
  Layout (protocols present: ${Array.from(protocolsSeen).join(", ")}):`);
1122
1120
  lines.push(` auth${ext}`);
1123
- if (protocolsSeen.has("rest"))
1124
- lines.push(` transport/rest${ext} — shared fetch wrapper with auth headers`);
1125
- if (protocolsSeen.has("graphql"))
1126
- lines.push(` transport/graphql${ext} — post(operationName|docId, variables) → shared GraphQL client`);
1127
- if (protocolsSeen.has("json-rpc"))
1128
- lines.push(` transport/jsonRpc${ext} — shared JSON-RPC client`);
1129
- if (protocolsSeen.has("grpc-web"))
1130
- lines.push(` transport/grpc${ext} — shared gRPC-web client`);
1131
- if (protocolsSeen.has("form-rpc"))
1132
- lines.push(` transport/formRpc${ext} — shared urlencoded RPC client`);
1133
- if (protocolsSeen.has("soap"))
1134
- lines.push(` transport/soap${ext} — shared SOAP client`);
1135
- lines.push(` <protocol>/<verb>/<functionName>${ext} — one file per endpoint, uses shared transport`);
1121
+ if (protocolsSeen.has("rest")) lines.push(` transport/rest${ext} \u2014 shared fetch wrapper with auth headers`);
1122
+ if (protocolsSeen.has("graphql")) lines.push(` transport/graphql${ext} \u2014 post(operationName|docId, variables) \u2192 shared GraphQL client`);
1123
+ if (protocolsSeen.has("json-rpc")) lines.push(` transport/jsonRpc${ext} \u2014 shared JSON-RPC client`);
1124
+ if (protocolsSeen.has("grpc-web")) lines.push(` transport/grpc${ext} \u2014 shared gRPC-web client`);
1125
+ if (protocolsSeen.has("form-rpc")) lines.push(` transport/formRpc${ext} \u2014 shared urlencoded RPC client`);
1126
+ if (protocolsSeen.has("soap")) lines.push(` transport/soap${ext} \u2014 shared SOAP client`);
1127
+ lines.push(` <protocol>/<verb>/<functionName>${ext} \u2014 one file per endpoint, uses shared transport`);
1136
1128
  lines.push(` e.g. graphql/queries/<fn>${ext}, graphql/mutations/<fn>${ext}, rest/read/<fn>${ext}, rest/create/<fn>${ext}`);
1137
- lines.push(` index${ext} — re-export all endpoint functions`);
1138
- if (params.typed)
1139
- lines.push(` types.ts — interfaces inferred from response bodies`);
1140
- lines.push(` README.md — endpoints grouped by protocol + verb, auth expiry warning`);
1129
+ lines.push(` index${ext} \u2014 re-export all endpoint functions`);
1130
+ if (params.typed) lines.push(` types.ts \u2014 interfaces inferred from response bodies`);
1131
+ lines.push(` README.md \u2014 endpoints grouped by protocol + verb, auth expiry warning`);
1132
+ lines.push(`
1133
+ How to read the endpoint list \u2014 one request \u2260 one endpoint; an endpoint is (protocol, action):`);
1134
+ lines.push(` REST: action = "METHOD /parameterized/path" \xB7 GraphQL: action = operationName (or doc_id for persisted queries) \u2014 many ops share one /graphql URL, EACH is its own endpoint \xB7 JSON-RPC: action = body.method \xB7 gRPC-web: action = request path \xB7 Form-RPC (FB-style urlencoded): action = fb_api_req_friendly_name / doc_id \xB7 SOAP: action = SOAPAction header.`);
1135
+ lines.push(` iframer already classified everything \u2014 use .protocol, .action, .verb, .functionName directly.`);
1141
1136
  lines.push(`
1142
1137
  Rules:`);
1143
- lines.push(` - One function per (protocol, action). Use the functionName field verbatim for the file + export name.`);
1144
- lines.push(` - verb=read|list → queries/ (GraphQL) or read/ (REST). verb=create|update|delete|action → mutations/ (GraphQL) or verb dir (REST).`);
1138
+ lines.push(` - One function per (protocol, action) \u2014 NEVER merge GraphQL operations just because they share the URL. Use the functionName field verbatim for the file + export name.`);
1139
+ lines.push(` - verb=read|list \u2192 queries/ (GraphQL) or read/ (REST). verb=create|update|delete|action \u2192 mutations/ (GraphQL) or verb dir (REST).`);
1145
1140
  lines.push(` - Each endpoint file is minimal: import transport + auth, call transport with the action id, pass variables, return typed result.`);
1146
1141
  lines.push(` - GraphQL transport signature: post(opNameOrDocId: string, variables: object). Pick doc_id when present in captured body, else operationName.`);
1147
- lines.push(` - Do NOT inline auth headers per endpoint — they live in auth${ext} and the transport reads them.`);
1142
+ lines.push(` - Do NOT inline auth headers per endpoint \u2014 they live in auth${ext} and the transport reads them.`);
1148
1143
  lines.push(`
1149
- IMPORTANT: Auth data contains real tokens/cookies — they expire. Remind the user.`);
1144
+ IMPORTANT: Auth data contains real tokens/cookies \u2014 they expire. Remind the user.`);
1150
1145
  }
1151
1146
  function extractSignalKeys(body) {
1152
- const NOISE = new Set([
1147
+ const NOISE = /* @__PURE__ */ new Set([
1153
1148
  "__csr",
1154
1149
  "__hsdp",
1155
1150
  "__hblp",
@@ -1176,13 +1171,12 @@ function extractSignalKeys(body) {
1176
1171
  ]);
1177
1172
  const signalEntries = [];
1178
1173
  for (const [k, v] of Object.entries(body)) {
1179
- if (NOISE.has(k))
1180
- continue;
1174
+ if (NOISE.has(k)) continue;
1181
1175
  if (typeof v === "string" && v.length > 80) {
1182
1176
  signalEntries.push(`${k}=${v.slice(0, 40)}...`);
1183
1177
  } else if (Array.isArray(v)) {
1184
1178
  const items = v.slice(0, 8).map((x) => String(x)).join(",");
1185
- signalEntries.push(`${k}=[${items}${v.length > 8 ? ",…" : ""}]`);
1179
+ signalEntries.push(`${k}=[${items}${v.length > 8 ? ",\u2026" : ""}]`);
1186
1180
  } else if (typeof v === "object" && v !== null) {
1187
1181
  const keys = Object.keys(v).slice(0, 5).join(",");
1188
1182
  signalEntries.push(`${k}={${keys}${Object.keys(v).length > 5 ? ",..." : ""}}`);
@@ -1194,135 +1188,123 @@ function extractSignalKeys(body) {
1194
1188
  }
1195
1189
 
1196
1190
  // src/mcp/tools/session.ts
1197
- function registerSessionTool(server) {
1198
- server.tool("session", `Manage the browser session and lifecycle.
1199
-
1200
- Actions:
1201
- - **stop**: save cookies/localStorage to the store, then close the browser. Session data persists for the next run.
1202
- - **clear**: wipe all stored session data (cookies/localStorage) from the database. Does NOT kill running browsers.
1203
- - **restart**: kill all running browser instances (local + Docker) and reset state. The next \`execute\` call will launch a fresh browser automatically. Use this when the browser is frozen, crashed, or in a bad state. Credentials and knowledge cache are NOT affected.
1204
- - **capture-start**: attach a persistent XHR/fetch listener to the running browser. Accumulates ALL requests indefinitely — not tied to any pipeline. Use before triggering the action you want to capture (e.g. upload, delete, async mutation).
1205
- - **capture-stop**: stop the persistent listener and return all captured endpoints + save to disk. Call this when you're satisfied you've seen the requests you need.
1206
- - **get-cookies**: extract ALL cookies from the browser context via CDP (including HttpOnly/Secure — below the JS sandbox). Pass urls to scope. Returns name, value, domain, path, httpOnly, secure, expiry.
1207
- - **get-auth**: extract cookies + localStorage + sessionStorage in one shot. Everything needed to replay authenticated requests from Node.js.
1208
-
1209
- Sessions live in the single local SQLite database (~/.iframer/iframer.db), shared across every browser mode.`, {
1210
- action: import_zod5.z.enum(["stop", "clear", "restart", "capture-start", "capture-stop", "get-cookies", "get-auth"]).describe("stop | clear | restart | capture-start | capture-stop | get-cookies | get-auth"),
1211
- mode: import_zod5.z.enum(["headless", "binary-headful"]).optional().describe("Browser mode (default: binary-headful)"),
1212
- urls: import_zod5.z.array(import_zod5.z.string()).optional().describe("URLs to scope cookie extraction (get-cookies/get-auth). Omit for all cookies."),
1213
- instanceId: import_zod5.z.string().optional().describe("Target a named parallel browser within this session (default: 'default'). Match the instanceId used in execute."),
1214
- outputDir: import_zod5.z.string().optional().describe("Where to save captured-api.json for capture-stop")
1215
- }, async ({ action, mode, urls, instanceId, outputDir }) => {
1216
- try {
1217
- if (action === "stop") {
1218
- const result = await localApiPost("/interactive/stop").catch(() => ({ ok: true, sessionSaved: false }));
1219
- return { content: [{ type: "text", text: `Session stopped. State saved: ${result.sessionSaved ?? false}` }] };
1220
- }
1221
- if (action === "clear") {
1222
- await localApiDelete("/session").catch(() => {});
1223
- return { content: [{ type: "text", text: "Session data cleared from database." }] };
1224
- }
1225
- if (action === "restart") {
1226
- const parts = [];
1227
- try {
1228
- await localApiPost("/browser/restart");
1229
- parts.push("Local browser restarted.");
1230
- } catch {
1231
- try {
1232
- await localServer.restart();
1233
- parts.push("Local server respawned.");
1234
- } catch (e) {
1235
- parts.push(`Local restart failed: ${e instanceof Error ? e.message : String(e)}`);
1236
- }
1191
+ function registerSessionTool(server2) {
1192
+ server2.tool(
1193
+ "session",
1194
+ `Manage the browser session/lifecycle.
1195
+
1196
+ stop: save cookies/localStorage and close the browser \u2014 ALWAYS call when browser work is done. clear: wipe stored session data (doesn't kill browsers). restart: kill all browser instances and reset \u2014 use when frozen/crashed/bad state (credentials + knowledge unaffected). capture-start / capture-stop: persistent XHR/fetch recorder not tied to a pipeline \u2014 start it, trigger the action you want captured, stop to return + save endpoints. get-cookies: all cookies via CDP incl. HttpOnly (pass urls to scope). get-auth: cookies + localStorage + sessionStorage in one shot, for replaying authed requests.`,
1197
+ {
1198
+ action: import_zod5.z.enum(["stop", "clear", "restart", "capture-start", "capture-stop", "get-cookies", "get-auth"]).describe("stop | clear | restart | capture-start | capture-stop | get-cookies | get-auth"),
1199
+ mode: import_zod5.z.enum(["headless", "binary-headful"]).optional().describe("Browser mode (default: binary-headful)"),
1200
+ urls: import_zod5.z.array(import_zod5.z.string()).optional().describe("URLs to scope cookie extraction (get-cookies/get-auth). Omit for all cookies."),
1201
+ instanceId: import_zod5.z.string().optional().describe("Target a named parallel browser within this session (default: 'default'). Match the instanceId used in execute."),
1202
+ outputDir: import_zod5.z.string().optional().describe("Where to save captured-api.json for capture-stop")
1203
+ },
1204
+ async ({ action, mode, urls, instanceId, outputDir }) => {
1205
+ try {
1206
+ if (action === "stop") {
1207
+ const result = await localApiPost("/interactive/stop").catch(() => ({ ok: true, sessionSaved: false }));
1208
+ return { content: [{ type: "text", text: `Session stopped. State saved: ${result.sessionSaved ?? false}` }] };
1237
1209
  }
1238
- try {
1239
- if (await isDockerRunning()) {
1240
- await apiPost("/browser/restart");
1241
- parts.push("Docker browser restarted.");
1210
+ if (action === "clear") {
1211
+ await localApiDelete("/session").catch(() => {
1212
+ });
1213
+ return { content: [{ type: "text", text: "Session data cleared from database." }] };
1214
+ }
1215
+ if (action === "restart") {
1216
+ const parts = [];
1217
+ try {
1218
+ await localApiPost("/browser/restart");
1219
+ parts.push("Local browser restarted.");
1220
+ } catch {
1221
+ try {
1222
+ await localServer.restart();
1223
+ parts.push("Local server respawned.");
1224
+ } catch (e) {
1225
+ parts.push(`Local restart failed: ${e instanceof Error ? e.message : String(e)}`);
1226
+ }
1242
1227
  }
1243
- } catch {}
1244
- parts.push("Credentials and knowledge cache are untouched. Next execute launches a fresh browser.");
1245
- return { content: [{ type: "text", text: parts.join(" ") }] };
1246
- }
1247
- if (action === "capture-start") {
1248
- const result = await localApiPost("/capture/start", { mode: mode ?? "binary-headful", instanceId });
1249
- return { content: [{ type: "text", text: result.message }] };
1250
- }
1251
- if (action === "capture-stop") {
1252
- const result = await localApiPost("/capture/stop", { mode: mode ?? "binary-headful", instanceId });
1253
- const lines = [result.message];
1254
- if (result.capturedApi && result.capturedApi.length > 0) {
1255
- formatCapturedApi(lines, result.capturedApi, { outputDir, typed: false });
1256
- const outDir = outputDir || import_path7.default.join(getDataDir(), "capture");
1257
1228
  try {
1258
- import_fs5.default.mkdirSync(outDir, { recursive: true });
1259
- const jsonPath = import_path7.default.join(outDir, "captured-api.json");
1260
- import_fs5.default.writeFileSync(jsonPath, JSON.stringify(result.capturedApi, null, 2));
1261
- lines.push(`
1229
+ if (await isDockerRunning()) {
1230
+ await apiPost("/browser/restart");
1231
+ parts.push("Docker browser restarted.");
1232
+ }
1233
+ } catch {
1234
+ }
1235
+ parts.push("Credentials and knowledge cache are untouched. Next execute launches a fresh browser.");
1236
+ return { content: [{ type: "text", text: parts.join(" ") }] };
1237
+ }
1238
+ if (action === "capture-start") {
1239
+ const result = await localApiPost("/capture/start", { mode: mode ?? "binary-headful", instanceId });
1240
+ return { content: [{ type: "text", text: result.message }] };
1241
+ }
1242
+ if (action === "capture-stop") {
1243
+ const result = await localApiPost("/capture/stop", { mode: mode ?? "binary-headful", instanceId });
1244
+ const lines = [result.message];
1245
+ if (result.capturedApi && result.capturedApi.length > 0) {
1246
+ formatCapturedApi(lines, result.capturedApi, { outputDir, typed: false });
1247
+ const outDir = outputDir || import_path7.default.join(getDataDir(), "capture");
1248
+ try {
1249
+ import_fs5.default.mkdirSync(outDir, { recursive: true });
1250
+ const jsonPath = import_path7.default.join(outDir, "captured-api.json");
1251
+ import_fs5.default.writeFileSync(jsonPath, JSON.stringify(result.capturedApi, null, 2));
1252
+ lines.push(`
1262
1253
  Full data saved to: ${jsonPath}`);
1263
- } catch (writeErr) {
1264
- lines.push(`
1254
+ } catch (writeErr) {
1255
+ lines.push(`
1265
1256
  (Could not save: ${writeErr instanceof Error ? writeErr.message : String(writeErr)})`);
1257
+ }
1266
1258
  }
1259
+ let text = lines.join("\n");
1260
+ if (text.length > 8e4) text = text.slice(0, 8e4) + "\n\n[truncated \u2014 read captured-api.json]";
1261
+ return { content: [{ type: "text", text }] };
1267
1262
  }
1268
- let text = lines.join(`
1269
- `);
1270
- if (text.length > 80000)
1271
- text = text.slice(0, 80000) + `
1272
-
1273
- [truncated — read captured-api.json]`;
1274
- return { content: [{ type: "text", text }] };
1275
- }
1276
- if (action === "get-cookies") {
1277
- const result = await localApiPost("/auth/cookies", { mode: mode ?? "binary-headful", urls, instanceId });
1278
- const lines = [result.message, ""];
1279
- for (const c of result.cookies) {
1280
- lines.push(`${c.name}=${c.value} [domain=${c.domain} path=${c.path} httpOnly=${c.httpOnly} secure=${c.secure}]`);
1281
- }
1282
- return { content: [{ type: "text", text: lines.join(`
1283
- `) }] };
1284
- }
1285
- if (action === "get-auth") {
1286
- const result = await localApiPost("/auth/full", { mode: mode ?? "binary-headful", urls, instanceId });
1287
- const lines = [result.message, ""];
1288
- lines.push("=== Cookies ===");
1289
- for (const c of result.cookies) {
1290
- lines.push(`${c.name}=${c.value} [domain=${c.domain} httpOnly=${c.httpOnly}]`);
1263
+ if (action === "get-cookies") {
1264
+ const result = await localApiPost("/auth/cookies", { mode: mode ?? "binary-headful", urls, instanceId });
1265
+ const lines = [result.message, ""];
1266
+ for (const c of result.cookies) {
1267
+ lines.push(`${c.name}=${c.value} [domain=${c.domain} path=${c.path} httpOnly=${c.httpOnly} secure=${c.secure}]`);
1268
+ }
1269
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1291
1270
  }
1292
- if (Object.keys(result.localStorage || {}).length > 0) {
1293
- lines.push(`
1294
- === localStorage ===`);
1295
- for (const [origin, store] of Object.entries(result.localStorage)) {
1296
- lines.push(`[${origin}]`);
1297
- for (const [k, v] of Object.entries(store)) {
1298
- const val = String(v);
1299
- lines.push(` ${k}: ${val.length > 120 ? val.slice(0, 120) + "…" : val}`);
1271
+ if (action === "get-auth") {
1272
+ const result = await localApiPost("/auth/full", { mode: mode ?? "binary-headful", urls, instanceId });
1273
+ const lines = [result.message, ""];
1274
+ lines.push("=== Cookies ===");
1275
+ for (const c of result.cookies) {
1276
+ lines.push(`${c.name}=${c.value} [domain=${c.domain} httpOnly=${c.httpOnly}]`);
1277
+ }
1278
+ if (Object.keys(result.localStorage || {}).length > 0) {
1279
+ lines.push("\n=== localStorage ===");
1280
+ for (const [origin, store] of Object.entries(result.localStorage)) {
1281
+ lines.push(`[${origin}]`);
1282
+ for (const [k, v] of Object.entries(store)) {
1283
+ const val = String(v);
1284
+ lines.push(` ${k}: ${val.length > 120 ? val.slice(0, 120) + "\u2026" : val}`);
1285
+ }
1300
1286
  }
1301
1287
  }
1302
- }
1303
- if (Object.keys(result.sessionStorage || {}).length > 0) {
1304
- lines.push(`
1305
- === sessionStorage ===`);
1306
- for (const [origin, store] of Object.entries(result.sessionStorage)) {
1307
- lines.push(`[${origin}]`);
1308
- for (const [k, v] of Object.entries(store)) {
1309
- const val = String(v);
1310
- lines.push(` ${k}: ${val.length > 120 ? val.slice(0, 120) + "…" : val}`);
1288
+ if (Object.keys(result.sessionStorage || {}).length > 0) {
1289
+ lines.push("\n=== sessionStorage ===");
1290
+ for (const [origin, store] of Object.entries(result.sessionStorage)) {
1291
+ lines.push(`[${origin}]`);
1292
+ for (const [k, v] of Object.entries(store)) {
1293
+ const val = String(v);
1294
+ lines.push(` ${k}: ${val.length > 120 ? val.slice(0, 120) + "\u2026" : val}`);
1295
+ }
1311
1296
  }
1312
1297
  }
1298
+ let text = lines.join("\n");
1299
+ if (text.length > 8e4) text = text.slice(0, 8e4) + "\n[truncated]";
1300
+ return { content: [{ type: "text", text }] };
1313
1301
  }
1314
- let text = lines.join(`
1315
- `);
1316
- if (text.length > 80000)
1317
- text = text.slice(0, 80000) + `
1318
- [truncated]`;
1319
- return { content: [{ type: "text", text }] };
1302
+ return err("Unknown action");
1303
+ } catch (e) {
1304
+ return err(`Error: ${getErrorMessage(e)}`);
1320
1305
  }
1321
- return err("Unknown action");
1322
- } catch (e) {
1323
- return err(`Error: ${getErrorMessage(e)}`);
1324
1306
  }
1325
- });
1307
+ );
1326
1308
  }
1327
1309
 
1328
1310
  // src/mcp/tools/credentials.ts
@@ -1343,15 +1325,15 @@ function getKnowledgePath(domain) {
1343
1325
  }
1344
1326
  function normalizeDomain(input) {
1345
1327
  let d = (input || "").trim().toLowerCase();
1346
- if (!d)
1347
- return "";
1328
+ if (!d) return "";
1348
1329
  try {
1349
1330
  if (d.includes("://")) {
1350
1331
  d = new URL(d).hostname;
1351
1332
  } else if (d.includes("/")) {
1352
1333
  d = new URL(`https://${d}`).hostname;
1353
1334
  }
1354
- } catch {}
1335
+ } catch {
1336
+ }
1355
1337
  d = d.replace(/:\d+$/, "");
1356
1338
  d = d.replace(/^www\./, "");
1357
1339
  return d;
@@ -1378,8 +1360,7 @@ function listKnowledge() {
1378
1360
  }
1379
1361
  const results = [];
1380
1362
  for (const file of entries) {
1381
- if (!file.endsWith(".md"))
1382
- continue;
1363
+ if (!file.endsWith(".md")) continue;
1383
1364
  const full = import_path8.default.join(dir, file);
1384
1365
  try {
1385
1366
  const stat = import_fs6.default.statSync(full);
@@ -1391,7 +1372,8 @@ function listKnowledge() {
1391
1372
  lastMode: parsed?.lastMode ?? "unknown",
1392
1373
  sizeBytes: stat.size
1393
1374
  });
1394
- } catch {}
1375
+ } catch {
1376
+ }
1395
1377
  }
1396
1378
  results.sort((a, b) => a.lastVerified < b.lastVerified ? 1 : -1);
1397
1379
  return results;
@@ -1415,22 +1397,21 @@ function clearKnowledge(domain) {
1415
1397
  try {
1416
1398
  import_fs6.default.unlinkSync(import_path8.default.join(dir, f));
1417
1399
  removed++;
1418
- } catch {}
1400
+ } catch {
1401
+ }
1419
1402
  }
1420
1403
  }
1421
- } catch {}
1404
+ } catch {
1405
+ }
1422
1406
  return { removed };
1423
1407
  }
1424
1408
  function parseMarkdown(raw) {
1425
1409
  const frontmatterMatch = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
1426
- if (!frontmatterMatch)
1427
- return null;
1410
+ if (!frontmatterMatch) return null;
1428
1411
  const fm = {};
1429
- for (const line of frontmatterMatch[1].split(`
1430
- `)) {
1412
+ for (const line of frontmatterMatch[1].split("\n")) {
1431
1413
  const m = line.match(/^(\w+):\s*(.*)$/);
1432
- if (m)
1433
- fm[m[1]] = m[2].trim();
1414
+ if (m) fm[m[1]] = m[2].trim();
1434
1415
  }
1435
1416
  const body = raw.slice(frontmatterMatch[0].length);
1436
1417
  const auth = { type: fm.authType ?? "unknown" };
@@ -1453,11 +1434,9 @@ function parseMarkdown(raw) {
1453
1434
  const notes = [];
1454
1435
  const notesSection = extractSection(body, "Notes");
1455
1436
  if (notesSection) {
1456
- for (const line of notesSection.split(`
1457
- `)) {
1437
+ for (const line of notesSection.split("\n")) {
1458
1438
  const m = line.match(/^-\s+(.+)$/);
1459
- if (m)
1460
- notes.push(m[1].trim());
1439
+ if (m) notes.push(m[1].trim());
1461
1440
  }
1462
1441
  }
1463
1442
  return {
@@ -1473,8 +1452,7 @@ function parseMarkdown(raw) {
1473
1452
  function extractSection(body, heading) {
1474
1453
  const re = new RegExp(`^##\\s+${heading}\\s*$`, "m");
1475
1454
  const match = body.match(re);
1476
- if (!match || match.index === undefined)
1477
- return null;
1455
+ if (!match || match.index === void 0) return null;
1478
1456
  const start = match.index + match[0].length;
1479
1457
  const nextSection = body.slice(start).match(/^##\s+/m);
1480
1458
  const end = nextSection?.index != null ? start + nextSection.index : body.length;
@@ -1482,360 +1460,327 @@ function extractSection(body, heading) {
1482
1460
  }
1483
1461
  function extractBackticks(re, text) {
1484
1462
  const m = text.match(re);
1485
- if (!m)
1486
- return;
1463
+ if (!m) return void 0;
1487
1464
  const items = [...m[1].matchAll(/`([^`]+)`/g)].map((x) => x[1]);
1488
- return items.length > 0 ? items : undefined;
1465
+ return items.length > 0 ? items : void 0;
1489
1466
  }
1490
1467
 
1491
1468
  // src/mcp/tools/credentials.ts
1492
- var ELICIT_TIMEOUT_MS = 45000;
1469
+ var ELICIT_TIMEOUT_MS = 45e3;
1493
1470
  function mcpLog(event, data) {
1494
1471
  try {
1495
1472
  const dir = getDataDir();
1496
1473
  import_fs7.default.mkdirSync(dir, { recursive: true });
1497
- import_fs7.default.appendFileSync(import_path9.default.join(dir, "mcp.log"), JSON.stringify({ ts: new Date().toISOString(), event, ...data }) + `
1498
- `);
1499
- } catch {}
1474
+ import_fs7.default.appendFileSync(
1475
+ import_path9.default.join(dir, "mcp.log"),
1476
+ JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), event, ...data }) + "\n"
1477
+ );
1478
+ } catch {
1479
+ }
1500
1480
  }
1501
1481
  function domainMatches(normalized, stored) {
1502
- return stored.some((d) => d === normalized || normalized.endsWith("." + d) || d.endsWith("." + normalized));
1503
- }
1504
- function registerCredentialsTool(server) {
1505
- server.tool("credentials", `Manage stored login credentials. This tool ONLY stores and lists credentials — it does NOT log you into anything. Actual logins happen via the \`execute\` tool with a \`login\` step. Credentials are stored in a single local SQLite database shared by ALL browser modes (headless, binary-headful, docker-headful) — store once, login anywhere.
1506
-
1507
- CORRECT WORKFLOW when the user needs to be logged into a site:
1508
- 1. Call \`credentials\` with \`action=list\`. READ THE RESPONSE LITERALLY. If it says "No credentials stored" then NO credentials exist. If it lists domains, those are the only ones stored.
1509
- 2. If the target domain IS in the list → skip to step 4. Credentials exist and are valid. Move on.
1510
- 3. If the target domain is NOT in the list → call \`credentials\` with \`action=store, domain=<site>\`. This attempts to pop a secure form in the user's UI. The response is either \`Credentials stored for <site>.\` (success) OR a loud error telling you the client doesn't support form elicitation, with instructions for the user to run a CLI command. Relay the error verbatim and STOP — do not proceed with login until the user confirms they ran the command.
1511
- 4. Call \`execute\` with \`[{type:"navigate", url:"https://<site>/login"}, {type:"login", domain:"<site>"}]\`. The login step auto-detects the form, fills stored credentials, handles 2FA, submits, and auto-escalates browser modes if blocked.
1512
-
1513
- ═══════════════════════════════════════════════════════════════════════
1514
- CRITICAL RULES
1515
- ═══════════════════════════════════════════════════════════════════════
1516
-
1517
- 1. **NEVER re-store credentials as a recovery from a failed login.** If credentials already exist, the store call will be REJECTED. Login failures are browser-mode / bot-detection / page-structure problems, not credential problems.
1518
-
1519
- 2. **NEVER ask the user "do you have credentials?"** — call action=list and read the response.
1520
-
1521
- 3. **NEVER confabulate.** If action=list returns "No credentials stored", the database is empty.
1522
-
1523
- 4. **NEVER pretend a store call succeeded if the response was an error.**
1524
-
1525
- 5. **NEVER ask the user to paste their password in chat.**
1526
-
1527
- 6. **\`force: true\` on store is ONLY for explicit password changes.**`, {
1528
- action: import_zod6.z.enum(["store", "list"]).describe("store: prompt for credentials | list: show stored domains"),
1529
- domain: import_zod6.z.string().optional().describe("Domain (required for store). Use the bare registrable domain."),
1530
- force: import_zod6.z.boolean().optional().describe("Overwrite existing. ONLY for explicit password changes.")
1531
- }, async ({ action, domain, force }) => {
1532
- try {
1533
- if (action === "list") {
1534
- const credData2 = await localApiGet("/credentials");
1535
- const domains = credData2.domains || [];
1536
- if (!domains.length) {
1537
- return { content: [{ type: "text", text: "No credentials stored. Call this tool again with action=store and the domain to prompt the user for credentials now." }] };
1538
- }
1539
- return { content: [{ type: "text", text: `Stored credentials for:
1540
- ${domains.map((d) => ` - ${d}`).join(`
1541
- `)}` }] };
1542
- }
1543
- if (action !== "store")
1544
- return err("Unknown action");
1545
- if (!domain)
1546
- return err("domain is required for action=store");
1547
- const normalized = normalizeDomain(domain);
1548
- const credData = await localApiGet("/credentials");
1549
- const stored = credData.domains || [];
1550
- if (domainMatches(normalized, stored) && !force) {
1551
- mcpLog("credentials.store.rejected_already_exists", { domain: normalized });
1552
- return err(`REFUSING TO RE-STORE: credentials for "${normalized}" already exist. ` + `Login failures are NOT credential problems — retry with a stronger browser mode.
1553
-
1554
- ` + `Use \`force: true\` ONLY if the user explicitly says their password changed.`);
1555
- }
1556
- mcpLog("credentials.store.attempt", { domain: normalized, force: !!force });
1557
- let result;
1482
+ return stored.some(
1483
+ (d) => d === normalized || normalized.endsWith("." + d) || d.endsWith("." + normalized)
1484
+ );
1485
+ }
1486
+ function registerCredentialsTool(server2) {
1487
+ server2.tool(
1488
+ "credentials",
1489
+ `Store/list login credentials (one local SQLite store shared by all browser modes). This tool never logs in \u2014 logins run via \`execute\`'s \`login\` step.
1490
+
1491
+ WORKFLOW: (1) action=list and read the response LITERALLY \u2014 never ask the user whether credentials exist, never confabulate. (2) Domain missing \u2192 action=store pops a secure form in the user's UI; if the response is an elicitation-unsupported error, relay its CLI instructions verbatim and STOP (never pretend it succeeded). (3) Then \`execute\` [{type:"navigate",url:"https://<site>/login"},{type:"login",domain:"<site>"}] \u2014 auto-detects the form, fills stored credentials, handles 2FA, escalates modes if blocked.
1492
+
1493
+ RULES: NEVER re-store after a failed login (that's a browser/bot problem; the store is rejected anyway). NEVER ask for passwords in chat. force:true only for an explicit password change.`,
1494
+ {
1495
+ action: import_zod6.z.enum(["store", "list"]).describe("store: prompt for credentials | list: show stored domains"),
1496
+ domain: import_zod6.z.string().optional().describe("Domain (required for store). Use the bare registrable domain."),
1497
+ force: import_zod6.z.boolean().optional().describe("Overwrite existing. ONLY for explicit password changes.")
1498
+ },
1499
+ async ({ action, domain, force }) => {
1558
1500
  try {
1559
- const elicitPromise = server.server.elicitInput({
1560
- mode: "form",
1561
- message: `Enter your login credentials for ${normalized}. These are encrypted and stored locally — the agent never sees them.`,
1562
- requestedSchema: {
1563
- type: "object",
1564
- properties: {
1565
- username: { type: "string", title: "Username / Email" },
1566
- password: { type: "string", title: "Password" },
1567
- totp_secret: { type: "string", title: "TOTP Secret (leave empty if no 2FA)" }
1568
- },
1569
- required: ["username", "password"]
1501
+ if (action === "list") {
1502
+ const credData2 = await localApiGet("/credentials");
1503
+ const domains = credData2.domains || [];
1504
+ if (!domains.length) {
1505
+ return { content: [{ type: "text", text: "No credentials stored. Call this tool again with action=store and the domain to prompt the user for credentials now." }] };
1570
1506
  }
1571
- });
1572
- const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`elicitation timed out after ${ELICIT_TIMEOUT_MS}ms`)), ELICIT_TIMEOUT_MS));
1573
- result = await Promise.race([elicitPromise, timeoutPromise]);
1574
- } catch (elicitErr) {
1575
- const msg = elicitErr instanceof Error ? elicitErr.message : String(elicitErr);
1576
- mcpLog("credentials.store.elicit_failed", { domain: normalized, error: msg });
1577
- return err(`FAILED TO STORE CREDENTIALS for ${normalized}: ${msg}
1578
-
1579
- ` + `The user MUST run this command in their terminal:
1580
-
1581
- ` + ` iframer-toolkit credentials add ${normalized}
1582
-
1583
- ` + `After they run it, retry the login. DO NOT pretend credentials were stored.`);
1584
- }
1585
- mcpLog("credentials.store.elicit_result", { domain: normalized, action: result.action, hasContent: !!result.content });
1586
- if (result.action !== "accept" || !result.content) {
1587
- return err(`Credential form was ${result.action || "dismissed"}. No credentials saved for ${normalized}.`);
1588
- }
1589
- const { username, password, totp_secret } = result.content;
1590
- if (!username || !password) {
1591
- return err(`Form submitted but username or password was empty. No credentials saved.`);
1592
- }
1593
- try {
1594
- const storeResult = await localApiPost("/credentials", {
1595
- domain: normalized,
1596
- username,
1597
- password,
1598
- totp_secret: totp_secret || undefined
1599
- });
1600
- if (!storeResult.ok)
1601
- return err(`Failed to store: ${storeResult.error}`);
1602
- } catch (storeErr) {
1603
- const msg = storeErr instanceof Error ? storeErr.message : String(storeErr);
1604
- mcpLog("credentials.store.write_failed", { domain: normalized, error: msg });
1605
- return err(`Failed to write credentials: ${msg}`);
1507
+ return { content: [{ type: "text", text: `Stored credentials for:
1508
+ ${domains.map((d) => ` - ${d}`).join("\n")}` }] };
1509
+ }
1510
+ if (action !== "store") return err("Unknown action");
1511
+ if (!domain) return err("domain is required for action=store");
1512
+ const normalized = normalizeDomain(domain);
1513
+ const credData = await localApiGet("/credentials");
1514
+ const stored = credData.domains || [];
1515
+ if (domainMatches(normalized, stored) && !force) {
1516
+ mcpLog("credentials.store.rejected_already_exists", { domain: normalized });
1517
+ return err(
1518
+ `REFUSING TO RE-STORE: credentials for "${normalized}" already exist. Login failures are NOT credential problems \u2014 retry with a stronger browser mode.
1519
+
1520
+ Use \`force: true\` ONLY if the user explicitly says their password changed.`
1521
+ );
1522
+ }
1523
+ mcpLog("credentials.store.attempt", { domain: normalized, force: !!force });
1524
+ let result;
1525
+ try {
1526
+ const elicitPromise = server2.server.elicitInput({
1527
+ mode: "form",
1528
+ message: `Enter your login credentials for ${normalized}. These are encrypted and stored locally \u2014 the agent never sees them.`,
1529
+ requestedSchema: {
1530
+ type: "object",
1531
+ properties: {
1532
+ username: { type: "string", title: "Username / Email" },
1533
+ password: { type: "string", title: "Password" },
1534
+ totp_secret: { type: "string", title: "TOTP Secret (leave empty if no 2FA)" }
1535
+ },
1536
+ required: ["username", "password"]
1537
+ }
1538
+ });
1539
+ const timeoutPromise = new Promise(
1540
+ (_, reject) => setTimeout(() => reject(new Error(`elicitation timed out after ${ELICIT_TIMEOUT_MS}ms`)), ELICIT_TIMEOUT_MS)
1541
+ );
1542
+ result = await Promise.race([elicitPromise, timeoutPromise]);
1543
+ } catch (elicitErr) {
1544
+ const msg = elicitErr instanceof Error ? elicitErr.message : String(elicitErr);
1545
+ mcpLog("credentials.store.elicit_failed", { domain: normalized, error: msg });
1546
+ return err(
1547
+ `FAILED TO STORE CREDENTIALS for ${normalized}: ${msg}
1548
+
1549
+ The user MUST run this command in their terminal:
1550
+
1551
+ iframer-toolkit credentials add ${normalized}
1552
+
1553
+ After they run it, retry the login. DO NOT pretend credentials were stored.`
1554
+ );
1555
+ }
1556
+ mcpLog("credentials.store.elicit_result", { domain: normalized, action: result.action, hasContent: !!result.content });
1557
+ if (result.action !== "accept" || !result.content) {
1558
+ return err(`Credential form was ${result.action || "dismissed"}. No credentials saved for ${normalized}.`);
1559
+ }
1560
+ const { username, password, totp_secret } = result.content;
1561
+ if (!username || !password) {
1562
+ return err(`Form submitted but username or password was empty. No credentials saved.`);
1563
+ }
1564
+ try {
1565
+ const storeResult = await localApiPost("/credentials", {
1566
+ domain: normalized,
1567
+ username,
1568
+ password,
1569
+ totp_secret: totp_secret || void 0
1570
+ });
1571
+ if (!storeResult.ok) return err(`Failed to store: ${storeResult.error}`);
1572
+ } catch (storeErr) {
1573
+ const msg = storeErr instanceof Error ? storeErr.message : String(storeErr);
1574
+ mcpLog("credentials.store.write_failed", { domain: normalized, error: msg });
1575
+ return err(`Failed to write credentials: ${msg}`);
1576
+ }
1577
+ mcpLog("credentials.store.success", { domain: normalized });
1578
+ return { content: [{ type: "text", text: `Credentials stored for ${normalized}. Shared across all browser modes.` }] };
1579
+ } catch (e) {
1580
+ return err(`Error: ${getErrorMessage(e)}`);
1606
1581
  }
1607
- mcpLog("credentials.store.success", { domain: normalized });
1608
- return { content: [{ type: "text", text: `Credentials stored for ${normalized}. Shared across all browser modes.` }] };
1609
- } catch (e) {
1610
- return err(`Error: ${getErrorMessage(e)}`);
1611
1582
  }
1612
- });
1583
+ );
1613
1584
  }
1614
1585
 
1615
1586
  // src/mcp/tools/knowledge.ts
1616
1587
  var import_zod7 = require("zod");
1617
- function registerKnowledgeTool(server) {
1618
- server.tool("knowledge", `Per-domain knowledge cache. Call this BEFORE every \`execute\` or \`browse\` on a website — it's orders of magnitude faster than launching a browser when the cache already has what you need.
1619
-
1620
- Each domain's cache is a plain markdown file at ~/.iframer/knowledge/<domain>.md containing:
1621
- - Auth mechanism (which cookies / localStorage keys / headers are load-bearing)
1622
- - Known API endpoints the site uses (captured from real browser runs)
1623
- - Notes about captchas, bot detection, session behavior
1624
- - Which browser mode last worked
1588
+ function registerKnowledgeTool(server2) {
1589
+ server2.tool(
1590
+ "knowledge",
1591
+ `Per-domain knowledge cache (markdown at ~/.iframer/knowledge/<domain>.md): auth mechanism (load-bearing cookies/headers), captured API endpoints, captcha/bot notes, last working browser mode.
1625
1592
 
1626
- MANDATORY WORKFLOW — for any task that targets a specific website:
1593
+ MANDATORY: \`knowledge get <domain>\` BEFORE any execute/browse on a site. If it shows a direct-API path that satisfies the request, call the endpoints directly and SKIP the browser entirely \u2014 orders of magnitude cheaper. Empty/stale \u2192 fall through to \`execute\` (successful runs update the cache automatically). Cached endpoints returning 401/403 \u2192 stale session: run an execute pipeline with a \`login\` step.
1627
1594
 
1628
- 1. Call \`knowledge get <domain>\` first.
1629
- 2. If the cache shows a direct-API path (auth material + endpoints) that satisfies the request, hit the endpoints directly using the agent's own fetch capability — the session cookies/headers are already injected into the current browser context, and the cache tells you exactly how to call them. Skip the browser entirely.
1630
- 3. If the cache is empty, outdated, or doesn't cover what you need, fall through to \`execute\` with a pipeline. After \`execute\` succeeds, the cache gets updated automatically — future calls benefit.
1631
- 4. If cached endpoints return 401/403, the session is stale — run an \`execute\` pipeline containing a \`login\` step to refresh. The cache will be re-verified automatically on success.
1632
-
1633
- Actions:
1634
- - get: return the markdown cache for a specific domain
1635
- - list: show all cached domains with last-verified timestamps
1636
- - clear: delete cache for a specific domain, or everything if domain is omitted`, {
1637
- action: import_zod7.z.enum(["get", "list", "clear"]).describe("get: return cache for a domain | list: all cached domains | clear: delete cache"),
1638
- domain: import_zod7.z.string().optional().describe("Domain (required for get; optional for clear — omit to clear everything)")
1639
- }, async ({ action, domain }) => {
1640
- try {
1641
- if (action === "list") {
1642
- const entries = listKnowledge();
1643
- if (entries.length === 0) {
1644
- return {
1645
- content: [
1646
- {
1647
- type: "text",
1648
- text: "No cached knowledge yet. Every successful `execute` run populates the cache for its target domain automatically."
1649
- }
1650
- ]
1651
- };
1652
- }
1653
- const lines = [`${entries.length} domain${entries.length === 1 ? "" : "s"} cached:
1595
+ Actions: get <domain> \xB7 list \xB7 clear [domain].`,
1596
+ {
1597
+ action: import_zod7.z.enum(["get", "list", "clear"]).describe("get: return cache for a domain | list: all cached domains | clear: delete cache"),
1598
+ domain: import_zod7.z.string().optional().describe("Domain (required for get; optional for clear \u2014 omit to clear everything)")
1599
+ },
1600
+ async ({ action, domain }) => {
1601
+ try {
1602
+ if (action === "list") {
1603
+ const entries = listKnowledge();
1604
+ if (entries.length === 0) {
1605
+ return {
1606
+ content: [
1607
+ {
1608
+ type: "text",
1609
+ text: "No cached knowledge yet. Every successful `execute` run populates the cache for its target domain automatically."
1610
+ }
1611
+ ]
1612
+ };
1613
+ }
1614
+ const lines = [`${entries.length} domain${entries.length === 1 ? "" : "s"} cached:
1654
1615
  `];
1655
- for (const e of entries) {
1656
- lines.push(` ${e.domain} (${e.lastMode}, verified ${e.lastVerified}, ${e.sizeBytes}B)`);
1616
+ for (const e of entries) {
1617
+ lines.push(` ${e.domain} (${e.lastMode}, verified ${e.lastVerified}, ${e.sizeBytes}B)`);
1618
+ }
1619
+ lines.push("\nCall `knowledge get <domain>` for the full cache contents.");
1620
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1657
1621
  }
1658
- lines.push("\nCall `knowledge get <domain>` for the full cache contents.");
1659
- return { content: [{ type: "text", text: lines.join(`
1660
- `) }] };
1661
- }
1662
- if (action === "get") {
1663
- if (!domain)
1664
- return err("domain is required for action=get");
1665
- const md = readKnowledge(domain);
1666
- if (!md) {
1622
+ if (action === "get") {
1623
+ if (!domain) return err("domain is required for action=get");
1624
+ const md = readKnowledge(domain);
1625
+ if (!md) {
1626
+ return {
1627
+ content: [
1628
+ {
1629
+ type: "text",
1630
+ text: `No cache for ${sanitizeDomain(domain)}. Run \`execute\` with a pipeline that navigates to this domain (and optionally enables captureApi) \u2014 the cache will be populated automatically on success.`
1631
+ }
1632
+ ]
1633
+ };
1634
+ }
1635
+ return { content: [{ type: "text", text: md }] };
1636
+ }
1637
+ if (action === "clear") {
1638
+ const { removed } = clearKnowledge(domain);
1639
+ const scope = domain ? `for ${sanitizeDomain(domain)}` : "(all domains)";
1667
1640
  return {
1668
1641
  content: [
1669
1642
  {
1670
1643
  type: "text",
1671
- text: `No cache for ${sanitizeDomain(domain)}. Run \`execute\` with a pipeline that navigates to this domain (and optionally enables captureApi) — the cache will be populated automatically on success.`
1644
+ text: `Cleared ${removed} cache entr${removed === 1 ? "y" : "ies"} ${scope}.`
1672
1645
  }
1673
1646
  ]
1674
1647
  };
1675
1648
  }
1676
- return { content: [{ type: "text", text: md }] };
1677
- }
1678
- if (action === "clear") {
1679
- const { removed } = clearKnowledge(domain);
1680
- const scope = domain ? `for ${sanitizeDomain(domain)}` : "(all domains)";
1681
- return {
1682
- content: [
1683
- {
1684
- type: "text",
1685
- text: `Cleared ${removed} cache entr${removed === 1 ? "y" : "ies"} ${scope}.`
1686
- }
1687
- ]
1688
- };
1649
+ return err("Unknown action");
1650
+ } catch (e) {
1651
+ return err(`Error: ${getErrorMessage(e)}`);
1689
1652
  }
1690
- return err("Unknown action");
1691
- } catch (e) {
1692
- return err(`Error: ${getErrorMessage(e)}`);
1693
1653
  }
1694
- });
1654
+ );
1695
1655
  }
1696
1656
 
1697
1657
  // src/mcp/tools/tabs.ts
1698
1658
  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}
1659
+ function registerTabsTool(server2) {
1660
+ server2.tool(
1661
+ "tabs",
1662
+ `List/control tabs in the user's REAL Chrome via the iframer extension (must be installed + paired; returns a connect hint otherwise).
1663
+
1664
+ Use when the user references an open tab ("my Gmail tab"). Match by url/title from the returned tabs, then drive that tab with \`execute\` options.mode="extension", options.tabId=<id> (Chrome shows its debug bar during runs). Several matches \u2192 ask, don't guess.
1665
+
1666
+ Actions: list (default) \u2192 {connected, tabs:[{id,title,url,active,windowId}]} \xB7 open \u2192 new native tab at options.url, returns its id \xB7 group / ungroup / update-group / groups \u2192 native Chrome tab-group management (title/color/collapsed; groups lists ids).`,
1667
+ {
1668
+ 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."),
1669
+ url: import_zod8.z.string().optional().describe("With action='open': the URL to open (omit for a blank tab)."),
1670
+ active: import_zod8.z.boolean().optional().describe("With action='open': focus the new tab (default true)."),
1671
+ 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)."),
1672
+ title: import_zod8.z.string().optional().describe("With action='group': the group's label."),
1673
+ color: import_zod8.z.enum(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]).optional().describe("With action='group': the group's color."),
1674
+ collapsed: import_zod8.z.boolean().optional().describe("With action='group': collapse the group."),
1675
+ groupId: import_zod8.z.number().optional().describe("With action='group': add tabs to this existing group instead of creating a new one."),
1676
+ clientId: import_zod8.z.string().optional().describe("With action='open'/'group' and multiple profiles connected: which profile to act in."),
1677
+ 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.")
1678
+ },
1679
+ async ({ action, url, active, tabIds, title, color, collapsed, groupId, clientId, filter }) => {
1680
+ try {
1681
+ if (action === "open") {
1682
+ const res = await localApiPost("/extension/tab/create", { url, active, clientId });
1683
+ if (!res.ok || !res.tab) return err(res.error || "Failed to open tab.");
1684
+ const t = res.tab;
1685
+ return {
1686
+ content: [
1687
+ {
1688
+ type: "text",
1689
+ text: `Opened tab [id ${t.id}] ${t.title || t.url}
1737
1690
  ${t.url}
1738
1691
  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.");
1692
+ }
1693
+ ]
1694
+ };
1695
+ }
1696
+ if (action === "group") {
1697
+ if (!tabIds || tabIds.length === 0) return err("action='group' requires tabIds (array of tab ids).");
1698
+ const res = await localApiPost("/extension/tab/group", { tabIds, title, color, collapsed, groupId, clientId });
1699
+ if (!res.ok || !res.group) return err(res.error || "Failed to group tabs.");
1700
+ const g = res.group;
1701
+ return {
1702
+ content: [
1703
+ {
1704
+ type: "text",
1705
+ text: `Grouped ${tabIds.length} tab(s) into group ${g.groupId}${g.title ? ` "${g.title}"` : ""}${g.color ? ` (${g.color})` : ""}${g.collapsed ? ", collapsed" : ""}.`
1706
+ }
1707
+ ]
1708
+ };
1709
+ }
1710
+ if (action === "ungroup") {
1711
+ if (!tabIds || tabIds.length === 0) return err("action='ungroup' requires tabIds.");
1712
+ const res = await localApiPost("/extension/tab/ungroup", { tabIds, clientId });
1713
+ if (!res.ok) return err(res.error || "Failed to ungroup tabs.");
1714
+ return { content: [{ type: "text", text: `Ungrouped ${tabIds.length} tab(s).` }] };
1715
+ }
1716
+ if (action === "update-group") {
1717
+ if (typeof groupId !== "number") return err("action='update-group' requires groupId (from action='groups'). Pass any of title, color, collapsed.");
1718
+ const res = await localApiPost("/extension/group/update", { groupId, title, color, collapsed, clientId });
1719
+ if (!res.ok || !res.group) return err(res.error || "Failed to update group.");
1720
+ const g = res.group;
1721
+ return { content: [{ type: "text", text: `Updated group ${g.groupId} \u2192 title "${g.title}", color ${g.color}${g.collapsed ? ", collapsed" : ""}.` }] };
1722
+ }
1723
+ if (action === "groups") {
1724
+ const res = await localApiPost("/extension/groups", { clientId });
1725
+ if (!res.ok) return err(res.error || "Failed to list groups.");
1726
+ const groups = res.groups || [];
1727
+ if (groups.length === 0) return { content: [{ type: "text", text: "No tab groups open." }] };
1728
+ const lines2 = [`${groups.length} tab group(s):`, ""];
1729
+ for (const g of groups) lines2.push(` [group ${g.groupId}] "${g.title || "(untitled)"}" \u2014 ${g.color}${g.collapsed ? ", collapsed" : ""} (window ${g.windowId})`);
1730
+ return { content: [{ type: "text", text: lines2.join("\n") }] };
1731
+ }
1732
+ const status = await localApiGet("/extension/status");
1733
+ if (!status.connected) {
1734
+ return err(
1735
+ "No iframer extension is connected.\n\nTo use your real Chrome tabs:\n1. Install the iframer extension (chrome://extensions \u2192 Load unpacked \u2192 the `extension/` folder).\n2. Run `iframer install extension` in a terminal, then restart the browser \u2014 the extension pairs itself.\n (Manual fallback: click the iframer icon and paste the token from `cat ~/.iframer/secret`.)\nOnce the dot is green, run `tabs` again \u2014 iframer can then see and drive any open tab."
1736
+ );
1737
+ }
1738
+ const data = await localApiPost("/extension/tabs", {});
1739
+ let tabs = data.tabs || [];
1740
+ const clients = data.clients || [];
1741
+ const multiProfile = clients.length > 1;
1742
+ if (filter) {
1743
+ const f = filter.toLowerCase();
1744
+ tabs = tabs.filter(
1745
+ (t) => t.url.toLowerCase().includes(f) || (t.title || "").toLowerCase().includes(f)
1746
+ );
1747
+ }
1748
+ if (tabs.length === 0) {
1749
+ return {
1750
+ content: [
1751
+ {
1752
+ type: "text",
1753
+ text: filter ? `Extension connected, but no open tab matches "${filter}".` : "Extension connected, but no tabs were reported."
1754
+ }
1755
+ ]
1756
+ };
1757
+ }
1758
+ const profiles = clients.map((c) => c.profileName || c.clientId.slice(0, 8)).join(", ");
1759
+ const ver = clients[0]?.extVersion ? ` [ext v${clients[0].extVersion}]` : "";
1760
+ const lines = [
1761
+ `Connected: ${clients.length} profile${clients.length > 1 ? "s" : ""}${profiles ? ` (${profiles})` : ""}${ver}.`,
1762
+ `${tabs.length} tab${tabs.length > 1 ? "s" : ""}${filter ? ` matching "${filter}"` : ""}:`,
1763
+ ""
1764
+ ];
1765
+ for (const t of tabs) {
1766
+ const prof = multiProfile ? ` \xAB${t.profileName || t.clientId.slice(0, 8)}\xBB` : "";
1767
+ lines.push(` [id ${t.id}]${t.active ? " (active)" : ""}${prof} ${t.title}`);
1768
+ lines.push(` ${t.url}`);
1769
+ if (multiProfile) lines.push(` clientId: ${t.clientId}`);
1770
+ }
1771
+ lines.push("");
1772
+ lines.push('To drive one: execute with options.mode="extension", options.tabId=<id>.');
1773
+ if (multiProfile) {
1774
+ lines.push(
1775
+ "Multiple profiles are connected \u2014 if two tabs share a title, also pass options.clientId (shown as \xABprofile\xBB above maps to a clientId) so the right profile is driven."
1776
+ );
1777
+ }
1778
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1779
+ } catch (e) {
1780
+ return err(`Error listing tabs: ${getErrorMessage(e)}`);
1832
1781
  }
1833
- return { content: [{ type: "text", text: lines.join(`
1834
- `) }] };
1835
- } catch (e) {
1836
- return err(`Error listing tabs: ${getErrorMessage(e)}`);
1837
1782
  }
1838
- });
1783
+ );
1839
1784
  }
1840
1785
 
1841
1786
  // src/mcp/tools/anchors.ts
@@ -1872,7 +1817,7 @@ function saveAnchor(domain, input, now) {
1872
1817
  selector: input.selector,
1873
1818
  role: input.role,
1874
1819
  description: input.description,
1875
- quirks: input.quirks && input.quirks.length ? input.quirks : undefined,
1820
+ quirks: input.quirks && input.quirks.length ? input.quirks : void 0,
1876
1821
  uses: 0,
1877
1822
  fails: 0,
1878
1823
  lastVerified: now
@@ -1881,15 +1826,14 @@ function saveAnchor(domain, input, now) {
1881
1826
  }
1882
1827
  function removeAnchor(domain, name) {
1883
1828
  const cm = loadComponentMap(domain);
1884
- if (!(name in cm.anchors))
1885
- return false;
1829
+ if (!(name in cm.anchors)) return false;
1886
1830
  delete cm.anchors[name];
1887
1831
  write(cm);
1888
1832
  return true;
1889
1833
  }
1890
1834
  function setDomainQuirks(domain, quirks) {
1891
1835
  const cm = loadComponentMap(domain);
1892
- cm.quirks = Array.from(new Set([...cm.quirks, ...quirks]));
1836
+ cm.quirks = Array.from(/* @__PURE__ */ new Set([...cm.quirks, ...quirks]));
1893
1837
  write(cm);
1894
1838
  }
1895
1839
  function listAnchorDomains() {
@@ -1901,99 +1845,86 @@ function listAnchorDomains() {
1901
1845
  }
1902
1846
 
1903
1847
  // 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." }] };
1848
+ function registerRememberTool(server2) {
1849
+ server2.tool(
1850
+ "remember",
1851
+ `Persisted per-domain map of a site's UI elements ("anchors") \u2014 recall where things are instead of re-exploring the DOM each run.
1852
+
1853
+ (1) Before a UI task: \`remember get <domain>\`; an existing anchor is targeted as @a:<name> in any execute selector \u2014 no snapshot needed. (2) A newly discovered selector that worked \u2192 \`remember save\` it (prefer stable selectors: aria-label, data-qa, role+name). (3) SELF-HEAL: if @a:<name> fails, the page changed \u2014 re-discover with snapshot/find and \`remember save\` the new selector; do NOT retry the stale one. (4) \`remember quirk\` records site-wide gotchas ("synthetic clicks ignored \u2014 use trusted"); \`get\` surfaces them.`,
1854
+ {
1855
+ 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)"),
1856
+ domain: import_zod9.z.string().optional().describe("Site domain, e.g. 'slack.com' or 'app.slack.com' (required for all actions except list)."),
1857
+ name: import_zod9.z.string().optional().describe("Anchor name for save/forget, e.g. 'composer', 'send-button', 'search'. Short, stable, kebab-case."),
1858
+ 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."),
1859
+ role: import_zod9.z.string().optional().describe("With save (optional): the element role, e.g. textbox, button, link."),
1860
+ description: import_zod9.z.string().optional().describe("With save (optional): a short human note about the element."),
1861
+ quirks: import_zod9.z.array(import_zod9.z.string()).optional().describe("With save: element-specific gotchas. With quirk: site-wide gotchas to append.")
1862
+ },
1863
+ async ({ action, domain, name, selector, role, description, quirks }) => {
1864
+ try {
1865
+ if (action === "list") {
1866
+ const domains = listAnchorDomains();
1867
+ if (domains.length === 0) {
1868
+ return { content: [{ type: "text", text: "No anchors saved yet. Use `remember save` after locating an element with snapshot/find." }] };
1869
+ }
1870
+ const lines = ["Domains with saved anchors:\n"];
1871
+ for (const d of domains) {
1872
+ const cm = loadComponentMap(d);
1873
+ lines.push(` ${d} \u2014 ${Object.keys(cm.anchors).length} anchor(s)${cm.quirks.length ? `, ${cm.quirks.length} quirk(s)` : ""}`);
1874
+ }
1875
+ lines.push("\nCall `remember get <domain>` for details.");
1876
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1927
1877
  }
1928
- const lines = [`Domains with saved anchors:
1878
+ if (!domain) return err(`\`${action}\` requires a domain (e.g. 'app.slack.com').`);
1879
+ if (action === "get") {
1880
+ const cm = loadComponentMap(domain);
1881
+ const names = Object.keys(cm.anchors);
1882
+ if (names.length === 0 && cm.quirks.length === 0) {
1883
+ return { content: [{ type: "text", text: `No anchors saved for ${domain} yet. Discover elements with snapshot/find, then \`remember save\` them.` }] };
1884
+ }
1885
+ const lines = [`Component map for ${domain}:
1929
1886
  `];
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.` }] };
1887
+ if (cm.quirks.length) {
1888
+ lines.push("Site quirks:");
1889
+ for (const q of cm.quirks) lines.push(` - ${q}`);
1890
+ lines.push("");
1891
+ }
1892
+ lines.push(`Anchors (use as @a:<name> in any selector):`);
1893
+ for (const a of Object.values(cm.anchors)) {
1894
+ const health = a.fails > 0 ? ` [${a.uses}\u2713/${a.fails}\u2717${a.fails >= a.uses && a.fails >= 2 ? " \u2014 likely STALE, re-verify" : ""}]` : a.uses > 0 ? ` [${a.uses}\u2713]` : "";
1895
+ lines.push(` @a:${a.name}${a.role ? ` (${a.role})` : ""} \u2192 ${a.selector}${health}`);
1896
+ if (a.description) lines.push(` ${a.description}`);
1897
+ if (a.quirks?.length) for (const q of a.quirks) lines.push(` \u2022 ${q}`);
1898
+ }
1899
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1945
1900
  }
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("");
1901
+ if (action === "quirk") {
1902
+ if (!quirks || quirks.length === 0) return err("`quirk` requires a non-empty `quirks` array.");
1903
+ setDomainQuirks(domain, quirks);
1904
+ return { content: [{ type: "text", text: `Added ${quirks.length} quirk(s) to ${domain}.` }] };
1953
1905
  }
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}`);
1906
+ if (action === "forget") {
1907
+ if (!name) return err("`forget` requires the anchor `name`.");
1908
+ const removed = removeAnchor(domain, name);
1909
+ return { content: [{ type: "text", text: removed ? `Removed anchor @a:${name} from ${domain}.` : `No anchor named '${name}' for ${domain}.` }] };
1963
1910
  }
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}
1911
+ if (!name) return err("`save` requires an anchor `name`.");
1912
+ if (!selector) return err("`save` requires a `selector` (the CSS that locates the element).");
1913
+ saveAnchor(domain, { name, selector, role, description, quirks }, (/* @__PURE__ */ new Date()).toISOString());
1914
+ return {
1915
+ content: [
1916
+ {
1917
+ type: "text",
1918
+ text: `Saved anchor @a:${name} for ${domain} \u2192 ${selector}
1989
1919
  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)}`);
1920
+ }
1921
+ ]
1922
+ };
1923
+ } catch (e) {
1924
+ return err(`remember failed: ${getErrorMessage(e)}`);
1925
+ }
1995
1926
  }
1996
- });
1927
+ );
1997
1928
  }
1998
1929
 
1999
1930
  // src/mcp/tools/clipboard.ts
@@ -2002,17 +1933,15 @@ var import_zod10 = require("zod");
2002
1933
  // src/lib/clipboard.ts
2003
1934
  var import_child_process2 = require("child_process");
2004
1935
  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"]];
1936
+ if (process.platform === "darwin") return [[mode === "read" ? "pbpaste" : "pbcopy"]];
1937
+ if (mode === "read") return [["wl-paste", "-n"], ["xclip", "-selection", "clipboard", "-o"], ["xsel", "-b", "-o"]];
2009
1938
  return [["wl-copy"], ["xclip", "-selection", "clipboard", "-i"], ["xsel", "-b", "-i"]];
2010
1939
  }
2011
1940
  function run(cmd, input) {
2012
1941
  return new Promise((resolve) => {
2013
1942
  let child;
2014
1943
  try {
2015
- child = import_child_process2.spawn(cmd[0], cmd.slice(1));
1944
+ child = (0, import_child_process2.spawn)(cmd[0], cmd.slice(1));
2016
1945
  } catch {
2017
1946
  resolve({ ok: false, out: "", err: `spawn ${cmd[0]} failed` });
2018
1947
  return;
@@ -2023,7 +1952,7 @@ function run(cmd, input) {
2023
1952
  child.stderr?.on("data", (d) => errOut += d);
2024
1953
  child.on("error", (e) => resolve({ ok: false, out: "", err: e.message }));
2025
1954
  child.on("close", (code) => resolve({ ok: code === 0, out, err: errOut }));
2026
- if (input !== undefined) {
1955
+ if (input !== void 0) {
2027
1956
  child.stdin?.write(input);
2028
1957
  child.stdin?.end();
2029
1958
  }
@@ -2032,8 +1961,7 @@ function run(cmd, input) {
2032
1961
  async function clipboardWrite(text) {
2033
1962
  const tools = platformTools("write");
2034
1963
  for (const cmd of tools) {
2035
- if ((await run(cmd, text)).ok)
2036
- return;
1964
+ if ((await run(cmd, text)).ok) return;
2037
1965
  }
2038
1966
  throw new Error(`No working clipboard tool found (${tools.map((t) => t[0]).join(", ")}).`);
2039
1967
  }
@@ -2042,37 +1970,35 @@ async function clipboardRead() {
2042
1970
  let lastErr = "";
2043
1971
  for (const cmd of tools) {
2044
1972
  const r = await run(cmd);
2045
- if (r.ok)
2046
- return r.out;
1973
+ if (r.ok) return r.out;
2047
1974
  lastErr = r.err;
2048
1975
  }
2049
1976
  throw new Error(`No working clipboard tool found (${tools.map((t) => t[0]).join(", ")}). ${lastErr}`);
2050
1977
  }
2051
1978
 
2052
1979
  // 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.` }] };
1980
+ function registerClipboardTool(server2) {
1981
+ server2.tool(
1982
+ "clipboard",
1983
+ `Read/write the machine's clipboard (the same one the user's Chrome uses). get \u2192 read what a site copied (codes, links); set <text> \u2192 write. To paste INTO a page field use the \`paste\` execute step, not a \u2318V keyboard step (the extension relay ignores modifier keys).`,
1984
+ {
1985
+ action: import_zod10.z.enum(["get", "set"]).describe("get: read clipboard text | set: write text to clipboard"),
1986
+ text: import_zod10.z.string().optional().describe("With action='set': the text to put on the clipboard.")
1987
+ },
1988
+ async ({ action, text }) => {
1989
+ try {
1990
+ if (action === "set") {
1991
+ if (text === void 0) return err("action='set' requires `text`.");
1992
+ await clipboardWrite(text);
1993
+ return { content: [{ type: "text", text: `Clipboard set (${text.length} chars). To paste into a field, use the \`paste\` execute step.` }] };
1994
+ }
1995
+ const value = await clipboardRead();
1996
+ return { content: [{ type: "text", text: value.length ? value : "(clipboard is empty)" }] };
1997
+ } catch (e) {
1998
+ return err(`clipboard failed: ${getErrorMessage(e)}`);
2069
1999
  }
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
2000
  }
2075
- });
2001
+ );
2076
2002
  }
2077
2003
 
2078
2004
  // src/mcp/telemetry.ts
@@ -2081,27 +2007,26 @@ var import_path11 = __toESM(require("path"));
2081
2007
  var CHARS_PER_TOKEN = 4;
2082
2008
  var est = (chars) => Math.round(chars / CHARS_PER_TOKEN);
2083
2009
  var enabled = process.env.IFRAMER_TELEMETRY !== "0";
2084
- var sessionId = `${new Date().toISOString().slice(0, 10)}-${process.pid}`;
2010
+ var sessionId = `${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}-${process.pid}`;
2085
2011
  function telemetryPath() {
2086
2012
  return import_path11.default.join(getDataDir(), "telemetry.jsonl");
2087
2013
  }
2088
2014
  var sessionCalls = 0;
2089
2015
  var sessionChars = 0;
2090
2016
  function write2(rec) {
2091
- if (!enabled)
2092
- return;
2017
+ if (!enabled) return;
2093
2018
  try {
2094
- import_fs9.default.appendFileSync(telemetryPath(), JSON.stringify({ ts: new Date().toISOString(), session: sessionId, ...rec }) + `
2095
- `);
2096
- } catch {}
2019
+ import_fs9.default.appendFileSync(telemetryPath(), JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), session: sessionId, ...rec }) + "\n");
2020
+ } catch {
2021
+ }
2097
2022
  }
2098
- function recordDefinitions(toolCount, defChars, instructionChars) {
2023
+ function recordDefinitions(toolCount, defChars2, instructionChars) {
2099
2024
  write2({
2100
2025
  kind: "definitions",
2101
2026
  toolCount,
2102
- defChars,
2027
+ defChars: defChars2,
2103
2028
  instructionChars,
2104
- estTokens: est(defChars + instructionChars)
2029
+ estTokens: est(defChars2 + instructionChars)
2105
2030
  });
2106
2031
  }
2107
2032
  function recordCall(tool, inChars, outChars, ms, isError) {
@@ -2121,20 +2046,17 @@ function recordCall(tool, inChars, outChars, ms, isError) {
2121
2046
  }
2122
2047
  function contentChars(res) {
2123
2048
  const r = res;
2124
- if (!r || !Array.isArray(r.content))
2125
- return 0;
2049
+ if (!r || !Array.isArray(r.content)) return 0;
2126
2050
  let n = 0;
2127
2051
  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;
2052
+ if (typeof c?.text === "string") n += c.text.length;
2053
+ else if (typeof c?.data === "string") n += c.data.length;
2132
2054
  }
2133
2055
  return n;
2134
2056
  }
2135
2057
  function safeJsonLen(v) {
2136
2058
  try {
2137
- return v === undefined ? 0 : JSON.stringify(v).length;
2059
+ return v === void 0 ? 0 : JSON.stringify(v).length;
2138
2060
  } catch {
2139
2061
  return 0;
2140
2062
  }
@@ -2142,7 +2064,7 @@ function safeJsonLen(v) {
2142
2064
 
2143
2065
  // src/mcp/server.ts
2144
2066
  var IS_DEV = process.env.IFRAMER_URL?.includes("localhost") || process.env.IFRAMER_URL?.includes("127.0.0.1");
2145
- var INSTRUCTIONS = IS_DEV ? `iframer-dev — local development instance of iframer (connects to ${BASE_URL}).
2067
+ var INSTRUCTIONS = IS_DEV ? `iframer-dev \u2014 local development instance of iframer (connects to ${BASE_URL}).
2146
2068
 
2147
2069
  CRITICAL RULES:
2148
2070
  1. NEVER ask the user "do you have credentials?". Call credentials action=list and read the response.
@@ -2151,8 +2073,8 @@ CRITICAL RULES:
2151
2073
  4. If execute fails, read the error. It tells you exactly what happened and where.
2152
2074
  5. Call "session" action=stop when done to save session state.
2153
2075
 
2154
- CAPTCHA: Use the "solve-captcha" step — auto-detects reCAPTCHA/hCaptcha and solves with vision AI.
2155
- REVERSE ENGINEERING: Use the "reverse-engineer" tool when the user asks to capture/map APIs.` : `iframer — browser access for AI agents when normal methods fail.
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.
2156
2078
 
2157
2079
  CRITICAL RULES:
2158
2080
  1. NEVER ask "do you have credentials?". Call credentials action=list and read the response.
@@ -2160,17 +2082,20 @@ CRITICAL RULES:
2160
2082
  3. DO NOT present options when you can just act. Check status, credentials, execute.
2161
2083
  4. NEVER re-store credentials when login fails. Login failures are browser-mode problems, not credential problems.
2162
2084
  5. ALWAYS check "knowledge get <domain>" before launching a browser. Skip the browser if the cache has what you need.
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).
2085
+ 6. For UI tasks on a site, check "remember get <domain>" first \u2014 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
2086
  7. If execute fails, read the FULL error. It tells you the step, error type, and suggestion.
2165
2087
  7. If the browser crashes, call "session restart" and retry. Don't panic.
2166
2088
  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.
2167
2089
 
2168
- BROWSER MODES: Don't specify options.mode — iframer auto-selects and auto-escalates (headless → binary-headful). Only set a mode if the user explicitly asks.
2090
+ 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.
2169
2091
 
2170
2092
  CAPTCHA: In binary-headful mode, ask the user to solve it in the visible window. In docker-headful mode, use the "solve-captcha" step.
2171
2093
 
2172
2094
  REVERSE ENGINEERING: Use the "reverse-engineer" tool when the user asks to capture/map/save API endpoints.`;
2173
- var server = new import_mcp.McpServer({ name: "iframer", version: "3.0.0" }, { instructions: INSTRUCTIONS });
2095
+ var server = new import_mcp.McpServer(
2096
+ { name: "iframer", version: "3.0.0" },
2097
+ { instructions: INSTRUCTIONS }
2098
+ );
2174
2099
  var defChars = 0;
2175
2100
  var defCount = 0;
2176
2101
  {
@@ -2210,21 +2135,22 @@ process.stdin.on("end", () => process.exit(0));
2210
2135
  process.stdin.on("close", () => process.exit(0));
2211
2136
  var originalPpid = process.ppid;
2212
2137
  var orphanCheck = setInterval(() => {
2213
- if (process.ppid !== originalPpid || process.ppid === 1)
2214
- process.exit(0);
2215
- }, 30000);
2138
+ if (process.ppid !== originalPpid || process.ppid === 1) process.exit(0);
2139
+ }, 3e4);
2216
2140
  orphanCheck.unref?.();
2217
2141
  process.on("uncaughtException", (err2) => {
2218
2142
  try {
2219
2143
  console.error(`[mcp] uncaughtException: ${err2?.message}`);
2220
- } catch {}
2144
+ } catch {
2145
+ }
2221
2146
  });
2222
2147
  process.on("unhandledRejection", (reason) => {
2223
2148
  try {
2224
2149
  console.error(`[mcp] unhandledRejection: ${reason}`);
2225
- } catch {}
2150
+ } catch {
2151
+ }
2226
2152
  });
2227
- var transport = new import_stdio.StdioServerTransport;
2153
+ var transport = new import_stdio.StdioServerTransport();
2228
2154
  (async () => {
2229
2155
  await server.connect(transport);
2230
2156
  })();