iframer-toolkit 0.9.0 → 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
- var PORT_SCAN_ATTEMPTS = 200;
143
- var STARTUP_TIMEOUT_MS = 15000;
131
+ var PORT_SCAN_ATTEMPTS = 21;
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,71 +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
+ }
262
253
  }
263
254
  }
264
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 {
267
+ }
268
+ const parent = import_path4.default.dirname(dir);
269
+ if (parent === dir) break;
270
+ dir = parent;
271
+ }
272
+ return import_path4.default.join(__dirname, "..", "..");
273
+ }
265
274
  resolveRuntime() {
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");
278
+ if (import_fs3.default.existsSync(serverCjs)) {
279
+ return { command: "node", args: [serverCjs] };
280
+ }
281
+ if (import_fs3.default.existsSync(serverTs)) {
282
+ try {
283
+ require.resolve("tsx");
284
+ return { command: "node", args: ["--import", "tsx", serverTs] };
285
+ } catch {
286
+ }
287
+ }
266
288
  try {
267
289
  const bunPath = require("child_process").execSync("which bun", { encoding: "utf8" }).trim();
268
- const serverTs2 = import_path4.default.join(__dirname, "..", "..", "index.ts");
269
- if (import_fs3.default.existsSync(serverTs2)) {
270
- return { command: bunPath, args: ["run", serverTs2] };
290
+ if (bunPath && import_fs3.default.existsSync(serverTs)) {
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");
292
+ return { command: bunPath, args: ["run", serverTs] };
271
293
  }
272
- } catch {}
273
- const serverCjs = import_path4.default.join(__dirname, "..", "..", "dist", "local-server.cjs");
274
- if (import_fs3.default.existsSync(serverCjs)) {
275
- return { command: "node", args: [serverCjs] };
294
+ } catch {
276
295
  }
277
- const serverTs = import_path4.default.join(__dirname, "..", "..", "index.ts");
278
- return { command: "node", args: ["--import", "tsx", serverTs] };
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
+ );
279
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. */
280
302
  async restart() {
281
303
  const info = readServerInfo();
282
304
  if (info && this.baseUrl) {
283
305
  try {
284
- await fetch(`${this.baseUrl}/shutdown`, { method: "POST", signal: AbortSignal.timeout(3000) });
285
- } catch {}
286
- 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;
287
310
  while (Date.now() < deadline && isPidAlive(info.pid)) {
288
311
  await sleep(200);
289
312
  }
290
313
  if (isPidAlive(info.pid)) {
291
314
  try {
292
315
  process.kill(info.pid, "SIGKILL");
293
- } catch {}
316
+ } catch {
317
+ }
294
318
  }
295
319
  }
296
320
  this.baseUrl = "";
297
321
  await this.ensureRunning();
298
322
  }
299
- 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
+ }
300
327
  readLogTail() {
301
328
  try {
302
329
  const content = import_fs3.default.readFileSync(this.logPath, "utf8");
303
- const lines = content.trim().split(`
304
- `);
305
- return lines.slice(-10).join(`
306
- `);
330
+ const lines = content.trim().split("\n");
331
+ return lines.slice(-10).join("\n");
307
332
  } catch {
308
333
  return "(no log file)";
309
334
  }
310
335
  }
311
- }
336
+ };
312
337
  function sleep(ms) {
313
338
  return new Promise((r) => setTimeout(r, ms));
314
339
  }
315
340
  async function healthCheck(baseUrl) {
316
341
  try {
317
- const res = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(2000) });
342
+ const res = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(2e3) });
318
343
  const data = await res.json();
319
344
  return data.ok === true;
