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.
package/README.md CHANGED
@@ -82,8 +82,6 @@ The wizard targets Claude Code. Any other MCP client works too — register
82
82
  Running from a clone instead of npm? Point the client at the file directly:
83
83
  `{"command": "node", "args": ["/absolute/path/to/poltertab/mcp-server/index.js"]}`
84
84
 
85
- > **Legacy:** This extension began as a ZeroClaw plugin and still speaks ZeroClaw's built-in REST bridge (`backend = "bridge"` in `~/.zeroclaw/config.toml`). That path is optional and not required for the MCP setup above.
86
-
87
85
  ## Supported MCP Tools
88
86
 
89
87
  The server exposes 23 tools to the LLM (all prefixed with `browser_`). Every
@@ -211,20 +209,14 @@ next use.
211
209
 
212
210
  Selectors are resolved in order:
213
211
 
214
- 1. **CSS selector** — `#id`, `.class`, `div > span`
215
- 2. **XPath** — `//div[@class="foo"]`
216
- 3. **Text match** — exact text content of an element
217
-
218
- ## CLI Wrapper (Legacy)
219
-
220
- `zc-browser.sh` lets you send commands directly to the legacy ZeroClaw REST bridge for testing (only relevant if you use the optional ZeroClaw bridge path above):
212
+ 1. **Snapshot ref** — `@e12`, as returned by `browser_snapshot`. Resolves to the
213
+ exact node the snapshot described, and beats a generated class chain.
214
+ 2. **CSS selector** — `#id`, `.class`, `div > span`
215
+ 3. **XPath** — `//div[@class="foo"]`
216
+ 4. **Text match** — exact text content of an element
221
217
 
222
- ```bash
223
- ./zc-browser.sh navigate url=https://example.com
224
- ./zc-browser.sh scrape selector="h1"
225
- ./zc-browser.sh get_title
226
- ./zc-browser.sh health
227
- ```
218
+ A miss on all four is retried with shadow-root piercing, so a selector that only
219
+ exists inside a web component still resolves.
228
220
 
229
221
  ## Troubleshooting & Options
230
222
 
@@ -288,7 +280,6 @@ poltertab/
288
280
  │ ├── popup.html/js # Connected-agents UI
289
281
  │ ├── options.html/js # Port configuration UI
290
282
  │ └── icons/
291
- ├── zc-browser.sh # CLI wrapper (legacy)
292
283
  └── README.md
