poltertab 1.8.0-beta.3 → 1.8.1-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,21 +244,82 @@ 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
- 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.
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.
247
+ `POLTERTAB_MAX_SECONDARIES` v2 secondaries are accepted (default 50); if the
248
+ primary exits, one secondary promotes itself to replace it. The extension popup
249
+ lists whichever agents are connected.
250
+
251
+ **How many agents can share one browser:**
252
+ Up to 50 v2 secondaries (one process each) plus the primary can share one
253
+ browser. Raise or lower the ceiling with `POLTERTAB_MAX_SECONDARIES`. An agent
254
+ that is idle for `POLTERTAB_IDLE_EVICTION_MS` (default 5 minutes) is evicted
255
+ with close code 4001 to free the slot; it reconnects automatically the next time
256
+ it sends a command, so eviction is transparent to the agent. An agent whose
257
+ major version does not match the primary's is refused with close code 4002 —
258
+ see [Upgrade path](#upgrade-path-1x--18) below.
259
+
260
+ **Command queue:**
261
+ The primary limits how many commands it sends to the extension at once
262
+ (`POLTERTAB_MAX_CONCURRENCY`, default 6). When that limit is reached, a v2
263
+ secondary's command is queued per-secondary (up to `POLTERTAB_QUEUE_DEPTH`
264
+ slots, default 3). The queued command is acknowledged immediately with
265
+ `{"type":"queued","id":"...","position":N}` so the caller knows its command is
266
+ pending, not lost. A v2 secondary re-arms its 35 s timeout on this
267
+ acknowledgement and again on `{"type":"dispatched","id":"..."}` when the command
268
+ leaves the queue, so queue wait never consumes the execution budget. A command
269
+ that arrives when the per-secondary queue is already full is refused immediately
270
+ with `[queue_full action=X]`. The primary itself queues its own direct commands
271
+ under the same ceiling. A queued command is dropped with `[queue_timeout action=X]`
272
+ when the caller's 35 s budget is nearly spent (a 2 s margin keeps the drop ahead
273
+ of the caller's own timer). `POLTERTAB_QUEUE_TIMEOUT_MS` (default 60 s) is a
274
+ separate GC backstop for commands in a queue that never gets dequeued; it does
275
+ not extend queue wait time.
276
+
277
+ **`/status` endpoint:**
278
+ `GET http://127.0.0.1:7822/status` returns a JSON snapshot of the primary's
279
+ queue state, connected agents, and wait-time percentiles (p50/p95/p99). It is
280
+ restricted to loopback callers (`127.0.0.1`, `::1`, or `::ffff:127.0.0.1`) and
281
+ to requests whose `Host` header names `localhost` or a loopback address; all
282
+ others receive 404.
283
+
284
+ **Extension reconnection:**
285
+ The Chrome extension reconnects automatically when the bridge server restarts or
286
+ the WebSocket drops. It dials immediately on disconnect, then backs off 1 s,
287
+ 2 s, 4 s … up to 30 s, with an alarm that wakes the service worker if it was
288
+ suspended mid-schedule. While reconnecting, commands return:
289
+
290
+ - `[extension_reconnecting port=7822 elapsed=Xs]` — the extension disconnected
291
+ and is on its way back. Retry shortly.
292
+ - `[extension_never_connected port=7822]` — the extension has never connected to
293
+ this server instance. Check that the extension is installed, enabled, and
294
+ pointed at the right port.
295
+
296
+ **Close codes:**
297
+
298
+ | Code | Meaning | Agent behaviour |
299
+ |------|---------|-----------------|
300
+ | 4001 | Idle eviction — the agent was silent for `POLTERTAB_IDLE_EVICTION_MS` | Secondary dials again lazily on next command; no action needed |
301
+ | 4002 | Version mismatch — major version incompatible with the primary | Secondary stops retrying until restarted; update the npm package so every session runs the same major |
302
+
303
+ **Upgrade path (1.x → 1.8):**
304
+ PolterTab 1.8 introduces a version handshake. A secondary whose major version
305
+ does not match the primary's receives close code 4002 and stops retrying; updating
306
+ the npm package (so all sessions run the same major) resolves it. The Chrome
307
+ extension should also be updated at the same time to get the PR-4 reconnect
308
+ behaviour. Run `poltertab doctor` to check for skew before upgrading. A 1.7.x
309
+ secondary that has not upgraded does not receive 4002 (it does not speak the
310
+ handshake) and continues to work as an unversioned legacy secondary; it bypasses
311
+ the command queue and is never idle-evicted.
312
+
313
+ **Env vars:**
314
+
315
+ | Variable | Default | Description |
316
+ |----------|---------|-------------|
317
+ | `POLTERTAB_MAX_SECONDARIES` | `50` | Maximum v2 secondaries accepted |
318
+ | `POLTERTAB_IDLE_EVICTION_MS` | `300000` (5 min) | Idle before a v2 secondary is evicted with 4001 |
319
+ | `POLTERTAB_MAX_CONCURRENCY` | `6` | Extension command concurrency limit |
320
+ | `POLTERTAB_QUEUE_DEPTH` | `3` | Per-secondary queue depth before `[queue_full]` |
321
+ | `POLTERTAB_QUEUE_TIMEOUT_MS` | `60000` (60 s) | GC backstop for stuck queued commands (does not extend queue wait) |
322
+ | `POLTERTAB_COMMAND_TIMEOUT_MS` | `35000` (35 s) | Per-command budget. Must exceed the extension's 30 s navigation wait + 500 ms settle; tune for tests only |
262
323
 