320
345
  } catch {
@@ -343,8 +368,8 @@ function findFreePort(start, attempts) {
343
368
  }
344
369
 
345
370
  // src/lib/errors.ts
346
- function getErrorMessage(err) {
347
- return err instanceof Error ? err.message : String(err);
371
+ function getErrorMessage(err2) {
372
+ return err2 instanceof Error ? err2.message : String(err2);
348
373
  }
349
374
 
350
375
  // src/mcp/helpers.ts
@@ -353,56 +378,55 @@ var BASE_URL = process.env.IFRAMER_URL || "http://localhost:3021";
353
378
  var IFRAMER_SECRET = process.env.IFRAMER_SECRET;
354
379
  var IFRAMER_MODE = process.env.IFRAMER_MODE;
355
380
  var LOCAL_TOKEN = getLocalToken();
356
- var localServer = new LocalServerManager;
381
+ var localServer = new LocalServerManager();
357
382
  async function ensureLocalServer() {
358
383
  await localServer.ensureRunning();
359
384
  }
360
385
  function authHeaders(secret) {
361
386
  const headers = { "Content-Type": "application/json" };
362
387
  const key = secret || IFRAMER_SECRET;
363
- if (key)
364
- headers["x-api-key"] = key;
388
+ if (key) headers["x-api-key"] = key;
365
389
  return headers;
366
390
  }
367
391
  async function apiPost(endpoint, body) {
368
392
  const res = await fetch(`${BASE_URL}${endpoint}`, {
369
393
  method: "POST",
370
394
  headers: authHeaders(),
371
- body: body ? JSON.stringify(body) : undefined,
372
- signal: AbortSignal.timeout(180000)
395
+ body: body ? JSON.stringify(body) : void 0,
396
+ signal: AbortSignal.timeout(18e4)
373
397
  });
374
398
  return res.json();
375
399
  }
376
400
  async function apiGet(endpoint) {
377
- const res = await fetch(`${BASE_URL}${endpoint}`, { headers: authHeaders() });
401
+ const res = await fetch(`${BASE_URL}${endpoint}`, { headers: authHeaders(), signal: AbortSignal.timeout(3e4) });
378
402
  return res.json();
379
403
  }
380
- async function localApiPost(endpoint, body) {
404
+ async function localApiPost(endpoint, body, timeoutMs = 18e4) {
381
405
  await ensureLocalServer();
382
406
  const url = localServer.getBaseUrl();
383
407
  const res = await fetch(`${url}${endpoint}`, {
384
408
  method: "POST",
385
409
  headers: authHeaders(LOCAL_TOKEN),
386
- body: body ? JSON.stringify(body) : undefined,
387
- signal: AbortSignal.timeout(180000)
410
+ body: body ? JSON.stringify(body) : void 0,
411
+ signal: AbortSignal.timeout(timeoutMs)
388
412
  });
389
413
  return res.json();
390
414
  }
391
415
  async function localApiGet(endpoint) {
392
416
  await ensureLocalServer();
393
417
  const url = localServer.getBaseUrl();
394
- const res = await fetch(`${url}${endpoint}`, { headers: authHeaders(LOCAL_TOKEN) });
418
+ const res = await fetch(`${url}${endpoint}`, { headers: authHeaders(LOCAL_TOKEN), signal: AbortSignal.timeout(3e4) });
395
419
  return res.json();
396
420
  }
397
421
  async function localApiDelete(endpoint) {
398
422
  await ensureLocalServer();
399
423
  const url = localServer.getBaseUrl();
400
- const res = await fetch(`${url}${endpoint}`, { method: "DELETE", headers: authHeaders(LOCAL_TOKEN) });
424
+ const res = await fetch(`${url}${endpoint}`, { method: "DELETE", headers: authHeaders(LOCAL_TOKEN), signal: AbortSignal.timeout(3e4) });
401
425
  return res.json();
402
426
  }
403
427
  async function isDockerRunning() {
404
428
  try {
405
- const res = await fetch(`${BASE_URL}/health`, { signal: AbortSignal.timeout(3000) });
429
+ const res = await fetch(`${BASE_URL}/health`, { signal: AbortSignal.timeout(3e3) });
406
430
  const data = await res.json();
407
431
  return data.ok === true;
408
432
  } catch {
@@ -414,20 +438,18 @@ async function resolveScreenshotPath(url) {
414
438
  if (url.startsWith("file://")) {
415
439
  const { fileURLToPath } = await import("url");
416
440
  const filePath2 = fileURLToPath(url);
417
- const fs5 = await import("fs");
418
- if (fs5.existsSync(filePath2))
419
- return filePath2;
441
+ const fs11 = await import("fs");
442
+ if (fs11.existsSync(filePath2)) return filePath2;
420
443
  return null;
421
444
  }
422
445
  const res = await fetch(url);
423
- if (!res.ok)
424
- return null;
446
+ if (!res.ok) return null;
425
447
  const buf = Buffer.from(await res.arrayBuffer());
426
- const fs4 = await import("fs");
448
+ const fs10 = await import("fs");
427
449
  const dir = import_path5.default.join(import_os3.default.tmpdir(), "iframer-screenshots", "screenshots");
428
- fs4.mkdirSync(dir, { recursive: true });
450
+ fs10.mkdirSync(dir, { recursive: true });
429
451
  const filePath = import_path5.default.join(dir, `docker-${Date.now()}.jpg`);
430
- fs4.writeFileSync(filePath, buf);
452
+ fs10.writeFileSync(filePath, buf);
431
453
  return filePath;
432
454
  } catch {
433
455
  return null;
@@ -446,24 +468,23 @@ function defaultFile() {
446
468
  }
447
469
  var TTL_DAYS = 14;
448
470
  var ESCALATION_LADDER = ["headless", "docker-headful", "binary-headful"];
449
-
450
- class DomainModeStore {
471
+ var DomainModeStore = class {
451
472
  data = {};
452
473
  filePath;
453
474
  constructor(filePath = defaultFile()) {
454
475
  this.filePath = filePath;
455
476
  this.load();
456
477
  }
478
+ /** Get the recommended mode for a domain, or null if unknown/expired */
457
479
  getMode(domain) {
458
480
  const entry = this.data[domain];
459
- if (!entry)
460
- return null;
461
- if (this.isExpired(entry))
462
- return null;
481
+ if (!entry) return null;
482
+ if (this.isExpired(entry)) return null;
463
483
  return entry.mode;
464
484
  }
485
+ /** Record a successful access */
465
486
  recordSuccess(domain, mode) {
466
- const now = new Date().toISOString();
487
+ const now = (/* @__PURE__ */ new Date()).toISOString();
467
488
  const existing = this.data[domain];
468
489
  this.data[domain] = {
469
490
  mode,
@@ -475,8 +496,9 @@ class DomainModeStore {
475
496
  };
476
497
  this.save();
477
498
  }
499
+ /** Record a blocked attempt */
478
500
  recordFailure(domain, mode, reason) {
479
- const now = new Date().toISOString();
501
+ const now = (/* @__PURE__ */ new Date()).toISOString();
480
502
  const existing = this.data[domain];
481
503
  this.data[domain] = {
482
504
  mode: existing?.mode || mode,
@@ -488,26 +510,28 @@ class DomainModeStore {
488
510
  };
489
511
  this.save();
490
512
  }
513
+ /** Get the next mode to try after a failure, or null if exhausted */
491
514
  getNextMode(failedMode, availableModes) {
492
515
  const idx = ESCALATION_LADDER.indexOf(failedMode);
493
- for (let i = idx + 1;i < ESCALATION_LADDER.length; i++) {
516
+ for (let i = idx + 1; i < ESCALATION_LADDER.length; i++) {
494
517
  if (availableModes.includes(ESCALATION_LADDER[i])) {
495
518
  return ESCALATION_LADDER[i];
496
519
  }
497
520
  }
498
521
  return null;
499
522
  }
523
+ /** Get the best starting mode: domain memory > first available in ladder */
500
524
  getBestMode(domain, availableModes) {
501
525
  const remembered = this.getMode(domain);
502
526
  if (remembered && availableModes.includes(remembered)) {
503
527
  return remembered;
504
528
  }
505
529
  for (const mode of ESCALATION_LADDER) {
506
- if (availableModes.includes(mode))
507
- return mode;
530
+ if (availableModes.includes(mode)) return mode;
508
531
  }
509
532
  return "headless";
510
533
  }
534
+ /** Get summary stats for the status tool */
511
535
  getSummary() {
512
536
  const entries = Object.entries(this.data).filter(([, e]) => !this.isExpired(e)).sort(([, a], [, b]) => b.lastSuccess.localeCompare(a.lastSuccess));
513
537
  return {
@@ -516,10 +540,9 @@ class DomainModeStore {
516
540
  };
517
541
  }
518
542
  isExpired(entry) {
519
- if (!entry.lastSuccess)
520
- return true;
543
+ if (!entry.lastSuccess) return true;
521
544
  const age = Date.now() - new Date(entry.lastSuccess).getTime();
522
- return age > TTL_DAYS * 24 * 60 * 60 * 1000;
545
+ return age > TTL_DAYS * 24 * 60 * 60 * 1e3;
523
546
  }
524
547
  load() {
525
548
  try {
@@ -538,81 +561,95 @@ class DomainModeStore {
538
561
  log3.error("Failed to save:", err2);
539
562
  }
540
563
  }
541
- }
564
+ };
542
565
 
543
566
  // src/mcp/tools/status.ts
544
- function registerStatusTool(server) {
545
- 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 () => {
546
- try {
547
- const status = {};
548
- const dockerRunning = await isDockerRunning();
549
- 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 () => {
550
573
  try {
551
- const localHealth = await localApiGet("/health");
552
- status.localServer = localHealth.ok;
553
- } catch {
554
- status.localServer = false;
555
- }
556
- try {
557
- const browserHealth = await localApiGet("/browser/health");
558
- status.browserAlive = browserHealth.alive;
559
- status.runningModes = browserHealth.modes;
560
- } catch {}
561
- if (dockerRunning) {
574
+ const status = {};
575
+ const dockerRunning = await isDockerRunning();
576
+ status.dockerApi = dockerRunning;
562
577
  try {
563
- const sessionData = await apiGet("/interactive/status");
564
- status.dockerSession = sessionData.active ? { active: true, noVncUrl: sessionData.noVncUrl } : { active: false };
565
- } catch {}
566
- }
567
- try {
568
- const credData = await localApiGet("/credentials");
569
- status.credentials = credData.domains || [];
570
- } catch {
571
- 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)}`);
572
615
  }
573
- try {
574
- const domainModes = new DomainModeStore;
575
- status.domainMemory = domainModes.getSummary();
576
- } catch {}
577
- return { content: [{ type: "text", text: JSON.stringify(status, null, 2) }] };
578
- } catch (e) {
579
- return err(`Error: ${getErrorMessage(e)}`);
580
616
  }
581
- });
617
+ );
582
618
  }
583
619
 
584
620
  // src/mcp/tools/browse.ts
585
621
  var import_zod = require("zod");
586
- function registerBrowseTool(server) {
587
- 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.
588
-
589
- 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.`, {
590
- url: import_zod.z.string().describe("URL to navigate to"),
591
- extract: import_zod.z.string().optional().describe("JavaScript expression to evaluate (e.g. 'document.title')"),
592
- actions: import_zod.z.array(import_zod.z.object({
593
- type: import_zod.z.enum(["click", "fill", "wait", "scroll", "human-click", "human-type"]),
594
- selector: import_zod.z.string().optional(),
595
- value: import_zod.z.string().optional(),
596
- ms: import_zod.z.number().optional()
597
- })).optional().describe("Actions to execute before extracting"),
598
- returnHtml: import_zod.z.boolean().optional().describe("Return full page HTML"),
599
- waitForSelector: import_zod.z.string().optional().describe("Wait for this CSS selector before proceeding"),
600
- sessionless: import_zod.z.boolean().optional().describe("Skip session persistence")
601
- }, async (params) => {
602
- try {
603
- const fetchResult = await localApiPost("/fetch", params);
604
- if (!fetchResult.ok)
605
- return err(`Error: ${fetchResult.error}`);
606
- const { html, ...rest } = fetchResult;
607
- const text = html ? JSON.stringify(rest, null, 2) + `
608
-
609
- --- HTML ---
610
- ` + html : JSON.stringify(rest, null, 2);
611
- return { content: [{ type: "text", text }] };
612
- } catch (e) {
613
- 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
+ }
614
651
  }
615
- });
652
+ );
616
653
  }
617
654
 
618
655
  // src/mcp/tools/execute.ts
@@ -623,184 +660,79 @@ var import_zod2 = require("zod");
623
660
  var stepSchema = import_zod2.z.discriminatedUnion("type", [
624
661
  import_zod2.z.object({ type: import_zod2.z.literal("navigate"), url: import_zod2.z.string(), waitUntil: import_zod2.z.string().optional() }),
625
662
  import_zod2.z.object({ type: import_zod2.z.literal("click"), selector: import_zod2.z.string() }),
626
- import_zod2.z.object({ type: import_zod2.z.literal("fill"), selector: import_zod2.z.string(), value: import_zod2.z.string() }),
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.") }),
627
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() }),
628
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() }),
629
- 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.") }),
630
667
  import_zod2.z.object({ type: import_zod2.z.literal("evaluate"), expression: import_zod2.z.string() }),
631
668
  import_zod2.z.object({ type: import_zod2.z.literal("extract"), expression: import_zod2.z.string() }),
632
669
  import_zod2.z.object({ type: import_zod2.z.literal("wait"), ms: import_zod2.z.number() }),
633
670
  import_zod2.z.object({ type: import_zod2.z.literal("wait-for"), selector: import_zod2.z.string(), timeout: import_zod2.z.number().optional() }),
634
- import_zod2.z.object({ type: import_zod2.z.literal("scroll"), deltaY: import_zod2.z.number().optional() }),
635
- import_zod2.z.object({ type: import_zod2.z.literal("keyboard"), key: import_zod2.z.string() }),
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)") }),
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() }),
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>)") }),
636
678
  import_zod2.z.object({ type: import_zod2.z.literal("type-code"), value: import_zod2.z.string(), selector: import_zod2.z.string().optional() }),
637
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() }),
638
680
  import_zod2.z.object({ type: import_zod2.z.literal("solve-captcha") }),
639
- 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.") }),
640
- 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)") }),
641
- 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)") }),
642
- import_zod2.z.object({ type: import_zod2.z.literal("recaptcha-click") }),
643
- import_zod2.z.object({ type: import_zod2.z.literal("recaptcha-select"), tiles: import_zod2.z.array(import_zod2.z.number()) }),
644
- import_zod2.z.object({ type: import_zod2.z.literal("recaptcha-verify") }),
645
- import_zod2.z.object({ type: import_zod2.z.literal("recaptcha-info") }),
646
- import_zod2.z.object({ type: import_zod2.z.literal("recaptcha-solve") }),
647
- 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") })
648
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
+ }
649
697
 
650
- // src/mcp/tools/execute.ts
698
+ // src/lib/format-result.ts
651
699
  function resultOf(r, _type) {
652
700
  return r.result;
653
701
  }
