poltertab 1.4.0 → 1.5.0-beta.2

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.
@@ -8,34 +8,16 @@ const {
8
8
  CallToolRequestSchema,
9
9
  ListToolsRequestSchema,
10
10
  } = require("@modelcontextprotocol/sdk/types.js");
11
- const WebSocket = require("ws");
12
- const crypto = require("crypto");
13
- const fs = require("fs");
14
- const os = require("os");
15
- const path = require("path");
16
-
17
- // Anything the user accumulates lives outside the package. Under a global npm
18
- // install __dirname resolves inside node_modules/poltertab/, so site memory
19
- // written next to the code is destroyed by the next `npm update -g` — the
20
- // upgrade would read as amnesia. Downloads have it worse: output_file exists to
21
- // keep large payloads out of the context window, and burying them in
22
- // node_modules makes them hard to find and just as easy to lose.
23
- //
24
- // POLTERTAB_HOME exists so the test suite can point this somewhere disposable
25
- // instead of writing into the real one.
26
- const POLTERTAB_HOME =
27
- process.env.POLTERTAB_HOME || path.join(os.homedir(), ".poltertab");
28
- const MEMORY_DIR = path.join(POLTERTAB_HOME, "navigation_memory");
29
- const DOWNLOADS_DIR = path.join(POLTERTAB_HOME, "downloads");
30
-
31
- fs.mkdirSync(MEMORY_DIR, { recursive: true });
11
+ const { SMART_SCROLL_SETTLE_MS, POLTERTAB_HOME } = require("./config.js");
32
12
 
33
13
  const updates = require("./update-check.js");
14
+ const { BROWSER_TOOLS } = require("./tools.js");
15
+ const { writeOutput, summarizeOutput, rowsOf } = require("./output.js");
16
+ const { readMemory, saveMemory } = require("./memory.js");
17
+ const { extractAll } = require("./extract-all.js");
18
+ const bridge = require("./bridge.js");
34
19
  const OWN_VERSION = require("./../package.json").version;
35
20
 
36
- // Populated when the extension connects; stays null until then, which is itself
37
- // worth reporting — a skew warning must not fire on "no extension yet".
38
- let extensionVersion = null;
39
21
  let updateState = { latest: null, updateAvailable: false };
40
22
  let noticeDelivered = false;
41
23
 
@@ -50,547 +32,6 @@ if (!updates.disabled()) {
50
32
  .catch(() => {});
51
33
  }
52
34
 