293
284
  ```
294
285
 
package/bin/doctor.js CHANGED
@@ -119,7 +119,7 @@ async function run(argv = []) {
119
119
  todo.push(
120
120
  `${c.red("The extension is older than the server.")} Browser commands can fail\n` +
121
121
  ` in ways that look like a missing element. Download and reload it:\n` +
122
- ` ${c.cyan(updates.EXTENSION_URL)}`,
122
+ ` ${c.cyan(updates.extensionUrl(sk.server))}`,
123
123
  );
124
124
  } else if (sk && sk.kind === "server-behind") {
125
125
  todo.push(
@@ -131,7 +131,7 @@ async function run(argv = []) {
131
131
  todo.push(
132
132
  `PolterTab ${c.bold(check.latest)} is available:\n` +
133
133
  ` ${c.cyan("npm update -g poltertab")}\n` +
134
- ` ${c.dim("then reload the extension from")} ${c.cyan(updates.EXTENSION_URL)}`,
134
+ ` ${c.dim("then reload the extension from")} ${c.cyan(updates.extensionUrl(check.latest))}`,
135
135
  );
136
136
  }
137
137
  if (!listening) {
@@ -150,7 +150,7 @@ async function run(argv = []) {
150
150
  console.log(
151
151
  `\n ${c.yellow("Nothing wrong found, but the extension has never checked in.")}\n` +
152
152
  ` ${c.dim("Install it, open a page, and run doctor again to compare versions:")}\n` +
153
- ` ${c.cyan(updates.EXTENSION_URL)}`,
153
+ ` ${c.cyan(updates.extensionUrl(own))}`,
154
154
  );
155
155
  } else {
156
156
  console.log(`\n ${c.green("Nothing to do — both halves agree and are current.")}`);
package/bin/setup.js CHANGED
@@ -24,7 +24,16 @@ const SNIPPET_SRC = path.join(PKG, "assets", "claude-md-snippet.md");
24
24
 
25
25
  // Bump to the Chrome Web Store listing once it clears review. The GitHub
26
26
  // release stays valid either way, so this is a one-line switch.
27
- const EXTENSION_URL = "https://github.com/abhinav162/poltertab/releases/latest";
27
+ //
28
+ // Pinned to the release for the version being installed, not "latest". Someone
29
+ // who installed a prerelease (npm i -g poltertab@beta) and followed a /latest
30
+ // link would get the previous stable extension, because GitHub's
31
+ // /releases/latest skips prereleases — a fresh, correctly-followed setup that
32
+ // lands in version skew.
33
+ const updates = require("../mcp-server/update-check.js");
34
+ const EXTENSION_URL = updates.extensionUrl(
35
+ require("../package.json").version,
36
+ );
28
37
 
29
38
  // Identifies our section in a CLAUDE.md we do not own. Presence means
30
39
  // "already installed" — this is the whole idempotency check for step 2.
@@ -0,0 +1,593 @@
1
+ // The bridge: this process's connection to the Chrome extension, and its
2
+ // arbitration with any other MCP server that wants the same browser.
3
+ //
4
+ // Two roles. Whoever binds the port is Primary and owns the extension socket;
5
+ // everyone else becomes a Secondary and proxies commands through it. A Primary
6
+ // that dies frees the port, and the orphaned Secondaries race (with jitter) to
7
+ // replace it. No lockfile, no supervisor.
8
+ //
9
+ // Everything mutable about that arrangement lives in this file. index.js sees
10
+ // four verbs — sendCommand, readNetworkState, isSecondary, noteActiveTab — and
11
+ // none of the state behind them.
12
+
13
+ const WebSocket = require("ws");
14
+ const crypto = require("crypto");
15
+ const {
16
+ resolvePort,
17
+ DEFAULT_WS_PORT,
18
+ COMMAND_TIMEOUT_MS,
19
+ HEARTBEAT_MS,
20
+ NETWORK_TTL_MS,
21
+ NETWORK_GC_INTERVAL_MS,
22
+ NETWORK_MAX_REQUESTS,
23
+ NETWORK_MAX_BODY_BYTES,
24
+ PROMOTION_DELAY_MS,
25
+ PROMOTION_JITTER_MS,
26
+ MAX_SECONDARIES,
27
+ POLTERTAB_HOME,
28
+ } = require("./config.js");
29
+ const updates = require("./update-check.js");
30
+ const OWN_VERSION = require("./../package.json").version;
31
+
32
+ let WS_PORT = resolvePort();
33
+
34
+ // Populated when the extension connects; stays null until then, which is itself
35
+ // worth reporting — a skew warning must not fire on "no extension yet".
36
+ let extensionVersion = null;
37
+
38
+ // Global state
39
+ let extensionSocket = null;
40
+ const pendingCommands = new Map();
41
+ const networkState = new Map(); // tabId -> { lastUpdated: number, requests: array }
42
+ const secondaryClients = new Map();
43
+ let primaryLastTabId = null;
44
+
45
+ let isSecondary = false;
46
+ let nodeId = null;
47
+ let wss = null;
48
+ let httpServer = null;
49
+
50
+ function broadcastSessionState() {
51
+ if (extensionSocket && extensionSocket.readyState === WebSocket.OPEN) {
52
+ const sessions = [];
53
+ sessions.push({
54
+ id: "Primary Agent",
55
+ nodeId: "primary",
56
+ lastTabId: primaryLastTabId || null,
57
+ });
58
+ for (const [nodeId, clientWs] of secondaryClients.entries()) {
59
+ sessions.push({
60
+ id: "Secondary Agent",
61
+ nodeId: nodeId,
62
+ lastTabId: clientWs.lastTabId || null,
63
+ });
64
+ }
65
+ extensionSocket.send(
66
+ JSON.stringify({
67
+ type: "state_update",
68
+ sessions,
69
+ }),
70
+ );
71
+ }
72
+ }
73
+
74
+ // The bridge listens on localhost, and localhost is reachable from any page the
75
+ // user happens to visit — browsers allow ws:// to loopback from an https origin.
76
+ // Nothing below the handshake distinguishes callers, so a drive-by page could
77
+ // send {type:"ping"}, be installed as `extensionSocket` (the nodeId-less branch
78
+ // in setupPrimaryWss), and thereby drop the real extension *and* receive every
79
+ // subsequent command — URLs, selectors — answering each with whatever it liked.
80
+ //
81
+ // Origin is the one thing the page cannot lie about: browsers set it themselves
82
+ // on the handshake request. The extension sends chrome-extension://<its id>, and
83
+ // a Node client (a Secondary MCP node) sends none at all. Both are us; a page is
84
+ // never either.
85
+ //
86
+ // ponytail: origin check only. It stops the drive-by, which is the reachable
87
+ // attack. It does not authenticate a hostile *local process* — that needs a
88
+ // shared token in POLTERTAB_HOME, and any local process able to make one is
89
+ // already able to read the browser profile directly.
90
+ function allowOrigin(info, cb) {
91
+ const origin = info.origin || info.req.headers.origin;
92
+ if (!origin || origin.startsWith("chrome-extension://")) return cb(true);
93
+ // Loud on purpose: a rejected handshake is otherwise indistinguishable from
94
+ // an extension that never connected, which is the worst thing to debug.
95
+ console.error(
96
+ `[PolterTab MCP] Refused WebSocket handshake from origin ${origin}`,
97
+ );
98
+ return cb(false, 403, "Forbidden");
99
+ }
100
+
101
+ function setupPrimaryServer() {
102
+ const http = require("http");
103
+ httpServer = http.createServer();
104
+
105
+ httpServer.on("error", (e) => {
106
+ if (e.code === "EADDRINUSE") {
107
+ console.error(
108
+ `[PolterTab MCP] Port ${WS_PORT} in use, switching to Secondary Mode...`,
109
+ );
110
+ startSecondaryMode();
111
+ } else {
112
+ console.error(`[PolterTab MCP] HTTP server error: ${e.message}`);
113
+ }
114
+ });
115
+
116
+ httpServer.listen(WS_PORT, () => {
117
+ console.error(
118
+ `[PolterTab MCP] Primary WebSocket server listening on ws://localhost:${WS_PORT}`,
119
+ );
120
+ wss = new WebSocket.WebSocketServer({
121
+ server: httpServer,
122
+ verifyClient: allowOrigin,
123
+ perMessageDeflate: {
124
+ zlibDeflateOptions: {
125
+ chunkSize: 1024,
126
+ memLevel: 7,
127
+ level: 1,
128
+ },
129
+ zlibInflateOptions: {
130
+ chunkSize: 10 * 1024,
131
+ },
132
+ clientNoContextTakeover: true,
133
+ serverNoContextTakeover: true,
134
+ serverMaxWindowBits: 10,
135
+ concurrencyLimit: 10,
136
+ threshold: 1024,
137
+ },
138
+ });
139
+
140
+ // Heartbeat mechanism to detect dead connections
141
+ const interval = setInterval(() => {
142
+ wss.clients.forEach((ws) => {
143
+ if (ws.isAlive === false) {
144
+ console.error(
145
+ `[PolterTab MCP] Terminating dead connection: ${ws.nodeId || "Extension/Unknown"}`,
146
+ );
147
+ return ws.terminate();
148
+ }
149
+ ws.isAlive = false;
150
+ ws.ping();
151
+ });
152
+ }, HEARTBEAT_MS);
153
+
154
+ wss.on("close", () => {
155
+ clearInterval(interval);
156
+ });
157
+
158
+ // Network State Garbage Collector (TTL: 5 minutes)
159
+ setInterval(() => {
160
+ const now = Date.now();
161
+ for (const [tabId, state] of networkState.entries()) {
162
+ if (now - state.lastUpdated > NETWORK_TTL_MS) {
163
+ console.error(
164
+ `[PolterTab MCP] Garbage collecting network state for tab ${tabId}`,
165
+ );
166
+ networkState.delete(tabId);
167
+ }
168
+ }
169
+ }, NETWORK_GC_INTERVAL_MS);
170
+
171
+ setupPrimaryWss(wss);
172
+ });
173
+ }
174
+
175
+ function startSecondaryMode() {
176
+ isSecondary = true;
177
+ nodeId = crypto.randomUUID();
178
+ console.error(
179
+ `[PolterTab MCP] Node ${nodeId} running as Secondary. Connecting to Primary...`,
180
+ );
181
+
182
+ connectToPrimary();
183
+ }
184
+
185
+ function connectToPrimary() {
186
+ extensionSocket = new WebSocket(`ws://localhost:${WS_PORT}`, {
187
+ perMessageDeflate: true,
188
+ });
189
+
190
+ extensionSocket.on("open", () => {
191
+ console.error("[PolterTab MCP] Connected to Primary MCP Server.");
192
+ extensionSocket.send(JSON.stringify({ type: "secondary_mcp", nodeId }));
193
+ });
194
+
195
+ extensionSocket.on("message", (message) => {
196
+ try {
197
+ const msg = JSON.parse(message);
198
+ if (msg.id && pendingCommands.has(msg.id)) {
199
+ const { resolve, reject, timer } = pendingCommands.get(msg.id);
200
+ clearTimeout(timer);
201
+ pendingCommands.delete(msg.id);
202
+
203
+ if (msg.success) {
204
+ resolve(msg.data);
205
+ } else {
206
+ reject(new Error(msg.error || "Unknown extension error"));
207
+ }
208
+ }
209
+ } catch (err) {
210
+ console.error(
211
+ "[PolterTab MCP] Failed to parse message from Primary",
212
+ err,
213
+ );
214
+ }
215
+ });
216
+
217
+ extensionSocket.on("close", () => {
218
+ console.error(
219
+ "[PolterTab MCP] Connection to Primary lost. Attempting to become Primary...",
220
+ );
221
+ extensionSocket = null;
222
+
223
+ // Reject all pending commands
224
+ for (const [id, { reject, timer }] of pendingCommands) {
225
+ clearTimeout(timer);
226
+ reject(new Error("Primary node disconnected. Command aborted."));
227
+ pendingCommands.delete(id);
228
+ }
229
+
230
+ // Try to become primary with exponential backoff & jitter
231
+ const delay =
232
+ PROMOTION_DELAY_MS + Math.floor(Math.random() * PROMOTION_JITTER_MS);
233
+
234
+ setTimeout(() => {
235
+ isSecondary = false;
236
+ setupPrimaryServer();
237
+ }, delay);
238
+ });
239
+
240
+ extensionSocket.on("error", (err) => {
241
+ console.error(`[PolterTab MCP] Secondary WebSocket error: ${err.message}`);
242
+ });
243
+ }
244
+
245
+ // Reading the capture buffer needs a tab, and the caller may not have named one
246
+ // — so ask the extension which tab it is actually on. Written out twice before
247
+ // (Primary answering a Secondary, and Primary answering itself), which is why
248
+ // the `clear` default and the envelope shape had to be kept in sync by hand.
249
+ async function readNetworkState(params = {}) {
250
+ let tabId = params.tabId;
251
+ if (!tabId) {
252
+ const tabInfo = await sendCommand("get_url", params);
253
+ tabId = tabInfo.tabId;
254
+ }
255
+
256
+ let data = [];
257
+ if (tabId && networkState.has(tabId)) {
258
+ data = networkState.get(tabId).requests;
259
+ // Reading is destructive by default: the buffer is a tail of what happened
260
+ // since the last read, and leaving it in place makes every later read
261
+ // re-report traffic the caller already saw.
262
+ if (params.clear !== false) {
263
+ networkState.get(tabId).requests = [];
264
+ }
265
+ }
266
+
267
+ return { tabId, capturedRequests: data.length, data };
268
+ }
269
+
270
+ function setupPrimaryWss(wss) {
271
+ wss.on("connection", (ws) => {
272
+ ws.isAlive = true;
273
+ ws.on("pong", () => {
274
+ ws.isAlive = true;
275
+ });
276
+
277
+ ws.on("message", (message) => {
278
+ try {
279
+ const msg = JSON.parse(message);
280
+
281
+ if (msg.type === "secondary_mcp") {
282
+ if (secondaryClients.size >= MAX_SECONDARIES) {
283
+ console.error(
284
+ `[PolterTab MCP] Rejecting secondary node ${msg.nodeId} - limit reached.`,
285
+ );
286
+ ws.close();
287
+ return;
288
+ }
289
+ secondaryClients.set(msg.nodeId, ws);
290
+ ws.nodeId = msg.nodeId;
291
+ console.error(
292
+ `[PolterTab MCP] Secondary node connected: ${msg.nodeId}. Total secondaries: ${secondaryClients.size}`,
293
+ );
294
+ broadcastSessionState();
295
+ return;
296
+ }
297
+
298
+ if (msg.type === "request_full_state") {
299
+ broadcastSessionState();
300
+ return;
301
+ }
302
+
303
+ if (msg.type === "proxy_command") {
304
+ const { id, action, params } = msg;
305
+ if (params && params.tabId) {
306
+ ws.lastTabId = params.tabId;
307
+ broadcastSessionState();
308
+ }
309
+
310
+ if (action === "get_network_state") {
311
+ (async () => {
312
+ try {
313
+ const data = await readNetworkState(params);
314
+ if (ws.readyState === WebSocket.OPEN) {
315
+ ws.send(JSON.stringify({ id, success: true, data }));
316
+ }
317
+ } catch (err) {
318
+ if (ws.readyState === WebSocket.OPEN) {
319
+ ws.send(
320
+ JSON.stringify({ id, success: false, error: err.message }),
321
+ );
322
+ }
323
+ }
324
+ })();
325
+ return;
326
+ }
327
+
328
+ if (
329
+ !extensionSocket ||
330
+ extensionSocket.readyState !== WebSocket.OPEN
331
+ ) {
332
+ if (ws.readyState === WebSocket.OPEN) {
333
+ ws.send(
334
+ JSON.stringify({
335
+ id,
336
+ success: false,
337
+ error: "Browser extension not connected to Primary.",
338
+ }),
339
+ );
340
+ }
341
+ return;
342
+ }
343
+
344
+ const timer = setTimeout(() => {
345
+ pendingCommands.delete(id);
346
+ if (ws.readyState === WebSocket.OPEN) {
347
+ ws.send(
348
+ JSON.stringify({
349
+ id,
350
+ success: false,
351
+ error: `Command '${action}' timed out.`,
352
+ }),
353
+ );
354
+ }
355
+ }, COMMAND_TIMEOUT_MS);
356
+
357
+ pendingCommands.set(id, { isProxy: true, sourceWs: ws, timer });
358
+ extensionSocket.send(JSON.stringify({ id, action, ...params }));
359
+ return;
360
+ }
361
+
362
+ // Handle keepalive pings
363
+ if (msg.type === "ping" || msg.type === "extension_ready") {
364
+ if (!ws.nodeId) {
365
+ // if it's the extension
366
+ if (
367
+ extensionSocket &&
368
+ extensionSocket !== ws &&
369
+ extensionSocket.readyState === WebSocket.OPEN
370
+ ) {
371
+ console.error(
372
+ "[PolterTab MCP] Another extension trying to connect. Dropping old connection.",
373
+ );
374
+ extensionSocket.close();
375
+ }
376
+ if (extensionSocket !== ws) {
377
+ extensionSocket = ws;
378
+ console.error("[PolterTab MCP] Chrome extension connected.");
379
+ }
380
+ // The extension has always sent its version here and we always
381
+ // dropped it, so the one mismatch that actually breaks commands was
382
+ // invisible to both halves.
383
+ if (msg.version && msg.version !== extensionVersion) {
384
+ extensionVersion = msg.version;
385
+ updates.recordExtension(POLTERTAB_HOME, msg.version);
386
+ const sk = updates.skew(OWN_VERSION, msg.version);
387
+ console.error(
388
+ `[PolterTab MCP] Extension v${msg.version} (server v${OWN_VERSION})` +
389
+ (sk ? ` — VERSION SKEW: ${sk.kind}` : ""),
390
+ );
391
+ // Let the extension show the mismatch in its popup; it cannot
392
+ // know the server's version otherwise.
393
+ try {
394
+ ws.send(
395
+ JSON.stringify({ type: "server_version", version: OWN_VERSION }),
396
+ );
397
+ } catch (_) {
398
+ // A socket that died mid-handshake reconnects on its own.
399
+ }
400
+ }
401
+ }
402
+ return;
403
+ }
404
+
405
+ // Handle captured network data
406
+ if (msg.type === "network_data") {
407
+ const { tabId, url, body } = msg;
408
+ if (!networkState.has(tabId)) {
409
+ networkState.set(tabId, { lastUpdated: Date.now(), requests: [] });
410
+ }
411
+ const state = networkState.get(tabId);
412
+ state.lastUpdated = Date.now();
413
+
414
+ try {
415
+ // Truncate bodies over 1MB
416
+ let processedBody = body;
417
+ if (body && body.length > NETWORK_MAX_BODY_BYTES) {
418
+ processedBody =
419
+ '{"error": "Payload exceeded 1MB limit and was truncated"}';
420
+ }
421
+
422
+ const jsonBody = JSON.parse(processedBody);
423
+ state.requests.push({ url, timestamp: Date.now(), data: jsonBody });
424
+ } catch (e) {
425
+ state.requests.push({ url, timestamp: Date.now(), data: body });
426
+ }
427
+
428
+ if (state.requests.length > NETWORK_MAX_REQUESTS) {
429
+ state.requests.shift();
430
+ }
431
+ return;
432
+ }
433
+
434
+ // Handle tab closed
435
+ if (msg.type === "tab_closed") {
436
+ const { tabId } = msg;
437
+ if (networkState.has(tabId)) {
438
+ console.error(
439
+ `[PolterTab MCP] Clearing network state for closed tab ${tabId}`,
440
+ );
441
+ networkState.delete(tabId);
442
+ }
443
+ return;
444
+ }
445
+
446
+ // Response from Extension
447
+ if (msg.id && pendingCommands.has(msg.id)) {
448
+ const cmd = pendingCommands.get(msg.id);
449
+ clearTimeout(cmd.timer);
450
+ pendingCommands.delete(msg.id);
451
+
452
+ if (cmd.isProxy) {
453
+ if (cmd.sourceWs.readyState === WebSocket.OPEN) {
454
+ cmd.sourceWs.send(JSON.stringify(msg));
455
+ }
456
+ } else if (msg.success) {
457
+ cmd.resolve(msg.data);
458
+ } else {
459
+ cmd.reject(new Error(msg.error || "Unknown extension error"));
460
+ }
461
+ }
462
+ } catch (err) {
463
+ console.error("[PolterTab MCP] Failed to parse message", err);
464
+ }
465
+ });
466
+
467
+ ws.on("close", () => {
468
+ if (ws.nodeId) {
469
+ console.error(
470
+ `[PolterTab MCP] Secondary node disconnected: ${ws.nodeId}`,
471
+ );
472
+ secondaryClients.delete(ws.nodeId);
473
+ broadcastSessionState();
474
+ } else if (extensionSocket === ws) {
475
+ console.error("[PolterTab MCP] Chrome extension disconnected.");
476
+ extensionSocket = null;
477
+ }
478
+ });
479
+
480
+ ws.on("error", (err) => {
481
+ console.error(`[PolterTab MCP] WebSocket error: ${err.message}`);
482
+ });
483
+ });
484
+ }
485
+
486
+ function shutdown() {
487
+ console.error("[PolterTab MCP] Shutting down...");
488
+ if (wss) wss.close();
489
+ if (httpServer) httpServer.close();
490
+ process.exit(0);
491
+ }
492
+ // Helper to wait for WebSocket connection
493
+ async function waitForConnection(timeoutMs = 5000) {
494
+ const start = Date.now();
495
+ while (Date.now() - start < timeoutMs) {
496
+ if (extensionSocket && extensionSocket.readyState === WebSocket.OPEN) {
497
+ return true;
498
+ }
499
+ await new Promise((resolve) => setTimeout(resolve, 100));
500
+ }
501
+ return false;
502
+ }
503
+
504
+ // Register a pending command, arm its timeout, send it, and unwind all three if
505
+ // the socket throws. This was spelled out twice below — once for the Secondary
506
+ // proxy path and once for the direct one — differing only in the envelope and
507
+ // the wording of two errors, which left the timeout as two independent facts.
508
+ // The third copy (Primary answering a Secondary, in setupPrimaryWss) replies
509
+ // over a socket instead of settling a promise, so it stays where it is and
510
+ // shares only the constant.
511
+ function awaitReply(envelope, { timedOut, sendFailed }) {
512
+ const { id } = envelope;
513
+ return new Promise((resolve, reject) => {
514
+ const timer = setTimeout(() => {
515
+ pendingCommands.delete(id);
516
+ reject(new Error(timedOut));
517
+ }, COMMAND_TIMEOUT_MS);
518
+
519
+ pendingCommands.set(id, { resolve, reject, timer });
520
+
521
+ try {
522
+ extensionSocket.send(JSON.stringify(envelope));
523
+ } catch (err) {
524
+ clearTimeout(timer);
525
+ pendingCommands.delete(id);
526
+ reject(new Error(sendFailed(err)));
527
+ }
528
+ });
529
+ }
530
+
531
+ // Helper to send command to the extension
532
+ async function sendCommand(action, params) {
533
+ const isConnected = await waitForConnection(5000);
534
+
535
+ if (isSecondary) {
536
+ if (!isConnected) {
537
+ throw new Error("Secondary node not connected to Primary MCP server.");
538
+ }
539
+
540
+ // Auto-inject a unique session to prevent collisions over shared tabs
541
+ if (!params.session) {
542
+ params.session = "agent_" + nodeId;
543
+ }
544
+
545
+ return awaitReply(
546
+ { type: "proxy_command", id: crypto.randomUUID(), action, params },
547
+ {
548
+ timedOut: `Proxy Command '${action}' timed out after ${COMMAND_TIMEOUT_MS / 1000} seconds.`,
549
+ sendFailed: (e) =>
550
+ `Failed to proxy command to Primary over WebSocket: ${e.message}`,
551
+ },
552
+ );
553
+ }
554
+
555
+ if (!isConnected) {
556
+ throw new Error(
557
+ `Browser extension not connected. Please ensure the extension is installed, enabled, and pointing to the correct port (default: ${DEFAULT_WS_PORT}).`,
558
+ );
559
+ }
560
+
561
+ return awaitReply(
562
+ { id: crypto.randomUUID(), action, ...params },
563
+ {
564
+ timedOut: `Command '${action}' timed out after ${COMMAND_TIMEOUT_MS / 1000} seconds.`,
565
+ sendFailed: (e) => `Failed to send command over WebSocket: ${e.message}`,
566
+ },
567
+ );
568
+ }
569
+
570
+ // The Primary's view of which tab the agent is working in, shown in the
571
+ // extension popup. Collapses what used to be four copies of
572
+ // `primaryLastTabId = x; broadcastSessionState();` scattered through the tool
573
+ // dispatch, each with its own guard.
574
+ function noteActiveTab(tabId) {
575
+ if (isSecondary || !tabId) return;
576
+ primaryLastTabId = tabId;
577
+ broadcastSessionState();
578
+ }
579
+
580
+ function start() {
581
+ setupPrimaryServer();
582
+ process.on("SIGINT", shutdown);
583
+ process.on("SIGTERM", shutdown);
584
+ }
585
+
586
+ module.exports = {
587
+ start,
588
+ sendCommand,
589
+ readNetworkState,
590
+ noteActiveTab,
591
+ isSecondary: () => isSecondary,
592
+ extensionVersion: () => extensionVersion,
593
+ };