654
- function registerExecuteTool(server) {
655
- server.tool("execute", `Execute a pipeline of browser steps. Auto-starts a session if needed. Handles obstacles (captcha, cookie banners) automatically.
656
-
657
- 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.
658
-
659
- 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.
660
-
661
- Key step types:
662
- - navigate: go to a URL (obstacle detection runs after this)
663
- - 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.
664
- - find: locate a specific element by role, name, text, placeholder, or label. Returns a ref.
665
- - screenshot: take a screenshot. Add annotate=true to overlay numbered badges on interactive elements.
666
- - extract: evaluate JS and include the result in the response
667
- - solve-captcha: auto-detect and solve reCAPTCHA/hCaptcha with vision AI
668
- - login: fill login form with stored credentials (never exposes passwords). Handles email-first flows (Slack, Microsoft, Google) and standard forms.
669
-
670
- IMPORTANT — Element refs (@e1, @e2...): All selector fields accept @e refs from snapshot, find, or annotated screenshot. PREFER refs over CSS selectors.
671
-
672
- Returns: ok, completedSteps, results, obstacles, capturedApi, and on failure: errorContext with screenshot path, URL, errorType, suggestion, retryable.`, {
673
- steps: import_zod3.z.array(stepSchema).describe("Pipeline steps to execute sequentially"),
674
- options: import_zod3.z.object({
675
- staleTimeoutMs: import_zod3.z.number().optional().describe("Override the 20s stale-state timeout per step"),
676
- screenshotAfterEach: import_zod3.z.boolean().optional().describe("Take a screenshot after every step (expensive)"),
677
- continueOnObstacle: import_zod3.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
678
- continueOnError: import_zod3.z.boolean().optional().describe("Continue past failing steps (default: false)"),
679
- captureApi: import_zod3.z.boolean().optional().describe("Record all API calls (XHR/fetch) the page makes."),
680
- mode: import_zod3.z.enum(["headless", "binary-headful", "docker-headful"]).optional().describe("DO NOT SET THIS unless user explicitly requests a mode. iframer auto-selects and auto-escalates."),
681
- autoEscalate: import_zod3.z.boolean().optional().describe("Auto-retry with a stronger mode if blocked (default: true)"),
682
- instanceId: import_zod3.z.string().optional().describe("Run in a named parallel browser within this session (default: 'default'). Use distinct ids to drive several browsers at once, e.g. one per account — each keeps its own login/session state.")
683
- }).optional()
684
- }, async (params) => {
685
- try {
686
- const dockerRunning = await isDockerRunning();
687
- async function runWithMode(mode) {
688
- if (mode === "docker-headful") {
689
- if (!dockerRunning) {
690
- return {
691
- ok: false,
692
- completedSteps: 0,
693
- totalSteps: params.steps.length,
694
- results: [],
695
- finalState: { url: "", title: "" },
696
- obstacles: [],
697
- durationMs: 0,
698
- modeUsed: "docker-headful",
699
- error: {
700
- failedAtStep: 0,
701
- failedStep: params.steps[0],
702
- errorType: "action-failed",
703
- message: "docker-headful mode was requested but the Docker API is not reachable.",
704
- pageState: { url: "", title: "" },
705
- suggestion: "Start Docker with `bun run start:docker`, or omit options.mode.",
706
- retryable: false
707
- }
708
- };
709
- }
710
- return apiPost("/execute", {
711
- steps: params.steps,
712
- options: { ...params.options, mode: "docker-headful", autoEscalate: false }
713
- });
714
- }
715
- return localApiPost("/execute", {
716
- steps: params.steps,
717
- options: { ...params.options, mode: mode || undefined }
718
- });
719
- }
720
- let execResult;
721
- try {
722
- execResult = await runWithMode(params.options?.mode);
723
- } catch (execErr) {
724
- const msg = execErr instanceof Error ? execErr.message : String(execErr);
725
- const isCrash = /ECONNREFUSED|ECONNRESET|EPIPE|socket hang up|fetch failed/i.test(msg);
726
- if (isCrash) {
727
- log2.info(`Execute crashed (${msg.slice(0, 80)}), restarting local server and retrying...`);
728
- try {
729
- await localServer.restart();
730
- } catch {}
731
- try {
732
- execResult = await runWithMode(params.options?.mode);
733
- } catch (retryErr) {
734
- return err(`Browser server crashed and retry also failed.
735
- ` + `Original: ${msg}
736
- Retry: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}
737
-
738
- ` + `Call \`session restart\` to reset, then retry.`);
739
- }
740
- } else {
741
- throw execErr;
742
- }
743
- }
744
- const requestedMode = params.options?.mode;
745
- if (!execResult.ok && execResult.error?.errorType === "bot-blocked" && params.options?.autoEscalate !== false && !requestedMode) {
746
- const escalation = ["docker-headful", "binary-headful"];
747
- for (const nextMode of escalation) {
748
- if (nextMode === "docker-headful" && !dockerRunning)
749
- continue;
750
- log2.info(`Auto-escalating to ${nextMode}`);
751
- execResult = await runWithMode(nextMode);
752
- if (execResult.ok)
753
- break;
754
- if (execResult.error?.errorType !== "bot-blocked")
755
- break;
756
- }
757
- }
758
- const lines = formatExecuteResult(execResult);
759
- let screenshotUrl = null;
760
- if (execResult.error) {
761
- screenshotUrl = execResult.error.pageState?.screenshotUrl ?? null;
762
- } else {
763
- screenshotUrl = execResult.finalState?.screenshotUrl ?? null;
764
- }
765
- if (screenshotUrl) {
766
- const filePath = await resolveScreenshotPath(screenshotUrl);
767
- if (filePath) {
768
- lines.push(`
769
- Screenshot saved: ${filePath}`);
770
- lines.push("Use the Read tool on the path above to view the screenshot.");
771
- }
772
- }
773
- const content = [{ type: "text", text: lines.join(`
774
- `) }];
775
- if (!execResult.ok)
776
- return { content, isError: true };
777
- return { content };
778
- } catch (e) {
779
- return err(`Error: ${getErrorMessage(e)}`);
780
- }
781
- });
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
+ );
782
717
  }
783
718
  function formatExecuteResult(data) {
784
719
  const lines = [];
785
720
  lines.push(`ok: ${data.ok}`);
786
721
  lines.push(`steps: ${data.completedSteps}/${data.totalSteps}`);
787
- if (data.durationMs)
788
- lines.push(`duration: ${data.durationMs}ms`);
789
- if (data.modeUsed)
790
- 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)" : ""}`);
791
724
  if (data.finalState) {
792
725
  lines.push(`
793
726
  Final page: ${data.finalState.title}`);
794
727
  lines.push(`URL: ${data.finalState.url}`);
795
728
  }
796
- if (data.ok && data.finalState?.url) {
797
- try {
798
- const domain = new URL(data.finalState.url).hostname.replace(/^www\./, "");
729
+ for (const r of data.results || []) {
730
+ if (r.tabSwitchedTo) {
799
731
  lines.push(`
800
- Knowledge cache updated for ${domain}. Before the next browser run on this domain, call \`knowledge get ${domain}\` — you may be able to skip the browser entirely.`);
801
- } catch {}
732
+ \u21B3 step ${r.stepIndex} opened a new tab \u2014 pipeline is now on: ${r.tabSwitchedTo}`);
733
+ }
802
734
  }
803
- 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);
804
736
  for (const r of meaningful) {
805
737
  if (r.step.type === "snapshot") {
806
738
  const res = resultOf(r, "snapshot");
@@ -822,21 +754,26 @@ Found: ${res.ref} ${res.role} "${res.name}" (${res.matchCount} match${res.matchC
822
754
  --- Annotated screenshot refs ---`);
823
755
  lines.push(res.refs);
824
756
  }
825
- } else if (r.result !== undefined && r.result !== null) {
757
+ } else if (r.step.type === "read") {
758
+ const res = r.result;
759
+ if (res?.text !== void 0) {
760
+ lines.push(`
761
+ --- Read (step ${r.stepIndex}${res.truncated ? ", truncated" : ""}) ---`);
762
+ lines.push(res.text);
763
+ }
764
+ } else if (r.step.type === "extract" || r.step.type === "evaluate") {
826
765
  lines.push(`
827
- step ${r.stepIndex}: ${JSON.stringify(r.result)}`);
766
+ step ${r.stepIndex} (${r.step.type}): ${JSON.stringify(r.result)}`);
828
767
  }
829
768
  }
830
769
  if (data.obstacles && data.obstacles.length > 0) {
831
- lines.push(`
832
- Obstacles handled:`);
770
+ lines.push("\nObstacles handled:");
833
771
  for (const o of data.obstacles) {
834
772
  lines.push(` [step ${o.detectedAtStep}] ${o.type}: ${o.resolved ? o.resolution : "UNRESOLVED - " + (o.resolution || "unknown")}`);
835
773
  }
836
774
  }
837
775
  if (data.capturedApi && data.capturedApi.length > 0) {
838
- lines.push(`
839
- --- Captured API ---`);
776
+ lines.push("\n--- Captured API ---");
840
777
  for (const api of data.capturedApi) {
841
778
  lines.push(`
842
779
  ${api.domain} (${api.baseUrl})`);
@@ -847,8 +784,7 @@ ${api.domain} (${api.baseUrl})`);
847
784
  }
848
785
  }
849
786
  if (data.error) {
850
- lines.push(`
851
- --- Failure ---`);
787
+ lines.push("\n--- Failure ---");
852
788
  if (typeof data.error === "string") {
853
789
  lines.push(`Error: ${data.error}`);
854
790
  } else {
@@ -856,15 +792,173 @@ ${api.domain} (${api.baseUrl})`);
856
792
  lines.push(`Error type: ${data.error.errorType}`);
857
793
  lines.push(`Message: ${data.error.message}`);
858
794
  lines.push(`Retryable: ${data.error.retryable}`);
859
- if (data.error.suggestion)
860
- lines.push(`Suggestion: ${data.error.suggestion}`);
861
- if (data.error.pageState?.url)
862
- 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}`);
863
797
  }
864
798
  }
799
+ if (captchaBlocked(data)) {
800
+ lines.push(RECAPTCHA_MANUAL);
801
+ }
865
802
  return lines;
866
803
  }
867
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
+
868
962
  // src/mcp/tools/session.ts
869
963
  var import_zod5 = require("zod");
870
964
  var import_path7 = __toESM(require("path"));
@@ -872,148 +966,121 @@ var import_fs5 = __toESM(require("fs"));
872
966
 
873
967
  // src/mcp/tools/reverse-engineer.ts
874
968
  var import_zod4 = require("zod");