53
- // Installs predating the move kept memory beside the code. Copy it forward once
54
- // so an upgrade does not look like the agent forgot everything it learned.
55
- // Never overwrite: if both sides have a note for a domain, the one already in
56
- // the new location is the live one.
57
- (() => {
58
- const legacy = path.join(__dirname, "navigation_memory");
59
- if (legacy === MEMORY_DIR || !fs.existsSync(legacy)) return;
60
- let copied = 0;
61
- for (const name of fs.readdirSync(legacy)) {
62
- const to = path.join(MEMORY_DIR, name);
63
- if (!name.endsWith(".json") || fs.existsSync(to)) continue;
64
- try {
65
- fs.copyFileSync(path.join(legacy, name), to);
66
- copied++;
67
- } catch (_) {
68
- // A read-only or half-removed legacy dir is not worth failing startup.
69
- }
70
- }
71
- if (copied) {
72
- console.error(
73
- `[PolterTab MCP] Migrated ${copied} site memory file(s) to ${MEMORY_DIR}`,
74
- );
75
- }
76
- })();
77
-
78
- // Port configuration: use MCP_BROWSER_WS_PORT env, or --port CLI arg, or 7822 fallback
79
- let WS_PORT = process.env.MCP_BROWSER_WS_PORT
80
- ? parseInt(process.env.MCP_BROWSER_WS_PORT, 10)
81
- : 7822;
82
- const portArgIndex = process.argv.indexOf("--port");
83
- if (portArgIndex !== -1 && process.argv[portArgIndex + 1]) {
84
- WS_PORT = parseInt(process.argv[portArgIndex + 1], 10);
85
- }
86
-
87
- // Global state
88
- let extensionSocket = null;
89
- const pendingCommands = new Map();
90
- const networkState = new Map(); // tabId -> { lastUpdated: number, requests: array }
91
- const secondaryClients = new Map();
92
- let primaryLastTabId = null;
93
-
94
- let isSecondary = false;
95
- let nodeId = null;
96
- let wss = null;
97
- let httpServer = null;
98
-
99
- function broadcastSessionState() {
100
- if (extensionSocket && extensionSocket.readyState === WebSocket.OPEN) {
101
- const sessions = [];
102
- sessions.push({
103
- id: "Primary Agent",
104
- nodeId: "primary",
105
- lastTabId: primaryLastTabId || null,
106
- });
107
- for (const [nodeId, clientWs] of secondaryClients.entries()) {
108
- sessions.push({
109
- id: "Secondary Agent",
110
- nodeId: nodeId,
111
- lastTabId: clientWs.lastTabId || null,
112
- });
113
- }
114
- extensionSocket.send(
115
- JSON.stringify({
116
- type: "state_update",
117
- sessions,
118
- }),
119
- );
120
- }
121
- }
122
-
123
- function setupPrimaryServer() {
124
- const http = require("http");
125
- httpServer = http.createServer();
126
-
127
- httpServer.on("error", (e) => {
128
- if (e.code === "EADDRINUSE") {
129
- console.error(
130
- `[PolterTab MCP] Port ${WS_PORT} in use, switching to Secondary Mode...`,
131
- );
132
- startSecondaryMode();
133
- } else {
134
- console.error(`[PolterTab MCP] HTTP server error: ${e.message}`);
135
- }
136
- });
137
-
138
- httpServer.listen(WS_PORT, () => {
139
- console.error(
140
- `[PolterTab MCP] Primary WebSocket server listening on ws://localhost:${WS_PORT}`,
141
- );
142
- wss = new WebSocket.WebSocketServer({
143
- server: httpServer,
144
- perMessageDeflate: {
145
- zlibDeflateOptions: {
146
- chunkSize: 1024,
147
- memLevel: 7,
148
- level: 1,
149
- },
150
- zlibInflateOptions: {
151
- chunkSize: 10 * 1024,
152
- },
153
- clientNoContextTakeover: true,
154
- serverNoContextTakeover: true,
155
- serverMaxWindowBits: 10,
156
- concurrencyLimit: 10,
157
- threshold: 1024,
158
- },
159
- });
160
-
161
- // Heartbeat mechanism to detect dead connections
162
- const interval = setInterval(() => {
163
- wss.clients.forEach((ws) => {
164
- if (ws.isAlive === false) {
165
- console.error(
166
- `[PolterTab MCP] Terminating dead connection: ${ws.nodeId || "Extension/Unknown"}`,
167
- );
168
- return ws.terminate();
169
- }
170
- ws.isAlive = false;
171
- ws.ping();
172
- });
173
- }, 60000); // 60 seconds
174
-
175
- wss.on("close", () => {
176
- clearInterval(interval);
177
- });
178
-
179
- // Network State Garbage Collector (TTL: 5 minutes)
180
- setInterval(() => {
181
- const now = Date.now();
182
- for (const [tabId, state] of networkState.entries()) {
183
- if (now - state.lastUpdated > 5 * 60 * 1000) {
184
- console.error(
185
- `[PolterTab MCP] Garbage collecting network state for tab ${tabId}`,
186
- );
187
- networkState.delete(tabId);
188
- }
189
- }
190
- }, 60000);
191
-
192
- setupPrimaryWss(wss);
193
- });
194
- }
195
-
196
- function startSecondaryMode() {
197
- isSecondary = true;
198
- nodeId = crypto.randomUUID();
199
- console.error(
200
- `[PolterTab MCP] Node ${nodeId} running as Secondary. Connecting to Primary...`,
201
- );
202
-
203
- connectToPrimary();
204
- }
205
-
206
- function connectToPrimary() {
207
- extensionSocket = new WebSocket(`ws://localhost:${WS_PORT}`, {
208
- perMessageDeflate: true,
209
- });
210
-
211
- extensionSocket.on("open", () => {
212
- console.error("[PolterTab MCP] Connected to Primary MCP Server.");
213
- extensionSocket.send(JSON.stringify({ type: "secondary_mcp", nodeId }));
214
- });
215
-
216
- extensionSocket.on("message", (message) => {
217
- try {
218
- const msg = JSON.parse(message);
219
- if (msg.id && pendingCommands.has(msg.id)) {
220
- const { resolve, reject, timer } = pendingCommands.get(msg.id);
221
- clearTimeout(timer);
222
- pendingCommands.delete(msg.id);
223
-
224
- if (msg.success) {
225
- resolve(msg.data);
226
- } else {
227
- reject(new Error(msg.error || "Unknown extension error"));
228
- }
229
- }
230
- } catch (err) {
231
- console.error(
232
- "[PolterTab MCP] Failed to parse message from Primary",
233
- err,
234
- );
235
- }
236
- });
237
-
238
- extensionSocket.on("close", () => {
239
- console.error(
240
- "[PolterTab MCP] Connection to Primary lost. Attempting to become Primary...",
241
- );
242
- extensionSocket = null;
243
-
244
- // Reject all pending commands
245
- for (const [id, { reject, timer }] of pendingCommands) {
246
- clearTimeout(timer);
247
- reject(new Error("Primary node disconnected. Command aborted."));
248
- pendingCommands.delete(id);
249
- }
250
-
251
- // Try to become primary with exponential backoff & jitter
252
- const jitter = Math.floor(Math.random() * 2000);
253
- const delay = 500 + jitter;
254
-
255
- setTimeout(() => {
256
- isSecondary = false;
257
- setupPrimaryServer();
258
- }, delay);
259
- });
260
-
261
- extensionSocket.on("error", (err) => {
262
- console.error(`[PolterTab MCP] Secondary WebSocket error: ${err.message}`);
263
- });
264
- }
265
-
266
- function setupPrimaryWss(wss) {
267
- wss.on("connection", (ws) => {
268
- ws.isAlive = true;
269
- ws.on("pong", () => {
270
- ws.isAlive = true;
271
- });
272
-
273
- ws.on("message", (message) => {
274
- try {
275
- const msg = JSON.parse(message);
276
-
277
- if (msg.type === "secondary_mcp") {
278
- if (secondaryClients.size >= 5) {
279
- console.error(
280
- `[PolterTab MCP] Rejecting secondary node ${msg.nodeId} - limit reached.`,
281
- );
282
- ws.close();
283
- return;
284
- }
285
- secondaryClients.set(msg.nodeId, ws);
286
- ws.nodeId = msg.nodeId;
287
- console.error(
288
- `[PolterTab MCP] Secondary node connected: ${msg.nodeId}. Total secondaries: ${secondaryClients.size}`,
289
- );
290
- broadcastSessionState();
291
- return;
292
- }
293
-
294
- if (msg.type === "request_full_state") {
295
- broadcastSessionState();
296
- return;
297
- }
298
-
299
- if (msg.type === "proxy_command") {
300
- const { id, action, params } = msg;
301
- if (params && params.tabId) {
302
- ws.lastTabId = params.tabId;
303
- broadcastSessionState();
304
- }
305
-
306
- if (action === "get_network_state") {
307
- (async () => {
308
- try {
309
- let tabId = params.tabId;
310
- if (!tabId) {
311
- const tabInfo = await sendCommand("get_url", params);
312
- tabId = tabInfo.tabId;
313
- }
314
- let data = [];
315
- if (tabId && networkState.has(tabId)) {
316
- data = networkState.get(tabId).requests;
317
- if (params.clear !== false) {
318
- networkState.get(tabId).requests = [];
319
- }
320
- }
321
- if (ws.readyState === WebSocket.OPEN) {
322
- ws.send(
323
- JSON.stringify({
324
- id,
325
- success: true,
326
- data: { tabId, capturedRequests: data.length, data },
327
- }),
328
- );
329
- }
330
- } catch (err) {
331
- if (ws.readyState === WebSocket.OPEN) {
332
- ws.send(
333
- JSON.stringify({ id, success: false, error: err.message }),
334
- );
335
- }
336
- }
337
- })();
338
- return;
339
- }
340
-
341
- if (
342
- !extensionSocket ||
343
- extensionSocket.readyState !== WebSocket.OPEN
344
- ) {
345
- if (ws.readyState === WebSocket.OPEN) {
346
- ws.send(
347
- JSON.stringify({
348
- id,
349
- success: false,
350
- error: "Browser extension not connected to Primary.",
351
- }),
352
- );
353
- }
354
- return;
355
- }
356
-
357
- const timer = setTimeout(() => {
358
- pendingCommands.delete(id);
359
- if (ws.readyState === WebSocket.OPEN) {
360
- ws.send(
361
- JSON.stringify({
362
- id,
363
- success: false,
364
- error: `Command '${action}' timed out.`,
365
- }),
366
- );
367
- }
368
- }, 35000);
369
-
370
- pendingCommands.set(id, { isProxy: true, sourceWs: ws, timer });
371
- extensionSocket.send(JSON.stringify({ id, action, ...params }));
372
- return;
373
- }
374
-
375
- // Handle keepalive pings
376
- if (msg.type === "ping" || msg.type === "extension_ready") {
377
- if (!ws.nodeId) {
378
- // if it's the extension
379
- if (
380
- extensionSocket &&
381
- extensionSocket !== ws &&
382
- extensionSocket.readyState === WebSocket.OPEN
383
- ) {
384
- console.error(
385
- "[PolterTab MCP] Another extension trying to connect. Dropping old connection.",
386
- );
387
- extensionSocket.close();
388
- }
389
- if (extensionSocket !== ws) {
390
- extensionSocket = ws;
391
- console.error("[PolterTab MCP] Chrome extension connected.");
392
- }
393
- // The extension has always sent its version here and we always
394
- // dropped it, so the one mismatch that actually breaks commands was
395
- // invisible to both halves.
396
- if (msg.version && msg.version !== extensionVersion) {
397
- extensionVersion = msg.version;
398
- updates.recordExtension(POLTERTAB_HOME, msg.version);
399
- const sk = updates.skew(OWN_VERSION, msg.version);
400
- console.error(
401
- `[PolterTab MCP] Extension v${msg.version} (server v${OWN_VERSION})` +
402
- (sk ? ` — VERSION SKEW: ${sk.kind}` : ""),
403
- );
404
- // Let the extension show the mismatch in its popup; it cannot
405
- // know the server's version otherwise.
406
- try {
407
- ws.send(
408
- JSON.stringify({ type: "server_version", version: OWN_VERSION }),
409
- );
410
- } catch (_) {
411
- // A socket that died mid-handshake reconnects on its own.
412
- }
413
- }
414
- }
415
- return;
416
- }
417
-
418
- // Handle captured network data
419
- if (msg.type === "network_data") {
420
- const { tabId, url, body } = msg;
421
- if (!networkState.has(tabId)) {
422
- networkState.set(tabId, { lastUpdated: Date.now(), requests: [] });
423
- }
424
- const state = networkState.get(tabId);
425
- state.lastUpdated = Date.now();
426
-
427
- try {
428
- // Truncate bodies over 1MB
429
- let processedBody = body;
430
- if (body && body.length > 1024 * 1024) {
431
- processedBody =
432
- '{"error": "Payload exceeded 1MB limit and was truncated"}';
433
- }
434
-
435
- const jsonBody = JSON.parse(processedBody);
436
- state.requests.push({ url, timestamp: Date.now(), data: jsonBody });
437
- } catch (e) {
438
- state.requests.push({ url, timestamp: Date.now(), data: body });
439
- }
440
-
441
- // Cap at 500 requests
442
- if (state.requests.length > 500) {
443
- state.requests.shift();
444
- }
445
- return;
446
- }
447
-
448
- // Handle tab closed
449
- if (msg.type === "tab_closed") {
450
- const { tabId } = msg;
451
- if (networkState.has(tabId)) {
452
- console.error(
453
- `[PolterTab MCP] Clearing network state for closed tab ${tabId}`,
454
- );
455
- networkState.delete(tabId);
456
- }
457
- return;
458
- }
459
-
460
- // Response from Extension
461
- if (msg.id && pendingCommands.has(msg.id)) {
462
- const cmd = pendingCommands.get(msg.id);
463
- clearTimeout(cmd.timer);
464
- pendingCommands.delete(msg.id);
465
-
466
- if (cmd.isProxy) {
467
- if (cmd.sourceWs.readyState === WebSocket.OPEN) {
468
- cmd.sourceWs.send(JSON.stringify(msg));
469
- }
470
- } else if (msg.success) {
471
- cmd.resolve(msg.data);
472
- } else {
473
- cmd.reject(new Error(msg.error || "Unknown extension error"));
474
- }
475
- }
476
- } catch (err) {
477
- console.error("[PolterTab MCP] Failed to parse message", err);
478
- }
479
- });
480
-
481
- ws.on("close", () => {
482
- if (ws.nodeId) {
483
- console.error(
484
- `[PolterTab MCP] Secondary node disconnected: ${ws.nodeId}`,
485
- );
486
- secondaryClients.delete(ws.nodeId);
487
- broadcastSessionState();
488
- } else if (extensionSocket === ws) {
489
- console.error("[PolterTab MCP] Chrome extension disconnected.");
490
- extensionSocket = null;
491
- }
492
- });
493
-
494
- ws.on("error", (err) => {
495
- console.error(`[PolterTab MCP] WebSocket error: ${err.message}`);
496
- });
497
- });
498
- }
499
-
500
- // Start the server
501
- setupPrimaryServer();
502
-
503
- function shutdown() {
504
- console.error("[PolterTab MCP] Shutting down...");
505
- if (wss) wss.close();
506
- if (httpServer) httpServer.close();
507
- process.exit(0);
508
- }
509
- process.on("SIGINT", shutdown);
510
- process.on("SIGTERM", shutdown);
511
-
512
- // Helper to wait for WebSocket connection
513
- async function waitForConnection(timeoutMs = 5000) {
514
- const start = Date.now();
515
- while (Date.now() - start < timeoutMs) {
516
- if (extensionSocket && extensionSocket.readyState === WebSocket.OPEN) {
517
- return true;
518
- }
519
- await new Promise((resolve) => setTimeout(resolve, 100));
520
- }
521
- return false;
522
- }
523
-
524
- // Helper to send command to the extension
525
- async function sendCommand(action, params) {
526
- const isConnected = await waitForConnection(5000);
527
-
528
- if (isSecondary) {
529
- if (!isConnected) {
530
- throw new Error("Secondary node not connected to Primary MCP server.");
531
- }
532
-
533
- // Auto-inject a unique session to prevent collisions over shared tabs
534
- if (!params.session) {
535
- params.session = "agent_" + nodeId;
536
- }
537
-
538
- return new Promise((resolve, reject) => {
539
- const id = crypto.randomUUID();
540
- const timer = setTimeout(() => {
541
- pendingCommands.delete(id);
542
- reject(
543
- new Error(`Proxy Command '${action}' timed out after 35 seconds.`),
544
- );
545
- }, 35000);
546
-
547
- pendingCommands.set(id, { resolve, reject, timer });
548
-
549
- try {
550
- extensionSocket.send(
551
- JSON.stringify({ type: "proxy_command", id, action, params }),
552
- );
553
- } catch (err) {
554
- clearTimeout(timer);
555
- pendingCommands.delete(id);
556
- reject(
557
- new Error(
558
- `Failed to proxy command to Primary over WebSocket: ${err.message}`,
559
- ),
560
- );
561
- }
562
- });
563
- }
564
-
565
- if (!isConnected) {
566
- throw new Error(
567
- "Browser extension not connected. Please ensure the extension is installed, enabled, and pointing to the correct port (default: 7822).",
568
- );
569
- }
570
-
571
- return new Promise((resolve, reject) => {
572
- const id = crypto.randomUUID();
573
-
574
- // Strict timeout (35 seconds) to prevent hanging
575
- const timer = setTimeout(() => {
576
- pendingCommands.delete(id);
577
- reject(new Error(`Command '${action}' timed out after 35 seconds.`));
578
- }, 35000);
579
-
580
- pendingCommands.set(id, { resolve, reject, timer });
581
-
582
- try {
583
- extensionSocket.send(JSON.stringify({ id, action, ...params }));
584
- } catch (err) {
585
- clearTimeout(timer);
586
- pendingCommands.delete(id);
587
- reject(
588
- new Error(`Failed to send command over WebSocket: ${err.message}`),
589
- );
590
- }
591
- });
592
- }
593
-
594
35
  // Create MCP Server
