iframer-toolkit 0.8.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,8 +35,8 @@ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
35
35
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
36
36
 
37
37
  // src/mcp/helpers.ts
38
- var import_path4 = __toESM(require("path"));
39
- var import_os2 = __toESM(require("os"));
38
+ var import_path5 = __toESM(require("path"));
39
+ var import_os3 = __toESM(require("os"));
40
40
 
41
41
  // src/lib/logger.ts
42
42
  var LEVELS = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
@@ -66,6 +66,7 @@ function createLogger(tag) {
66
66
  // src/lib/auth/crypto.ts
67
67
  var import_crypto = __toESM(require("crypto"));
68
68
  var import_fs = __toESM(require("fs"));
69
+ var import_os2 = __toESM(require("os"));
69
70
  var import_path2 = __toESM(require("path"));
70
71
 
71
72
  // src/lib/paths.ts
@@ -79,42 +80,76 @@ function getDataDir() {
79
80
  function getLocalToken() {
80
81
  if (process.env.IFRAMER_SECRET)
81
82
  return process.env.IFRAMER_SECRET;
82
- const dir = getDataDir();
83
- const file = import_path2.default.join(dir, "secret");
84
- try {
85
- const existing = import_fs.default.readFileSync(file, "utf8").trim();
86
- if (existing)
87
- return existing;
88
- } catch {}
89
- try {
90
- import_fs.default.mkdirSync(dir, { recursive: true });
91
- const secret = import_crypto.default.randomBytes(32).toString("hex");
92
- import_fs.default.writeFileSync(file, secret, { mode: 384 });
93
- return secret;
94
- } catch {
95
- return "iframer-local-default-token";
83
+ const candidates = [
84
+ import_path2.default.join(getDataDir(), "secret"),
85
+ import_path2.default.join(process.env.XDG_RUNTIME_DIR || import_os2.default.tmpdir(), "iframer-secret")
86
+ ];
87
+ for (const file of candidates) {
88
+ try {
89
+ const existing = import_fs.default.readFileSync(file, "utf8").trim();
90
+ if (existing)
91
+ return existing;
92
+ } catch {}
96
93
  }
94
+ for (const file of candidates) {
95
+ try {
96
+ import_fs.default.mkdirSync(import_path2.default.dirname(file), { recursive: true });
97
+ const secret = import_crypto.default.randomBytes(32).toString("hex");
98
+ import_fs.default.writeFileSync(file, secret, { mode: 384 });
99
+ return secret;
100
+ } catch {}
101
+ }
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).");
97
103
  }
98
104
 
99
105
  // src/mcp/local-server.ts
100
106
  var import_child_process = require("child_process");
101
107
  var import_net = __toESM(require("net"));
108
+ var import_fs3 = __toESM(require("fs"));
109
+ var import_path4 = __toESM(require("path"));
110
+
111
+ // src/lib/browser/registry.ts
102
112
  var import_fs2 = __toESM(require("fs"));
103
113
  var import_path3 = __toESM(require("path"));
114
+ var log = createLogger("registry");
115
+ function serverInfoPath() {
116
+ return import_path3.default.join(getDataDir(), "server.json");
117
+ }
118
+ function isPidAlive(pid) {
119
+ if (!Number.isInteger(pid) || pid <= 1)
120
+ return false;
121
+ try {
122
+ process.kill(pid, 0);
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+ function readServerInfo() {
129
+ try {
130
+ const info = JSON.parse(import_fs2.default.readFileSync(serverInfoPath(), "utf8"));
131
+ if (!Number.isInteger(info.pid) || !Number.isInteger(info.port))
132
+ return null;
133
+ return info;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ // src/mcp/local-server.ts
104
140
  var __dirname = "/Users/eduardoverona/tools/iframer-toolkit/src/mcp";
105
141
  var BASE_PORT = parseInt(process.env.IFRAMER_LOCAL_PORT || "3022", 10);
106
- var PORT_SCAN_ATTEMPTS = 200;
142
+ var PORT_SCAN_ATTEMPTS = 21;
107
143
  var STARTUP_TIMEOUT_MS = 15000;
108
144
  var HEALTH_POLL_MS = 300;
145
+ var SPAWN_LOCK_STALE_MS = 20000;
109
146
 
110
147
  class LocalServerManager {
111
- child = null;
112
148
  startingPromise = null;
113
- port = null;
114
149
  baseUrl = "";
115
150
  logPath;
116
151
  constructor() {
117
- this.logPath = import_path3.default.join(getDataDir(), "local-server.log");
152
+ this.logPath = import_path4.default.join(getDataDir(), "local-server.log");
118
153
  }
119
154
  getBaseUrl() {
120
155
  if (!this.baseUrl) {
@@ -123,113 +158,154 @@ class LocalServerManager {
123
158
  return this.baseUrl;
124
159
  }
125
160
  async ensureRunning() {
126
- if (this.child && !this.child.killed && await this.healthCheck())
127
- return;
128
161
  if (this.startingPromise)
129
162
  return this.startingPromise;
130
- this.startingPromise = this.doStart().finally(() => {
163
+ if (await this.adoptExisting())
164
+ return;
165
+ this.startingPromise = this.startShared().finally(() => {
131
166
  this.startingPromise = null;
132
167
  });
133
168
  return this.startingPromise;
134
169
  }
135
- async doStart() {
136
- this.port = await findFreePort(BASE_PORT, PORT_SCAN_ATTEMPTS);
137
- this.baseUrl = `http://127.0.0.1:${this.port}`;
170
+ async adoptExisting() {
171
+ const info = readServerInfo();
172
+ if (!info || !isPidAlive(info.pid))
173
+ return false;
174
+ const url = `http://127.0.0.1:${info.port}`;
175
+ if (await healthCheck(url)) {
176
+ this.baseUrl = url;
177
+ return true;
178
+ }
179
+ return false;
180
+ }
181
+ async startShared() {
138
182
  const dataDir = getDataDir();
139
- import_fs2.default.mkdirSync(dataDir, { recursive: true });
140
- const { command, args } = this.resolveRuntime();
141
- const logFd = import_fs2.default.openSync(this.logPath, "a");
142
- const env = {
143
- ...process.env,
144
- PORT: String(this.port),
145
- IFRAMER_MODE: "local",
146
- IFRAMER_DATA_DIR: dataDir,
147
- IFRAMER_PARENT_PID: String(process.pid)
148
- };
149
- if (process.env.IFRAMER_SECRET) {
150
- env.IFRAMER_SECRET = process.env.IFRAMER_SECRET;
151
- } else {
152
- try {
153
- const secret = import_fs2.default.readFileSync(import_path3.default.join(dataDir, "secret"), "utf8").trim();
154
- if (secret)
155
- env.IFRAMER_SECRET = secret;
156
- } catch {}
183
+ import_fs3.default.mkdirSync(dataDir, { recursive: true });
184
+ const lockDir = import_path4.default.join(dataDir, "server.spawn-lock");
185
+ let holdingLock = false;
186
+ try {
187
+ import_fs3.default.mkdirSync(lockDir);
188
+ holdingLock = true;
189
+ } catch {
190
+ const stale = (() => {
191
+ try {
192
+ return Date.now() - import_fs3.default.statSync(lockDir).mtimeMs > SPAWN_LOCK_STALE_MS;
193
+ } catch {
194
+ return true;
195
+ }
196
+ })();
197
+ if (stale) {
198
+ try {
199
+ import_fs3.default.rmdirSync(lockDir);
200
+ import_fs3.default.mkdirSync(lockDir);
201
+ holdingLock = true;
202
+ } catch {}
203
+ }
157
204
  }
158
- this.child = import_child_process.spawn(command, args, {
159
- env,
160
- stdio: ["ignore", logFd, logFd],
161
- detached: false
162
- });
163
- import_fs2.default.closeSync(logFd);
164
- this.child.on("exit", (code) => {
165
- this.child = null;
166
- });
167
- const deadline = Date.now() + STARTUP_TIMEOUT_MS;
168
- while (Date.now() < deadline) {
169
- if (await this.healthCheck())
205
+ try {
206
+ if (!holdingLock) {
207
+ const deadline2 = Date.now() + STARTUP_TIMEOUT_MS;
208
+ while (Date.now() < deadline2) {
209
+ if (await this.adoptExisting())
210
+ return;
211
+ await sleep(HEALTH_POLL_MS);
212
+ }
213
+ throw new Error("Timed out waiting for another session to start the shared iframer server.");
214
+ }
215
+ if (await this.adoptExisting())
170
216
  return;
171
- await sleep(HEALTH_POLL_MS);
172
- }
173
- this.kill();
174
- const logTail = this.readLogTail();
175
- throw new Error(`Local iframer server failed to start on port ${this.port} within ${STARTUP_TIMEOUT_MS}ms.
217
+ const port = await findFreePort(BASE_PORT, PORT_SCAN_ATTEMPTS);
218
+ const url = `http://127.0.0.1:${port}`;
219
+ const { command, args } = this.resolveRuntime();
220
+ const logFd = import_fs3.default.openSync(this.logPath, "a");
221
+ const env = {
222
+ ...process.env,
223
+ PORT: String(port),
224
+ IFRAMER_MODE: "local",
225
+ IFRAMER_DATA_DIR: dataDir
226
+ };
227
+ if (process.env.IFRAMER_SECRET) {
228
+ env.IFRAMER_SECRET = process.env.IFRAMER_SECRET;
229
+ } else {
230
+ try {
231
+ const secret = import_fs3.default.readFileSync(import_path4.default.join(dataDir, "secret"), "utf8").trim();
232
+ if (secret)
233
+ env.IFRAMER_SECRET = secret;
234
+ } catch {}
235
+ }
236
+ const child = import_child_process.spawn(command, args, {
237
+ env,
238
+ stdio: ["ignore", logFd, logFd],
239
+ detached: true
240
+ });
241
+ child.unref();
242
+ import_fs3.default.closeSync(logFd);
243
+ const deadline = Date.now() + STARTUP_TIMEOUT_MS;
244
+ while (Date.now() < deadline) {
245
+ if (await healthCheck(url)) {
246
+ this.baseUrl = url;
247
+ return;
248
+ }
249
+ await sleep(HEALTH_POLL_MS);
250
+ }
251
+ try {
252
+ child.kill("SIGKILL");
253
+ } catch {}
254
+ throw new Error(`Local iframer server failed to start on port ${port} within ${STARTUP_TIMEOUT_MS}ms.
176
255
  ` + `Last log lines:
177
- ${logTail}`);
256
+ ${this.readLogTail()}`);
257
+ } finally {
258
+ if (holdingLock) {
259
+ try {
260
+ import_fs3.default.rmdirSync(lockDir);
261
+ } catch {}
262
+ }
263
+ }
178
264
  }
179
265
  resolveRuntime() {
266
+ const serverTs = import_path4.default.join(__dirname, "..", "..", "index.ts");
267
+ const serverCjs = import_path4.default.join(__dirname, "..", "..", "dist", "local-server.cjs");
268
+ if (import_fs3.default.existsSync(serverCjs)) {
269
+ return { command: "node", args: [serverCjs] };
270
+ }
271
+ if (import_fs3.default.existsSync(serverTs)) {
272
+ try {
273
+ require.resolve("/Users/eduardoverona/tools/iframer-toolkit/node_modules/tsx/dist/loader.mjs");
274
+ return { command: "node", args: ["--import", "tsx", serverTs] };
275
+ } catch {}
276
+ }
180
277
  try {
181
278
  const bunPath = require("child_process").execSync("which bun", { encoding: "utf8" }).trim();
182
- const serverTs2 = import_path3.default.join(__dirname, "..", "..", "index.ts");
183
- if (import_fs2.default.existsSync(serverTs2)) {
184
- return { command: bunPath, args: ["run", serverTs2] };
279
+ if (bunPath && import_fs3.default.existsSync(serverTs)) {
280
+ console.error("[iframer] running local server under bun — extension mode (connectOverCDP) will not work; run `bun run build` to get the node bundle");
281
+ return { command: bunPath, args: ["run", serverTs] };
185
282
  }
186
283
  } catch {}
187
- const serverCjs = import_path3.default.join(__dirname, "..", "..", "dist", "local-server.cjs");
188
- if (import_fs2.default.existsSync(serverCjs)) {
189
- return { command: "node", args: [serverCjs] };
190
- }
191
- const serverTs = import_path3.default.join(__dirname, "..", "..", "index.ts");
192
- return { command: "node", args: ["--import", "tsx", serverTs] };
284
+ throw new Error("No runnable iframer server entry found: dist/local-server.cjs is missing, tsx is not " + "installed, and bun is unavailable. Run `bun install && bun run build` in the iframer repo.");
193
285
  }
194
286
  async restart() {
195
- this.kill();
196
- await sleep(500);
197
- await this.ensureRunning();
198
- }
199
- shutdown() {
200
- this.kill();
201
- }
202
- kill() {
203
- if (this.child && !this.child.killed) {
287
+ const info = readServerInfo();
288
+ if (info && this.baseUrl) {
204
289
  try {
205
- this.child.kill("SIGTERM");
290
+ await fetch(`${this.baseUrl}/shutdown`, { method: "POST", signal: AbortSignal.timeout(3000) });
206
291
  } catch {}
207
- const c = this.child;
208
- setTimeout(() => {
292
+ const deadline = Date.now() + 12000;
293
+ while (Date.now() < deadline && isPidAlive(info.pid)) {
294
+ await sleep(200);
295
+ }
296
+ if (isPidAlive(info.pid)) {
209
297
  try {
210
- if (!c.killed)
211
- c.kill("SIGKILL");
298
+ process.kill(info.pid, "SIGKILL");
212
299
  } catch {}
213
- }, 2000);
214
- }
215
- this.child = null;
216
- }
217
- async healthCheck() {
218
- if (!this.baseUrl)
219
- return false;
220
- try {
221
- const res = await fetch(`${this.baseUrl}/health`, {
222
- signal: AbortSignal.timeout(2000)
223
- });
224
- const data = await res.json();
225
- return data.ok === true;
226
- } catch {
227
- return false;
300
+ }
228
301
  }
302
+ this.baseUrl = "";
303
+ await this.ensureRunning();
229
304
  }
305
+ shutdown() {}
230
306
  readLogTail() {
231
307
  try {
232
- const content = import_fs2.default.readFileSync(this.logPath, "utf8");
308
+ const content = import_fs3.default.readFileSync(this.logPath, "utf8");
233
309
  const lines = content.trim().split(`
234
310
  `);
235
311
  return lines.slice(-10).join(`
@@ -242,6 +318,15 @@ ${logTail}`);
242
318
  function sleep(ms) {
243
319
  return new Promise((r) => setTimeout(r, ms));
244
320
  }
321
+ async function healthCheck(baseUrl) {
322
+ try {
323
+ const res = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(2000) });
324
+ const data = await res.json();
325
+ return data.ok === true;
326
+ } catch {
327
+ return false;
328
+ }
329
+ }
245
330
  function findFreePort(start, attempts) {
246
331
  return new Promise((resolve, reject) => {
247
332
  const tryPort = (p) => {
@@ -263,8 +348,13 @@ function findFreePort(start, attempts) {
263
348
  });
264
349
  }
265
350
 
351
+ // src/lib/errors.ts
352
+ function getErrorMessage(err) {
353
+ return err instanceof Error ? err.message : String(err);
354
+ }
355
+
266
356
  // src/mcp/helpers.ts
267
- var log = createLogger("mcp");
357
+ var log2 = createLogger("mcp");
268
358
  var BASE_URL = process.env.IFRAMER_URL || "http://localhost:3021";
269
359
  var IFRAMER_SECRET = process.env.IFRAMER_SECRET;
270
360
  var IFRAMER_MODE = process.env.IFRAMER_MODE;
@@ -290,7 +380,7 @@ async function apiPost(endpoint, body) {
290
380
  return res.json();
291
381
  }
292
382
  async function apiGet(endpoint) {
293
- const res = await fetch(`${BASE_URL}${endpoint}`, { headers: authHeaders() });
383
+ const res = await fetch(`${BASE_URL}${endpoint}`, { headers: authHeaders(), signal: AbortSignal.timeout(30000) });
294
384
  return res.json();
295
385
  }
296
386
  async function localApiPost(endpoint, body) {
@@ -307,13 +397,13 @@ async function localApiPost(endpoint, body) {
307
397
  async function localApiGet(endpoint) {
308
398
  await ensureLocalServer();
309
399
  const url = localServer.getBaseUrl();
310
- const res = await fetch(`${url}${endpoint}`, { headers: authHeaders(LOCAL_TOKEN) });
400
+ const res = await fetch(`${url}${endpoint}`, { headers: authHeaders(LOCAL_TOKEN), signal: AbortSignal.timeout(30000) });
311
401
  return res.json();
312
402
  }
313
403
  async function localApiDelete(endpoint) {
314
404
  await ensureLocalServer();
315
405
  const url = localServer.getBaseUrl();
316
- const res = await fetch(`${url}${endpoint}`, { method: "DELETE", headers: authHeaders(LOCAL_TOKEN) });
406
+ const res = await fetch(`${url}${endpoint}`, { method: "DELETE", headers: authHeaders(LOCAL_TOKEN), signal: AbortSignal.timeout(30000) });
317
407
  return res.json();
318
408
  }
319
409
  async function isDockerRunning() {
@@ -330,8 +420,8 @@ async function resolveScreenshotPath(url) {
330
420
  if (url.startsWith("file://")) {
331
421
  const { fileURLToPath } = await import("url");
332
422
  const filePath2 = fileURLToPath(url);
333
- const fs4 = await import("fs");
334
- if (fs4.existsSync(filePath2))
423
+ const fs5 = await import("fs");
424
+ if (fs5.existsSync(filePath2))
335
425
  return filePath2;
336
426
  return null;
337
427
  }
@@ -339,11 +429,11 @@ async function resolveScreenshotPath(url) {
339
429
  if (!res.ok)
340
430
  return null;
341
431
  const buf = Buffer.from(await res.arrayBuffer());
342
- const fs3 = await import("fs");
343
- const dir = import_path4.default.join(import_os2.default.tmpdir(), "iframer-screenshots", "screenshots");
344
- fs3.mkdirSync(dir, { recursive: true });
345
- const filePath = import_path4.default.join(dir, `docker-${Date.now()}.jpg`);
346
- fs3.writeFileSync(filePath, buf);
432
+ const fs4 = await import("fs");
433
+ const dir = import_path5.default.join(import_os3.default.tmpdir(), "iframer-screenshots", "screenshots");
434
+ fs4.mkdirSync(dir, { recursive: true });
435
+ const filePath = import_path5.default.join(dir, `docker-${Date.now()}.jpg`);
436
+ fs4.writeFileSync(filePath, buf);
347
437
  return filePath;
348
438
  } catch {
349
439
  return null;
@@ -352,16 +442,13 @@ async function resolveScreenshotPath(url) {
352
442
  function err(message) {
353
443
  return { content: [{ type: "text", text: message }], isError: true };
354
444
  }
355
- function getErrorMessage(err2) {
356
- return err2 instanceof Error ? err2.message : String(err2);
357
- }
358
445
 
359
446
  // src/lib/domain-modes.ts
360
- var import_fs3 = __toESM(require("fs"));
361
- var import_path5 = __toESM(require("path"));
362
- var log2 = createLogger("domain-modes");
447
+ var import_fs4 = __toESM(require("fs"));
448
+ var import_path6 = __toESM(require("path"));
449
+ var log3 = createLogger("domain-modes");
363
450
  function defaultFile() {
364
- return import_path5.default.join(getDataDir(), "domain-modes.json");
451
+ return import_path6.default.join(getDataDir(), "domain-modes.json");
365
452
  }
366
453
  var TTL_DAYS = 14;
367
454
  var ESCALATION_LADDER = ["headless", "docker-headful", "binary-headful"];
@@ -442,8 +529,8 @@ class DomainModeStore {
442
529
  }
443
530
  load() {
444
531
  try {
445
- if (import_fs3.default.existsSync(this.filePath)) {
446
- this.data = JSON.parse(import_fs3.default.readFileSync(this.filePath, "utf-8"));
532
+ if (import_fs4.default.existsSync(this.filePath)) {
533
+ this.data = JSON.parse(import_fs4.default.readFileSync(this.filePath, "utf-8"));
447
534
  }
448
535
  } catch {
449
536
  this.data = {};
@@ -451,10 +538,10 @@ class DomainModeStore {
451
538
  }
452
539
  save() {
453
540
  try {
454
- import_fs3.default.mkdirSync(import_path5.default.dirname(this.filePath), { recursive: true });
455
- import_fs3.default.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
541
+ import_fs4.default.mkdirSync(import_path6.default.dirname(this.filePath), { recursive: true });
542
+ import_fs4.default.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
456
543
  } catch (err2) {
457
- log2.error("Failed to save:", err2);
544
+ log3.error("Failed to save:", err2);
458
545
  }
459
546
  }
460
547
  }
@@ -542,7 +629,7 @@ var import_zod2 = require("zod");
542
629
  var stepSchema = import_zod2.z.discriminatedUnion("type", [
543
630
  import_zod2.z.object({ type: import_zod2.z.literal("navigate"), url: import_zod2.z.string(), waitUntil: import_zod2.z.string().optional() }),
544
631
  import_zod2.z.object({ type: import_zod2.z.literal("click"), selector: import_zod2.z.string() }),
545
- import_zod2.z.object({ type: import_zod2.z.literal("fill"), selector: import_zod2.z.string(), value: import_zod2.z.string() }),
632
+ import_zod2.z.object({ type: import_zod2.z.literal("fill"), selector: import_zod2.z.string(), value: import_zod2.z.string().describe("Sets an input/textarea's value. Framework-aware: fires the React-safe native setter + input/change/blur, so controlled forms (React, react-hook-form, Formik, Vue) register the value AND mark the field touched. This is the fix for 'I filled the field but submit says it's still empty' — always use fill for form fields, not evaluate.") }),
546
633
  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() }),
547
634
  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() }),
548
635
  import_zod2.z.object({ type: import_zod2.z.literal("human-type"), selector: import_zod2.z.string(), value: import_zod2.z.string() }),
@@ -550,8 +637,12 @@ var stepSchema = import_zod2.z.discriminatedUnion("type", [
550
637
  import_zod2.z.object({ type: import_zod2.z.literal("extract"), expression: import_zod2.z.string() }),
551
638
  import_zod2.z.object({ type: import_zod2.z.literal("wait"), ms: import_zod2.z.number() }),
552
639
  import_zod2.z.object({ type: import_zod2.z.literal("wait-for"), selector: import_zod2.z.string(), timeout: import_zod2.z.number().optional() }),
553
- import_zod2.z.object({ type: import_zod2.z.literal("scroll"), deltaY: import_zod2.z.number().optional() }),
554
- import_zod2.z.object({ type: import_zod2.z.literal("keyboard"), key: import_zod2.z.string() }),
640
+ import_zod2.z.object({ type: import_zod2.z.literal("scroll"), deltaY: import_zod2.z.number().optional(), selector: import_zod2.z.string().optional().describe("Scroll within this element instead of the window") }),
641
+ import_zod2.z.object({ type: import_zod2.z.literal("keyboard"), key: import_zod2.z.string(), meta: import_zod2.z.boolean().optional(), ctrl: import_zod2.z.boolean().optional(), shift: import_zod2.z.boolean().optional(), alt: import_zod2.z.boolean().optional() }),
642
+ import_zod2.z.object({ type: import_zod2.z.literal("read"), selector: import_zod2.z.string().optional().describe("Element to read visible text from (CSS or @e ref). Omit for the whole page body."), maxChars: import_zod2.z.number().optional().describe("Cap the returned text length (default 6000). Raise it only when you actually need more — larger reads cost proportionally more context.") }),
643
+ import_zod2.z.object({ type: import_zod2.z.literal("upload"), selector: import_zod2.z.string().describe("The <input type=file> to set (CSS, @e ref, or @a anchor)."), files: import_zod2.z.array(import_zod2.z.string()).describe("Absolute local file path(s) to upload. Paths must exist on this machine (same machine as the browser).") }),
644
+ import_zod2.z.object({ type: import_zod2.z.literal("paste"), selector: import_zod2.z.string().optional().describe("Field to paste the OS clipboard into (CSS, @e ref, or @a anchor). Omit to insert at the currently focused element. Reliable — inserts via CDP Input.insertText, unlike a ⌘V keyboard step (the extension relay ignores modifier keys).") }),
645
+ import_zod2.z.object({ type: import_zod2.z.literal("download"), url: import_zod2.z.string().describe("URL of the file to download. Fetched through the browser's session (cookies included, so auth'd downloads work) and written to disk server-side — no Save-As dialog."), path: import_zod2.z.string().optional().describe("Absolute local path to save to. Omit to save into ~/.iframer/downloads/ with the URL's filename. Read the returned path to open the file.") }),
555
646
  import_zod2.z.object({ type: import_zod2.z.literal("type-code"), value: import_zod2.z.string(), selector: import_zod2.z.string().optional() }),
556
647
  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() }),
557
648
  import_zod2.z.object({ type: import_zod2.z.literal("solve-captcha") }),
@@ -567,6 +658,9 @@ var stepSchema = import_zod2.z.discriminatedUnion("type", [
567
658
  ]);
568
659
 
569
660
  // src/mcp/tools/execute.ts
661
+ function resultOf(r, _type) {
662
+ return r.result;
663
+ }
570
664
  function registerExecuteTool(server) {
571
665
  server.tool("execute", `Execute a pipeline of browser steps. Auto-starts a session if needed. Handles obstacles (captcha, cookie banners) automatically.
572
666
 
@@ -585,6 +679,10 @@ Key step types:
585
679
 
586
680
  IMPORTANT — Element refs (@e1, @e2...): All selector fields accept @e refs from snapshot, find, or annotated screenshot. PREFER refs over CSS selectors.
587
681
 
682
+ IMPORTANT — Saved anchors (@a:<name>): Selector fields also accept persisted, per-domain anchors from the \`remember\` tool. Call \`remember get <domain>\` before a UI task; if an anchor exists, target it directly (e.g. selector="@a:composer") — no snapshot needed. After finding a new element that works, \`remember save\` it. Unlike @e refs (which reset every snapshot), @a: anchors persist across runs.
683
+
684
+ FILLING FORMS: Always use the \`fill\` step for text inputs/textareas — NOT \`evaluate\` to set .value. \`fill\` is framework-aware: it fires the React-safe native setter plus input/change/blur, so controlled forms (React, react-hook-form, Formik, Vue) actually register the value and mark the field "touched". This prevents the common "I filled every field but submit still says they're required/empty" failure — which is a form-framework state issue, not a real empty field. If a submit is still rejected as incomplete after filling, re-run \`fill\` on the flagged field (it re-triggers the blur/validation) rather than assuming the value didn't land.
685
+
588
686
  Returns: ok, completedSteps, results, obstacles, capturedApi, and on failure: errorContext with screenshot path, URL, errorType, suggestion, retryable.`, {
589
687
  steps: import_zod3.z.array(stepSchema).describe("Pipeline steps to execute sequentially"),
590
688
  options: import_zod3.z.object({
@@ -593,12 +691,33 @@ Returns: ok, completedSteps, results, obstacles, capturedApi, and on failure: er
593
691
  continueOnObstacle: import_zod3.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
594
692
  continueOnError: import_zod3.z.boolean().optional().describe("Continue past failing steps (default: false)"),
595
693
  captureApi: import_zod3.z.boolean().optional().describe("Record all API calls (XHR/fetch) the page makes."),
596
- 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."),
694
+ mode: import_zod3.z.enum(["headless", "binary-headful", "docker-headful", "extension"]).optional().describe("DO NOT SET THIS unless user explicitly requests a mode. iframer auto-selects and auto-escalates. Use 'extension' ONLY to drive a tab in the user's real Chrome via the iframer extension — requires options.tabId (get it from the `tabs` tool)."),
597
695
  autoEscalate: import_zod3.z.boolean().optional().describe("Auto-retry with a stronger mode if blocked (default: true)"),
598
- 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.")
696
+ instanceId: import_zod3.z.string().optional().describe("Run in a named parallel browser within this session (default: 'default'). Use distinct ids to drive several browsers at once, e.g. one per account — each keeps its own login/session state."),
697
+ tabId: import_zod3.z.number().optional().describe("Only with mode='extension': the id of the real Chrome tab to drive (from the `tabs` tool). Input is trusted OS-level (chrome.debugger); Chrome shows its 'is being debugged' bar while the run is in progress."),
698
+ clientId: import_zod3.z.string().optional().describe("Only with mode='extension', and only needed when several profiles/browsers are connected and a tab is ambiguous: the clientId of the profile that owns the tab (from the `tabs` tool)."),
699
+ focus: import_zod3.z.boolean().optional().describe("Only with mode='extension': bring the tab's window to the OS foreground while driving. Default false — the tab is driven in the background (activated in its window, focus-emulated) without interrupting the user. Set true only if a site ignores background input.")
599
700
  }).optional()
600
701
  }, async (params) => {
601
702
  try {
703
+ if (params.options?.mode === "extension") {
704
+ const tabId = params.options?.tabId;
705
+ if (typeof tabId !== "number") {
706
+ return err("mode='extension' requires options.tabId. Call the `tabs` tool first to " + "find the id of the tab the user wants to drive.");
707
+ }
708
+ const extResult = await localApiPost("/extension/execute", {
709
+ tabId,
710
+ clientId: params.options?.clientId,
711
+ steps: params.steps,
712
+ options: params.options
713
+ });
714
+ const extLines = formatExecuteResult(extResult);
715
+ const content2 = [{ type: "text", text: extLines.join(`
716
+ `) }];
717
+ if (!extResult.ok)
718
+ return { content: content2, isError: true };
719
+ return { content: content2 };
720
+ }
602
721
  const dockerRunning = await isDockerRunning();
603
722
  async function runWithMode(mode) {
604
723
  if (mode === "docker-headful") {
@@ -638,9 +757,9 @@ Returns: ok, completedSteps, results, obstacles, capturedApi, and on failure: er
638
757
  execResult = await runWithMode(params.options?.mode);
639
758
  } catch (execErr) {
640
759
  const msg = execErr instanceof Error ? execErr.message : String(execErr);
641
- const isCrash = /connect|ECONNREFUSED|EPIPE|closed|timed out|abort|fetch failed/i.test(msg);
760
+ const isCrash = /ECONNREFUSED|ECONNRESET|EPIPE|socket hang up|fetch failed/i.test(msg);
642
761
  if (isCrash) {
643
- log.info(`Execute crashed (${msg.slice(0, 80)}), restarting local server and retrying...`);
762
+ log2.info(`Execute crashed (${msg.slice(0, 80)}), restarting local server and retrying...`);
644
763
  try {
645
764
  await localServer.restart();
646
765
  } catch {}
@@ -663,7 +782,7 @@ Retry: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}
663
782
  for (const nextMode of escalation) {
664
783
  if (nextMode === "docker-headful" && !dockerRunning)
665
784
  continue;
666
- log.info(`Auto-escalating to ${nextMode}`);
785
+ log2.info(`Auto-escalating to ${nextMode}`);
667
786
  execResult = await runWithMode(nextMode);
668
787
  if (execResult.ok)
669
788
  break;
@@ -716,22 +835,44 @@ Final page: ${data.finalState.title}`);
716
835
  Knowledge cache updated for ${domain}. Before the next browser run on this domain, call \`knowledge get ${domain}\` — you may be able to skip the browser entirely.`);
717
836
  } catch {}
718
837
  }
838
+ for (const r of data.results || []) {
839
+ if (r.tabSwitchedTo) {
840
+ lines.push(`
841
+ ↳ step ${r.stepIndex} opened a new tab — pipeline is now on: ${r.tabSwitchedTo}`);
842
+ }
843
+ }
719
844
  const meaningful = (data.results || []).filter((r) => r.ok && r.result !== undefined && r.result !== null);
720
845
  for (const r of meaningful) {
721
- if (r.step?.type === "snapshot" && r.result?.snapshot) {
722
- lines.push(`
723
- --- Snapshot (${r.result.elementCount} elements) ---`);
724
- lines.push(r.result.snapshot);
725
- } else if (r.step?.type === "find" && r.result?.ref) {
726
- lines.push(`
727
- Found: ${r.result.ref} ${r.result.role} "${r.result.name}" (${r.result.matchCount} match${r.result.matchCount > 1 ? "es" : ""})`);
728
- } else if (r.step?.type === "screenshot" && r.result?.refs) {
729
- lines.push(`
846
+ if (r.step.type === "snapshot") {
847
+ const res = resultOf(r, "snapshot");
848
+ if (res?.snapshot) {
849
+ lines.push(`
850
+ --- Snapshot (${res.elementCount} elements) ---`);
851
+ lines.push(res.snapshot);
852
+ }
853
+ } else if (r.step.type === "find") {
854
+ const res = resultOf(r, "find");
855
+ if (res?.ref) {
856
+ lines.push(`
857
+ Found: ${res.ref} ${res.role} "${res.name}" (${res.matchCount} match${res.matchCount > 1 ? "es" : ""})`);
858
+ }
859
+ } else if (r.step.type === "screenshot") {
860
+ const res = resultOf(r, "screenshot");
861
+ if (res?.refs) {
862
+ lines.push(`
730
863
  --- Annotated screenshot refs ---`);
731
- lines.push(r.result.refs);
732
- } else if (r.result !== undefined && r.result !== null) {
864
+ lines.push(res.refs);
865
+ }
866
+ } else if (r.step.type === "read") {
867
+ const res = r.result;
868
+ if (res?.text !== undefined) {
869
+ lines.push(`
870
+ --- Read (step ${r.stepIndex}${res.truncated ? ", truncated" : ""}) ---`);
871
+ lines.push(res.text);
872
+ }
873
+ } else if (r.step.type === "extract" || r.step.type === "evaluate") {
733
874
  lines.push(`
734
- step ${r.stepIndex}: ${JSON.stringify(r.result)}`);
875
+ step ${r.stepIndex} (${r.step.type}): ${JSON.stringify(r.result)}`);
735
876
  }
736
877
  }
737
878
  if (data.obstacles && data.obstacles.length > 0) {
@@ -774,8 +915,8 @@ ${api.domain} (${api.baseUrl})`);
774
915
 
775
916
  // src/mcp/tools/session.ts
776
917
  var import_zod5 = require("zod");
777
- var import_path6 = __toESM(require("path"));
778
- var import_fs4 = __toESM(require("fs"));
918
+ var import_path7 = __toESM(require("path"));
919
+ var import_fs5 = __toESM(require("fs"));
779
920
 
780
921
  // src/mcp/tools/reverse-engineer.ts
781
922
  var import_zod4 = require("zod");
@@ -830,7 +971,9 @@ The outputDir defaults to ./<domain>/. Ask the user where to save if unclear.`,
830
971
  staleTimeoutMs: import_zod4.z.number().optional().describe("Override the 20s stale-state timeout per step"),
831
972
  continueOnObstacle: import_zod4.z.boolean().optional().describe("Try to auto-resolve obstacles (default: true)"),
832
973
  continueOnError: import_zod4.z.boolean().optional().describe("Continue past failing steps (default: false)"),
833
- mode: import_zod4.z.enum(["headless", "binary-headful", "docker-headful"]).optional().describe("Browser mode override")
974
+ mode: import_zod4.z.enum(["headless", "binary-headful", "docker-headful", "extension"]).optional().describe("Browser mode override. Use 'extension' to capture the API of a tab already open in the user's real Chrome — requires options.tabId from the `tabs` tool. Chrome shows its 'is being debugged' bar while the capture runs."),
975
+ tabId: import_zod4.z.number().optional().describe("Only with mode='extension': the real Chrome tab to reverse-engineer (from the `tabs` tool)."),
976
+ clientId: import_zod4.z.string().optional().describe("Only with mode='extension', when multiple profiles are connected and the tab is ambiguous: the owning profile's clientId (from the `tabs` tool).")
834
977
  }).optional()
835
978
  }, async (params) => {
836
979
  try {
@@ -840,7 +983,22 @@ The outputDir defaults to ./<domain>/. Ask the user where to save if unclear.`,
840
983
  };
841
984
  const mode = params.options?.mode;
842
985
  const dockerRunning = await isDockerRunning();
843
- const captureResult = mode === "docker-headful" && dockerRunning ? await apiPost("/execute", execParams) : await localApiPost("/execute", execParams);
986
+ let captureResult;
987
+ if (mode === "extension") {
988
+ if (typeof params.options?.tabId !== "number") {
989
+ return err("mode='extension' requires options.tabId. Call the `tabs` tool first to find the tab to reverse-engineer.");
990
+ }
991
+ captureResult = await localApiPost("/extension/execute", {
992
+ tabId: params.options.tabId,
993
+ clientId: params.options.clientId,
994
+ steps: params.steps,
995
+ options: { ...params.options, captureApi: true }
996
+ });
997
+ } else if (mode === "docker-headful" && dockerRunning) {
998
+ captureResult = await apiPost("/execute", execParams);
999
+ } else {
1000
+ captureResult = await localApiPost("/execute", execParams);
1001
+ }
844
1002
  const lines = [];
845
1003
  lines.push(`ok: ${captureResult.ok}`);
846
1004
  lines.push(`steps: ${captureResult.completedSteps}/${captureResult.totalSteps}`);
@@ -883,11 +1041,11 @@ Screenshot saved: ${filePath}`);
883
1041
  const mainDomain = captureResult.capturedApi[0]?.domain || "api";
884
1042
  const outDir = params.outputDir || `./${mainDomain}`;
885
1043
  try {
886
- const fs4 = await import("fs");
887
- const path6 = await import("path");
888
- fs4.mkdirSync(outDir, { recursive: true });
889
- const jsonPath = path6.join(outDir, "captured-api.json");
890
- fs4.writeFileSync(jsonPath, JSON.stringify(captureResult.capturedApi, null, 2));
1044
+ const fs5 = await import("fs");
1045
+ const path7 = await import("path");
1046
+ fs5.mkdirSync(outDir, { recursive: true });
1047
+ const jsonPath = path7.join(outDir, "captured-api.json");
1048
+ fs5.writeFileSync(jsonPath, JSON.stringify(captureResult.capturedApi, null, 2));
891
1049
  lines.push(`
892
1050
  Full captured data saved to: ${jsonPath}`);
893
1051
  lines.push("Read this file for complete curl commands, request/response bodies, and auth data.");
@@ -898,10 +1056,10 @@ Full captured data saved to: ${jsonPath}`);
898
1056
  }
899
1057
  let text = lines.join(`
900
1058
  `);
901
- if (text.length > 80000) {
902
- text = text.slice(0, 80000) + `
1059
+ if (text.length > 30000) {
1060
+ text = text.slice(0, 30000) + `
903
1061
 
904
- [... response truncated — read captured-api.json for full data]`;
1062
+ [... index truncated — read captured-api.json for the full endpoint list and all detail]`;
905
1063
  }
906
1064
  const content = [{ type: "text", text }];
907
1065
  if (!captureResult.ok)
@@ -941,18 +1099,11 @@ Auth:`);
941
1099
  }
942
1100
  const protocolSummary = Array.from(byProtocol.entries()).map(([p, eps]) => `${p}=${eps.length}`).join(", ");
943
1101
  lines.push(`
944
- Endpoints (${api.endpoints.length}) [${protocolSummary}]:`);
1102
+ Endpoints (${api.endpoints.length}) [${protocolSummary}] — full detail in captured-api.json:`);
945
1103
  for (const ep of api.endpoints) {
946
- lines.push(`
947
- [${ep.protocol}/${ep.verb}] ${ep.functionName}`);
948
- lines.push(` Action: ${ep.action}`);
949
- lines.push(` ${ep.method} ${ep.path} → ${ep.responseStatus}`);
950
- if (ep.requestBody) {
951
- const body = ep.requestBody;
952
- const signalKeys = extractSignalKeys(body);
953
- if (signalKeys)
954
- lines.push(` Params: ${signalKeys}`);
955
- }
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} → ${ep.functionName}${tail}`);
956
1107
  }
957
1108
  }
958
1109
  const mainDomain = capturedApi[0]?.domain || "api";
@@ -1029,6 +1180,9 @@ function extractSignalKeys(body) {
1029
1180
  continue;
1030
1181
  if (typeof v === "string" && v.length > 80) {
1031
1182
  signalEntries.push(`${k}=${v.slice(0, 40)}...`);
1183
+ } else if (Array.isArray(v)) {
1184
+ const items = v.slice(0, 8).map((x) => String(x)).join(",");
1185
+ signalEntries.push(`${k}=[${items}${v.length > 8 ? ",…" : ""}]`);
1032
1186
  } else if (typeof v === "object" && v !== null) {
1033
1187
  const keys = Object.keys(v).slice(0, 5).join(",");
1034
1188
  signalEntries.push(`${k}={${keys}${Object.keys(v).length > 5 ? ",..." : ""}}`);
@@ -1099,11 +1253,11 @@ Sessions live in the single local SQLite database (~/.iframer/iframer.db), share
1099
1253
  const lines = [result.message];
1100
1254
  if (result.capturedApi && result.capturedApi.length > 0) {
1101
1255
  formatCapturedApi(lines, result.capturedApi, { outputDir, typed: false });
1102
- const outDir = outputDir || import_path6.default.join(getDataDir(), "capture");
1256
+ const outDir = outputDir || import_path7.default.join(getDataDir(), "capture");
1103
1257
  try {
1104
- import_fs4.default.mkdirSync(outDir, { recursive: true });
1105
- const jsonPath = import_path6.default.join(outDir, "captured-api.json");
1106
- import_fs4.default.writeFileSync(jsonPath, JSON.stringify(result.capturedApi, null, 2));
1258
+ import_fs5.default.mkdirSync(outDir, { recursive: true });
1259
+ const jsonPath = import_path7.default.join(outDir, "captured-api.json");
1260
+ import_fs5.default.writeFileSync(jsonPath, JSON.stringify(result.capturedApi, null, 2));
1107
1261
  lines.push(`
1108
1262
  Full data saved to: ${jsonPath}`);
1109
1263
  } catch (writeErr) {
@@ -1173,19 +1327,19 @@ Full data saved to: ${jsonPath}`);
1173
1327
 
1174
1328
  // src/mcp/tools/credentials.ts
1175
1329
  var import_zod6 = require("zod");
1176
- var import_fs6 = __toESM(require("fs"));
1177
- var import_path8 = __toESM(require("path"));
1330
+ var import_fs7 = __toESM(require("fs"));
1331
+ var import_path9 = __toESM(require("path"));
1178
1332
 
1179
1333
  // src/lib/knowledge.ts
1180
- var import_fs5 = __toESM(require("fs"));
1181
- var import_path7 = __toESM(require("path"));
1182
- var log3 = createLogger("knowledge");
1334
+ var import_fs6 = __toESM(require("fs"));
1335
+ var import_path8 = __toESM(require("path"));
1336
+ var log4 = createLogger("knowledge");
1183
1337
  function getKnowledgeDir() {
1184
- return import_path7.default.join(getDataDir(), "knowledge");
1338
+ return import_path8.default.join(getDataDir(), "knowledge");
1185
1339
  }
1186
1340
  function getKnowledgePath(domain) {
1187
1341
  const safe = sanitizeDomain(domain);
1188
- return import_path7.default.join(getKnowledgeDir(), `${safe}.md`);
1342
+ return import_path8.default.join(getKnowledgeDir(), `${safe}.md`);
1189
1343
  }
1190
1344
  function normalizeDomain(input) {
1191
1345
  let d = (input || "").trim().toLowerCase();
@@ -1209,7 +1363,7 @@ function sanitizeDomain(input) {
1209
1363
  function readKnowledge(domain) {
1210
1364
  const p = getKnowledgePath(domain);
1211
1365
  try {
1212
- return import_fs5.default.readFileSync(p, "utf8");
1366
+ return import_fs6.default.readFileSync(p, "utf8");
1213
1367
  } catch {
1214
1368
  return null;
1215
1369
  }
@@ -1218,7 +1372,7 @@ function listKnowledge() {
1218
1372
  const dir = getKnowledgeDir();
1219
1373
  let entries = [];
1220
1374
  try {
1221
- entries = import_fs5.default.readdirSync(dir);
1375
+ entries = import_fs6.default.readdirSync(dir);
1222
1376
  } catch {
1223
1377
  return [];
1224
1378
  }
@@ -1226,10 +1380,10 @@ function listKnowledge() {
1226
1380
  for (const file of entries) {
1227
1381
  if (!file.endsWith(".md"))
1228
1382
  continue;
1229
- const full = import_path7.default.join(dir, file);
1383
+ const full = import_path8.default.join(dir, file);
1230
1384
  try {
1231
- const stat = import_fs5.default.statSync(full);
1232
- const raw = import_fs5.default.readFileSync(full, "utf8");
1385
+ const stat = import_fs6.default.statSync(full);
1386
+ const raw = import_fs6.default.readFileSync(full, "utf8");
1233
1387
  const parsed = parseMarkdown(raw);
1234
1388
  results.push({
1235
1389
  domain: parsed?.domain ?? file.replace(/\.md$/, ""),
@@ -1247,7 +1401,7 @@ function clearKnowledge(domain) {
1247
1401
  if (domain) {
1248
1402
  const p = getKnowledgePath(domain);
1249
1403
  try {
1250
- import_fs5.default.unlinkSync(p);
1404
+ import_fs6.default.unlinkSync(p);
1251
1405
  return { removed: 1 };
1252
1406
  } catch {
1253
1407
  return { removed: 0 };
@@ -1255,11 +1409,11 @@ function clearKnowledge(domain) {
1255
1409
  }
1256
1410
  let removed = 0;
1257
1411
  try {
1258
- const entries = import_fs5.default.readdirSync(dir);
1412
+ const entries = import_fs6.default.readdirSync(dir);
1259
1413
  for (const f of entries) {
1260
1414
  if (f.endsWith(".md")) {
1261
1415
  try {
1262
- import_fs5.default.unlinkSync(import_path7.default.join(dir, f));
1416
+ import_fs6.default.unlinkSync(import_path8.default.join(dir, f));
1263
1417
  removed++;
1264
1418
  } catch {}
1265
1419
  }
@@ -1339,8 +1493,8 @@ var ELICIT_TIMEOUT_MS = 45000;
1339
1493
  function mcpLog(event, data) {
1340
1494
  try {
1341
1495
  const dir = getDataDir();
1342
- import_fs6.default.mkdirSync(dir, { recursive: true });
1343
- import_fs6.default.appendFileSync(import_path8.default.join(dir, "mcp.log"), JSON.stringify({ ts: new Date().toISOString(), event, ...data }) + `
1496
+ import_fs7.default.mkdirSync(dir, { recursive: true });
1497
+ import_fs7.default.appendFileSync(import_path9.default.join(dir, "mcp.log"), JSON.stringify({ ts: new Date().toISOString(), event, ...data }) + `
1344
1498
  `);
1345
1499
  } catch {}
1346
1500
  }
@@ -1540,6 +1694,452 @@ Actions:
1540
1694
  });
1541
1695
  }
1542
1696
 
1697
+ // src/mcp/tools/tabs.ts
1698
+ var import_zod8 = require("zod");
1699
+ function registerTabsTool(server) {
1700
+ server.tool("tabs", `List the tabs currently open in the user's REAL Chrome browser, through the iframer browser extension.
1701
+
1702
+ Use this when the user says something like "use my open tab", "the tab I have here", "my Gmail tab", or references a site/screenshot they already have open. Match their reference against the returned tabs (by url or title), pick the tab id, then call \`execute\` with options.mode="extension" and options.tabId=<id> to drive that exact tab on their real logged-in session (Chrome shows its "is being debugged" bar while a run is in progress).
1703
+
1704
+ Requires the iframer Chrome extension to be installed and connected (paired once with the token). Once connected, iframer can see and drive any open tab. If nothing is connected, this returns a clear message telling the user how to connect.
1705
+
1706
+ Returns: connected (bool), and tabs: [{ id, title, url, active, windowId }]. If several tabs match the user's reference, ask them which one rather than guessing.
1707
+
1708
+ action="open" opens a NEW tab in the user's real Chrome (native — not a page popup) at options.url, and returns its tab id so you can immediately drive it with \`execute\` mode="extension".
1709
+
1710
+ Tab GROUP management (native Chrome tab groups):
1711
+ - action="group": put tabIds into a group (new, or add to an existing groupId); optionally title/color/collapsed.
1712
+ - action="ungroup": remove tabIds from their group.
1713
+ - action="update-group": rename/recolor/collapse an EXISTING group by groupId (no tabIds needed).
1714
+ - action="groups": list all current groups (id, title, color, collapsed) — use to find a groupId to update.`, {
1715
+ action: import_zod8.z.enum(["list", "open", "group", "ungroup", "update-group", "groups"]).optional().describe("'list' (default) lists open tabs; 'open' opens a new tab; 'group'/'ungroup' add/remove tabIds to a group; 'update-group' renames/recolors an existing group by groupId; 'groups' lists all groups."),
1716
+ url: import_zod8.z.string().optional().describe("With action='open': the URL to open (omit for a blank tab)."),
1717
+ active: import_zod8.z.boolean().optional().describe("With action='open': focus the new tab (default true)."),
1718
+ tabIds: import_zod8.z.array(import_zod8.z.number()).optional().describe("With action='group': the tab ids to group (must be in the same window)."),
1719
+ title: import_zod8.z.string().optional().describe("With action='group': the group's label."),
1720
+ color: import_zod8.z.enum(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]).optional().describe("With action='group': the group's color."),
1721
+ collapsed: import_zod8.z.boolean().optional().describe("With action='group': collapse the group."),
1722
+ groupId: import_zod8.z.number().optional().describe("With action='group': add tabs to this existing group instead of creating a new one."),
1723
+ clientId: import_zod8.z.string().optional().describe("With action='open'/'group' and multiple profiles connected: which profile to act in."),
1724
+ filter: import_zod8.z.string().optional().describe("With action='list': case-insensitive substring to match against tab url or title (e.g. 'gmail', 'github.com'). Omit to list every open tab.")
1725
+ }, async ({ action, url, active, tabIds, title, color, collapsed, groupId, clientId, filter }) => {
1726
+ try {
1727
+ if (action === "open") {
1728
+ const res = await localApiPost("/extension/tab/create", { url, active, clientId });
1729
+ if (!res.ok || !res.tab)
1730
+ return err(res.error || "Failed to open tab.");
1731
+ const t = res.tab;
1732
+ return {
1733
+ content: [
1734
+ {
1735
+ type: "text",
1736
+ text: `Opened tab [id ${t.id}] ${t.title || t.url}
1737
+ ${t.url}
1738
+ Drive it with: execute mode="extension", tabId=${t.id}.`
1739
+ }
1740
+ ]
1741
+ };
1742
+ }
1743
+ if (action === "group") {
1744
+ if (!tabIds || tabIds.length === 0)
1745
+ return err("action='group' requires tabIds (array of tab ids).");
1746
+ const res = await localApiPost("/extension/tab/group", { tabIds, title, color, collapsed, groupId, clientId });
1747
+ if (!res.ok || !res.group)
1748
+ return err(res.error || "Failed to group tabs.");
1749
+ const g = res.group;
1750
+ return {
1751
+ content: [
1752
+ {
1753
+ type: "text",
1754
+ text: `Grouped ${tabIds.length} tab(s) into group ${g.groupId}${g.title ? ` "${g.title}"` : ""}${g.color ? ` (${g.color})` : ""}${g.collapsed ? ", collapsed" : ""}.`
1755
+ }
1756
+ ]
1757
+ };
1758
+ }
1759
+ if (action === "ungroup") {
1760
+ if (!tabIds || tabIds.length === 0)
1761
+ return err("action='ungroup' requires tabIds.");
1762
+ const res = await localApiPost("/extension/tab/ungroup", { tabIds, clientId });
1763
+ if (!res.ok)
1764
+ return err(res.error || "Failed to ungroup tabs.");
1765
+ return { content: [{ type: "text", text: `Ungrouped ${tabIds.length} tab(s).` }] };
1766
+ }
1767
+ if (action === "update-group") {
1768
+ if (typeof groupId !== "number")
1769
+ return err("action='update-group' requires groupId (from action='groups'). Pass any of title, color, collapsed.");
1770
+ const res = await localApiPost("/extension/group/update", { groupId, title, color, collapsed, clientId });
1771
+ if (!res.ok || !res.group)
1772
+ return err(res.error || "Failed to update group.");
1773
+ const g = res.group;
1774
+ return { content: [{ type: "text", text: `Updated group ${g.groupId} → title "${g.title}", color ${g.color}${g.collapsed ? ", collapsed" : ""}.` }] };
1775
+ }
1776
+ if (action === "groups") {
1777
+ const res = await localApiPost("/extension/groups", { clientId });
1778
+ if (!res.ok)
1779
+ return err(res.error || "Failed to list groups.");
1780
+ const groups = res.groups || [];
1781
+ if (groups.length === 0)
1782
+ return { content: [{ type: "text", text: "No tab groups open." }] };
1783
+ const lines2 = [`${groups.length} tab group(s):`, ""];
1784
+ for (const g of groups)
1785
+ lines2.push(` [group ${g.groupId}] "${g.title || "(untitled)"}" — ${g.color}${g.collapsed ? ", collapsed" : ""} (window ${g.windowId})`);
1786
+ return { content: [{ type: "text", text: lines2.join(`
1787
+ `) }] };
1788
+ }
1789
+ const status = await localApiGet("/extension/status");
1790
+ if (!status.connected) {
1791
+ return err(`No iframer extension is connected.
1792
+
1793
+ ` + `To use your real Chrome tabs:
1794
+ ` + "1. Install the iframer extension (chrome://extensions → Load unpacked → the `extension/` folder).\n" + "2. Run `iframer install extension` in a terminal, then restart the browser — the extension pairs itself.\n" + " (Manual fallback: click the iframer icon and paste the token from `cat ~/.iframer/secret`.)\n" + "Once the dot is green, run `tabs` again — iframer can then see and drive any open tab.");
1795
+ }
1796
+ const data = await localApiPost("/extension/tabs", {});
1797
+ let tabs = data.tabs || [];
1798
+ const clients = data.clients || [];
1799
+ const multiProfile = clients.length > 1;
1800
+ if (filter) {
1801
+ const f = filter.toLowerCase();
1802
+ tabs = tabs.filter((t) => t.url.toLowerCase().includes(f) || (t.title || "").toLowerCase().includes(f));
1803
+ }
1804
+ if (tabs.length === 0) {
1805
+ return {
1806
+ content: [
1807
+ {
1808
+ type: "text",
1809
+ text: filter ? `Extension connected, but no open tab matches "${filter}".` : "Extension connected, but no tabs were reported."
1810
+ }
1811
+ ]
1812
+ };
1813
+ }
1814
+ const profiles = clients.map((c) => c.profileName || c.clientId.slice(0, 8)).join(", ");
1815
+ const ver = clients[0]?.extVersion ? ` [ext v${clients[0].extVersion}]` : "";
1816
+ const lines = [
1817
+ `Connected: ${clients.length} profile${clients.length > 1 ? "s" : ""}${profiles ? ` (${profiles})` : ""}${ver}.`,
1818
+ `${tabs.length} tab${tabs.length > 1 ? "s" : ""}${filter ? ` matching "${filter}"` : ""}:`,
1819
+ ""
1820
+ ];
1821
+ for (const t of tabs) {
1822
+ const prof = multiProfile ? ` «${t.profileName || t.clientId.slice(0, 8)}»` : "";
1823
+ lines.push(` [id ${t.id}]${t.active ? " (active)" : ""}${prof} ${t.title}`);
1824
+ lines.push(` ${t.url}`);
1825
+ if (multiProfile)
1826
+ lines.push(` clientId: ${t.clientId}`);
1827
+ }
1828
+ lines.push("");
1829
+ lines.push('To drive one: execute with options.mode="extension", options.tabId=<id>.');
1830
+ if (multiProfile) {
1831
+ lines.push("Multiple profiles are connected — if two tabs share a title, also pass options.clientId " + "(shown as «profile» above maps to a clientId) so the right profile is driven.");
1832
+ }
1833
+ return { content: [{ type: "text", text: lines.join(`
1834
+ `) }] };
1835
+ } catch (e) {
1836
+ return err(`Error listing tabs: ${getErrorMessage(e)}`);
1837
+ }
1838
+ });
1839
+ }
1840
+
1841
+ // src/mcp/tools/anchors.ts
1842
+ var import_zod9 = require("zod");
1843
+
1844
+ // src/lib/knowledge/component-map.ts
1845
+ var import_fs8 = __toESM(require("fs"));
1846
+ var import_path10 = __toESM(require("path"));
1847
+ function anchorsPath(domain) {
1848
+ return import_path10.default.join(getKnowledgeDir(), `${sanitizeDomain(domain)}.anchors.json`);
1849
+ }
1850
+ function loadComponentMap(domain) {
1851
+ const norm = normalizeDomain(domain);
1852
+ try {
1853
+ const raw = import_fs8.default.readFileSync(anchorsPath(norm), "utf8");
1854
+ const parsed = JSON.parse(raw);
1855
+ return {
1856
+ domain: parsed.domain || norm,
1857
+ anchors: parsed.anchors || {},
1858
+ quirks: Array.isArray(parsed.quirks) ? parsed.quirks : []
1859
+ };
1860
+ } catch {
1861
+ return { domain: norm, anchors: {}, quirks: [] };
1862
+ }
1863
+ }
1864
+ function write(cm) {
1865
+ import_fs8.default.mkdirSync(getKnowledgeDir(), { recursive: true });
1866
+ import_fs8.default.writeFileSync(anchorsPath(cm.domain), JSON.stringify(cm, null, 2), "utf8");
1867
+ }
1868
+ function saveAnchor(domain, input, now) {
1869
+ const cm = loadComponentMap(domain);
1870
+ cm.anchors[input.name] = {
1871
+ name: input.name,
1872
+ selector: input.selector,
1873
+ role: input.role,
1874
+ description: input.description,
1875
+ quirks: input.quirks && input.quirks.length ? input.quirks : undefined,
1876
+ uses: 0,
1877
+ fails: 0,
1878
+ lastVerified: now
1879
+ };
1880
+ write(cm);
1881
+ }
1882
+ function removeAnchor(domain, name) {
1883
+ const cm = loadComponentMap(domain);
1884
+ if (!(name in cm.anchors))
1885
+ return false;
1886
+ delete cm.anchors[name];
1887
+ write(cm);
1888
+ return true;
1889
+ }
1890
+ function setDomainQuirks(domain, quirks) {
1891
+ const cm = loadComponentMap(domain);
1892
+ cm.quirks = Array.from(new Set([...cm.quirks, ...quirks]));
1893
+ write(cm);
1894
+ }
1895
+ function listAnchorDomains() {
1896
+ try {
1897
+ return import_fs8.default.readdirSync(getKnowledgeDir()).filter((f) => f.endsWith(".anchors.json")).map((f) => f.replace(/\.anchors\.json$/, ""));
1898
+ } catch {
1899
+ return [];
1900
+ }
1901
+ }
1902
+
1903
+ // src/mcp/tools/anchors.ts
1904
+ function registerRememberTool(server) {
1905
+ server.tool("remember", `Persisted per-domain map of a website's UI elements ("anchors"), so the agent recalls where things are instead of re-exploring the DOM every run.
1906
+
1907
+ WORKFLOW:
1908
+ 1. Before a UI task on a site, call \`remember get <domain>\`. If an anchor exists (e.g. "composer", "send-button"), use it directly: put \`@a:<name>\` in any selector field of an \`execute\` step (click/fill/etc.). It resolves to the saved selector — no snapshot needed.
1909
+ 2. If the site is new or the anchor you need is missing, discover the element with a \`snapshot\` or \`find\` step, act on it, then \`remember save\` it (name + the selector that worked, plus any quirks) so future runs are instant.
1910
+ 3. SELF-HEAL: if a step using \`@a:<name>\` FAILS, the page changed — do NOT keep retrying the same anchor. Re-discover with snapshot/find, then \`remember save\` the new selector under the same name (overwrites, resets the health counters). Prefer stable selectors (aria-label, data-qa, role+name) over brittle generated CSS.
1911
+ 4. Record site quirks with \`remember quirk\` (e.g. "synthetic clicks ignored — use trusted/coordinate clicks", "@here triggers a confirmation modal — click Send"). \`get\` surfaces them so the agent isn't surprised.
1912
+
1913
+ Anchors live at ~/.iframer/knowledge/<domain>.anchors.json, alongside the API knowledge cache.`, {
1914
+ action: import_zod9.z.enum(["get", "save", "forget", "list", "quirk"]).describe("get: show a domain's anchors+quirks | save: create/overwrite an anchor | forget: delete an anchor | list: all domains with anchors | quirk: add site-wide quirk note(s)"),
1915
+ domain: import_zod9.z.string().optional().describe("Site domain, e.g. 'slack.com' or 'app.slack.com' (required for all actions except list)."),
1916
+ name: import_zod9.z.string().optional().describe("Anchor name for save/forget, e.g. 'composer', 'send-button', 'search'. Short, stable, kebab-case."),
1917
+ selector: import_zod9.z.string().optional().describe("With save: the CSS selector that locates the element. Prefer stable attributes: [aria-label=...], [data-qa=...], role+name. Avoid brittle generated class chains."),
1918
+ role: import_zod9.z.string().optional().describe("With save (optional): the element role, e.g. textbox, button, link."),
1919
+ description: import_zod9.z.string().optional().describe("With save (optional): a short human note about the element."),
1920
+ quirks: import_zod9.z.array(import_zod9.z.string()).optional().describe("With save: element-specific gotchas. With quirk: site-wide gotchas to append.")
1921
+ }, async ({ action, domain, name, selector, role, description, quirks }) => {
1922
+ try {
1923
+ if (action === "list") {
1924
+ const domains = listAnchorDomains();
1925
+ if (domains.length === 0) {
1926
+ return { content: [{ type: "text", text: "No anchors saved yet. Use `remember save` after locating an element with snapshot/find." }] };
1927
+ }
1928
+ const lines = [`Domains with saved anchors:
1929
+ `];
1930
+ for (const d of domains) {
1931
+ const cm = loadComponentMap(d);
1932
+ lines.push(` ${d} — ${Object.keys(cm.anchors).length} anchor(s)${cm.quirks.length ? `, ${cm.quirks.length} quirk(s)` : ""}`);
1933
+ }
1934
+ lines.push("\nCall `remember get <domain>` for details.");
1935
+ return { content: [{ type: "text", text: lines.join(`
1936
+ `) }] };
1937
+ }
1938
+ if (!domain)
1939
+ return err(`\`${action}\` requires a domain (e.g. 'app.slack.com').`);
1940
+ if (action === "get") {
1941
+ const cm = loadComponentMap(domain);
1942
+ const names = Object.keys(cm.anchors);
1943
+ if (names.length === 0 && cm.quirks.length === 0) {
1944
+ return { content: [{ type: "text", text: `No anchors saved for ${domain} yet. Discover elements with snapshot/find, then \`remember save\` them.` }] };
1945
+ }
1946
+ const lines = [`Component map for ${domain}:
1947
+ `];
1948
+ if (cm.quirks.length) {
1949
+ lines.push("Site quirks:");
1950
+ for (const q of cm.quirks)
1951
+ lines.push(` - ${q}`);
1952
+ lines.push("");
1953
+ }
1954
+ lines.push(`Anchors (use as @a:<name> in any selector):`);
1955
+ for (const a of Object.values(cm.anchors)) {
1956
+ const health = a.fails > 0 ? ` [${a.uses}✓/${a.fails}✗${a.fails >= a.uses && a.fails >= 2 ? " — likely STALE, re-verify" : ""}]` : a.uses > 0 ? ` [${a.uses}✓]` : "";
1957
+ lines.push(` @a:${a.name}${a.role ? ` (${a.role})` : ""} → ${a.selector}${health}`);
1958
+ if (a.description)
1959
+ lines.push(` ${a.description}`);
1960
+ if (a.quirks?.length)
1961
+ for (const q of a.quirks)
1962
+ lines.push(` • ${q}`);
1963
+ }
1964
+ return { content: [{ type: "text", text: lines.join(`
1965
+ `) }] };
1966
+ }
1967
+ if (action === "quirk") {
1968
+ if (!quirks || quirks.length === 0)
1969
+ return err("`quirk` requires a non-empty `quirks` array.");
1970
+ setDomainQuirks(domain, quirks);
1971
+ return { content: [{ type: "text", text: `Added ${quirks.length} quirk(s) to ${domain}.` }] };
1972
+ }
1973
+ if (action === "forget") {
1974
+ if (!name)
1975
+ return err("`forget` requires the anchor `name`.");
1976
+ const removed = removeAnchor(domain, name);
1977
+ return { content: [{ type: "text", text: removed ? `Removed anchor @a:${name} from ${domain}.` : `No anchor named '${name}' for ${domain}.` }] };
1978
+ }
1979
+ if (!name)
1980
+ return err("`save` requires an anchor `name`.");
1981
+ if (!selector)
1982
+ return err("`save` requires a `selector` (the CSS that locates the element).");
1983
+ saveAnchor(domain, { name, selector, role, description, quirks }, new Date().toISOString());
1984
+ return {
1985
+ content: [
1986
+ {
1987
+ type: "text",
1988
+ text: `Saved anchor @a:${name} for ${domain} → ${selector}
1989
+ Use it in any selector field: e.g. {"type":"click","selector":"@a:${name}"}.`
1990
+ }
1991
+ ]
1992
+ };
1993
+ } catch (e) {
1994
+ return err(`remember failed: ${getErrorMessage(e)}`);
1995
+ }
1996
+ });
1997
+ }
1998
+
1999
+ // src/mcp/tools/clipboard.ts
2000
+ var import_zod10 = require("zod");
2001
+
2002
+ // src/lib/clipboard.ts
2003
+ var import_child_process2 = require("child_process");
2004
+ function platformTools(mode) {
2005
+ if (process.platform === "darwin")
2006
+ return [[mode === "read" ? "pbpaste" : "pbcopy"]];
2007
+ if (mode === "read")
2008
+ return [["wl-paste", "-n"], ["xclip", "-selection", "clipboard", "-o"], ["xsel", "-b", "-o"]];
2009
+ return [["wl-copy"], ["xclip", "-selection", "clipboard", "-i"], ["xsel", "-b", "-i"]];
2010
+ }
2011
+ function run(cmd, input) {
2012
+ return new Promise((resolve) => {
2013
+ let child;
2014
+ try {
2015
+ child = import_child_process2.spawn(cmd[0], cmd.slice(1));
2016
+ } catch {
2017
+ resolve({ ok: false, out: "", err: `spawn ${cmd[0]} failed` });
2018
+ return;
2019
+ }
2020
+ let out = "";
2021
+ let errOut = "";
2022
+ child.stdout?.on("data", (d) => out += d);
2023
+ child.stderr?.on("data", (d) => errOut += d);
2024
+ child.on("error", (e) => resolve({ ok: false, out: "", err: e.message }));
2025
+ child.on("close", (code) => resolve({ ok: code === 0, out, err: errOut }));
2026
+ if (input !== undefined) {
2027
+ child.stdin?.write(input);
2028
+ child.stdin?.end();
2029
+ }
2030
+ });
2031
+ }
2032
+ async function clipboardWrite(text) {
2033
+ const tools = platformTools("write");
2034
+ for (const cmd of tools) {
2035
+ if ((await run(cmd, text)).ok)
2036
+ return;
2037
+ }
2038
+ throw new Error(`No working clipboard tool found (${tools.map((t) => t[0]).join(", ")}).`);
2039
+ }
2040
+ async function clipboardRead() {
2041
+ const tools = platformTools("read");
2042
+ let lastErr = "";
2043
+ for (const cmd of tools) {
2044
+ const r = await run(cmd);
2045
+ if (r.ok)
2046
+ return r.out;
2047
+ lastErr = r.err;
2048
+ }
2049
+ throw new Error(`No working clipboard tool found (${tools.map((t) => t[0]).join(", ")}). ${lastErr}`);
2050
+ }
2051
+
2052
+ // src/mcp/tools/clipboard.ts
2053
+ function registerClipboardTool(server) {
2054
+ server.tool("clipboard", `Read or write the machine's clipboard (the same one the user's Chrome pastes from — iframer runs locally).
2055
+
2056
+ Use this instead of trying to grant a page clipboard permission (not possible via the extension):
2057
+ - To READ something a site copied (a code, a link): clipboard get.
2058
+ - To WRITE text to the clipboard: clipboard set <text>.
2059
+ - To PASTE the clipboard into a page field: use the \`paste\` execute step (do NOT rely on a ⌘V keyboard step — the extension relay ignores modifier keys).`, {
2060
+ action: import_zod10.z.enum(["get", "set"]).describe("get: read clipboard text | set: write text to clipboard"),
2061
+ text: import_zod10.z.string().optional().describe("With action='set': the text to put on the clipboard.")
2062
+ }, async ({ action, text }) => {
2063
+ try {
2064
+ if (action === "set") {
2065
+ if (text === undefined)
2066
+ return err("action='set' requires `text`.");
2067
+ await clipboardWrite(text);
2068
+ return { content: [{ type: "text", text: `Clipboard set (${text.length} chars). To paste into a field, use the \`paste\` execute step.` }] };
2069
+ }
2070
+ const value = await clipboardRead();
2071
+ return { content: [{ type: "text", text: value.length ? value : "(clipboard is empty)" }] };
2072
+ } catch (e) {
2073
+ return err(`clipboard failed: ${getErrorMessage(e)}`);
2074
+ }
2075
+ });
2076
+ }
2077
+
2078
+ // src/mcp/telemetry.ts
2079
+ var import_fs9 = __toESM(require("fs"));
2080
+ var import_path11 = __toESM(require("path"));
2081
+ var CHARS_PER_TOKEN = 4;
2082
+ var est = (chars) => Math.round(chars / CHARS_PER_TOKEN);
2083
+ var enabled = process.env.IFRAMER_TELEMETRY !== "0";
2084
+ var sessionId = `${new Date().toISOString().slice(0, 10)}-${process.pid}`;
2085
+ function telemetryPath() {
2086
+ return import_path11.default.join(getDataDir(), "telemetry.jsonl");
2087
+ }
2088
+ var sessionCalls = 0;
2089
+ var sessionChars = 0;
2090
+ function write2(rec) {
2091
+ if (!enabled)
2092
+ return;
2093
+ try {
2094
+ import_fs9.default.appendFileSync(telemetryPath(), JSON.stringify({ ts: new Date().toISOString(), session: sessionId, ...rec }) + `
2095
+ `);
2096
+ } catch {}
2097
+ }
2098
+ function recordDefinitions(toolCount, defChars, instructionChars) {
2099
+ write2({
2100
+ kind: "definitions",
2101
+ toolCount,
2102
+ defChars,
2103
+ instructionChars,
2104
+ estTokens: est(defChars + instructionChars)
2105
+ });
2106
+ }
2107
+ function recordCall(tool, inChars, outChars, ms, isError) {
2108
+ sessionCalls++;
2109
+ sessionChars += inChars + outChars;
2110
+ write2({
2111
+ kind: "call",
2112
+ tool,
2113
+ inChars,
2114
+ outChars,
2115
+ estTokens: est(inChars + outChars),
2116
+ ms,
2117
+ isError,
2118
+ sessionCalls,
2119
+ sessionEstTokens: est(sessionChars)
2120
+ });
2121
+ }
2122
+ function contentChars(res) {
2123
+ const r = res;
2124
+ if (!r || !Array.isArray(r.content))
2125
+ return 0;
2126
+ let n = 0;
2127
+ for (const c of r.content) {
2128
+ if (typeof c?.text === "string")
2129
+ n += c.text.length;
2130
+ else if (typeof c?.data === "string")
2131
+ n += c.data.length;
2132
+ }
2133
+ return n;
2134
+ }
2135
+ function safeJsonLen(v) {
2136
+ try {
2137
+ return v === undefined ? 0 : JSON.stringify(v).length;
2138
+ } catch {
2139
+ return 0;
2140
+ }
2141
+ }
2142
+
1543
2143
  // src/mcp/server.ts
1544
2144
  var IS_DEV = process.env.IFRAMER_URL?.includes("localhost") || process.env.IFRAMER_URL?.includes("127.0.0.1");
1545
2145
  var INSTRUCTIONS = IS_DEV ? `iframer-dev — local development instance of iframer (connects to ${BASE_URL}).
@@ -1560,8 +2160,10 @@ CRITICAL RULES:
1560
2160
  3. DO NOT present options when you can just act. Check status, credentials, execute.
1561
2161
  4. NEVER re-store credentials when login fails. Login failures are browser-mode problems, not credential problems.
1562
2162
  5. ALWAYS check "knowledge get <domain>" before launching a browser. Skip the browser if the cache has what you need.
1563
- 6. If execute fails, read the FULL error. It tells you the step, error type, and suggestion.
2163
+ 6. For UI tasks on a site, check "remember get <domain>" first — saved anchors let you target elements as @a:<name> (in any selector) instead of re-finding them. After you locate a new element with snapshot/find and it works, "remember save" it. If an @a: anchor fails, the page changed: re-discover and "remember save" the new selector (don't retry the stale one).
2164
+ 7. If execute fails, read the FULL error. It tells you the step, error type, and suggestion.
1564
2165
  7. If the browser crashes, call "session restart" and retry. Don't panic.
2166
+ 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.
1565
2167
 
1566
2168
  BROWSER MODES: Don't specify options.mode — iframer auto-selects and auto-escalates (headless → binary-headful). Only set a mode if the user explicitly asks.
1567
2169
 
@@ -1569,6 +2171,28 @@ CAPTCHA: In binary-headful mode, ask the user to solve it in the visible window.
1569
2171
 
1570
2172
  REVERSE ENGINEERING: Use the "reverse-engineer" tool when the user asks to capture/map/save API endpoints.`;
1571
2173
  var server = new import_mcp.McpServer({ name: "iframer", version: "3.0.0" }, { instructions: INSTRUCTIONS });
2174
+ var defChars = 0;
2175
+ var defCount = 0;
2176
+ {
2177
+ const origTool = server.tool.bind(server);
2178
+ server.tool = (...a) => {
2179
+ const name = String(a[0]);
2180
+ defCount++;
2181
+ defChars += name.length + (typeof a[1] === "string" ? a[1].length : 0);
2182
+ const hIdx = a.length - 1;
2183
+ const handler = a[hIdx];
2184
+ if (typeof handler === "function") {
2185
+ a[hIdx] = async (...ha) => {
2186
+ const started = Date.now();
2187
+ const res = await handler(...ha);
2188
+ const r = res;
2189
+ recordCall(name, safeJsonLen(ha[0]), contentChars(res), Date.now() - started, !!r?.isError);
2190
+ return res;
2191
+ };
2192
+ }
2193
+ return origTool(...a);
2194
+ };
2195
+ }
1572
2196
  registerStatusTool(server);
1573
2197
  registerBrowseTool(server);
1574
2198
  registerExecuteTool(server);
@@ -1576,18 +2200,20 @@ registerSessionTool(server);
1576
2200
  registerCredentialsTool(server);
1577
2201
  registerReverseEngineerTool(server);
1578
2202
  registerKnowledgeTool(server);
1579
- function cleanup() {
1580
- localServer.shutdown();
1581
- }
1582
- process.on("exit", cleanup);
1583
- process.on("SIGTERM", () => {
1584
- cleanup();
1585
- process.exit(0);
1586
- });
1587
- process.on("SIGINT", () => {
1588
- cleanup();
1589
- process.exit(0);
1590
- });
2203
+ registerTabsTool(server);
2204
+ registerRememberTool(server);
2205
+ registerClipboardTool(server);
2206
+ recordDefinitions(defCount, defChars, INSTRUCTIONS.length);
2207
+ process.on("SIGTERM", () => process.exit(0));
2208
+ process.on("SIGINT", () => process.exit(0));
2209
+ process.stdin.on("end", () => process.exit(0));
2210
+ process.stdin.on("close", () => process.exit(0));
2211
+ var originalPpid = process.ppid;
2212
+ var orphanCheck = setInterval(() => {
2213
+ if (process.ppid !== originalPpid || process.ppid === 1)
2214
+ process.exit(0);
2215
+ }, 30000);
2216
+ orphanCheck.unref?.();
1591
2217
  process.on("uncaughtException", (err2) => {
1592
2218
  try {
1593
2219
  console.error(`[mcp] uncaughtException: ${err2?.message}`);