875
- function registerReverseEngineerTool(server) {
876
- 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.
877
-
878
- Use this when the user asks to:
879
- - "reverse engineer" or "map" a site's API
880
- - "capture the endpoints" or "save the API"
881
- - "figure out how this site works under the hood"
882
- - "record the API calls" so they can be replayed later
883
-
884
- How it works:
885
- 1. You provide steps (same as execute) — navigate, click, fill, extract, etc.
886
- 2. iframer runs them while recording all API calls the page makes
887
- 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
888
-
889
- IMPORTANT — one request ≠ one endpoint. Each endpoint has (protocol, action):
890
- - REST: action = "METHOD /parameterized/path". Verb from HTTP method.
891
- - GraphQL: action = operationName (or doc_id for persisted queries). Many ops share the single /graphql URL — EACH operation is its own endpoint.
892
- - JSON-RPC: action = body.method (e.g. eth_getBalance, user.list).
893
- - gRPC-web: action = request path.
894
- - Form-RPC (FB-style urlencoded): action = fb_api_req_friendly_name / doc_id.
895
- - SOAP: action = SOAPAction header.
896
-
897
- Generate ONE function per (protocol, action). Do NOT merge different GraphQL operations into one function just because they share the URL.
898
-
899
- Output layout — save as RUNNABLE CODE to <outputDir>/:
900
- auth.{js,ts} — shared cookies, tokens, authorization
901
- transport/
902
- rest.{js,ts} — shared REST helper (only if any rest endpoints)
903
- graphql.{js,ts} — shared GraphQL client: post(operationName|docId, variables)
904
- jsonRpc.{js,ts} — shared JSON-RPC client (if any)
905
- grpc.{js,ts} — shared gRPC-web client (if any)
906
- <protocol>/<verb>/<functionName>.{js,ts}
907
- e.g. graphql/queries/getTimelineFeed.ts
908
- graphql/mutations/reactToPost.ts
909
- rest/read/getChannelMessages.ts
910
- rest/create/createMessage.ts
911
- jsonRpc/ethGetBalance.ts
912
- index.{js,ts} — re-exports all endpoint functions
913
- types.ts — (typed mode only) inferred interfaces from responses
914
- README.md — endpoints grouped by protocol + verb, dependency chain, auth expiry warning
915
-
916
- 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.
917
-
918
- The outputDir defaults to ./<domain>/. Ask the user where to save if unclear.`, {
919
- steps: import_zod4.z.array(stepSchema).describe("Pipeline steps to execute while capturing API calls"),
920
- outputDir: import_zod4.z.string().optional().describe("Directory to save the captured API files. If not provided, ask the user or default to ./<domain>/"),
921
- 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."),
922
- options: import_zod4.z.object({
923
- staleTimeoutMs: import_zod4.z.number().optional().describe("Override the 20s stale-state timeout per step"),
924
- continueOnObstacle: import_zod4.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
925
- continueOnError: import_zod4.z.boolean().optional().describe("Continue past failing steps (default: false)"),
926
- mode: import_zod4.z.enum(["headless", "binary-headful", "docker-headful"]).optional().describe("Browser mode override")
927
- }).optional()
928
- }, async (params) => {
929
- try {
930
- const execParams = {
931
- steps: params.steps,
932
- options: { ...params.options, captureApi: true }
933
- };
934
- const mode = params.options?.mode;
935
- const dockerRunning = await isDockerRunning();
936
- const captureResult = mode === "docker-headful" && dockerRunning ? await apiPost("/execute", execParams) : await localApiPost("/execute", execParams);
937
- const lines = [];
938
- lines.push(`ok: ${captureResult.ok}`);
939
- lines.push(`steps: ${captureResult.completedSteps}/${captureResult.totalSteps}`);
940
- if (captureResult.durationMs)
941
- lines.push(`duration: ${captureResult.durationMs}ms`);
942
- if (captureResult.finalState) {
943
- lines.push(`
944
- Final page: ${captureResult.finalState.title}`);
945
- lines.push(`URL: ${captureResult.finalState.url}`);
946
- }
947
- if (captureResult.capturedApi && captureResult.capturedApi.length > 0) {
948
- formatCapturedApi(lines, captureResult.capturedApi, params);
949
- } else {
950
- lines.push(`
951
- 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.`);
952
- }
953
- if (captureResult.error) {
954
- lines.push(`
955
- --- Failure ---`);
956
- if (typeof captureResult.error === "string") {
957
- lines.push(`Error: ${captureResult.error}`);
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,
993
+ options: { ...params.options, captureApi: true }
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);
958
1010
  } else {
959
- lines.push(`Failed at step ${captureResult.error.failedAtStep}: ${JSON.stringify(captureResult.error.failedStep)}`);
960
- lines.push(`Error type: ${captureResult.error.errorType}`);
961
- lines.push(`Message: ${captureResult.error.message}`);
962
- if (captureResult.error.suggestion)
963
- lines.push(`Suggestion: ${captureResult.error.suggestion}`);
1011
+ captureResult = await localApiPost("/execute", execParams);
964
1012
  }
965
- }
966
- const screenshotUrl = captureResult.error?.pageState?.screenshotUrl ?? captureResult.finalState?.screenshotUrl;
967
- if (screenshotUrl) {
968
- const filePath = await resolveScreenshotPath(screenshotUrl);
969
- 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) {
970
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(`
971
1043
  Screenshot saved: ${filePath}`);
972
- 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
+ }
973
1046
  }
974
- }
975
- if (captureResult.capturedApi && captureResult.capturedApi.length > 0) {
976
- const mainDomain = captureResult.capturedApi[0]?.domain || "api";
977
- const outDir = params.outputDir || `./${mainDomain}`;
978
- try {
979
- const fs5 = await import("fs");
980
- const path7 = await import("path");
981
- fs5.mkdirSync(outDir, { recursive: true });
982
- const jsonPath = path7.join(outDir, "captured-api.json");
983
- fs5.writeFileSync(jsonPath, JSON.stringify(captureResult.capturedApi, null, 2));
984
- 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(`
985
1057
  Full captured data saved to: ${jsonPath}`);
986
- lines.push("Read this file for complete curl commands, request/response bodies, and auth data.");
987
- } catch (writeErr) {
988
- lines.push(`
1058
+ lines.push("Read this file for complete curl commands, request/response bodies, and auth data.");
1059
+ } catch (writeErr) {
1060
+ lines.push(`
989
1061
  (Could not save captured JSON: ${writeErr instanceof Error ? writeErr.message : String(writeErr)})`);
1062
+ }
990
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.`);
991
1073
  }
992
- let text = lines.join(`
993
- `);
994
- if (text.length > 80000) {
995
- text = text.slice(0, 80000) + `
996
-
997
- [... response truncated — read captured-api.json for full data]`;
998
- }
999
- const content = [{ type: "text", text }];
1000
- if (!captureResult.ok)
1001
- return { content, isError: true };
1002
- return { content };
1003
- } catch (e) {
1004
- return err(`Connection error: ${getErrorMessage(e)}. Try \`session restart\` and retry.`);
1005
1074
  }
1006
- });
1075
+ );
1007
1076
  }
