poltertab 1.7.0 → 1.8.0-beta.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.
package/README.md CHANGED
@@ -244,8 +244,11 @@ themselves into **secondary** mode, proxying their commands through the primary,
244
244
  so concurrent agents share one browser with no extra configuration. The
245
245
  primary tags each secondary's commands with its id, and the extension keeps a
246
246
  separate current tab per agent, so agents get their own tabs. Up to
247
- 5 secondaries are accepted, and if the primary exits one of them promotes itself
248
- to replace it. The extension popup lists whichever agents are connected.
247
+ 50 secondaries are accepted (set `POLTERTAB_MAX_SECONDARIES` to change the
248
+ limit). A secondary refused for capacity is told why and retries with backoff
249
+ (2s doubling to 60s) instead of looping. If the primary exits, exactly one
250
+ secondary promotes itself to replace it and the others reconnect to it. The
251
+ extension popup lists whichever agents are connected.
249
252
 
250
253
  **Changing the WebSocket Port:**
251
254
  Only needed when something that *isn't* PolterTab already holds `7822` —
@@ -12,6 +12,7 @@
12
12
 
13
13
  const WebSocket = require("ws");
14
14
  const crypto = require("crypto");
15
+ const net = require("net");
15
16
  const {
16
17
  resolvePort,
17
18
  DEFAULT_WS_PORT,
@@ -23,6 +24,9 @@ const {
23
24
  NETWORK_MAX_BODY_BYTES,
24
25
  PROMOTION_DELAY_MS,
25
26
  PROMOTION_JITTER_MS,
27
+ PROMOTION_BACKOFF_INITIAL_MS,
28
+ PROMOTION_BACKOFF_MAX_MS,
29
+ PROMOTION_BACKOFF_JITTER,
26
30
  MAX_SECONDARIES,
27
31
  POLTERTAB_HOME,
28
32
  } = require("./config.js");
@@ -47,6 +51,15 @@ let nodeId = null;
47
51
  let wss = null;
48
52
  let httpServer = null;
49
53
 
54
+ // Secondary-side retry state. One counter for the whole reconnect/probe/promote
55
+ // cycle, reset only by a Primary's secondary_accepted; `refusal` and
56
+ // `unreachable` say why this node is between connections, so a command sent
57
+ // meanwhile can name the cause instead of a generic "not connected".
58
+ let backoffAttempt = 0;
59
+ let retryTimer = null;
60
+ let refusal = null;
61
+ let unreachable = false;
62
+
50
63
  function broadcastSessionState() {
51
64
  if (extensionSocket && extensionSocket.readyState === WebSocket.OPEN) {
52
65
  const sessions = [];
@@ -107,6 +120,10 @@ function setupPrimaryServer() {
107
120
  console.error(
108
121
  `[PolterTab MCP] Port ${WS_PORT} in use, switching to Secondary Mode...`,
109
122
  );
123
+ // A server that never bound is still a handle; every lost promotion race
124
+ // used to leave one behind.
125
+ httpServer.close();
126
+ httpServer = null;
110
127
  startSecondaryMode();
111
128
  } else {
112
129
  console.error(`[PolterTab MCP] HTTP server error: ${e.message}`);
@@ -174,7 +191,10 @@ function setupPrimaryServer() {
174
191
 
175
192
  function startSecondaryMode() {
176
193
  isSecondary = true;
177
- nodeId = crypto.randomUUID();
194
+ // Once per process. The extension keys this agent's current tab by nodeId,
195
+ // so a fresh one after a lost promotion race orphaned that pointer and the
196
+ // agent's next untargeted command landed on the user's focused tab.
197
+ if (!nodeId) nodeId = crypto.randomUUID();
178
198
  console.error(
179
199
  `[PolterTab MCP] Node ${nodeId} running as Secondary. Connecting to Primary...`,
180
200
  );
@@ -182,19 +202,93 @@ function startSecondaryMode() {
182
202
  connectToPrimary();
183
203
  }
184
204
 
205
+ // The retry schedule: INITIAL × 2^attempt, capped, ±JITTER so a crowd of
206
+ // refused Secondaries does not come back in step. `rand` is injectable for
207
+ // the tests.
208
+ function _backoffDelay(attempt, rand = Math.random) {
209
+ const base = Math.min(
210
+ PROMOTION_BACKOFF_INITIAL_MS * 2 ** attempt,
211
+ PROMOTION_BACKOFF_MAX_MS,
212
+ );
213
+ const jitter = 1 + PROMOTION_BACKOFF_JITTER * (2 * rand() - 1);
214
+ return Math.min(Math.round(base * jitter), PROMOTION_BACKOFF_MAX_MS);
215
+ }
216
+
217
+ function scheduleRetry(fn, delay) {
218
+ clearTimeout(retryTimer);
219
+ retryTimer = setTimeout(fn, delay);
220
+ }
221
+
222
+ // Is anything listening on the port? Binding to find out is what leaked a
223
+ // server per attempt; a refused TCP connect is the only answer that means the
224
+ // Primary is gone.
225
+ function probePrimary() {
226
+ return new Promise((resolve) => {
227
+ const sock = net.connect(WS_PORT, "localhost");
228
+ // A listener with a full accept backlog answers with neither connect nor
229
+ // error, which would park this node with no retry scheduled. Treat silence
230
+ // as "something is there": connectToPrimary's handshake timeout then sorts
231
+ // a hung Primary from a live one and keeps the backoff running.
232
+ sock.setTimeout(1000, () => {
233
+ sock.destroy();
234
+ resolve(true);
235
+ });
236
+ sock.once("connect", () => {
237
+ sock.destroy();
238
+ resolve(true);
239
+ });
240
+ sock.once("error", (err) => resolve(err.code !== "ECONNREFUSED"));
241
+ });
242
+ }
243
+
244
+ async function promoteOrRejoin() {
245
+ if (await probePrimary()) {
246
+ connectToPrimary();
247
+ return;
248
+ }
249
+ isSecondary = false;
250
+ setupPrimaryServer();
251
+ }
252
+
185
253
  function connectToPrimary() {
186
- extensionSocket = new WebSocket(`ws://localhost:${WS_PORT}`, {
254
+ const ws = new WebSocket(`ws://localhost:${WS_PORT}`, {
187
255
  perMessageDeflate: true,
256
+ // A Primary that accepts TCP but never answers the upgrade (event loop
257
+ // blocked, SIGSTOP) would otherwise hold this node forever.
258
+ handshakeTimeout: 5000,
188
259
  });
260
+ extensionSocket = ws;
261
+ let opened = false;
262
+ let accepted = false;
263
+ let errCode = null;
189
264
 
190
- extensionSocket.on("open", () => {
265
+ ws.on("open", () => {
266
+ opened = true;
191
267
  console.error("[PolterTab MCP] Connected to Primary MCP Server.");
192
- extensionSocket.send(JSON.stringify({ type: "secondary_mcp", nodeId }));
268
+ // The version is what tells a Primary this node understands 4xxx closes.
269
+ ws.send(
270
+ JSON.stringify({
271
+ type: "secondary_mcp",
272
+ nodeId,
273
+ version: OWN_VERSION,
274
+ pid: process.pid,
275
+ parentPid: process.ppid,
276
+ }),
277
+ );
193
278
  });
194
279
 
195
- extensionSocket.on("message", (message) => {
280
+ ws.on("message", (message) => {
196
281
  try {
197
282
  const msg = JSON.parse(message);
283
+ // Frames about this connection carry a type, never a command id, and
284
+ // are handled before the id branch so none can settle a pending command.
285
+ if (msg.type === "secondary_accepted") {
286
+ accepted = true;
287
+ backoffAttempt = 0;
288
+ refusal = null;
289
+ unreachable = false;
290
+ return;
291
+ }
198
292
  if (msg.id && pendingCommands.has(msg.id)) {
199
293
  const { resolve, reject, timer } = pendingCommands.get(msg.id);
200
294
  clearTimeout(timer);
@@ -214,30 +308,70 @@ function connectToPrimary() {
214
308
  }
215
309
  });
216
310
 
217
- extensionSocket.on("close", () => {
218
- console.error(
219
- "[PolterTab MCP] Connection to Primary lost. Attempting to become Primary...",
220
- );
221
- extensionSocket = null;
311
+ ws.on("close", (code, reason) => {
312
+ if (extensionSocket === ws) extensionSocket = null;
313
+ // Each branch below sets the one that applies; neither may outlive the
314
+ // close that caused it.
315
+ refusal = null;
316
+ unreachable = false;
317
+
318
+ const refused = code >= 4000 && code <= 4999;
319
+ let info = {};
320
+ if (refused) {
321
+ try {
322
+ info = JSON.parse(reason.toString());
323
+ } catch (_) {}
324
+ }
222
325
 
223
326
  // Reject all pending commands
327
+ const aborted =
328
+ info.reason === "capacity_rejected"
329
+ ? `[capacity_rejected limit=${info.limit}] Primary (pid ${info.pid}) refused this node. Command aborted.`
330
+ : "Primary node disconnected. Command aborted.";
224
331
  for (const [id, { reject, timer }] of pendingCommands) {
225
332
  clearTimeout(timer);
226
- reject(new Error("Primary node disconnected. Command aborted."));
333
+ reject(new Error(aborted));
227
334
  pendingCommands.delete(id);
228
335
  }
229
336
 
230
- // Try to become primary with exponential backoff & jitter
231
- const delay =
232
- PROMOTION_DELAY_MS + Math.floor(Math.random() * PROMOTION_JITTER_MS);
337
+ // Refused by a live Primary: it is not dying, so promoting can only fail.
338
+ if (refused) {
339
+ const delay = _backoffDelay(backoffAttempt++);
340
+ refusal = { ...info, retryAt: Date.now() + delay };
341
+ console.error(
342
+ `[PolterTab MCP] Primary refused this node (${code} ${info.reason || "unknown"}). Retrying as Secondary in ${delay}ms.`,
343
+ );
344
+ scheduleRetry(connectToPrimary, delay);
345
+ return;
346
+ }
347
+
348
+ // Something holds the port but never completed the handshake: a hung
349
+ // Primary, or not a PolterTab one. Promoting would only hit EADDRINUSE.
350
+ if (!opened && errCode !== "ECONNREFUSED") {
351
+ const delay = _backoffDelay(backoffAttempt++);
352
+ unreachable = true;
353
+ console.error(
354
+ `[PolterTab MCP] Primary on port ${WS_PORT} is not responding (${errCode || code}). Retrying in ${delay}ms.`,
355
+ );
356
+ scheduleRetry(connectToPrimary, delay);
357
+ return;
358
+ }
233
359
 
234
- setTimeout(() => {
235
- isSecondary = false;
236
- setupPrimaryServer();
237
- }, delay);
360
+ // The Primary went away, or closed this node without saying why (as a
361
+ // pre-1.8 Primary does at its limit). Probe first; promote only if the port
362
+ // is free. A node that was never accepted stays on the backoff schedule, so
363
+ // even a codeless refusal cannot loop at full speed.
364
+ const delay = accepted
365
+ ? PROMOTION_DELAY_MS + Math.floor(Math.random() * PROMOTION_JITTER_MS)
366
+ : _backoffDelay(backoffAttempt++);
367
+ console.error(
368
+ `[PolterTab MCP] Connection to Primary lost. Attempting to become Primary in ${delay}ms...`,
369
+ );
370
+ scheduleRetry(promoteOrRejoin, delay);
238
371
  });
239
372
 
240
- extensionSocket.on("error", (err) => {
373
+ ws.on("error", (err) => {
374
+ errCode = err.code || null;
241
375
  console.error(`[PolterTab MCP] Secondary WebSocket error: ${err.message}`);
242
376
  });
243
377
  }
@@ -270,6 +404,7 @@ async function readNetworkState(params = {}) {
270
404
  function setupPrimaryWss(wss) {
271
405
  wss.on("connection", (ws) => {
272
406
  ws.isAlive = true;
407
+ ws.supportsNewCloseCodes = false;
273
408
  ws.on("pong", () => {
274
409
  ws.isAlive = true;
275
410
  });
@@ -279,18 +414,55 @@ function setupPrimaryWss(wss) {
279
414
  const msg = JSON.parse(message);
280
415
 
281
416
  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;
417
+ // Only a Secondary that names its version understands a 4xxx close.
418
+ // An older one answers any close by trying to promote — the loop this
419
+ // limit used to trigger — so it is never refused and never counted.
420
+ ws.supportsNewCloseCodes =
421
+ typeof msg.version === "string" && msg.version !== "";
422
+ if (ws.supportsNewCloseCodes) {
423
+ let v2 = 0;
424
+ for (const [id, c] of secondaryClients) {
425
+ if (id !== msg.nodeId && c.supportsNewCloseCodes) v2++;
426
+ }
427
+ if (v2 >= MAX_SECONDARIES) {
428
+ console.error(
429
+ `[PolterTab MCP] Rejecting secondary node ${msg.nodeId} - limit reached.`,
430
+ );
431
+ ws.close(
432
+ 4000,
433
+ JSON.stringify({
434
+ reason: "capacity_rejected",
435
+ limit: MAX_SECONDARIES,
436
+ retry_in_ms: PROMOTION_BACKOFF_INITIAL_MS,
437
+ pid: process.pid,
438
+ }),
439
+ );
440
+ return;
441
+ }
442
+ }
443
+ // Same node, new socket: the old one is dead weight the client may
444
+ // never end, and the close guard below keeps its close harmless.
445
+ const displaced = secondaryClients.get(msg.nodeId);
446
+ if (displaced && displaced !== ws && displaced.readyState === WebSocket.OPEN) {
447
+ displaced.close(1000);
288
448
  }
289
449
  secondaryClients.set(msg.nodeId, ws);
290
450
  ws.nodeId = msg.nodeId;
451
+ ws.version = msg.version || null;
452
+ ws.pid = msg.pid || null;
453
+ ws.parentPid = msg.parentPid || null;
291
454
  console.error(
292
455
  `[PolterTab MCP] Secondary node connected: ${msg.nodeId}. Total secondaries: ${secondaryClients.size}`,
293
456
  );
457
+ if (ws.supportsNewCloseCodes) {
458
+ ws.send(
459
+ JSON.stringify({
460
+ type: "secondary_accepted",
461
+ nodeId: msg.nodeId,
462
+ primaryVersion: OWN_VERSION,
463
+ }),
464
+ );
465
+ }
294
466
  broadcastSessionState();
295
467
  return;
296
468
  }
@@ -302,6 +474,23 @@ function setupPrimaryWss(wss) {
302
474
 
303
475
  if (msg.type === "proxy_command") {
304
476
  const { id, action } = msg;
477
+ // No nodeId means no secondary_mcp yet — or a socket refused with
478
+ // 4000 an instant ago. The extension would bucket its command as the
479
+ // Primary agent's and move that agent's current tab; a refused
480
+ // socket would not even get the reply. A well-behaved client's
481
+ // handshake always precedes its first command on the same socket.
482
+ if (!ws.nodeId) {
483
+ if (ws.readyState === WebSocket.OPEN) {
484
+ ws.send(
485
+ JSON.stringify({
486
+ id,
487
+ success: false,
488
+ error: "[not_registered] Send secondary_mcp before proxy_command.",
489
+ }),
490
+ );
491
+ }
492
+ return;
493
+ }
305
494
  // Stamp which agent is asking, from the socket rather than the
306
495
  // message, so the extension keeps this Secondary's current tab apart
307
496
  // from the Primary's and every other Secondary's.
@@ -481,8 +670,12 @@ function setupPrimaryWss(wss) {
481
670
  console.error(
482
671
  `[PolterTab MCP] Secondary node disconnected: ${ws.nodeId}`,
483
672
  );
484
- secondaryClients.delete(ws.nodeId);
485
- broadcastSessionState();
673
+ // A node now keeps its id across reconnects, so its new socket may
674
+ // already be registered by the time the old one's close arrives.
675
+ if (secondaryClients.get(ws.nodeId) === ws) {
676
+ secondaryClients.delete(ws.nodeId);
677
+ broadcastSessionState();
678
+ }
486
679
  } else if (extensionSocket === ws) {
487
680
  console.error("[PolterTab MCP] Chrome extension disconnected.");
488
681
  extensionSocket = null;
@@ -560,6 +753,17 @@ async function sendCommand(action, params) {
560
753
 
561
754
  if (isSecondary) {
562
755
  if (!isConnected) {
756
+ if (refusal && refusal.reason === "capacity_rejected") {
757
+ const s = Math.max(0, Math.ceil((refusal.retryAt - Date.now()) / 1000));
758
+ throw new Error(
759
+ `[capacity_rejected limit=${refusal.limit}] Primary (pid ${refusal.pid}) has ${refusal.limit} secondaries. Retry in ${s}s.`,
760
+ );
761
+ }
762
+ if (unreachable) {
763
+ throw new Error(
764
+ `[primary_unreachable port=${WS_PORT}] Primary is not responding. Backing off.`,
765
+ );
766
+ }
563
767
  throw new Error("Secondary node not connected to Primary MCP server.");
564
768
  }
565
769
 
@@ -618,4 +822,6 @@ module.exports = {
618
822
  isSecondary: () => isSecondary,
619
823
  extensionVersion: () => extensionVersion,
620
824
  _buildEnvelope,
825
+ _backoffDelay,
826
+ _probePrimary: probePrimary,
621
827
  };
@@ -46,6 +46,18 @@ function resolvePort(argv = process.argv, env = process.env) {
46
46
  return DEFAULT_WS_PORT;
47
47
  }
48
48
 
49
+ // The v2 Secondary ceiling: POLTERTAB_MAX_SECONDARIES if it is a whole number
50
+ // ≥ 0, else 50. 0 is honoured — it refuses every versioned Secondary (old ones
51
+ // are still accepted, see MAX_SECONDARIES below) — so an explicit 0 must not
52
+ // fall through to the default the way `parseInt(x) || 50` let it.
53
+ const DEFAULT_MAX_SECONDARIES = 50;
54
+ function resolveMaxSecondaries(env = process.env) {
55
+ const raw = env.POLTERTAB_MAX_SECONDARIES;
56
+ if (raw === undefined || raw.trim() === "") return DEFAULT_MAX_SECONDARIES;
57
+ const n = Number(raw);
58
+ return Number.isInteger(n) && n >= 0 ? n : DEFAULT_MAX_SECONDARIES;
59
+ }
60
+
49
61
  module.exports = {
50
62
  POLTERTAB_HOME,
51
63
  MEMORY_DIR: path.join(POLTERTAB_HOME, "navigation_memory"),
@@ -53,6 +65,7 @@ module.exports = {
53
65
 
54
66
  DEFAULT_WS_PORT,
55
67
  resolvePort,
68
+ resolveMaxSecondaries,
56
69
 
57
70
  // See the budget above before changing this.
58
71
  COMMAND_TIMEOUT_MS: 35000,
@@ -74,9 +87,20 @@ module.exports = {
74
87
  PROMOTION_DELAY_MS: 500,
75
88
  PROMOTION_JITTER_MS: 2000,
76
89
 
77
- // More concurrent agents than this sharing one browser stops being useful and
78
- // starts being a way to lose track of which tab belongs to whom.
79
- MAX_SECONDARIES: 5,
90
+ // A refused or unreachable Secondary retries on this schedule — 2 s, 4 s,
91
+ // 8 s … ±25%, capped — and the whole probe/connect/promote cycle runs under
92
+ // it, reset only when a Primary sends secondary_accepted. Without it a
93
+ // refusal was answered by an immediate reconnect, refused again, forever.
94
+ PROMOTION_BACKOFF_INITIAL_MS: 2000,
95
+ PROMOTION_BACKOFF_MAX_MS: 60000,
96
+ PROMOTION_BACKOFF_JITTER: 0.25,
97
+
98
+ // A safety net against runaway processes, not a usage limit: at 5 it was
99
+ // routinely hit by idle helpers, and every refusal fed the loop above. Only
100
+ // Secondaries that declare a version count; an old one is always accepted,
101
+ // because the only refusal it understands is a close it answers by trying to
102
+ // promote.
103
+ MAX_SECONDARIES: resolveMaxSecondaries(),
80
104
 
81
105
  // Lazy-loaded content arrives after the scroll returns. Long enough for the
82
106
  // XHR it triggered to land in the capture buffer, short enough that a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poltertab",
3
- "version": "1.7.0",
3
+ "version": "1.8.0-beta.1",
4
4
  "description": "Let any AI assistant drive and scrape your existing Chrome profile — no headless browser, no complex setup.",
5
5
  "bin": {
6
6
  "poltertab": "./bin/poltertab.js"