poltertab 1.8.0-beta.1 → 1.8.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
@@ -250,6 +250,16 @@ limit). A secondary refused for capacity is told why and retries with backoff
250
250
  secondary promotes itself to replace it and the others reconnect to it. The
251
251
  extension popup lists whichever agents are connected.
252
252
 
253
+ A secondary that sends no command for 5 minutes gives up its slot and
254
+ reconnects on its next tool call (`POLTERTAB_IDLE_EVICTION_MS`). The primary
255
+ sends at most 6 commands to the extension at once
256
+ (`POLTERTAB_MAX_CONCURRENCY`) and queues up to 3 more per secondary
257
+ (`POLTERTAB_QUEUE_DEPTH`), taking turns between secondaries. A command beyond
258
+ that fails at once with `[queue_full]`; one that waits longer than 60s fails
259
+ with `[queue_timeout]` and never runs (`POLTERTAB_QUEUE_TIMEOUT_MS`).
260
+ `curl http://127.0.0.1:7822/status` shows the primary's secondaries and queue.
261
+ It answers on this machine only.
262
+
253
263
  **Changing the WebSocket Port:**
254
264
  Only needed when something that *isn't* PolterTab already holds `7822` —
255
265
  PolterTab servers share that port by design (see above). Both sides must move:
@@ -28,6 +28,10 @@ const {
28
28
  PROMOTION_BACKOFF_MAX_MS,
29
29
  PROMOTION_BACKOFF_JITTER,
30
30
  MAX_SECONDARIES,
31
+ IDLE_EVICTION_MS,
32
+ MAX_EXTENSION_CONCURRENCY,
33
+ PER_SECONDARY_QUEUE_DEPTH,
34
+ QUEUE_TIMEOUT_MS,
31
35
  POLTERTAB_HOME,
32
36
  } = require("./config.js");
33
37
  const updates = require("./update-check.js");
@@ -50,6 +54,7 @@ let isSecondary = false;
50
54
  let nodeId = null;
51
55
  let wss = null;
52
56
  let httpServer = null;
57
+ let httpServer6 = null; // IPv6 loopback companion; null if host has no IPv6
53
58
 
54
59
  // Secondary-side retry state. One counter for the whole reconnect/probe/promote
55
60
  // cycle, reset only by a Primary's secondary_accepted; `refusal` and
@@ -60,6 +65,104 @@ let retryTimer = null;
60
65
  let refusal = null;
61
66
  let unreachable = false;
62
67
 