1008
1077
  function formatCapturedApi(lines, capturedApi, params) {
1009
1078
  for (const api of capturedApi) {
1010
1079
  lines.push(`
1011
- ━━━ ${api.domain} (${api.baseUrl}) ━━━`);
1080
+ \u2501\u2501\u2501 ${api.domain} (${api.baseUrl}) \u2501\u2501\u2501`);
1012
1081
  const authParts = [];
1013
- if (api.auth?.authorization)
1014
- authParts.push(`Authorization: ${api.auth.authorization.slice(0, 30)}...`);
1015
- if (api.auth?.cookies && Object.keys(api.auth.cookies).length > 0)
1016
- 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`);
1017
1084
  if (api.auth?.tokens && Object.keys(api.auth.tokens).length > 0) {
1018
1085
  for (const [k, v] of Object.entries(api.auth.tokens)) {
1019
1086
  authParts.push(`${k}: ${String(v).slice(0, 30)}...`);
@@ -1022,76 +1089,62 @@ function formatCapturedApi(lines, capturedApi, params) {
1022
1089
  if (authParts.length > 0) {
1023
1090
  lines.push(`
1024
1091
  Auth:`);
1025
- for (const part of authParts)
1026
- lines.push(` ${part}`);
1092
+ for (const part of authParts) lines.push(` ${part}`);
1027
1093
  }
1028
- const byProtocol = new Map;
1094
+ const byProtocol = /* @__PURE__ */ new Map();
1029
1095
  for (const ep of api.endpoints) {
1030
1096
  const p = ep.protocol || "rest";
1031
- if (!byProtocol.has(p))
1032
- byProtocol.set(p, []);
1097
+ if (!byProtocol.has(p)) byProtocol.set(p, []);
1033
1098
  byProtocol.get(p).push(ep);
1034
1099
  }
1035
1100
  const protocolSummary = Array.from(byProtocol.entries()).map(([p, eps]) => `${p}=${eps.length}`).join(", ");
1036
1101
  lines.push(`
1037
- Endpoints (${api.endpoints.length}) [${protocolSummary}]:`);
1102
+ Endpoints (${api.endpoints.length}) [${protocolSummary}] \u2014 full detail in captured-api.json:`);
1038
1103
  for (const ep of api.endpoints) {
1039
- lines.push(`
1040
- [${ep.protocol}/${ep.verb}] ${ep.functionName}`);
1041
- lines.push(` Action: ${ep.action}`);
1042
- lines.push(` ${ep.method} ${ep.path} → ${ep.responseStatus}`);
1043
- if (ep.requestBody) {
1044
- const body = ep.requestBody;
1045
- const signalKeys = extractSignalKeys(body);
1046
- if (signalKeys)
1047
- lines.push(` Params: ${signalKeys}`);
1048
- }
1104
+ const params2 = ep.requestBody ? extractSignalKeys(ep.requestBody) : null;
1105
+ const tail = params2 ? ` {${params2}}` : "";
1106
+ lines.push(` [${ep.protocol}/${ep.verb}] ${ep.method} ${ep.path} \u2192 ${ep.functionName}${tail}`);
1049
1107
  }
1050
1108
  }
1051
1109
  const mainDomain = capturedApi[0]?.domain || "api";
1052
1110
  const dir = params.outputDir || `./${mainDomain}`;
1053
1111
  const ext = params.typed ? ".ts" : ".js";
1054
1112
  lines.push(`
1055
- ━━━ Save instructions ━━━`);
1113
+ \u2501\u2501\u2501 Save instructions \u2501\u2501\u2501`);
1056
1114
  lines.push(`Save to: ${dir}`);
1057
1115
  lines.push(`Format: ${ext} files`);
1058
- const protocolsSeen = new Set;
1059
- for (const api of capturedApi)
1060
- for (const ep of api.endpoints)
1061
- 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);
1062
1118
  lines.push(`
1063
1119
  Layout (protocols present: ${Array.from(protocolsSeen).join(", ")}):`);
1064
1120
  lines.push(` auth${ext}`);
1065
- if (protocolsSeen.has("rest"))
1066
- lines.push(` transport/rest${ext} — shared fetch wrapper with auth headers`);
1067
- if (protocolsSeen.has("graphql"))
1068
- lines.push(` transport/graphql${ext} — post(operationName|docId, variables) → shared GraphQL client`);
1069
- if (protocolsSeen.has("json-rpc"))
1070
- lines.push(` transport/jsonRpc${ext} — shared JSON-RPC client`);
1071
- if (protocolsSeen.has("grpc-web"))
1072
- lines.push(` transport/grpc${ext} — shared gRPC-web client`);
1073
- if (protocolsSeen.has("form-rpc"))
1074
- lines.push(` transport/formRpc${ext} — shared urlencoded RPC client`);
1075
- if (protocolsSeen.has("soap"))
1076
- lines.push(` transport/soap${ext} — shared SOAP client`);
1077
- 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`);
1078
1128
  lines.push(` e.g. graphql/queries/<fn>${ext}, graphql/mutations/<fn>${ext}, rest/read/<fn>${ext}, rest/create/<fn>${ext}`);
1079
- lines.push(` index${ext} — re-export all endpoint functions`);
1080
- if (params.typed)
1081
- lines.push(` types.ts — interfaces inferred from response bodies`);
1082
- 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.`);
1083
1136
  lines.push(`
1084
1137
  Rules:`);
1085
- lines.push(` - One function per (protocol, action). Use the functionName field verbatim for the file + export name.`);
1086
- 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).`);
1087
1140
  lines.push(` - Each endpoint file is minimal: import transport + auth, call transport with the action id, pass variables, return typed result.`);
1088
1141
  lines.push(` - GraphQL transport signature: post(opNameOrDocId: string, variables: object). Pick doc_id when present in captured body, else operationName.`);
1089
- 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.`);
1090
1143
  lines.push(`
1091
- 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.`);
1092
1145
  }
1093
1146
  function extractSignalKeys(body) {
1094
- const NOISE = new Set([
1147
+ const NOISE = /* @__PURE__ */ new Set([
1095
1148
  "__csr",
1096
1149
  "__hsdp",
1097
1150
  "__hblp",
@@ -1118,10 +1171,12 @@ function extractSignalKeys(body) {
1118
1171
  ]);
1119
1172
  const signalEntries = [];
1120
1173
  for (const [k, v] of Object.entries(body)) {
1121
- if (NOISE.has(k))
1122
- continue;
1174
+ if (NOISE.has(k)) continue;
1123
1175
  if (typeof v === "string" && v.length > 80) {
1124
1176
  signalEntries.push(`${k}=${v.slice(0, 40)}...`);
1177
+ } else if (Array.isArray(v)) {
1178
+ const items = v.slice(0, 8).map((x) => String(x)).join(",");
1179
+ signalEntries.push(`${k}=[${items}${v.length > 8 ? ",\u2026" : ""}]`);
1125
1180
  } else if (typeof v === "object" && v !== null) {
1126
1181
  const keys = Object.keys(v).slice(0, 5).join(",");
1127
1182
  signalEntries.push(`${k}={${keys}${Object.keys(v).length > 5 ? ",..." : ""}}`);
@@ -1133,135 +1188,123 @@ function extractSignalKeys(body) {
1133
1188
  }
1134
1189
 
1135
1190
  // src/mcp/tools/session.ts
1136
- function registerSessionTool(server) {
1137
- server.tool("session", `Manage the browser session and lifecycle.
1138
-
1139
- Actions:
1140
- - **stop**: save cookies/localStorage to the store, then close the browser. Session data persists for the next run.
1141
- - **clear**: wipe all stored session data (cookies/localStorage) from the database. Does NOT kill running browsers.
1142
- - **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.
1143
- - **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).
1144
- - **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.
1145
- - **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.
1146
- - **get-auth**: extract cookies + localStorage + sessionStorage in one shot. Everything needed to replay authenticated requests from Node.js.
1147
-
1148
- Sessions live in the single local SQLite database (~/.iframer/iframer.db), shared across every browser mode.`, {
1149
- 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"),
1150
- mode: import_zod5.z.enum(["headless", "binary-headful"]).optional().describe("Browser mode (default: binary-headful)"),
1151
- urls: import_zod5.z.array(import_zod5.z.string()).optional().describe("URLs to scope cookie extraction (get-cookies/get-auth). Omit for all cookies."),
1152
- instanceId: import_zod5.z.string().optional().describe("Target a named parallel browser within this session (default: 'default'). Match the instanceId used in execute."),
1153
- outputDir: import_zod5.z.string().optional().describe("Where to save captured-api.json for capture-stop")
1154
- }, async ({ action, mode, urls, instanceId, outputDir }) => {
1155
- try {
1156
- if (action === "stop") {
1157
- const result = await localApiPost("/interactive/stop").catch(() => ({ ok: true, sessionSaved: false }));
1158
- return { content: [{ type: "text", text: `Session stopped. State saved: ${result.sessionSaved ?? false}` }] };
1159
- }
1160
- if (action === "clear") {
1161
- await localApiDelete("/session").catch(() => {});
1162
- return { content: [{ type: "text", text: "Session data cleared from database." }] };
1163
- }
1164
- if (action === "restart") {
1165
- const parts = [];
1166
- try {
1167
- await localApiPost("/browser/restart");
1168
- parts.push("Local browser restarted.");
1169
- } catch {
1170
- try {
1171
- await localServer.restart();
1172
- parts.push("Local server respawned.");
1173
- } catch (e) {
1174
- parts.push(`Local restart failed: ${e instanceof Error ? e.message : String(e)}`);
1175
- }
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}` }] };
1176
1209
  }
1177
- try {
1178
- if (await isDockerRunning()) {
1179
- await apiPost("/browser/restart");
1180
- 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
+ }
1181
1227
  }
1182
- } catch {}
1183
- parts.push("Credentials and knowledge cache are untouched. Next execute launches a fresh browser.");
1184
- return { content: [{ type: "text", text: parts.join(" ") }] };
1185
- }
1186
- if (action === "capture-start") {
1187
- const result = await localApiPost("/capture/start", { mode: mode ?? "binary-headful", instanceId });
1188
- return { content: [{ type: "text", text: result.message }] };
1189
- }
1190
- if (action === "capture-stop") {
1191
- const result = await localApiPost("/capture/stop", { mode: mode ?? "binary-headful", instanceId });
1192
- const lines = [result.message];
1193
- if (result.capturedApi && result.capturedApi.length > 0) {
1194
- formatCapturedApi(lines, result.capturedApi, { outputDir, typed: false });
1195
- const outDir = outputDir || import_path7.default.join(getDataDir(), "capture");
1196
1228
  try {
1197
- import_fs5.default.mkdirSync(outDir, { recursive: true });
1198
- const jsonPath = import_path7.default.join(outDir, "captured-api.json");
1199
- import_fs5.default.writeFileSync(jsonPath, JSON.stringify(result.capturedApi, null, 2));
1200
- 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(`
1201
1253
  Full data saved to: ${jsonPath}`);
1202
- } catch (writeErr) {
1203
- lines.push(`
1254
+ } catch (writeErr) {
1255
+ lines.push(`
1204
1256
  (Could not save: ${writeErr instanceof Error ? writeErr.message : String(writeErr)})`);
1257
+ }
1205
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 }] };
1206
1262
  }
1207
- let text = lines.join(`
1208
- `);
1209
- if (text.length > 80000)
1210
- text = text.slice(0, 80000) + `
1211
-
1212
- [truncated — read captured-api.json]`;
1213
- return { content: [{ type: "text", text }] };
1214
- }
1215
- if (action === "get-cookies") {
1216
- const result = await localApiPost("/auth/cookies", { mode: mode ?? "binary-headful", urls, instanceId });
1217
- const lines = [result.message, ""];
1218
- for (const c of result.cookies) {
1219
- lines.push(`${c.name}=${c.value} [domain=${c.domain} path=${c.path} httpOnly=${c.httpOnly} secure=${c.secure}]`);
1220
- }
1221
- return { content: [{ type: "text", text: lines.join(`
1222
- `) }] };
1223
- }
1224
- if (action === "get-auth") {
1225
- const result = await localApiPost("/auth/full", { mode: mode ?? "binary-headful", urls, instanceId });
1226
- const lines = [result.message, ""];
1227
- lines.push("=== Cookies ===");
1228
- for (const c of result.cookies) {
1229
- 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") }] };
1230
1270
  }
1231
- if (Object.keys(result.localStorage || {}).length > 0) {
1232
- lines.push(`
1233
- === localStorage ===`);
1234
- for (const [origin, store] of Object.entries(result.localStorage)) {
1235
- lines.push(`[${origin}]`);
1236
- for (const [k, v] of Object.entries(store)) {
1237
- const val = String(v);
1238
- 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
+ }
1239
1286
  }
1240
1287
  }
1241
- }
1242
- if (Object.keys(result.sessionStorage || {}).length > 0) {
1243
- lines.push(`
1244
- === sessionStorage ===`);
1245
- for (const [origin, store] of Object.entries(result.sessionStorage)) {
1246
- lines.push(`[${origin}]`);
1247
- for (const [k, v] of Object.entries(store)) {
1248
- const val = String(v);
1249
- 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
+ }
1250
1296
  }
1251
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 }] };
1252
1301
  }
1253
- let text = lines.join(`
1254
- `);
1255
- if (text.length > 80000)
1256
- text = text.slice(0, 80000) + `
1257
- [truncated]`;
1258
- return { content: [{ type: "text", text }] };
1302
+ return err("Unknown action");
1303
+ } catch (e) {
1304
+ return err(`Error: ${getErrorMessage(e)}`);
1259
1305
  }
1260
- return err("Unknown action");
1261
- } catch (e) {
1262
- return err(`Error: ${getErrorMessage(e)}`);
1263
1306
  }