595
36
  const server = new Server(
596
37
  {
@@ -606,722 +47,12 @@ const server = new Server(
606
47
  },
607
48
  );
608
49
 
609
- // Define tools
610
- const BROWSER_TOOLS = [
611
- {
612
- name: "browser_navigate",
613
- description: "Navigate to any URL in the browser",
614
- inputSchema: {
615
- type: "object",
616
- properties: {
617
- url: { type: "string" },
618
- tabId: { type: "number" },
619
- session: {
620
- type: "string",
621
- description: "Optional session name to track this tab",
622
- },
623
- },
624
- required: ["url"],
625
- },
626
- },
627
- {
628
- name: "browser_click",
629
- description: "Click an element on the page",
630
- inputSchema: {
631
- type: "object",
632
- properties: {
633
- selector: { type: "string" },
634
- tabId: { type: "number" },
635
- session: { type: "string" },
636
- },
637
- required: ["selector"],
638
- },
639
- },
640
- {
641
- name: "browser_fill",
642
- description: "Fill an input field with text",
643
- inputSchema: {
644
- type: "object",
645
- properties: {
646
- selector: { type: "string" },
647
- value: { type: "string" },
648
- submit: { type: "boolean" },
649
- tabId: { type: "number" },
650
- session: { type: "string" },
651
- },
652
- required: ["selector", "value"],
653
- },
654
- },
655
- {
656
- name: "browser_scrape",
657
- description:
658
- "Scrape the page or specific elements. For repeating records (cards, rows, listings) use browser_extract instead — it keeps fields grouped per record.",
659
- inputSchema: {
660
- type: "object",
661
- properties: {
662
- selector: { type: "string" },
663
- attribute: {
664
- type: "string",
665
- description:
666
- "Attribute to read, or 'text' for the element's text. 'href'/'src' come back absolute.",
667
- },
668
- multiple: { type: "boolean" },
669
- fields: {
670
- type: "array",
671
- items: {
672
- type: "string",
673
- enum: ["meta", "jsonld", "links", "headings", "bodyText"],
674
- },
675
- description:
676
- "Full-page scrape only (no selector): which parts to return. title and url are always included. ['meta','jsonld'] is the cheap structured-data path — og: tags and schema.org blobs without 50KB of body text. Omit for everything.",
677
- },
678
- max_text: {
679
- type: "number",
680
- description: "Max characters of text per element (default 500)",
681
- },
682
- output_file: {
683
- type: "string",
684
- description:
685
- "Filename to write the payload under ~/.poltertab/downloads/, returning only a summary. A path is reduced to its basename — output cannot be written elsewhere.",
686
- },
687
- tabId: { type: "number" },
688
- session: { type: "string" },
689
- },
690
- required: [],
691
- },
692
- },
693
- {
694
- name: "browser_extract",
695
- description:
696
- "Extract repeating records (cards, rows, listings) with fields grouped per record. Fields resolve INSIDE each record root and a missing field yields null instead of shifting later records' values. Returns fill rates and warns when a field is empty inside the record scope but matches page-wide (record boundary too narrow).",
697
- inputSchema: {
698
- type: "object",
699
- properties: {
700
- record: {
701
- type: "string",
702
- description:
703
- "Selector for the repeating container, e.g. '.agent-card'. Verify with browser_snapshot that it encloses ALL the fields you want — the element that looks like the card often excludes siblings holding some of them.",
704
- },
705
- fields: {
706
- type: "object",
707
- description:
708
- "Map of field name -> {sel, get, many, strip}. sel is relative to the record root; omit it (or use '.') for the root itself. get: 'text' (default) | 'href' | 'src' | any attribute name. many: true collects all matches into an array. strip removes a leading prefix, e.g. 'tel:'.",
709
- additionalProperties: {
710
- type: "object",
711
- properties: {
712
- sel: { type: "string" },
713
- get: { type: "string" },
714
- many: { type: "boolean" },
715
- strip: { type: "string" },
716
- },
717
- },
718
- },
719
- anchor: {
720
- type: "string",
721
- description:
722
- "Name of the field that is always present on a real record (usually the detail-page link). Records missing it are dropped as placeholders and counted in 'dropped'.",
723
- },
724
- max_text: {
725
- type: "number",
726
- description: "Max characters per text field (default 500)",
727
- },
728
- probe: {
729
- type: "boolean",
730
- description:
731
- "Page-wide re-check of any field that came back entirely empty (default true)",
732
- },
733
- output_file: {
734
- type: "string",
735
- description:
736
- "Filename to write rows under ~/.poltertab/downloads/, returning only a summary. .jsonl and .csv are written in those formats, anything else as JSON. A path is reduced to its basename — output cannot be written elsewhere.",
737
- },
738
- tabId: { type: "number" },
739
- session: { type: "string" },
740
- },
741
- required: ["record", "fields"],
742
- },
743
- },
744
- {
745
- name: "browser_extract_all",
746
- description:
747
- "Paginate and extract in one call, with no model round-trip per page. Takes browser_extract's spec plus a URL template, walks pages, dedups on a key, and halts on: limit reached, empty page, a page whose records repeat an earlier page's (the trap where ignored page-size params silently return page 1 again), fill rates collapsing against page 1's baseline, or max_pages. Always reports which condition fired and returns everything collected so far.",
748
- inputSchema: {
749
- type: "object",
750
- properties: {
751
- url_template: {
752
- type: "string",
753
- description:
754
- "URL with a {page} placeholder, e.g. 'https://www.kw.com/agents?page={page}'",
755
- },
756
- record: { type: "string" },
757
- fields: { type: "object" },
758
- anchor: { type: "string" },
759
- key: {
760
- type: "string",
761
- description:
762
- "Field name to dedup on — use a stable per-record identifier such as the detail URL. Falls back to whole-row equality.",
763
- },
764
- limit: {
765
- type: "number",
766
- description: "Stop after this many records (default 200)",
767
- },
768
- offset: {
769
- type: "number",
770
- description:
771
- "Skip this many records from the start of the stream. Pages are re-fetched to reach the offset; pass start_page to skip cheaply.",
772
- },
773
- start_page: { type: "number", description: "First page (default 1)" },
774
- max_pages: {
775
- type: "number",
776
- description: "Hard guard on pages fetched (default 50)",
777
- },
778
- fill_tolerance: {
779
- type: "number",
780
- description:
781
- "Halt when a field well-populated on the baseline page falls below this fraction of it (default 0.5). 0 disables the check.",
782
- },
783
- max_text: { type: "number" },
784
- output_file: { type: "string" },
785
- session: { type: "string" },
786
- },
787
- required: ["url_template", "record", "fields"],
788
- },
789
- },
790
- {
791
- name: "browser_screenshot",
792
- description:
793
- "Capture visible tab as base64 PNG. This action will focus the target tab.",
794
- inputSchema: {
795
- type: "object",
796
- properties: {
797
- tabId: { type: "number" },
798
- session: { type: "string" },
799
- },
800
- required: [],
801
- },
802
- },
803
- {
804
- name: "browser_scroll",
805
- description: "Scroll the page",
806
- inputSchema: {
807
- type: "object",
808
- properties: {
809
- direction: {
810
- type: "string",
811
- enum: ["up", "down", "left", "right", "top", "bottom"],
812
- },
813
- amount: { type: "number" },
814
- selector: { type: "string" },
815
- tabId: { type: "number" },
816
- session: { type: "string" },
817
- },
818
- required: ["direction"],
819
- },
820
- },
821
- {
822
- name: "browser_hover",
823
- description: "Hover over an element",
824
- inputSchema: {
825
- type: "object",
826
- properties: {
827
- selector: { type: "string" },
828
- tabId: { type: "number" },
829
- session: { type: "string" },
830
- },
831
- required: ["selector"],
832
- },
833
- },
834
- {
835
- name: "browser_get_text",
836
- description: "Get text content of an element",
837
- inputSchema: {
838
- type: "object",
839
- properties: {
840
- selector: { type: "string" },
841
- max_text: {
842
- type: "number",
843
- description:
844
- "Max characters (default 10000). The result flags it when text was cut.",
845
- },
846
- output_file: { type: "string" },
847
- tabId: { type: "number" },
848
- session: { type: "string" },
849
- },
850
- required: ["selector"],
851
- },
852
- },
853
- {
854
- name: "browser_get_title",
855
- description: "Get page title and URL",
856
- inputSchema: {
857
- type: "object",
858
- properties: {
859
- tabId: { type: "number" },
860
- session: { type: "string" },
861
- },
862
- required: [],
863
- },
864
- },
865
- {
866
- name: "browser_get_url",
867
- description: "Get page URL",
868
- inputSchema: {
869
- type: "object",
870
- properties: {
871
- tabId: { type: "number" },
872
- session: { type: "string" },
873
- },
874
- required: [],
875
- },
876
- },
877
- {
878
- name: "browser_snapshot",
879
- description:
880
- "Get a snapshot of the DOM. Large pages run to tens of KB — narrow it with interactive_only/max_nodes, or send it to output_file, before spending the context on it.",
881
- inputSchema: {
882
- type: "object",
883
- properties: {
884
- interactive_only: {
885
- type: "boolean",
886
- description: "Only clickable/typable elements",
887
- },
888
- max_nodes: {
889
- type: "number",
890
- description: "Cap on nodes returned (default 400)",
891
- },
892
- max_depth: { type: "number" },
893
- output_file: { type: "string" },
894
- tabId: { type: "number" },
895
- session: { type: "string" },
896
- },
897
- required: [],
898
- },
899
- },
900
- {
901
- name: "browser_get_network_state",
902
- description:
903
- "Get captured raw JSON data (GraphQL/XHR) for the current tab, bypassing DOM virtualization",
904
- inputSchema: {
905
- type: "object",
906
- properties: {
907
- tabId: { type: "number" },
908
- session: { type: "string" },
909
- clear: {
910
- type: "boolean",
911
- description: "Clear the buffer after reading (default: true)",
912
- },
913
- output_file: {
914
- type: "string",
915
- description:
916
- "Optional filename to save the data directly to disk to avoid flooding the context window (e.g., 'data.json')",
917
- },
918
- },
919
- required: [],
920
- },
921
- },
922
- {
923
- name: "browser_set_intercept_patterns",
924
- description:
925
- "Set URL substrings to determine which network requests are captured in the MAIN world.",
926
- inputSchema: {
927
- type: "object",
928
- properties: {
929
- patterns: {
930
- type: "array",
931
- items: { type: "string" },
932
- description:
933
- "Array of substrings (e.g. ['graphql', '/api/v1/comments'])",
934
- },
935
- tabId: { type: "number" },
936
- session: { type: "string" },
937
- },
938
- required: ["patterns"],
939
- },
940
- },
941
- {
942
- name: "browser_smart_scroll",
943
- description:
944
- "Scroll the page and wait for new network data to load (handles lazy-loading)",
945
- inputSchema: {
946
- type: "object",
947
- properties: {
948
- direction: { type: "string", enum: ["up", "down"] },
949
- amount: { type: "number" },
950
- tabId: { type: "number" },
951
- session: { type: "string" },
952
- },
953
- required: ["direction"],
954
- },
955
- },
956
- {
957
- name: "browser_session_create",
958
- description: "Create or track a named browser session (tab)",
959
- inputSchema: {
960
- type: "object",
961
- properties: {
962
- name: { type: "string" },
963
- url: { type: "string" },
964
- },
965
- required: ["name"],
966
- },
967
- },
968
- {
969
- name: "browser_session_switch",
970
- description: "Switch to a different tracked session",
971
- inputSchema: {
972
- type: "object",
973
- properties: {
974
- name: { type: "string" },
975
- },
976
- required: ["name"],
977
- },
978
- },
979
- {
980
- name: "browser_session_list",
981
- description: "List all tracked sessions",
982
- inputSchema: {
983
- type: "object",
984
- properties: {},
985
- required: [],
986
- },
987
- },
988
- {
989
- name: "browser_session_close",
990
- description: "Close a tracked session",
991
- inputSchema: {
992
- type: "object",
993
- properties: {
994
- name: { type: "string" },
995
- },
996
- required: ["name"],
997
- },
998
- },
999
- {
1000
- name: "browser_session_context",
1001
- description: "Get context info for the current active session",
1002
- inputSchema: {
1003
- type: "object",
1004
- properties: {},
1005
- required: [],
1006
- },
1007
- },
1008
- {
1009
- name: "browser_get_site_memory",
1010
- description:
1011
- "Get navigation memory, obstacles, and fixes for a specific website domain",
1012
- inputSchema: {
1013
- type: "object",
1014
- properties: {
1015
- hostname: {
1016
- type: "string",
1017
- description: "e.g., 'www.linkedin.com' or 'x.com'",
1018
- },
1019
- domain: { type: "string", description: "Alias for hostname" },
1020
- },
1021
- required: [],
1022
- },
1023
- },
1024
- {
1025
- name: "browser_save_site_memory",
1026
- description:
1027
- "Save a new navigation memory, obstacle, or fix for a specific website domain",
1028
- inputSchema: {
1029
- type: "object",
1030
- properties: {
1031
- hostname: { type: "string", description: "e.g., 'www.linkedin.com'" },
1032
- domain: { type: "string", description: "Alias for hostname" },
1033
- obstacle: {
1034
- type: "string",
1035
- description: "What broke or was difficult?",
1036
- },
1037
- solution: { type: "string", description: "How did you solve it?" },
1038
- },
1039
- required: ["obstacle", "solution"],
1040
- },
1041
- },
1042
- ];
1043
-
1044
50
  server.setRequestHandler(ListToolsRequestSchema, async () => {
1045
51
  return {
1046
52
  tools: BROWSER_TOOLS,
1047
53
  };
1048
54
  });
1049
55
 
1050
- // --- Output handling ---
1051
- //
1052
- // The raw payload must never be the default path into the context window. Two
1053
- // costs dominated a 100-record scrape and neither was the data: envelopes
1054
- // several lines of JSON deep to carry one short string, and the agent then
1055
- // re-typing every record by hand into a file. Writing straight to disk removes
1056
- // both.
1057
-
1058
- function toCsv(rows) {
1059
- if (!rows.length) return "";
1060
- const cols = [...new Set(rows.flatMap((r) => Object.keys(r)))];
1061
- const cell = (v) => {
1062
- if (v === null || v === undefined) return "";
1063
- const s = Array.isArray(v)
1064
- ? v.join(" | ")
1065
- : typeof v === "object"
1066
- ? JSON.stringify(v) // beats a column of "[object Object]"
1067
- : String(v);
1068
- // Quote only when it would otherwise break the row, and double any
1069
- // embedded quote — the two mistakes that produce a file which imports
1070
- // silently and wrongly.
1071
- return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
1072
- };
1073
- return [
1074
- cols.join(","),
1075
- ...rows.map((r) => cols.map((c) => cell(r[c])).join(",")),
1076
- ].join("\n");
1077
- }
1078
-
1079
- // Site memory is keyed by hostname, and that key arrives from a model — so it
1080
- // is untrusted input rather than a filename. Two failures this closes: a note
1081
- // saved under kw.com was invisible to a lookup for www.kw.com (the same site),
1082
- // and the raw value was interpolated straight into a path, so "../.." reached
1083
- // outside MEMORY_DIR.
1084
- function memoryFile(rawHost) {
1085
- let host = String(rawHost).trim().toLowerCase();
1086
-
1087
- // The parameter is also documented as accepting `url`, so a full URL turning
1088
- // up here is expected rather than a caller mistake.
1089
- if (host.includes("/")) {
1090
- try {
1091
- host = new URL(host.includes("://") ? host : `https://${host}`).hostname;
1092
- } catch {
1093
- host = host.split("/")[0];
1094
- }
1095
- }
1096
-
1097
- host = host.replace(/[^a-z0-9.-]/g, "").replace(/^\.+/, "");
1098
- if (!host) throw new Error(`Not a usable hostname: ${rawHost}`);
1099
-
1100
- // Existing notes live under whichever spelling first created them — the store
1101
- // already holds both kw.com.json and www.linkedin.com.json — so try the
1102
- // variants before concluding this is a new file.
1103
- const bare = host.replace(/^www\./, "");
1104
- for (const name of [bare, host, `www.${bare}`]) {
1105
- const p = path.join(MEMORY_DIR, `${name}.json`);
1106
- if (fs.existsSync(p)) return p;
1107
- }
1108
- return path.join(MEMORY_DIR, `${bare}.json`);
1109
- }
1110
-
1111
- const isBlank = (v) =>
1112
- v === null || v === undefined || v === "" || (Array.isArray(v) && !v.length);
1113
-
1114
- // `rows` is passed separately when the payload has a records array: .jsonl and
1115
- // .csv are formats for records, not for envelopes.
1116
- function writeOutput(name, payload, rows) {
1117
- const requested = String(name);
1118
- const safeName = path.basename(requested);
1119
- const parts = safeName.split(".");
1120
- const ext = parts.length > 1 ? `.${parts.pop()}` : "";
1121
- const base = parts.join(".");
1122
- const finalName = `${base}_${Date.now()}${ext}`;
1123
-
1124
- fs.mkdirSync(DOWNLOADS_DIR, { recursive: true });
1125
- const safePath = path.join(DOWNLOADS_DIR, finalName);
1126
-
1127
- let body;
1128
- if (rows && ext === ".jsonl") {
1129
- body = rows.map((r) => JSON.stringify(r)).join("\n");
1130
- } else if (rows && ext === ".csv") {
1131
- body = toCsv(rows);
1132
- } else {
1133
- body = JSON.stringify(payload, null, 2);
1134
- }
1135
-
1136
- fs.writeFileSync(safePath, body);
1137
- const out = { file: safePath, bytes: Buffer.byteLength(body) };
1138
-
1139
- // Output stays inside DOWNLOADS_DIR: this path comes from a model, and a tool
1140
- // that writes to an arbitrary absolute path is a different capability than
1141
- // one that saves a scrape. But relocating without a word is how a caller ends
1142
- // up looking for a file that was never going to be there.
1143
- if (safeName !== requested) {
1144
- out.note = `output_file is confined to ${DOWNLOADS_DIR} — "${requested}" was written as ${path.basename(safePath)}, not to the path requested.`;
1145
- }
1146
- return out;
1147
- }
1148
-
1149
- // What comes back inline when the payload went to disk: enough to know the call
1150
- // worked and the shape is right, and nothing more.
1151
- function summarizeOutput(payload, rows, written) {
1152
- const summary = { ...written };
1153
- if (rows) {
1154
- summary.rows = rows.length;
1155
- summary.fields = rows.length ? Object.keys(rows[0]) : [];
1156
- summary.sample = rows.slice(0, 2);
1157
- for (const k of ["fill_rates", "dropped", "warnings", "stopped_because", "pages_fetched"]) {
1158
- if (payload && payload[k] !== undefined) summary[k] = payload[k];
1159
- }
1160
- } else if (Array.isArray(payload)) {
1161
- summary.items = payload.length;
1162
- summary.sample = payload.slice(0, 2);
1163
- } else if (payload && typeof payload === "object") {
1164
- summary.keys = Object.keys(payload);
1165
- }
1166
- return summary;
1167
- }
1168
-
1169
- // Payload shapes that carry a records array worth writing as jsonl/csv.
1170
- function rowsOf(payload) {
1171
- if (payload && Array.isArray(payload.rows)) return payload.rows;
1172
- if (Array.isArray(payload)) return payload;
1173
- return null;
1174
- }
1175
-
1176
- // --- The pagination loop ---
1177
- //
1178
- // This exists so the model stops being the for-loop. It supplies the spec once
1179
- // and receives records; every page in between costs a browser round-trip
1180
- // instead of a model round-trip.
1181
- //
1182
- // Every halt condition below is a case where continuing would produce data
1183
- // that looks complete and is not, so each one names itself in the result. A
1184
- // paused run costs far less than a confidently wrong dataset.
1185
- async function extractAll(args) {
1186
- const {
1187
- url_template,
1188
- record,
1189
- fields,
1190
- anchor,
1191
- key,
1192
- limit = 200,
1193
- offset = 0,
1194
- start_page = 1,
1195
- max_pages = 50,
1196
- fill_tolerance = 0.5,
1197
- max_text,
1198
- session,
1199
- } = args;
1200
-
1201
- if (!url_template.includes("{page}")) {
1202
- throw new Error("url_template must contain a {page} placeholder");
1203
- }
1204
-
1205
- const spec = { record, fields, anchor, max_text, session, probe: true };
1206
- const target = offset + limit;
1207
- const rows = [];
1208
- const seen = new Set();
1209
- const warnings = [];
1210
- const pages = [];
1211
-
1212
- let baseline = null;
1213
- let stopped_because = "max_pages";
1214
- let page = start_page;
1215
- let fetched = 0;
1216
-
1217
- const keyOf = (row) =>
1218
- key ? String(row[key] ?? "") : JSON.stringify(row);
1219
-
1220
- while (fetched < max_pages) {
1221
- const url = url_template.replace("{page}", String(page));
1222
- const nav = await sendCommand("navigate", { url, session });
1223
- fetched++;
1224
-
1225
- const res = await sendCommand("extract", spec);
1226
- const pageRows = (res && res.rows) || [];
1227
- pages.push({
1228
- page,
1229
- url: nav && nav.url,
1230
- found: res ? res.records_found : 0,
1231
- kept: pageRows.length,
1232
- dropped: res ? res.dropped : 0,
1233
- });
1234
- if (res && res.warnings && res.warnings.length) {
1235
- warnings.push(`page ${page}: ${res.warnings.join("; ")}`);
1236
- }
1237
-
1238
- if (!pageRows.length) {
1239
- stopped_because = "empty_page";
1240
- break;
1241
- }
1242
-
1243
- // A site that ignores an unrecognised page param answers every request with
1244
- // page 1. Identical content reads as real data, which is how a "next 100"
1245
- // silently becomes the same 12 records eight times over.
1246
- const fresh = pageRows.filter((r) => !seen.has(keyOf(r)));
1247
- if (!fresh.length) {
1248
- stopped_because = "duplicate_page";
1249
- warnings.push(
1250
- `page ${page} returned only records already seen — pagination is not advancing`,
1251
- );
1252
- break;
1253
- }
1254
-
1255
- // Fill rates against the first page. A spec learned on page 1 degrades
1256
- // quietly later: variant card layouts, a column that stops being populated.
1257
- // Halting beats emitting rows that are 40% empty.
1258
- const ratios = {};
1259
- for (const [name, n] of Object.entries(res.fill_rates || {})) {
1260
- ratios[name] = n / pageRows.length;
1261
- }
1262
- if (!baseline) {
1263
- baseline = ratios;
1264
- } else if (fill_tolerance > 0) {
1265
- const collapsed = Object.keys(baseline).filter(
1266
- (name) =>
1267
- baseline[name] >= 0.5 &&
1268
- ratios[name] < baseline[name] * fill_tolerance,
1269
- );
1270
- if (collapsed.length) {
1271
- stopped_because = "fill_rate_deviation";
1272
- warnings.push(
1273
- `page ${page}: ${collapsed
1274
- .map(
1275
- (n) =>
1276
- `${n} ${(ratios[n] * 100).toFixed(0)}% vs baseline ${(baseline[n] * 100).toFixed(0)}%`,
1277
- )
1278
- .join(", ")} — page layout likely differs from the learned spec`,
1279
- );
1280
- break;
1281
- }
1282
- }
1283
-
1284
- for (const r of fresh) {
1285
- seen.add(keyOf(r));
1286
- rows.push(r);
1287
- }
1288
-
1289
- if (rows.length >= target) {
1290
- stopped_because = "limit_reached";
1291
- break;
1292
- }
1293
- page++;
1294
- }
1295
-
1296
- const sliced = rows.slice(offset, offset + limit);
1297
- if (offset && fetched) {
1298
- warnings.push(
1299
- `offset ${offset} was reached by fetching from page ${start_page}; pass start_page to skip pages instead of re-reading them`,
1300
- );
1301
- }
1302
-
1303
- // extract reports fill as counts, so extract_all does too — the per-page
1304
- // baseline is a fraction because it is compared across pages of differing
1305
- // size, and carries the unit in its name rather than looking like a count.
1306
- const fill_rates = {};
1307
- for (const name of Object.keys(fields)) {
1308
- fill_rates[name] = sliced.filter((r) => !isBlank(r[name])).length;
1309
- }
1310
-
1311
- return {
1312
- rows: sliced,
1313
- count: sliced.length,
1314
- collected: rows.length,
1315
- pages_fetched: fetched,
1316
- last_page: page,
1317
- stopped_because,
1318
- fill_rates,
1319
- baseline_fill_ratios: baseline,
1320
- pages,
1321
- warnings,
1322
- };
1323
- }
1324
-
1325
56
  const handleToolCall = async (request) => {
1326
57
  const { name, arguments: args } = request.params;
1327
58
 
@@ -1337,26 +68,12 @@ const handleToolCall = async (request) => {
1337
68
  const opts = args || {};
1338
69
  let responsePayload;
1339
70
 
1340
- if (isSecondary) {
1341
- // Proxy it to primary! Primary handles resolving the networkState map
1342
- responsePayload = await sendCommand("get_network_state", opts);
71
+ if (bridge.isSecondary()) {
72
+ // Proxy it to the Primary — it is the one holding the capture buffer.
73
+ responsePayload = await bridge.sendCommand("get_network_state", opts);
1343
74
  } else {
1344
- // First, we need to resolve the active tab ID. We can do a dummy 'get_url' command to ask the extension what the current tabId is.
1345
- const tabInfo = await sendCommand("get_url", opts);
1346
- const tabId = tabInfo.tabId;
1347
-
1348
- primaryLastTabId = tabId;
1349
- broadcastSessionState();
1350
-
1351
- let data = [];
1352
- if (tabId && networkState.has(tabId)) {
1353
- data = networkState.get(tabId).requests;
1354
- if (opts.clear !== false) {
1355
- networkState.get(tabId).requests = [];
1356
- }
1357
- }
1358
-
1359
- responsePayload = { tabId, capturedRequests: data.length, data };
75
+ responsePayload = await bridge.readNetworkState(opts);
76
+ bridge.noteActiveTab(responsePayload.tabId);
1360
77
  }
1361
78
 
1362
79
  // Must be honoured in BOTH roles. A Secondary that returned the raw
@@ -1386,15 +103,11 @@ const handleToolCall = async (request) => {
1386
103
 
1387
104
  // Custom handling for smart scroll
1388
105
  if (action === "smart_scroll") {
1389
- const scrollResult = await sendCommand("scroll", args || {});
1390
-
1391
- if (!isSecondary && scrollResult && scrollResult.tabId) {
1392
- primaryLastTabId = scrollResult.tabId;
1393
- broadcastSessionState();
1394
- }
106
+ const scrollResult = await bridge.sendCommand("scroll", args || {});
107
+ bridge.noteActiveTab(scrollResult && scrollResult.tabId);
1395
108
 
1396
109
  // Wait for network requests to arrive (lazy loading)
1397
- await new Promise((r) => setTimeout(r, 2000));
110
+ await new Promise((r) => setTimeout(r, SMART_SCROLL_SETTLE_MS));
1398
111
 
1399
112
  return {
1400
113
  content: [
@@ -1415,7 +128,7 @@ const handleToolCall = async (request) => {
1415
128
 
1416
129
  // Custom handling for setting intercept patterns globally via storage, then updating current tab
1417
130
  if (action === "set_intercept_patterns") {
1418
- const result = await sendCommand("set_intercept_patterns", args);
131
+ const result = await bridge.sendCommand("set_intercept_patterns", args);
1419
132
  return {
1420
133
  content: [
1421
134
  {
@@ -1429,30 +142,17 @@ const handleToolCall = async (request) => {
1429
142
  if (action === "get_site_memory") {
1430
143
  const host = args.hostname || args.domain || args.url;
1431
144
  if (!host) throw new Error("Missing 'hostname' parameter");
1432
- const file = memoryFile(host);
1433
- let data = [];
1434
- if (fs.existsSync(file)) {
1435
- data = JSON.parse(fs.readFileSync(file, "utf8"));
1436
- }
1437
145
  return {
1438
- content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
146
+ content: [
147
+ { type: "text", text: JSON.stringify(readMemory(host), null, 2) },
148
+ ],
1439
149
  };
1440
150
  }
1441
151
 
1442
152
  if (action === "save_site_memory") {
1443
153
  const host = args.hostname || args.domain || args.url;
1444
154
  if (!host) throw new Error("Missing 'hostname' parameter");
1445
- const file = memoryFile(host);
1446
- let data = [];
1447
- if (fs.existsSync(file)) {
1448
- data = JSON.parse(fs.readFileSync(file, "utf8"));
1449
- }
1450
- data.push({
1451
- obstacle: args.obstacle,
1452
- solution: args.solution,
1453
- timestamp: Date.now(),
1454
- });
1455
- fs.writeFileSync(file, JSON.stringify(data, null, 2));
155
+ saveMemory(host, args.obstacle, args.solution);
1456
156
  return {
1457
157
  content: [{ type: "text", text: "Memory successfully saved." }],
1458
158
  };
@@ -1460,7 +160,7 @@ const handleToolCall = async (request) => {
1460
160
 
1461
161
  // Loops in the server, not in the model. One tool call covers every page.
1462
162
  if (action === "extract_all") {
1463
- const payload = await extractAll(args || {});
163
+ const payload = await extractAll(bridge.sendCommand, args || {});
1464
164
  const opts = args || {};
1465
165
 
1466
166
  if (opts.output_file) {
@@ -1484,18 +184,8 @@ const handleToolCall = async (request) => {
1484
184
  };
1485
185
  }
1486
186
 
1487
- const result = await sendCommand(action, args || {});
1488
-
1489
- if (!isSecondary) {
1490
- // Track primary's active tab
1491
- if (result && result.tabId) {
1492
- primaryLastTabId = result.tabId;
1493
- broadcastSessionState();
1494
- } else if (args && args.tabId) {
1495
- primaryLastTabId = args.tabId;
1496
- broadcastSessionState();
1497
- }
1498
- }
187
+ const result = await bridge.sendCommand(action, args || {});
188
+ bridge.noteActiveTab((result && result.tabId) || (args && args.tabId));
1499
189
 
1500
190
  // Any read tool can send its payload to disk. Placed after tab tracking so
1501
191
  // taking the file path does not cost the session its tab bookkeeping.
@@ -1567,7 +257,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1567
257
  current: OWN_VERSION,
1568
258
  latest: updateState.latest,
1569
259
  updateAvailable: updateState.updateAvailable,
1570
- skew: updates.skew(OWN_VERSION, extensionVersion),
260
+ skew: updates.skew(OWN_VERSION, bridge.extensionVersion()),
1571
261
  });
1572
262
  if (!text) return result;
1573
263
 
@@ -1578,6 +268,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1578
268
 
1579
269
  // Start the server
1580
270
  async function startMcp() {
271
+ bridge.start();
272
+
1581
273
  const transport = new StdioServerTransport();
1582
274
  await server.connect(transport);
1583
275