68
+ // Idle eviction, Secondary side. A 4001 from the Primary parks this node: no
69
+ // reconnect, no probe, no promotion. The next sendCommand dials again, and
70
+ // `reconnect` is that dial while it is in flight, shared by every caller that
71
+ // arrives meanwhile so they cost one connection, not one each (R-S3).
72
+ let isEvicted = false;
73
+ let reconnect = null; // { promise, resolve, reject, timer }
74
+
75
+ // Command queue, Primary side (PR-3). At most MAX_EXTENSION_CONCURRENCY
76
+ // commands may be in flight to the extension at once. v2 Secondary commands
77
+ // beyond that wait in a per-secondary FIFO, served round-robin across
78
+ // secondaries and the primary itself. Legacy commands bypass the queue.
79
+ //
80
+ // queuedBySource maps sourceId → [{id, action, ackSentAt, callerWs, onDispatch,
81
+ // onTimeout}]. sourceId is the secondary ws for proxy commands, or the symbol
82
+ // PRIMARY_SOURCE for the primary's own commands. roundRobinOrder is the list of
83
+ // active sources in round-robin order; rrCursor is the next position to serve.
84
+ // waitHistory holds completed wait-times (ms) for /status percentiles.
85
+ let extensionInFlight = 0;
86
+ const PRIMARY_SOURCE = Symbol("primary");
87
+ const queuedBySource = new Map();
88
+ const roundRobinOrder = [];
89
+ let rrCursor = 0;
90
+ const waitHistory = [];
91
+
92
+ // Enqueue an item for a source. If the source has no queue yet, append it to
93
+ // the round-robin order so it gets served on the next dequeue.
94
+ function _rrEnqueue(sourceId, item) {
95
+ if (!queuedBySource.has(sourceId)) {
96
+ queuedBySource.set(sourceId, []);
97
+ roundRobinOrder.push(sourceId);
98
+ }
99
+ queuedBySource.get(sourceId).push(item);
100
+ }
101
+
102
+ // Pop the next item according to the round-robin order. After taking from a
103
+ // source, advance the cursor: if the source is now empty, remove it and leave
104
+ // the cursor where it is (the next source slid into that slot); otherwise move
105
+ // the cursor to the next source.
106
+ function _rrDequeue() {
107
+ if (roundRobinOrder.length === 0) return null;
108
+ if (rrCursor >= roundRobinOrder.length) rrCursor = 0;
109
+ const sourceId = roundRobinOrder[rrCursor];
110
+ const items = queuedBySource.get(sourceId);
111
+ const item = items.shift();
112
+ if (items.length === 0) {
113
+ queuedBySource.delete(sourceId);
114
+ roundRobinOrder.splice(rrCursor, 1);
115
+ if (rrCursor >= roundRobinOrder.length) rrCursor = 0;
116
+ } else {
117
+ rrCursor = (rrCursor + 1) % roundRobinOrder.length;
118
+ }
119
+ return item;
120
+ }
121
+
122
+ // Try to dispatch as many queued items as the in-flight cap allows. Skips items
123
+ // whose caller disconnected or whose deadline has passed (both are dropped
124
+ // without consuming a slot).
125
+ function _tryDequeue() {
126
+ while (extensionInFlight < MAX_EXTENSION_CONCURRENCY) {
127
+ if (roundRobinOrder.length === 0) break;
128
+ const item = _rrDequeue();
129
+ if (!item) break;
130
+
131
+ // Deadline check: ackSentAt + budget − 2 s. The −2 s keeps the secondary's
132
+ // own timeout from firing *after* the drop (the secondary re-arms to budget
133
+ // on the queued frame, so the margin is budget − (budget − 2 s) = 2 s).
134
+ if (Date.now() > item.ackSentAt + COMMAND_TIMEOUT_MS - 2000) {
135
+ item.onTimeout();
136
+ continue;
137
+ }
138
+ // Caller gone: drop silently.
139
+ if (item.callerWs && item.callerWs.readyState !== WebSocket.OPEN) {
140
+ continue;
141
+ }
142
+ // Extension disconnected since the item was enqueued: reject to caller
143
+ // without consuming a slot. This is the guard against the MUST-1 crash:
144
+ // _dispatchProxyNow calls extensionSocket.send() synchronously, so a null
145
+ // socket inside a timer callback becomes an uncaughtException that kills
146
+ // the Primary and every Secondary with it.
147
+ if (!extensionSocket || extensionSocket.readyState !== WebSocket.OPEN) {
148
+ if (item.callerWs && item.callerWs.readyState === WebSocket.OPEN) {
149
+ item.callerWs.send(
150
+ JSON.stringify({ id: item.id, success: false, error: "Browser extension not connected to Primary." }),
151
+ );
152
+ }
153
+ continue;
154
+ }
155
+
156
+ extensionInFlight++;
157
+ const waitMs = Date.now() - item.ackSentAt;
158
+ waitHistory.push(waitMs);
159
+ if (waitHistory.length > 1000) waitHistory.shift();
160
+ item.onDispatch();
161
+ // onDispatch may or may not keep extensionInFlight++ live (it fires the
162
+ // extension send); the loop re-checks the cap after each dispatch.
163
+ }
164
+ }
165
+
63
166
  function broadcastSessionState() {
64
167
  if (extensionSocket && extensionSocket.readyState === WebSocket.OPEN) {
65
168
  const sessions = [];
@@ -113,7 +216,47 @@ function allowOrigin(info, cb) {
113
216
 
114
217
  function setupPrimaryServer() {
115
218
  const http = require("http");
116
- httpServer = http.createServer();
219
+ const handleRequest = (req, res) => {
220
+ if (req.method === "GET" && req.url === "/status") {
221
+ const now = Date.now();
222
+ const secondaries = [];
223
+ for (const [nid, ws] of secondaryClients.entries()) {
224
+ const srcQueue = queuedBySource.get(ws);
225
+ secondaries.push({
226
+ nodeId: nid,
227
+ version: ws.version || null,
228
+ protocol: ws.supportsNewCloseCodes ? "v2" : "legacy",
229
+ pid: ws.pid || null,
230
+ idle_ms: Math.floor(now - ws.lastCommandAt),
231
+ queued_commands: srcQueue ? srcQueue.length : 0,
232
+ });
233
+ }
234
+ let totalQueued = 0;
235
+ for (const items of queuedBySource.values()) totalQueued += items.length;
236
+ const sorted = waitHistory.slice().sort((a, b) => a - b);
237
+ const pct = (p) => sorted.length ? sorted[Math.floor((sorted.length - 1) * p)] : 0;
238
+ const body = JSON.stringify({
239
+ role: "primary",
240
+ pid: process.pid,
241
+ port: WS_PORT,
242
+ version: OWN_VERSION,
243
+ secondaries,
244
+ queue: {
245
+ in_flight: extensionInFlight,
246
+ max_in_flight: MAX_EXTENSION_CONCURRENCY,
247
+ queued: totalQueued,
248
+ max_queued_per_secondary: PER_SECONDARY_QUEUE_DEPTH,
249
+ wait_percentiles_ms: { p50: pct(0.5), p95: pct(0.95), p99: pct(0.99) },
250
+ },
251
+ });
252
+ res.writeHead(200, { "Content-Type": "application/json" });
253
+ res.end(body);
254
+ return;
255
+ }
256
+ res.writeHead(404);
257
+ res.end();
258
+ };
259
+ httpServer = http.createServer(handleRequest);
117
260
 
118
261
  httpServer.on("error", (e) => {
119
262
  if (e.code === "EADDRINUSE") {
@@ -125,15 +268,37 @@ function setupPrimaryServer() {
125
268
  httpServer.close();
126
269
  httpServer = null;
127
270
  startSecondaryMode();
271
+ // NICE-2 residual race: a legacy (wildcard-binding) PolterTab that starts
272
+ // in the ms between a successful probe and our listen() call will bind
273
+ // undetected on macOS — SO_REUSEADDR lets 127.0.0.1 bind over 0.0.0.0,
274
+ // so the new loopback Primary dual-binds silently. That window is
275
+ // milliseconds and requires two simultaneous starts; not worth a retry
276
+ // loop, but do not remove the probe and rely on this handler alone.
128
277
  } else {
129
278
  console.error(`[PolterTab MCP] HTTP server error: ${e.message}`);
130
279
  }
131
280
  });
132
281
 
133
- httpServer.listen(WS_PORT, () => {
134
- console.error(
135
- `[PolterTab MCP] Primary WebSocket server listening on ws://localhost:${WS_PORT}`,
136
- );
282
+ // Why probe-first (SHOULD-1 narrative, real reason for this change):
283
+ // PR-3 routes /status to loopback only, so the whole Primary now binds
284
+ // 127.0.0.1 instead of 0.0.0.0. On macOS, libuv sets SO_REUSEADDR, and
285
+ // BSD/macOS lets a specific-address bind succeed silently over a live
286
+ // wildcard listener — so the new loopback-binding Primary would start beside
287
+ // a pre-1.8 (wildcard) PolterTab without getting EADDRINUSE, then steal all
288
+ // loopback traffic. S11 is the regression test: it places a wildcard fixture
289
+ // and asserts the bridge-under-test demotes instead of dual-binding. The
290
+ // EADDRINUSE handler above remains as the fallback for same-host races.
291
+ probePrimary().then((alive) => {
292
+ if (alive) {
293
+ console.error(
294
+ `[PolterTab MCP] Port ${WS_PORT} in use, switching to Secondary Mode...`,
295
+ );
296
+ httpServer.close();
297
+ httpServer = null;
298
+ startSecondaryMode();
299
+ return;
300
+ }
301
+ httpServer.listen(WS_PORT, "127.0.0.1", () => {
137
302
  wss = new WebSocket.WebSocketServer({
138
303
  server: httpServer,
139
304
  verifyClient: allowOrigin,
@@ -168,8 +333,65 @@ function setupPrimaryServer() {
168
333
  });
169
334
  }, HEARTBEAT_MS);
170
335
 
336
+ // Idle eviction (4001) for v2 Secondaries silent for IDLE_EVICTION_MS. An
337
+ // old Secondary is never evicted: it answers any close by trying to
338
+ // promote. One with a command in flight is skipped (R-S2) — its clock
339
+ // restarts when the reply goes back. The check runs at half the idle
340
+ // period, capped at the heartbeat, so an eviction lands at most that late.
341
+ const evictionInterval = setInterval(() => {
342
+ const now = Date.now();
343
+ for (const ws of secondaryClients.values()) {
344
+ if (!ws.supportsNewCloseCodes || ws.readyState !== WebSocket.OPEN) {
345
+ continue;
346
+ }
347
+ const idleMs = now - ws.lastCommandAt;
348
+ if (idleMs < IDLE_EVICTION_MS) continue;
349
+ // NICE-1: also skip eviction when commands are queued for this source
350
+ // (spec §3.3: "zero in-flight AND zero queued").
351
+ const busy =
352
+ [...pendingCommands.values()].some((c) => c.isProxy && c.sourceWs === ws) ||
353
+ queuedBySource.has(ws);
354
+ if (busy) continue;
355
+ console.error(
356
+ `[PolterTab MCP] Evicting idle secondary ${ws.nodeId} (idle ${idleMs} ms)`,
357
+ );
358
+ ws.close(
359
+ 4001,
360
+ JSON.stringify({ reason: "idle_evicted", idle_ms: idleMs }),
361
+ );
362
+ }
363
+ }, Math.max(100, Math.min(HEARTBEAT_MS, Math.floor(IDLE_EVICTION_MS / 2))));
364
+
365
+ // GC: drop queued commands that have outlived QUEUE_TIMEOUT_MS. The
366
+ // deadline check at dequeue handles most cases; this is a safety net for
367
+ // commands in a source that never gets dequeued (e.g. its slot is always
368
+ // occupied by a slow extension command). It fires the same onTimeout
369
+ // handler, which sends [queue_timeout] to the caller and removes the item.
370
+ const queueGcInterval = setInterval(() => {
371
+ const now = Date.now();
372
+ for (const [sourceId, items] of queuedBySource.entries()) {
373
+ for (let i = items.length - 1; i >= 0; i--) {
374
+ if (now - items[i].ackSentAt > QUEUE_TIMEOUT_MS) {
375
+ const [expired] = items.splice(i, 1);
376
+ expired.onTimeout();
377
+ }
378
+ }
379
+ if (items.length === 0) {
380
+ queuedBySource.delete(sourceId);
381
+ const idx = roundRobinOrder.indexOf(sourceId);
382
+ if (idx !== -1) {
383
+ roundRobinOrder.splice(idx, 1);
384
+ if (rrCursor > idx) rrCursor--;
385
+ if (rrCursor >= roundRobinOrder.length) rrCursor = 0;
386
+ }
387
+ }
388
+ }
389
+ }, Math.min(QUEUE_TIMEOUT_MS, 10000));
390
+
171
391
  wss.on("close", () => {
172
392
  clearInterval(interval);
393
+ clearInterval(evictionInterval);
394
+ clearInterval(queueGcInterval);
173
395
  });
174
396
 
175
397
  // Network State Garbage Collector (TTL: 5 minutes)
@@ -186,6 +408,37 @@ function setupPrimaryServer() {
186
408
  }, NETWORK_GC_INTERVAL_MS);
187
409
 
188
410
  setupPrimaryWss(wss);
411
+
412
+ // Also listen on ::1 (IPv6 loopback) so Node 18 clients work: Node 18
413
+ // resolves 'localhost' to ::1 first with no happy-eyeballs fallback.
414
+ // Skip cleanly when the host has no IPv6 stack (EADDRNOTAVAIL/EAFNOSUPPORT).
415
+ // On macOS, SO_REUSEADDR lets a specific-address ::1 bind succeed silently
416
+ // over a live :: wildcard listener — but we only set up httpServer6 AFTER
417
+ // probing 127.0.0.1, so a false-primary that owns :: would be caught by that
418
+ // probe (it also owns 127.0.0.1) and we would have gone Secondary already.
419
+ //
420
+ // The "listening" log is delayed until ::1 is ready (or skipped) so that
421
+ // any client connecting on the log signal can reach both addresses.
422
+ const SKIP_IPV6 = new Set(["EADDRNOTAVAIL", "EAFNOSUPPORT", "EPROTONOSUPPORT"]);
423
+ const logListening = () =>
424
+ console.error(
425
+ `[PolterTab MCP] Primary WebSocket server listening on ws://localhost:${WS_PORT}`,
426
+ );
427
+ httpServer6 = http.createServer(handleRequest);
428
+ httpServer6.on("error", (e) => {
429
+ if (SKIP_IPV6.has(e.code)) {
430
+ httpServer6 = null;
431
+ } else {
432
+ console.error(`[PolterTab MCP] IPv6 loopback error: ${e.message}`);
433
+ }
434
+ logListening();
435
+ });
436
+ httpServer6.once("listening", logListening);
437
+ httpServer6.on("upgrade", (req, socket, head) => {
438
+ wss.handleUpgrade(req, socket, head, (client) => wss.emit("connection", client, req));
439
+ });
440
+ httpServer6.listen(WS_PORT, "::1");
441
+ });
189
442
  });
190
443
  }
191
444
 
@@ -219,12 +472,55 @@ function scheduleRetry(fn, delay) {
219
472
  retryTimer = setTimeout(fn, delay);
220
473
  }
221
474
 
475
+ // Dial the Primary again after an eviction. Settles on secondary_accepted, not
476
+ // on the socket opening: a command sent before the ACK can land on a Primary
477
+ // that is about to refuse this node. A failed dial rejects at once with the
478
+ // cause (the close handler calls finishReconnect) and leaves the node on PR-1's
479
+ // ordinary schedule — backoff, probe, promote — so the caller is not held for
480
+ // the full command timeout and the port is still taken over if it is free.
481
+ // isEvicted is cleared here rather than on the ACK: from now on the node is
482
+ // reconnecting, and a node that promotes itself must not dial its own port.
483
+ function lazyReconnect() {
484
+ if (reconnect) return reconnect.promise;
485
+ isEvicted = false;
486
+ let settle;
487
+ const promise = new Promise((resolve, reject) => {
488
+ settle = { resolve, reject };
489
+ });
490
+ reconnect = {
491
+ promise,
492
+ ...settle,
493
+ timer: setTimeout(
494
+ () => finishReconnect(new Error(reconnectFailed())),
495
+ COMMAND_TIMEOUT_MS,
496
+ ),
497
+ };
498
+ connectToPrimary();
499
+ return promise;
500
+ }
501
+
502
+ function finishReconnect(err) {
503
+ if (!reconnect) return;
504
+ const r = reconnect;
505
+ reconnect = null;
506
+ clearTimeout(r.timer);
507
+ if (err) r.reject(err);
508
+ else r.resolve();
509
+ }
510
+
511
+ function reconnectFailed() {
512
+ return `[reconnect_failed port=${WS_PORT}] Could not reconnect to primary.`;
513
+ }
514
+
222
515
  // Is anything listening on the port? Binding to find out is what leaked a
223
516
  // server per attempt; a refused TCP connect is the only answer that means the
224
517
  // Primary is gone.
225
518
  function probePrimary() {
226
519
  return new Promise((resolve) => {
227
- const sock = net.connect(WS_PORT, "localhost");
520
+ // Dial 127.0.0.1 explicitly; 'localhost' resolves to ::1 first on Node 18
521
+ // (no happy-eyeballs), which would give a false ECONNREFUSED when our own
522
+ // Primary is listening on 127.0.0.1.
523
+ const sock = net.connect(WS_PORT, "127.0.0.1");
228
524
  // A listener with a full accept backlog answers with neither connect nor
229
525
  // error, which would park this node with no retry scheduled. Treat silence
230
526
  // as "something is there": connectToPrimary's handshake timeout then sorts
@@ -251,7 +547,9 @@ async function promoteOrRejoin() {
251
547
  }
252
548
 
253
549
  function connectToPrimary() {
254
- const ws = new WebSocket(`ws://localhost:${WS_PORT}`, {
550
+ // Dial 127.0.0.1 explicitly so Node 18 (which resolves 'localhost' to ::1
551
+ // first with no fallback) does not get ECONNREFUSED against the IPv4 Primary.
552
+ const ws = new WebSocket(`ws://127.0.0.1:${WS_PORT}`, {
255
553
  perMessageDeflate: true,
256
554
  // A Primary that accepts TCP but never answers the upgrade (event loop
257
555
  // blocked, SIGSTOP) would otherwise hold this node forever.
@@ -287,6 +585,18 @@ function connectToPrimary() {
287
585
  backoffAttempt = 0;
288
586
  refusal = null;
289
587
  unreachable = false;
588
+ finishReconnect();
589
+ return;
590
+ }
591
+ // `queued` and `dispatched` frames re-arm the awaitReply timer (U11)
592
+ // but never settle the command — the reply does that.
593
+ if (
594
+ (msg.type === "queued" || msg.type === "dispatched") &&
595
+ msg.id &&
596
+ pendingCommands.has(msg.id)
597
+ ) {
598
+ const entry = pendingCommands.get(msg.id);
599
+ if (entry && entry.resetTimer) entry.resetTimer();
290
600
  return;
291
601
  }
292
602
  if (msg.id && pendingCommands.has(msg.id)) {
@@ -323,6 +633,36 @@ function connectToPrimary() {
323
633
  } catch (_) {}
324
634
  }
325
635
 
636
+ // A lazy reconnect whose dial ended before the ACK fails now, with the
637
+ // reason when the Primary gave one. The branches below still run, so the
638
+ // node retries or promotes exactly as it would have without the caller.
639
+ if (!accepted) {
640
+ finishReconnect(
641
+ new Error(
642
+ info.reason === "capacity_rejected"
643
+ ? `[capacity_rejected limit=${info.limit}] Primary (pid ${info.pid}) has ${info.limit} secondaries. Retry later.`
644
+ : reconnectFailed(),
645
+ ),
646
+ );
647
+ }
648
+
649
+ // 4001 = idle_evicted. Park: no reconnect, no probe, no promotion — the
650
+ // next sendCommand dials. A command already on the wire when the Primary
651
+ // closed is lost, and retrying it could run it twice, so say so (R-S2).
652
+ if (code === 4001) {
653
+ isEvicted = true;
654
+ for (const [, { reject, timer, action }] of pendingCommands) {
655
+ clearTimeout(timer);
656
+ reject(
657
+ new Error(
658
+ `[evicted_mid_command action=${action || "unknown"}] Session evicted while command was in progress. Not auto-retried.`,
659
+ ),
660
+ );
661
+ }
662
+ pendingCommands.clear();
663
+ return;
664
+ }
665
+
326
666
  // Reject all pending commands
327
667
  const aborted =
328
668
  info.reason === "capacity_rejected"
@@ -401,6 +741,43 @@ async function readNetworkState(params = {}) {
401
741
  return { tabId, capturedRequests: data.length, data };
402
742
  }
403
743
 
744
+ // Dispatch a proxy command to the extension. Must be called AFTER
745
+ // extensionInFlight has already been incremented by the caller. Arms the
746
+ // command timeout; on either a reply or a timeout, decrements extensionInFlight
747
+ // and drains the queue so the next waiting command gets its turn.
748
+ function _dispatchProxyNow(callerWs, id, action, params) {
749
+ const timer = setTimeout(() => {
750
+ pendingCommands.delete(id);
751
+ extensionInFlight--;
752
+ _tryDequeue();
753
+ if (callerWs.readyState === WebSocket.OPEN) {
754
+ callerWs.send(
755
+ JSON.stringify({ id, success: false, error: `Command '${action}' timed out.` }),
756
+ );
757
+ }
758
+ }, COMMAND_TIMEOUT_MS);
759
+ pendingCommands.set(id, { isProxy: true, sourceWs: callerWs, timer });
760
+ // Belt-and-braces: _tryDequeue's open-check guards the common case; this
761
+ // catches the narrow race where the extension socket closes between that
762
+ // check and the actual send (both are synchronous, but the close event is
763
+ // delivered between event-loop ticks, so the race is real on high-load paths).
764
+ try {
765
+ extensionSocket.send(
766
+ JSON.stringify({ ..._buildEnvelope(action, params, callerWs.nodeId), id }),
767
+ );
768
+ } catch (err) {
769
+ clearTimeout(timer);
770
+ pendingCommands.delete(id);
771
+ extensionInFlight--;
772
+ _tryDequeue();
773
+ if (callerWs.readyState === WebSocket.OPEN) {
774
+ callerWs.send(
775
+ JSON.stringify({ id, success: false, error: "Browser extension not connected to Primary." }),
776
+ );
777
+ }
778
+ }
779
+ }
780
+
404
781
  function setupPrimaryWss(wss) {
405
782
  wss.on("connection", (ws) => {
406
783
  ws.isAlive = true;
@@ -451,6 +828,7 @@ function setupPrimaryWss(wss) {
451
828
  ws.version = msg.version || null;
452
829
  ws.pid = msg.pid || null;
453
830
  ws.parentPid = msg.parentPid || null;
831
+ ws.lastCommandAt = Date.now();
454
832
  console.error(
455
833
  `[PolterTab MCP] Secondary node connected: ${msg.nodeId}. Total secondaries: ${secondaryClients.size}`,
456
834
  );
@@ -491,6 +869,8 @@ function setupPrimaryWss(wss) {
491
869
  }
492
870
  return;
493
871
  }
872
+ // Each command restarts this Secondary's idle clock.
873
+ ws.lastCommandAt = Date.now();
494
874
  // Stamp which agent is asking, from the socket rather than the
495
875
  // message, so the extension keeps this Secondary's current tab apart
496
876
  // from the Primary's and every other Secondary's.
@@ -534,29 +914,73 @@ function setupPrimaryWss(wss) {
534
914
  return;
535
915
  }
536
916
 
537
- const timer = setTimeout(() => {
538
- pendingCommands.delete(id);
539
- if (ws.readyState === WebSocket.OPEN) {
540
- ws.send(
541
- JSON.stringify({
917
+ // Legacy secondaries bypass the queue: they cannot receive `queued`
918
+ // or `dispatched` frames, so they go straight to the extension and
919
+ // keep today's timing contract. They still count toward extensionInFlight
920
+ // so v2 commands behind them wait their turn.
921
+ if (!ws.supportsNewCloseCodes) {
922
+ extensionInFlight++;
923
+ const timer = setTimeout(() => {
924
+ pendingCommands.delete(id);
925
+ extensionInFlight--;
926
+ _tryDequeue();
927
+ if (ws.readyState === WebSocket.OPEN) {
928
+ ws.send(JSON.stringify({ id, success: false, error: `Command '${action}' timed out.` }));
929
+ }
930
+ }, COMMAND_TIMEOUT_MS);
931
+ pendingCommands.set(id, { isProxy: true, sourceWs: ws, timer });
932
+ extensionSocket.send(JSON.stringify({ ..._buildEnvelope(action, msg.params, ws.nodeId), id }));
933
+ return;
934
+ }
935
+
936
+ // v2 Secondary: use the concurrency cap and queue.
937
+ if (extensionInFlight < MAX_EXTENSION_CONCURRENCY) {
938
+ // Free slot: dispatch immediately. No queued frame.
939
+ extensionInFlight++;
940
+ _dispatchProxyNow(ws, id, action, msg.params);
941
+ } else {
942
+ // Slot full: enqueue if there is room in this secondary's queue.
943
+ const srcQueue = queuedBySource.get(ws);
944
+ const depth = srcQueue ? srcQueue.length : 0;
945
+ if (depth >= PER_SECONDARY_QUEUE_DEPTH) {
946
+ if (ws.readyState === WebSocket.OPEN) {
947
+ ws.send(JSON.stringify({
542
948
  id,
543
949
  success: false,
544
- error: `Command '${action}' timed out.`,
545
- }),
546
- );
950
+ error: `[queue_full action=${action}] Per-secondary queue depth (${PER_SECONDARY_QUEUE_DEPTH}) reached. Try again shortly.`,
951
+ }));
952
+ }
953
+ return;
954
+ }
955
+ const ackSentAt = Date.now();
956
+ const position = depth + 1;
957
+ if (ws.readyState === WebSocket.OPEN) {
958
+ ws.send(JSON.stringify({ type: "queued", id, position }));
547
959
  }
548
- }, COMMAND_TIMEOUT_MS);
549
-
550
- pendingCommands.set(id, { isProxy: true, sourceWs: ws, timer });
551
- // Same envelope construction as the direct path, so a tool argument
552
- // named id or action cannot override the routing here either. The
553
- // Primary's own id is kept last: pendingCommands is keyed on it.
554
- extensionSocket.send(
555
- JSON.stringify({
556
- ..._buildEnvelope(action, msg.params, ws.nodeId),
960
+ _rrEnqueue(ws, {
557
961
  id,
558
- }),
559
- );
962
+ action,
963
+ params: msg.params,
964
+ nodeId: ws.nodeId,
965
+ ackSentAt,
966
+ callerWs: ws,
967
+ onDispatch() {
968
+ if (ws.readyState === WebSocket.OPEN) {
969
+ ws.send(JSON.stringify({ type: "dispatched", id }));
970
+ }
971
+ _dispatchProxyNow(ws, id, action, msg.params);
972
+ },
973
+ onTimeout() {
974
+ if (ws.readyState === WebSocket.OPEN) {
975
+ ws.send(JSON.stringify({
976
+ id,
977
+ success: false,
978
+ error: `[queue_timeout action=${action}] Command queued too long; dropped to prevent ghost execution.`,
979
+ }));
980
+ }
981
+ },
982
+ });
983
+ }
560
984
  return;
561
985
  }
562
986
 
@@ -650,7 +1074,16 @@ function setupPrimaryWss(wss) {
650
1074
  clearTimeout(cmd.timer);
651
1075
  pendingCommands.delete(msg.id);
652
1076
 
1077
+ // Every reply frees a slot; try to drain the queue before doing
1078
+ // anything else so the next command lands on the extension with
1079
+ // minimum latency.
1080
+ extensionInFlight--;
1081
+ _tryDequeue();
1082
+
653
1083
  if (cmd.isProxy) {
1084
+ // Idle time counts from the reply, so a long command cannot leave
1085
+ // its Secondary already due for eviction the moment it finishes.
1086
+ cmd.sourceWs.lastCommandAt = Date.now();
654
1087
  if (cmd.sourceWs.readyState === WebSocket.OPEN) {
655
1088
  cmd.sourceWs.send(JSON.stringify(msg));
656
1089
  }
@@ -692,6 +1125,7 @@ function shutdown() {
692
1125
  console.error("[PolterTab MCP] Shutting down...");
693
1126
  if (wss) wss.close();
694
1127
  if (httpServer) httpServer.close();
1128
+ if (httpServer6) httpServer6.close();
695
1129
  process.exit(0);
696
1130
  }
697
1131
  // Helper to wait for WebSocket connection
@@ -714,14 +1148,27 @@ async function waitForConnection(timeoutMs = 5000) {
714
1148
  // over a socket instead of settling a promise, so it stays where it is and
715
1149
  // shares only the constant.
716
1150
  function awaitReply(envelope, { timedOut, sendFailed }) {
717
- const { id } = envelope;
1151
+ const { id, action } = envelope;
718
1152
  return new Promise((resolve, reject) => {
719
- const timer = setTimeout(() => {
1153
+ let timer = setTimeout(() => {
720
1154
  pendingCommands.delete(id);
721
1155
  reject(new Error(timedOut));
722
1156
  }, COMMAND_TIMEOUT_MS);
723
1157
 
724
- pendingCommands.set(id, { resolve, reject, timer });
1158
+ // Re-arm the timer when the Primary sends a `queued` or `dispatched`
1159
+ // frame (U11). The frame means the Primary is actively working on this
1160
+ // command and extends the budget by a full COMMAND_TIMEOUT_MS from now.
1161
+ const resetTimer = () => {
1162
+ clearTimeout(timer);
1163
+ timer = setTimeout(() => {
1164
+ pendingCommands.delete(id);
1165
+ reject(new Error(timedOut));
1166
+ }, COMMAND_TIMEOUT_MS);
1167
+ const entry = pendingCommands.get(id);
1168
+ if (entry) entry.timer = timer;
1169
+ };
1170
+
1171
+ pendingCommands.set(id, { resolve, reject, timer, action, resetTimer });
725
1172
 
726
1173
  try {
727
1174
  extensionSocket.send(JSON.stringify(envelope));
@@ -749,7 +1196,18 @@ function _buildEnvelope(action, params, activeNodeId) {
749
1196
 
750
1197
  // Helper to send command to the extension
751
1198
  async function sendCommand(action, params) {
752
- const isConnected = await waitForConnection(5000);
1199
+ // Evicted, or a caller ahead of this one is already reconnecting: wait for
1200
+ // that one dial rather than racing it.
1201
+ if (isEvicted || reconnect) await lazyReconnect();
1202
+
1203
+ let isConnected = await waitForConnection(5000);
1204
+
1205
+ // During the wait the Primary may have evicted us (4001) or a concurrent
1206
+ // dial may have started; take the lazy path rather than erroring immediately.
1207
+ if (!isConnected && (isEvicted || reconnect)) {
1208
+ await lazyReconnect();
1209
+ isConnected = await waitForConnection(5000);
1210
+ }
753
1211
 
754
1212
  if (isSecondary) {
755
1213
  if (!isConnected) {
@@ -789,13 +1247,57 @@ async function sendCommand(action, params) {
789
1247
  );
790
1248
  }
791
1249
 
792
- return awaitReply(
793
- _buildEnvelope(action, params, nodeId),
794
- {
795
- timedOut: `Command '${action}' timed out after ${COMMAND_TIMEOUT_MS / 1000} seconds.`,
796
- sendFailed: (e) => `Failed to send command over WebSocket: ${e.message}`,
797
- },
798
- );
1250
+ const envelope = _buildEnvelope(action, params, nodeId);
1251
+ const timedOut = `Command '${action}' timed out after ${COMMAND_TIMEOUT_MS / 1000} seconds.`;
1252
+ const sendFailed = (e) => `Failed to send command over WebSocket: ${e.message}`;
1253
+
1254
+ // Primary's own commands share the concurrency cap and the round-robin
1255
+ // queue with proxy commands. The timer is armed at dispatch, not enqueue,
1256
+ // so the budget does not shrink while the command waits for a free slot.
1257
+ if (extensionInFlight < MAX_EXTENSION_CONCURRENCY) {
1258
+ extensionInFlight++;
1259
+ return _awaitPrimaryReply(envelope, { timedOut, sendFailed });
1260
+ }
1261
+ return new Promise((resolve, reject) => {
1262
+ const ackSentAt = Date.now();
1263
+ _rrEnqueue(PRIMARY_SOURCE, {
1264
+ id: envelope.id,
1265
+ action: envelope.action,
1266
+ ackSentAt,
1267
+ callerWs: null, // no secondary WS — primary commands have no caller socket
1268
+ onDispatch() {
1269
+ // extensionInFlight already incremented by _tryDequeue
1270
+ _awaitPrimaryReply(envelope, { timedOut, sendFailed }).then(resolve, reject);
1271
+ },
1272
+ onTimeout() {
1273
+ reject(new Error(timedOut));
1274
+ },
1275
+ });
1276
+ });
1277
+ }
1278
+
1279
+ // Register, arm, send, and settle one primary-mode command. Called after
1280
+ // extensionInFlight has already been incremented.
1281
+ function _awaitPrimaryReply(envelope, { timedOut, sendFailed }) {
1282
+ const { id, action } = envelope;
1283
+ return new Promise((resolve, reject) => {
1284
+ const timer = setTimeout(() => {
1285
+ pendingCommands.delete(id);
1286
+ extensionInFlight--;
1287
+ _tryDequeue();
1288
+ reject(new Error(timedOut));
1289
+ }, COMMAND_TIMEOUT_MS);
1290
+ pendingCommands.set(id, { resolve, reject, timer, action });
1291
+ try {
1292
+ extensionSocket.send(JSON.stringify(envelope));
1293
+ } catch (err) {
1294
+ clearTimeout(timer);
1295
+ pendingCommands.delete(id);
1296
+ extensionInFlight--;
1297
+ _tryDequeue();
1298
+ reject(new Error(sendFailed(err)));
1299
+ }
1300
+ });
799
1301
  }
800
1302
 
801
1303
  // The Primary's view of which tab the agent is working in, shown in the
@@ -820,6 +1322,7 @@ module.exports = {
820
1322
  readNetworkState,
821
1323
  noteActiveTab,
822
1324
  isSecondary: () => isSecondary,
1325
+ isEvicted: () => isEvicted,
823
1326
  extensionVersion: () => extensionVersion,
824
1327
  _buildEnvelope,
825
1328
  _backoffDelay,
@@ -58,6 +58,58 @@ function resolveMaxSecondaries(env = process.env) {
58
58
  return Number.isInteger(n) && n >= 0 ? n : DEFAULT_MAX_SECONDARIES;
59
59
  }
60
60
 
61
+ // How long a v2 Secondary may sit idle (no commands) before the Primary sends
62
+ // close code 4001. Only whole-number milliseconds > 0 are accepted; anything
63
+ // else falls back to the default. 0 would mean "evict immediately", which is
64
+ // not a meaningful setting, so it is treated as invalid.
65
+ const DEFAULT_IDLE_EVICTION_MS = 5 * 60 * 1000; // 5 minutes
66
+ function resolveIdleEvictionMs(env = process.env) {
67
+ const raw = env.POLTERTAB_IDLE_EVICTION_MS;
68
+ if (raw === undefined || raw.trim() === "") return DEFAULT_IDLE_EVICTION_MS;
69
+ const n = Number(raw);
70
+ return Number.isInteger(n) && n > 0 ? n : DEFAULT_IDLE_EVICTION_MS;
71
+ }
72
+
73
+ // The 35 s command budget. Exposed as an env override so tests can shrink it
74
+ // without waiting for real timeouts. Junk or zero fall back to the default.
75
+ const DEFAULT_COMMAND_TIMEOUT_MS = 35000;
76
+ function resolveCommandTimeoutMs(env = process.env) {
77
+ const raw = env.POLTERTAB_COMMAND_TIMEOUT_MS;
78
+ if (raw === undefined || raw.trim() === "") return DEFAULT_COMMAND_TIMEOUT_MS;
79
+ const n = Number(raw);
80
+ return Number.isInteger(n) && n > 0 ? n : DEFAULT_COMMAND_TIMEOUT_MS;
81
+ }
82
+
83
+ // How many commands the primary may have in flight to the extension at once.
84
+ // 0 would refuse every command; junk falls back to the default.
85
+ const DEFAULT_MAX_EXTENSION_CONCURRENCY = 6;
86
+ function resolveMaxConcurrency(env = process.env) {
87
+ const raw = env.POLTERTAB_MAX_CONCURRENCY;
88
+ if (raw === undefined || raw.trim() === "") return DEFAULT_MAX_EXTENSION_CONCURRENCY;
89
+ const n = Number(raw);
90
+ return Number.isInteger(n) && n >= 1 ? n : DEFAULT_MAX_EXTENSION_CONCURRENCY;
91
+ }
92
+
93
+ // Per-secondary command queue depth. A command beyond this is refused at once.
94
+ // 0 would refuse every queue attempt; junk falls back.
95
+ const DEFAULT_PER_SECONDARY_QUEUE_DEPTH = 3;
96
+ function resolveQueueDepth(env = process.env) {
97
+ const raw = env.POLTERTAB_QUEUE_DEPTH;
98
+ if (raw === undefined || raw.trim() === "") return DEFAULT_PER_SECONDARY_QUEUE_DEPTH;
99
+ const n = Number(raw);
100
+ return Number.isInteger(n) && n >= 1 ? n : DEFAULT_PER_SECONDARY_QUEUE_DEPTH;
101
+ }
102
+
103
+ // How long a command may sit in the server-side queue before being GC'd.
104
+ // 0 / negative / junk fall back to the default.
105
+ const DEFAULT_QUEUE_TIMEOUT_MS = 60000;
106
+ function resolveQueueTimeoutMs(env = process.env) {
107
+ const raw = env.POLTERTAB_QUEUE_TIMEOUT_MS;
108
+ if (raw === undefined || raw.trim() === "") return DEFAULT_QUEUE_TIMEOUT_MS;
109
+ const n = Number(raw);
110
+ return Number.isInteger(n) && n > 0 ? n : DEFAULT_QUEUE_TIMEOUT_MS;
111
+ }
112
+
61
113
  module.exports = {
62
114
  POLTERTAB_HOME,
63
115
  MEMORY_DIR: path.join(POLTERTAB_HOME, "navigation_memory"),
@@ -66,9 +118,14 @@ module.exports = {
66
118
  DEFAULT_WS_PORT,
67
119
  resolvePort,
68
120
  resolveMaxSecondaries,
121
+ resolveIdleEvictionMs,
122
+ resolveCommandTimeoutMs,
123
+ resolveMaxConcurrency,
124
+ resolveQueueDepth,
125
+ resolveQueueTimeoutMs,
69
126
 
70
127
  // See the budget above before changing this.
71
- COMMAND_TIMEOUT_MS: 35000,
128
+ COMMAND_TIMEOUT_MS: resolveCommandTimeoutMs(),
72
129
 
73
130
  // Dead-connection detection. A socket that misses a ping/pong round is
74
131
  // terminated so the extension's own reconnect can take over.
@@ -102,6 +159,16 @@ module.exports = {
102
159
  // promote.
103
160
  MAX_SECONDARIES: resolveMaxSecondaries(),
104
161
 
162
+ // How long a v2 Secondary may sit idle before the Primary evicts it with
163
+ // close code 4001. Configurable so tests can use a short value (e.g. 300 ms)
164
+ // without slowing the suite.
165
+ IDLE_EVICTION_MS: resolveIdleEvictionMs(),
166
+
167
+ // Command queue (PR-3).
168
+ MAX_EXTENSION_CONCURRENCY: resolveMaxConcurrency(),
169
+ PER_SECONDARY_QUEUE_DEPTH: resolveQueueDepth(),
170
+ QUEUE_TIMEOUT_MS: resolveQueueTimeoutMs(),
171
+
105
172
  // Lazy-loaded content arrives after the scroll returns. Long enough for the
106
173
  // XHR it triggered to land in the capture buffer, short enough that a
107
174
  // scroll-and-read loop is not dominated by waiting.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poltertab",
3
- "version": "1.8.0-beta.1",
3
+ "version": "1.8.0-beta.2",
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"