1264
- });
1307
+ );
1265
1308
  }
1266
1309
 
1267
1310
  // src/mcp/tools/credentials.ts
@@ -1282,15 +1325,15 @@ function getKnowledgePath(domain) {
1282
1325
  }
1283
1326
  function normalizeDomain(input) {
1284
1327
  let d = (input || "").trim().toLowerCase();
1285
- if (!d)
1286
- return "";
1328
+ if (!d) return "";
1287
1329
  try {
1288
1330
  if (d.includes("://")) {
1289
1331
  d = new URL(d).hostname;
1290
1332
  } else if (d.includes("/")) {
1291
1333
  d = new URL(`https://${d}`).hostname;
1292
1334
  }
1293
- } catch {}
1335
+ } catch {
1336
+ }
1294
1337
  d = d.replace(/:\d+$/, "");
1295
1338
  d = d.replace(/^www\./, "");
1296
1339
  return d;
@@ -1317,8 +1360,7 @@ function listKnowledge() {
1317
1360
  }
1318
1361
  const results = [];
1319
1362
  for (const file of entries) {
1320
- if (!file.endsWith(".md"))
1321
- continue;
1363
+ if (!file.endsWith(".md")) continue;
1322
1364
  const full = import_path8.default.join(dir, file);
1323
1365
  try {
1324
1366
  const stat = import_fs6.default.statSync(full);
@@ -1330,7 +1372,8 @@ function listKnowledge() {
1330
1372
  lastMode: parsed?.lastMode ?? "unknown",
1331
1373
  sizeBytes: stat.size
1332
1374
  });
1333
- } catch {}
1375
+ } catch {
1376
+ }
1334
1377
  }
1335
1378
  results.sort((a, b) => a.lastVerified < b.lastVerified ? 1 : -1);
1336
1379
  return results;
@@ -1354,22 +1397,21 @@ function clearKnowledge(domain) {
1354
1397
  try {
1355
1398
  import_fs6.default.unlinkSync(import_path8.default.join(dir, f));
1356
1399
  removed++;
1357
- } catch {}
1400
+ } catch {
1401
+ }
1358
1402
  }
1359
1403
  }
1360
- } catch {}
1404
+ } catch {
1405
+ }
1361
1406
  return { removed };
1362
1407
  }
1363
1408
  function parseMarkdown(raw) {
1364
1409
  const frontmatterMatch = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
1365
- if (!frontmatterMatch)
1366
- return null;
1410
+ if (!frontmatterMatch) return null;
1367
1411
  const fm = {};
1368
- for (const line of frontmatterMatch[1].split(`
1369
- `)) {
1412
+ for (const line of frontmatterMatch[1].split("\n")) {
1370
1413
  const m = line.match(/^(\w+):\s*(.*)$/);
1371
- if (m)
1372
- fm[m[1]] = m[2].trim();
1414
+ if (m) fm[m[1]] = m[2].trim();
1373
1415
  }
1374
1416
  const body = raw.slice(frontmatterMatch[0].length);
1375
1417
  const auth = { type: fm.authType ?? "unknown" };
@@ -1392,11 +1434,9 @@ function parseMarkdown(raw) {
1392
1434
  const notes = [];
1393
1435
  const notesSection = extractSection(body, "Notes");
1394
1436
  if (notesSection) {
1395
- for (const line of notesSection.split(`
1396
- `)) {
1437
+ for (const line of notesSection.split("\n")) {
1397
1438
  const m = line.match(/^-\s+(.+)$/);
1398
- if (m)
1399
- notes.push(m[1].trim());
1439
+ if (m) notes.push(m[1].trim());
1400
1440
  }
1401
1441
  }
1402
1442
  return {
@@ -1412,8 +1452,7 @@ function parseMarkdown(raw) {
1412
1452
  function extractSection(body, heading) {
1413
1453
  const re = new RegExp(`^##\\s+${heading}\\s*$`, "m");
1414
1454
  const match = body.match(re);
1415
- if (!match || match.index === undefined)
1416
- return null;
1455
+ if (!match || match.index === void 0) return null;
1417
1456
  const start = match.index + match[0].length;
1418
1457
  const nextSection = body.slice(start).match(/^##\s+/m);
1419
1458
  const end = nextSection?.index != null ? start + nextSection.index : body.length;
@@ -1421,221 +1460,611 @@ function extractSection(body, heading) {
1421
1460
  }
1422
1461
  function extractBackticks(re, text) {
1423
1462
  const m = text.match(re);
1424
- if (!m)
1425
- return;
1463
+ if (!m) return void 0;
1426
1464
  const items = [...m[1].matchAll(/`([^`]+)`/g)].map((x) => x[1]);
1427
- return items.length > 0 ? items : undefined;
1465
+ return items.length > 0 ? items : void 0;
1428
1466
  }
1429
1467
 
1430
1468
  // src/mcp/tools/credentials.ts
1431
- var ELICIT_TIMEOUT_MS = 45000;
1469
+ var ELICIT_TIMEOUT_MS = 45e3;
1432
1470
  function mcpLog(event, data) {
1433
1471
  try {
1434
1472
  const dir = getDataDir();
1435
1473
  import_fs7.default.mkdirSync(dir, { recursive: true });
1436
- import_fs7.default.appendFileSync(import_path9.default.join(dir, "mcp.log"), JSON.stringify({ ts: new Date().toISOString(), event, ...data }) + `
1437
- `);
1438
- } 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
+ }
1439
1480
  }
1440
1481
  function domainMatches(normalized, stored) {
1441
- return stored.some((d) => d === normalized || normalized.endsWith("." + d) || d.endsWith("." + normalized));
1482
+ return stored.some(
1483
+ (d) => d === normalized || normalized.endsWith("." + d) || d.endsWith("." + normalized)
1484
+ );
1442
1485
  }
1443
- function registerCredentialsTool(server) {
1444
- 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.
1445
-
1446
- CORRECT WORKFLOW when the user needs to be logged into a site:
1447
- 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.
1448
- 2. If the target domain IS in the list → skip to step 4. Credentials exist and are valid. Move on.
1449
- 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.
1450
- 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.
1451
-
1452
- ═══════════════════════════════════════════════════════════════════════
1453
- CRITICAL RULES
1454
- ═══════════════════════════════════════════════════════════════════════
1455
-
1456
- 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.
1457
-
1458
- 2. **NEVER ask the user "do you have credentials?"** — call action=list and read the response.
1459
-
1460
- 3. **NEVER confabulate.** If action=list returns "No credentials stored", the database is empty.
1461
-
1462
- 4. **NEVER pretend a store call succeeded if the response was an error.**
1463
-
1464
- 5. **NEVER ask the user to paste their password in chat.**
1465
-
1466
- 6. **\`force: true\` on store is ONLY for explicit password changes.**`, {
1467
- action: import_zod6.z.enum(["store", "list"]).describe("store: prompt for credentials | list: show stored domains"),
1468
- domain: import_zod6.z.string().optional().describe("Domain (required for store). Use the bare registrable domain."),
1469
- force: import_zod6.z.boolean().optional().describe("Overwrite existing. ONLY for explicit password changes.")
1470
- }, async ({ action, domain, force }) => {
1471
- try {
1472
- if (action === "list") {
1473
- const credData2 = await localApiGet("/credentials");
1474
- const domains = credData2.domains || [];
1475
- if (!domains.length) {
1476
- 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." }] };
1477
- }
1478
- return { content: [{ type: "text", text: `Stored credentials for:
1479
- ${domains.map((d) => ` - ${d}`).join(`
1480
- `)}` }] };
1481
- }
1482
- if (action !== "store")
1483
- return err("Unknown action");
1484
- if (!domain)
1485
- return err("domain is required for action=store");
1486
- const normalized = normalizeDomain(domain);
1487
- const credData = await localApiGet("/credentials");
1488
- const stored = credData.domains || [];
1489
- if (domainMatches(normalized, stored) && !force) {
1490
- mcpLog("credentials.store.rejected_already_exists", { domain: normalized });
1491
- return err(`REFUSING TO RE-STORE: credentials for "${normalized}" already exist. ` + `Login failures are NOT credential problems — retry with a stronger browser mode.
1492
-
1493
- ` + `Use \`force: true\` ONLY if the user explicitly says their password changed.`);
1494
- }
1495
- mcpLog("credentials.store.attempt", { domain: normalized, force: !!force });
1496
- let result;
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 }) => {
1497
1500
  try {
1498
- const elicitPromise = server.server.elicitInput({
1499
- mode: "form",
1500
- message: `Enter your login credentials for ${normalized}. These are encrypted and stored locally — the agent never sees them.`,
1501
- requestedSchema: {
1502
- type: "object",
1503
- properties: {
1504
- username: { type: "string", title: "Username / Email" },
1505
- password: { type: "string", title: "Password" },
1506
- totp_secret: { type: "string", title: "TOTP Secret (leave empty if no 2FA)" }
1507
- },
1508
- 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." }] };
1509
1506
  }
1510
- });
1511
- const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`elicitation timed out after ${ELICIT_TIMEOUT_MS}ms`)), ELICIT_TIMEOUT_MS));
1512
- result = await Promise.race([elicitPromise, timeoutPromise]);
1513
- } catch (elicitErr) {
1514
- const msg = elicitErr instanceof Error ? elicitErr.message : String(elicitErr);
1515
- mcpLog("credentials.store.elicit_failed", { domain: normalized, error: msg });
1516
- return err(`FAILED TO STORE CREDENTIALS for ${normalized}: ${msg}
1517
-
1518
- ` + `The user MUST run this command in their terminal:
1519
-
1520
- ` + ` iframer-toolkit credentials add ${normalized}
1521
-
1522
- ` + `After they run it, retry the login. DO NOT pretend credentials were stored.`);
1523
- }
1524
- mcpLog("credentials.store.elicit_result", { domain: normalized, action: result.action, hasContent: !!result.content });
1525
- if (result.action !== "accept" || !result.content) {
1526
- return err(`Credential form was ${result.action || "dismissed"}. No credentials saved for ${normalized}.`);
1527
- }
1528
- const { username, password, totp_secret } = result.content;
1529
- if (!username || !password) {
1530
- return err(`Form submitted but username or password was empty. No credentials saved.`);
1531
- }
1532
- try {
1533
- const storeResult = await localApiPost("/credentials", {
1534
- domain: normalized,
1535
- username,
1536
- password,
1537
- totp_secret: totp_secret || undefined
1538
- });
1539
- if (!storeResult.ok)
1540
- return err(`Failed to store: ${storeResult.error}`);
1541
- } catch (storeErr) {
1542
- const msg = storeErr instanceof Error ? storeErr.message : String(storeErr);
1543
- mcpLog("credentials.store.write_failed", { domain: normalized, error: msg });
1544
- 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)}`);
1545
1581
  }
1546
- mcpLog("credentials.store.success", { domain: normalized });
1547
- return { content: [{ type: "text", text: `Credentials stored for ${normalized}. Shared across all browser modes.` }] };
1548
- } catch (e) {
1549
- return err(`Error: ${getErrorMessage(e)}`);
1550
1582
  }
1551
- });
1583
+ );
1552
1584
  }
1553
1585
 
1554
1586
  // src/mcp/tools/knowledge.ts
1555
1587
  var import_zod7 = require("zod");
1556
- function registerKnowledgeTool(server) {
1557
- 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.
1558
-
1559
- Each domain's cache is a plain markdown file at ~/.iframer/knowledge/<domain>.md containing:
1560
- - Auth mechanism (which cookies / localStorage keys / headers are load-bearing)
1561
- - Known API endpoints the site uses (captured from real browser runs)
1562
- - Notes about captchas, bot detection, session behavior
1563
- - 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.
1564
1592
 
1565
- 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.
1566
1594
 
1567
- 1. Call \`knowledge get <domain>\` first.
1568
- 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.
1569
- 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.
1570
- 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.
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:
1615
+ `];
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") }] };
1621
+ }
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)";
1640
+ return {
1641
+ content: [
1642
+ {
1643
+ type: "text",
1644
+ text: `Cleared ${removed} cache entr${removed === 1 ? "y" : "ies"} ${scope}.`
1645
+ }
1646
+ ]
1647
+ };
1648
+ }
1649
+ return err("Unknown action");
1650
+ } catch (e) {
1651
+ return err(`Error: ${getErrorMessage(e)}`);
1652
+ }
1653
+ }
1654
+ );
1655
+ }
1571
1656
 