263
324
  **Changing the WebSocket Port:**
264
325
  Only needed when something that *isn't* PolterTab already holds `7822` —
@@ -15,7 +15,6 @@ const crypto = require("crypto");
15
15
  const net = require("net");
16
16
  const {
17
17
  resolvePort,
18
- DEFAULT_WS_PORT,
19
18
  COMMAND_TIMEOUT_MS,
20
19
  HEARTBEAT_MS,
21
20
  NETWORK_TTL_MS,
@@ -45,6 +44,11 @@ let extensionVersion = null;
45
44
 
46
45
  // Global state
47
46
  let extensionSocket = null;
47
+ // When the extension last left this Primary; null if it never connected. A
48
+ // missing extension is either on its way back (a Primary handover, a worker
49
+ // restart: retry shortly) or was never here (Chrome closed, extension off,
50
+ // wrong port: go and look). The agent needs to know which.
51
+ let extensionLostAt = null;
48
52
  const pendingCommands = new Map();
49
53
  const networkState = new Map(); // tabId -> { lastUpdated: number, requests: array }
50
54
  const secondaryClients = new Map();
@@ -72,6 +76,17 @@ let unreachable = false;
72
76
  let isEvicted = false;
73
77
  let reconnect = null; // { promise, resolve, reject, timer }
74
78
 
79
+ // Set once a Primary has accepted this node. If it later promotes, the
80
+ // extension was almost certainly on the Primary that died and is on its way
81
+ // here, so "reconnecting" is the honest answer, not "never connected". A node
82
+ // never accepted has no reason to believe an extension exists.
83
+ let everAccepted = false;
84
+
85
+ // A 4002 from the Primary: this node's major version cannot talk to it.
86
+ // Retrying cannot help until one side is upgraded, which means a restart, so
87
+ // the node stops here — no redial, no promotion — and every command says why.
88
+ let incompatible = null; // the Primary's close reason
89
+
75
90
  // Command queue, Primary side (PR-3). At most MAX_EXTENSION_CONCURRENCY
76
91
  // commands may be in flight to the extension at once. v2 Secondary commands
77
92
  // beyond that wait in a per-secondary FIFO, served round-robin across
@@ -147,7 +162,7 @@ function _tryDequeue() {
147
162
  if (!extensionSocket || extensionSocket.readyState !== WebSocket.OPEN) {
148
163
  if (item.callerWs && item.callerWs.readyState === WebSocket.OPEN) {
149
164
  item.callerWs.send(
150
- JSON.stringify({ id: item.id, success: false, error: "Browser extension not connected to Primary." }),
165
+ JSON.stringify({ id: item.id, success: false, error: extensionMissing() }),
151
166
  );
152
167
  }
153
168
  continue;
@@ -163,6 +178,46 @@ function _tryDequeue() {
163
178
  }
164
179
  }
165
180
 
181
+ function extensionMissing() {
182
+ if (extensionLostAt === null) {
183
+ return (
184
+ `[extension_never_connected port=${WS_PORT}] Browser extension not connected: it has never connected to this server. ` +
185
+ `Check that Chrome is open and the PolterTab extension is installed, enabled, and set to port ${WS_PORT}.`
186
+ );
187
+ }
188
+ const s = Math.round((Date.now() - extensionLostAt) / 1000);
189
+ return (
190
+ `[extension_reconnecting port=${WS_PORT} elapsed=${s}s] Browser extension not connected: ` +
191
+ `it disconnected ${s}s ago and reconnects on its own. Retry shortly.`
192
+ );
193
+ }
194
+
195
+ function incompatibleError() {
196
+ return (
197
+ `[version_incompatible] The Primary on port ${WS_PORT} requires PolterTab ${incompatible.required || "another major version"}; ` +
198
+ `this server is ${incompatible.got || OWN_VERSION}. Run: npm update -g poltertab, then restart every session using this browser.`
199
+ );
200
+ }
201
+
202
+ // The 4002 close reason. RFC 6455 caps it at 123 bytes and ws throws past
203
+ // that — which used to leave the peer unclosed and retrying forever. `got`
204
+ // is peer-supplied, so it is cut to printable ASCII and shortened until the
205
+ // encoded reason fits.
206
+ function versionReason(ours, theirs) {
207
+ let got = String(theirs).replace(/[^\x20-\x7e]/g, "?").slice(0, 32);
208
+ const build = () =>
209
+ JSON.stringify({ reason: "version_incompatible", required: `${ours}.x`, got });
210
+ while (Buffer.byteLength(build()) > 123) got = got.slice(0, -1);
211
+ return build();
212
+ }
213
+
214
+ // "1.7.0" -> 1. Anything else (a label like "test", a missing field) has no
215
+ // major to disagree with, so it never triggers a 4002.
216
+ function majorOf(version) {
217
+ const m = /^(\d+)\./.exec(String(version));
218
+ return m ? Number(m[1]) : null;
219
+ }
220
+
166
221
  function broadcastSessionState() {
167
222
  if (extensionSocket && extensionSocket.readyState === WebSocket.OPEN) {
168
223
  const sessions = [];
@@ -217,9 +272,17 @@ function allowOrigin(info, cb) {
217
272
  function setupPrimaryServer() {
218
273
  const http = require("http");
219
274
  const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
275
+ // The source address alone does not stop DNS rebinding: a page at
276
+ // http://evil.example:P whose name then resolves to 127.0.0.1 fetches
277
+ // /status same-origin, from loopback. Its Host header still names
278
+ // evil.example, so only a loopback name (any port, or none) is answered.
279
+ const LOOPBACK_HOST = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
220
280
  const handleRequest = (req, res) => {
221
281
  if (req.method === "GET" && req.url === "/status") {
222
- if (!LOOPBACK.has(req.socket.remoteAddress)) {
282
+ if (
283
+ !LOOPBACK.has(req.socket.remoteAddress) ||
284
+ !LOOPBACK_HOST.test(req.headers.host || "")
285
+ ) {
223
286
  res.writeHead(404);
224
287
  res.end();
225
288
  return;
@@ -288,6 +351,11 @@ function setupPrimaryServer() {
288
351
  // serving 127.0.0.1 and demotes us before we call listen(), avoiding a
289
352
  // race in the milliseconds between a successful probe and our own bind.
290
353
  // The EADDRINUSE handler above covers the symmetric case.
354
+ // The one window left: on macOS, a beta.2-style server binding 127.0.0.1/::1
355
+ // specifically *between* our probe and our listen. Our wildcard listen then
356
+ // succeeds beside it and its specific bind wins loopback traffic, leaving us
357
+ // a silent Primary. It needs two starts within milliseconds, one of them
358
+ // the superseded beta.2, whose own probe-first makes it lose that race.
291
359
  probePrimary().then((alive) => {
292
360
  if (alive) {
293
361
  console.error(
@@ -521,6 +589,7 @@ async function promoteOrRejoin() {
521
589
  return;
522
590
  }
523
591
  isSecondary = false;
592
+ if (everAccepted && extensionLostAt === null) extensionLostAt = Date.now();
524
593
  setupPrimaryServer();
525
594
  }
526
595
 
@@ -560,6 +629,7 @@ function connectToPrimary() {
560
629
  // are handled before the id branch so none can settle a pending command.
561
630
  if (msg.type === "secondary_accepted") {
562
631
  accepted = true;
632
+ everAccepted = true;
563
633
  backoffAttempt = 0;
564
634
  refusal = null;
565
635
  unreachable = false;
@@ -614,12 +684,21 @@ function connectToPrimary() {
614
684
  // A lazy reconnect whose dial ended before the ACK fails now, with the
615
685
  // reason when the Primary gave one. The branches below still run, so the
616
686
  // node retries or promotes exactly as it would have without the caller.
687
+ if (code === 4002) {
688
+ incompatible = { required: info.required, got: info.got };
689
+ console.error(
690
+ `[PolterTab MCP] Primary refused this node: version ${incompatible.got || OWN_VERSION} is incompatible (requires ${incompatible.required || "unknown"}). Not retrying.`,
691
+ );
692
+ }
693
+
617
694
  if (!accepted) {
618
695
  finishReconnect(
619
696
  new Error(
620
697
  info.reason === "capacity_rejected"
621
698
  ? `[capacity_rejected limit=${info.limit}] Primary (pid ${info.pid}) has ${info.limit} secondaries. Retry later.`
622
- : reconnectFailed(),
699
+ : incompatible
700
+ ? incompatibleError()
701
+ : reconnectFailed(),
623
702
  ),
624
703
  );
625
704
  }
@@ -645,13 +724,18 @@ function connectToPrimary() {
645
724
  const aborted =
646
725
  info.reason === "capacity_rejected"
647
726
  ? `[capacity_rejected limit=${info.limit}] Primary (pid ${info.pid}) refused this node. Command aborted.`
648
- : "Primary node disconnected. Command aborted.";
727
+ : incompatible
728
+ ? incompatibleError()
729
+ : "Primary node disconnected. Command aborted.";
649
730
  for (const [id, { reject, timer }] of pendingCommands) {
650
731
  clearTimeout(timer);
651
732
  reject(new Error(aborted));
652
733
  pendingCommands.delete(id);
653
734
  }
654
735
 
736
+ // Stop: neither a retry nor a promotion can fix a version mismatch.
737
+ if (incompatible) return;
738
+
655
739
  // Refused by a live Primary: it is not dying, so promoting can only fail.
656
740
  if (refused) {
657
741
  const delay = _backoffDelay(backoffAttempt++);
@@ -750,7 +834,7 @@ function _dispatchProxyNow(callerWs, id, action, params) {
750
834
  _tryDequeue();
751
835
  if (callerWs.readyState === WebSocket.OPEN) {
752
836
  callerWs.send(
753
- JSON.stringify({ id, success: false, error: "Browser extension not connected to Primary." }),
837
+ JSON.stringify({ id, success: false, error: extensionMissing() }),
754
838
  );
755
839
  }
756
840
  }
@@ -774,6 +858,18 @@ function setupPrimaryWss(wss) {
774
858
  // limit used to trigger — so it is never refused and never counted.
775
859
  ws.supportsNewCloseCodes =
776
860
  typeof msg.version === "string" && msg.version !== "";
861
+ // A different major speaks a different protocol; a v2 node is told
862
+ // so with 4002 and stops. Only a node that sent a version gets this,
863
+ // like every other 4xxx (§3.2).
864
+ const theirs = ws.supportsNewCloseCodes ? majorOf(msg.version) : null;
865
+ const ours = majorOf(OWN_VERSION);
866
+ if (theirs !== null && ours !== null && theirs !== ours) {
867
+ console.error(
868
+ `[PolterTab MCP] Rejecting secondary node ${msg.nodeId} - version ${msg.version} is incompatible with ${OWN_VERSION}.`,
869
+ );
870
+ ws.close(4002, versionReason(ours, msg.version));
871
+ return;
872
+ }
777
873
  if (ws.supportsNewCloseCodes) {
778
874
  let v2 = 0;
779
875
  for (const [id, c] of secondaryClients) {
@@ -885,7 +981,7 @@ function setupPrimaryWss(wss) {
885
981
  JSON.stringify({
886
982
  id,
887
983
  success: false,
888
- error: "Browser extension not connected to Primary.",
984
+ error: extensionMissing(),
889
985
  }),
890
986
  );
891
987
  }
@@ -1090,6 +1186,7 @@ function setupPrimaryWss(wss) {
1090
1186
  } else if (extensionSocket === ws) {
1091
1187
  console.error("[PolterTab MCP] Chrome extension disconnected.");
1092
1188
  extensionSocket = null;
1189
+ extensionLostAt = Date.now();
1093
1190
  }
1094
1191
  });
1095
1192
 
@@ -1173,6 +1270,7 @@ function _buildEnvelope(action, params, activeNodeId) {
1173
1270
 
1174
1271
  // Helper to send command to the extension
1175
1272
  async function sendCommand(action, params) {
1273
+ if (incompatible) throw new Error(incompatibleError());
1176
1274
  // Evicted, or a caller ahead of this one is already reconnecting: wait for
1177
1275
  // that one dial rather than racing it.
1178
1276
  if (isEvicted || reconnect) await lazyReconnect();
@@ -1219,9 +1317,7 @@ async function sendCommand(action, params) {
1219
1317
  }
1220
1318
 
1221
1319
  if (!isConnected) {
1222
- throw new Error(
1223
- `Browser extension not connected. Please ensure the extension is installed, enabled, and pointing to the correct port (default: ${DEFAULT_WS_PORT}).`,
1224
- );
1320
+ throw new Error(extensionMissing());
1225
1321
  }
1226
1322
 
1227
1323
  const envelope = _buildEnvelope(action, params, nodeId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poltertab",
3
- "version": "1.8.0-beta.3",
3
+ "version": "1.8.1-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"
@@ -195,6 +195,21 @@ lookups do not miss.
195
195
  A note saying "this page is tricky" helps nobody. Save what you would need to
196
196
  skip the discovery entirely next time.
197
197
 
198
+ ## Extension reconnection
199
+
200
+ The Chrome extension reconnects automatically when the bridge server restarts.
201
+ While it is reconnecting, commands return one of two error strings:
202
+
203
+ - `[extension_reconnecting port=7822 elapsed=Xs]` — the extension disconnected
204
+ and is on its way back. **Retry the command after a short pause** (1–5 s).
205
+ The elapsed time tells you how long it has been gone; if it keeps climbing
206
+ past 30 s, check that the extension is loaded and the port matches.
207
+ - `[extension_never_connected port=7822]` — the extension has never connected to
208
+ this server instance at all. Check that the extension is installed, enabled,
209
+ and pointed at the correct port.
210
+
211
+ Never treat these as final failures without retrying at least once.
212
+
198
213
  ## Multiple tabs
199
214
 
200
215
  You have a **current tab**; every page-facing tool without `tabId`/`session`