1572
- Actions:
1573
- - get: return the markdown cache for a specific domain
1574
- - list: show all cached domains with last-verified timestamps
1575
- - clear: delete cache for a specific domain, or everything if domain is omitted`, {
1576
- action: import_zod7.z.enum(["get", "list", "clear"]).describe("get: return cache for a domain | list: all cached domains | clear: delete cache"),
1577
- domain: import_zod7.z.string().optional().describe("Domain (required for get; optional for clear — omit to clear everything)")
1578
- }, async ({ action, domain }) => {
1579
- try {
1580
- if (action === "list") {
1581
- const entries = listKnowledge();
1582
- if (entries.length === 0) {
1657
+ // src/mcp/tools/tabs.ts
1658
+ var import_zod8 = require("zod");
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;
1583
1685
  return {
1584
1686
  content: [
1585
1687
  {
1586
1688
  type: "text",
1587
- text: "No cached knowledge yet. Every successful `execute` run populates the cache for its target domain automatically."
1689
+ text: `Opened tab [id ${t.id}] ${t.title || t.url}
1690
+ ${t.url}
1691
+ Drive it with: execute mode="extension", tabId=${t.id}.`
1588
1692
  }
1589
1693
  ]
1590
1694
  };
1591
1695
  }
1592
- const lines = [`${entries.length} domain${entries.length === 1 ? "" : "s"} cached:
1593
- `];
1594
- for (const e of entries) {
1595
- lines.push(` ${e.domain} (${e.lastMode}, verified ${e.lastVerified}, ${e.sizeBytes}B)`);
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
+ };
1596
1709
  }
1597
- lines.push("\nCall `knowledge get <domain>` for the full cache contents.");
1598
- return { content: [{ type: "text", text: lines.join(`
1599
- `) }] };
1600
- }
1601
- if (action === "get") {
1602
- if (!domain)
1603
- return err("domain is required for action=get");
1604
- const md = readKnowledge(domain);
1605
- if (!md) {
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) {
1606
1749
  return {
1607
1750
  content: [
1608
1751
  {
1609
1752
  type: "text",
1610
- 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.`
1753
+ text: filter ? `Extension connected, but no open tab matches "${filter}".` : "Extension connected, but no tabs were reported."
1611
1754
  }
1612
1755
  ]
1613
1756
  };
1614
1757
  }
1615
- return { content: [{ type: "text", text: md }] };
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)}`);
1616
1781
  }
1617
- if (action === "clear") {
1618
- const { removed } = clearKnowledge(domain);
1619
- const scope = domain ? `for ${sanitizeDomain(domain)}` : "(all domains)";
1782
+ }
1783
+ );
1784
+ }
1785
+
1786
+ // src/mcp/tools/anchors.ts
1787
+ var import_zod9 = require("zod");
1788
+
1789
+ // src/lib/knowledge/component-map.ts
1790
+ var import_fs8 = __toESM(require("fs"));
1791
+ var import_path10 = __toESM(require("path"));
1792
+ function anchorsPath(domain) {
1793
+ return import_path10.default.join(getKnowledgeDir(), `${sanitizeDomain(domain)}.anchors.json`);
1794
+ }
1795
+ function loadComponentMap(domain) {
1796
+ const norm = normalizeDomain(domain);
1797
+ try {
1798
+ const raw = import_fs8.default.readFileSync(anchorsPath(norm), "utf8");
1799
+ const parsed = JSON.parse(raw);
1800
+ return {
1801
+ domain: parsed.domain || norm,
1802
+ anchors: parsed.anchors || {},
1803
+ quirks: Array.isArray(parsed.quirks) ? parsed.quirks : []
1804
+ };
1805
+ } catch {
1806
+ return { domain: norm, anchors: {}, quirks: [] };
1807
+ }
1808
+ }
1809
+ function write(cm) {
1810
+ import_fs8.default.mkdirSync(getKnowledgeDir(), { recursive: true });
1811
+ import_fs8.default.writeFileSync(anchorsPath(cm.domain), JSON.stringify(cm, null, 2), "utf8");
1812
+ }
1813
+ function saveAnchor(domain, input, now) {
1814
+ const cm = loadComponentMap(domain);
1815
+ cm.anchors[input.name] = {
1816
+ name: input.name,
1817
+ selector: input.selector,
1818
+ role: input.role,
1819
+ description: input.description,
1820
+ quirks: input.quirks && input.quirks.length ? input.quirks : void 0,
1821
+ uses: 0,
1822
+ fails: 0,
1823
+ lastVerified: now
1824
+ };
1825
+ write(cm);
1826
+ }
1827
+ function removeAnchor(domain, name) {
1828
+ const cm = loadComponentMap(domain);
1829
+ if (!(name in cm.anchors)) return false;
1830
+ delete cm.anchors[name];
1831
+ write(cm);
1832
+ return true;
1833
+ }
1834
+ function setDomainQuirks(domain, quirks) {
1835
+ const cm = loadComponentMap(domain);
1836
+ cm.quirks = Array.from(/* @__PURE__ */ new Set([...cm.quirks, ...quirks]));
1837
+ write(cm);
1838
+ }
1839
+ function listAnchorDomains() {
1840
+ try {
1841
+ return import_fs8.default.readdirSync(getKnowledgeDir()).filter((f) => f.endsWith(".anchors.json")).map((f) => f.replace(/\.anchors\.json$/, ""));
1842
+ } catch {
1843
+ return [];
1844
+ }
1845
+ }
1846
+
1847
+ // src/mcp/tools/anchors.ts
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") }] };
1877
+ }
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}:
1886
+ `];
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") }] };
1900
+ }
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}.` }] };
1905
+ }
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}.` }] };
1910
+ }
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());
1620
1914
  return {
1621
1915
  content: [
1622
1916
  {
1623
1917
  type: "text",
1624
- text: `Cleared ${removed} cache entr${removed === 1 ? "y" : "ies"} ${scope}.`
1918
+ text: `Saved anchor @a:${name} for ${domain} \u2192 ${selector}
1919
+ Use it in any selector field: e.g. {"type":"click","selector":"@a:${name}"}.`
1625
1920
  }
1626
1921
  ]
1627
1922
  };
1923
+ } catch (e) {
1924
+ return err(`remember failed: ${getErrorMessage(e)}`);
1628
1925
  }
1629
- return err("Unknown action");
1630
- } catch (e) {
1631
- return err(`Error: ${getErrorMessage(e)}`);
1926
+ }
1927
+ );
1928
+ }
1929
+
1930
+ // src/mcp/tools/clipboard.ts
1931
+ var import_zod10 = require("zod");
1932
+
1933
+ // src/lib/clipboard.ts
1934
+ var import_child_process2 = require("child_process");
1935
+ function platformTools(mode) {
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"]];
1938
+ return [["wl-copy"], ["xclip", "-selection", "clipboard", "-i"], ["xsel", "-b", "-i"]];
1939
+ }
1940
+ function run(cmd, input) {
1941
+ return new Promise((resolve) => {
1942
+ let child;
1943
+ try {
1944
+ child = (0, import_child_process2.spawn)(cmd[0], cmd.slice(1));
1945
+ } catch {
1946
+ resolve({ ok: false, out: "", err: `spawn ${cmd[0]} failed` });
1947
+ return;
1948
+ }
1949
+ let out = "";
1950
+ let errOut = "";
1951
+ child.stdout?.on("data", (d) => out += d);
1952
+ child.stderr?.on("data", (d) => errOut += d);
1953
+ child.on("error", (e) => resolve({ ok: false, out: "", err: e.message }));
1954
+ child.on("close", (code) => resolve({ ok: code === 0, out, err: errOut }));
1955
+ if (input !== void 0) {
1956
+ child.stdin?.write(input);
1957
+ child.stdin?.end();
1632
1958
  }
1633
1959
  });
1634
1960
  }
1961
+ async function clipboardWrite(text) {
1962
+ const tools = platformTools("write");
1963
+ for (const cmd of tools) {
1964
+ if ((await run(cmd, text)).ok) return;
1965
+ }
1966
+ throw new Error(`No working clipboard tool found (${tools.map((t) => t[0]).join(", ")}).`);
1967
+ }
1968
+ async function clipboardRead() {
1969
+ const tools = platformTools("read");
1970
+ let lastErr = "";
1971
+ for (const cmd of tools) {
1972
+ const r = await run(cmd);
1973
+ if (r.ok) return r.out;
1974
+ lastErr = r.err;
1975
+ }
1976
+ throw new Error(`No working clipboard tool found (${tools.map((t) => t[0]).join(", ")}). ${lastErr}`);
1977
+ }
1978
+
1979
+ // src/mcp/tools/clipboard.ts
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)}`);
1999
+ }
2000
+ }
2001
+ );
2002
+ }
2003
+
2004
+ // src/mcp/telemetry.ts
2005
+ var import_fs9 = __toESM(require("fs"));
2006
+ var import_path11 = __toESM(require("path"));
2007
+ var CHARS_PER_TOKEN = 4;
2008
+ var est = (chars) => Math.round(chars / CHARS_PER_TOKEN);
2009
+ var enabled = process.env.IFRAMER_TELEMETRY !== "0";
2010
+ var sessionId = `${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}-${process.pid}`;
2011
+ function telemetryPath() {
2012
+ return import_path11.default.join(getDataDir(), "telemetry.jsonl");
2013
+ }
2014
+ var sessionCalls = 0;
2015
+ var sessionChars = 0;
2016
+ function write2(rec) {
2017
+ if (!enabled) return;
2018
+ try {
2019
+ import_fs9.default.appendFileSync(telemetryPath(), JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), session: sessionId, ...rec }) + "\n");
2020
+ } catch {
2021
+ }
2022
+ }
2023
+ function recordDefinitions(toolCount, defChars2, instructionChars) {
2024
+ write2({
2025
+ kind: "definitions",
2026
+ toolCount,
2027
+ defChars: defChars2,
2028
+ instructionChars,
2029
+ estTokens: est(defChars2 + instructionChars)
2030
+ });
2031
+ }
2032
+ function recordCall(tool, inChars, outChars, ms, isError) {
2033
+ sessionCalls++;
2034
+ sessionChars += inChars + outChars;
2035
+ write2({
2036
+ kind: "call",
2037
+ tool,
2038
+ inChars,
2039
+ outChars,
2040
+ estTokens: est(inChars + outChars),
2041
+ ms,
2042
+ isError,
2043
+ sessionCalls,
2044
+ sessionEstTokens: est(sessionChars)
2045
+ });
2046
+ }
2047
+ function contentChars(res) {
2048
+ const r = res;
2049
+ if (!r || !Array.isArray(r.content)) return 0;
2050
+ let n = 0;
2051
+ for (const c of r.content) {
2052
+ if (typeof c?.text === "string") n += c.text.length;
2053
+ else if (typeof c?.data === "string") n += c.data.length;
2054
+ }
2055
+ return n;
2056
+ }
2057
+ function safeJsonLen(v) {
2058
+ try {
2059
+ return v === void 0 ? 0 : JSON.stringify(v).length;
2060
+ } catch {
2061
+ return 0;
2062
+ }
2063
+ }
1635
2064
 
1636
2065
  // src/mcp/server.ts
1637
2066
  var IS_DEV = process.env.IFRAMER_URL?.includes("localhost") || process.env.IFRAMER_URL?.includes("127.0.0.1");
1638
- 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}).
1639
2068
 
1640
2069
  CRITICAL RULES:
1641
2070
  1. NEVER ask the user "do you have credentials?". Call credentials action=list and read the response.
@@ -1644,8 +2073,8 @@ CRITICAL RULES:
1644
2073
  4. If execute fails, read the error. It tells you exactly what happened and where.
1645
2074
  5. Call "session" action=stop when done to save session state.
1646
2075
 
1647
- CAPTCHA: Use the "solve-captcha" step — auto-detects reCAPTCHA/hCaptcha and solves with vision AI.
1648
- 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.
1649
2078
 
1650
2079
  CRITICAL RULES:
1651
2080
  1. NEVER ask "do you have credentials?". Call credentials action=list and read the response.
@@ -1653,16 +2082,42 @@ CRITICAL RULES:
1653
2082
  3. DO NOT present options when you can just act. Check status, credentials, execute.
1654
2083
  4. NEVER re-store credentials when login fails. Login failures are browser-mode problems, not credential problems.
1655
2084
  5. ALWAYS check "knowledge get <domain>" before launching a browser. Skip the browser if the cache has what you need.
1656
- 6. If execute fails, read the FULL error. It tells you the step, error type, and suggestion.
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).
2086
+ 7. If execute fails, read the FULL error. It tells you the step, error type, and suggestion.
1657
2087
  7. If the browser crashes, call "session restart" and retry. Don't panic.
1658
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.
1659
2089
 
1660
- 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.
1661
2091
 
1662
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.
1663
2093
 
1664
2094
  REVERSE ENGINEERING: Use the "reverse-engineer" tool when the user asks to capture/map/save API endpoints.`;
1665
- 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
+ );
2099
+ var defChars = 0;
2100
+ var defCount = 0;
2101
+ {
2102
+ const origTool = server.tool.bind(server);
2103
+ server.tool = (...a) => {
2104
+ const name = String(a[0]);
2105
+ defCount++;
2106
+ defChars += name.length + (typeof a[1] === "string" ? a[1].length : 0);
2107
+ const hIdx = a.length - 1;
2108
+ const handler = a[hIdx];
2109
+ if (typeof handler === "function") {
2110
+ a[hIdx] = async (...ha) => {
2111
+ const started = Date.now();
2112
+ const res = await handler(...ha);
2113
+ const r = res;
2114
+ recordCall(name, safeJsonLen(ha[0]), contentChars(res), Date.now() - started, !!r?.isError);
2115
+ return res;
2116
+ };
2117
+ }
2118
+ return origTool(...a);
2119
+ };
2120
+ }
1666
2121
  registerStatusTool(server);
1667
2122
  registerBrowseTool(server);
1668
2123
  registerExecuteTool(server);
@@ -1670,19 +2125,32 @@ registerSessionTool(server);
1670
2125
  registerCredentialsTool(server);
1671
2126
  registerReverseEngineerTool(server);
1672
2127
  registerKnowledgeTool(server);
2128
+ registerTabsTool(server);
2129
+ registerRememberTool(server);
2130
+ registerClipboardTool(server);
2131
+ recordDefinitions(defCount, defChars, INSTRUCTIONS.length);
1673
2132
  process.on("SIGTERM", () => process.exit(0));
1674
2133
  process.on("SIGINT", () => process.exit(0));
2134
+ process.stdin.on("end", () => process.exit(0));
2135
+ process.stdin.on("close", () => process.exit(0));
2136
+ var originalPpid = process.ppid;
2137
+ var orphanCheck = setInterval(() => {
2138
+ if (process.ppid !== originalPpid || process.ppid === 1) process.exit(0);
2139
+ }, 3e4);
2140
+ orphanCheck.unref?.();
1675
2141
  process.on("uncaughtException", (err2) => {
1676
2142
  try {
1677
2143
  console.error(`[mcp] uncaughtException: ${err2?.message}`);
1678
- } catch {}
2144
+ } catch {
2145
+ }
1679
2146
  });
1680
2147
  process.on("unhandledRejection", (reason) => {
1681
2148
  try {
1682
2149
  console.error(`[mcp] unhandledRejection: ${reason}`);
1683
- } catch {}
2150
+ } catch {
2151
+ }
1684
2152
  });
1685
- var transport = new import_stdio.StdioServerTransport;
2153
+ var transport = new import_stdio.StdioServerTransport();
1686
2154
  (async () => {
1687
2155
  await server.connect(transport);
1688
2156
  })();