poltertab 1.8.0-beta.2 → 1.8.0

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();
@@ -54,7 +58,7 @@ let isSecondary = false;
54
58
  let nodeId = null;
55
59
  let wss = null;
56
60
  let httpServer = null;
57
- let httpServer6 = null; // IPv6 loopback companion; null if host has no IPv6
61
+ // (httpServer6 removed — wildcard bind covers ::1; see fix/legacy-bind)
58
62
 
59
63
  // Secondary-side retry state. One counter for the whole reconnect/probe/promote
60
64
  // cycle, reset only by a Primary's secondary_accepted; `refusal` and
@@ -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 = [];
@@ -216,8 +271,22 @@ function allowOrigin(info, cb) {
216
271
 
217
272
  function setupPrimaryServer() {
218
273
  const http = require("http");
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;
219
280
  const handleRequest = (req, res) => {
220
281
  if (req.method === "GET" && req.url === "/status") {
282
+ if (
283
+ !LOOPBACK.has(req.socket.remoteAddress) ||
284
+ !LOOPBACK_HOST.test(req.headers.host || "")
285
+ ) {
286
+ res.writeHead(404);
287
+ res.end();
288
+ return;
289
+ }
221
290
  const now = Date.now();
222
291
  const secondaries = [];
223
292
  for (const [nid, ws] of secondaryClients.entries()) {
@@ -268,26 +337,25 @@ function setupPrimaryServer() {
268
337
  httpServer.close();
269
338
  httpServer = null;
270
339
  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.
277
340
  } else {
278
341
  console.error(`[PolterTab MCP] HTTP server error: ${e.message}`);
279
342
  }
280
343
  });
281
344
 
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.
345
+ // Why probe-first (SHOULD-1 narrative):
346
+ // We bind :: (IPv6 wildcard, dual-stack) so a second wildcard bind from a
347
+ // legacy 1.7.x/beta.1 server gets EADDRINUSE on every platform. But on
348
+ // macOS SO_REUSEADDR, a wildcard bind does NOT give EADDRINUSE when the
349
+ // existing listener is a specific-address bind (127.0.0.1/::1) — the old
350
+ // regression this PR fixes. The probe detects a real primary already
351
+ // serving 127.0.0.1 and demotes us before we call listen(), avoiding a
352
+ // race in the milliseconds between a successful probe and our own bind.
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(
@@ -298,7 +366,7 @@ function setupPrimaryServer() {
298
366
  startSecondaryMode();
299
367
  return;
300
368
  }
301
- httpServer.listen(WS_PORT, "127.0.0.1", () => {
369
+ httpServer.listen(WS_PORT, () => {
302
370
  wss = new WebSocket.WebSocketServer({
303
371
  server: httpServer,
304
372
  verifyClient: allowOrigin,
@@ -409,35 +477,13 @@ function setupPrimaryServer() {
409
477
 
410
478
  setupPrimaryWss(wss);
411
479
 
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");
480
+ // Wildcard bind (:: dual-stack) covers both 127.0.0.1 and ::1, so Node 18
481
+ // clients that resolve localhost → ::1 reach the primary without a separate
482
+ // httpServer6. A legacy server attempting listen(port) with no host gets
483
+ // EADDRINUSE because two wildcard binds on the same port always collide.
484
+ console.error(
485
+ `[PolterTab MCP] Primary WebSocket server listening on ws://localhost:${WS_PORT}`,
486
+ );
441
487
  });
442
488
  });
443
489
  }
@@ -543,6 +589,7 @@ async function promoteOrRejoin() {
543
589
  return;
544
590
  }
545
591
  isSecondary = false;
592
+ if (everAccepted && extensionLostAt === null) extensionLostAt = Date.now();
546
593
  setupPrimaryServer();
547
594
  }
548
595
 
@@ -582,6 +629,7 @@ function connectToPrimary() {
582
629
  // are handled before the id branch so none can settle a pending command.
583
630
  if (msg.type === "secondary_accepted") {
584
631
  accepted = true;
632
+ everAccepted = true;
585
633
  backoffAttempt = 0;
586
634
  refusal = null;
587
635
  unreachable = false;
@@ -636,12 +684,21 @@ function connectToPrimary() {
636
684
  // A lazy reconnect whose dial ended before the ACK fails now, with the
637
685
  // reason when the Primary gave one. The branches below still run, so the
638
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
+
639
694
  if (!accepted) {
640
695
  finishReconnect(
641
696
  new Error(
642
697
  info.reason === "capacity_rejected"
643
698
  ? `[capacity_rejected limit=${info.limit}] Primary (pid ${info.pid}) has ${info.limit} secondaries. Retry later.`
644
- : reconnectFailed(),
699
+ : incompatible
700
+ ? incompatibleError()
701
+ : reconnectFailed(),
645
702
  ),
646
703
  );
647
704
  }
@@ -667,13 +724,18 @@ function connectToPrimary() {
667
724
  const aborted =
668
725
  info.reason === "capacity_rejected"
669
726
  ? `[capacity_rejected limit=${info.limit}] Primary (pid ${info.pid}) refused this node. Command aborted.`
670
- : "Primary node disconnected. Command aborted.";
727
+ : incompatible
728
+ ? incompatibleError()
729
+ : "Primary node disconnected. Command aborted.";
671
730
  for (const [id, { reject, timer }] of pendingCommands) {
672
731
  clearTimeout(timer);
673
732
  reject(new Error(aborted));
674
733
  pendingCommands.delete(id);
675
734
  }
676
735
 
736
+ // Stop: neither a retry nor a promotion can fix a version mismatch.
737
+ if (incompatible) return;
738
+
677
739
  // Refused by a live Primary: it is not dying, so promoting can only fail.
678
740
  if (refused) {
679
741
  const delay = _backoffDelay(backoffAttempt++);
@@ -772,7 +834,7 @@ function _dispatchProxyNow(callerWs, id, action, params) {
772
834
  _tryDequeue();
773
835
  if (callerWs.readyState === WebSocket.OPEN) {
774
836
  callerWs.send(
775
- JSON.stringify({ id, success: false, error: "Browser extension not connected to Primary." }),
837
+ JSON.stringify({ id, success: false, error: extensionMissing() }),
776
838
  );
777
839
  }
778
840
  }
@@ -796,6 +858,18 @@ function setupPrimaryWss(wss) {
796
858
  // limit used to trigger — so it is never refused and never counted.
797
859
  ws.supportsNewCloseCodes =
798
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
+ }
799
873
  if (ws.supportsNewCloseCodes) {
800
874
  let v2 = 0;
801
875
  for (const [id, c] of secondaryClients) {
@@ -907,7 +981,7 @@ function setupPrimaryWss(wss) {
907
981
  JSON.stringify({
908
982
  id,
909
983
  success: false,
910
- error: "Browser extension not connected to Primary.",
984
+ error: extensionMissing(),
911
985
  }),
912
986
  );
913
987
  }
@@ -1112,6 +1186,7 @@ function setupPrimaryWss(wss) {
1112
1186
  } else if (extensionSocket === ws) {
1113
1187
  console.error("[PolterTab MCP] Chrome extension disconnected.");
1114
1188
  extensionSocket = null;
1189
+ extensionLostAt = Date.now();
1115
1190
  }
1116
1191
  });
1117
1192
 
@@ -1125,7 +1200,6 @@ function shutdown() {
1125
1200
  console.error("[PolterTab MCP] Shutting down...");
1126
1201
  if (wss) wss.close();
1127
1202
  if (httpServer) httpServer.close();
1128
- if (httpServer6) httpServer6.close();
1129
1203
  process.exit(0);
1130
1204
  }
1131
1205
  // Helper to wait for WebSocket connection
@@ -1196,6 +1270,7 @@ function _buildEnvelope(action, params, activeNodeId) {
1196
1270
 
1197
1271
  // Helper to send command to the extension
1198
1272
  async function sendCommand(action, params) {
1273
+ if (incompatible) throw new Error(incompatibleError());
1199
1274
  // Evicted, or a caller ahead of this one is already reconnecting: wait for
1200
1275
  // that one dial rather than racing it.
1201
1276
  if (isEvicted || reconnect) await lazyReconnect();
@@ -1242,9 +1317,7 @@ async function sendCommand(action, params) {
1242
1317
  }
1243
1318
 
1244
1319
  if (!isConnected) {
1245
- throw new Error(
1246
- `Browser extension not connected. Please ensure the extension is installed, enabled, and pointing to the correct port (default: ${DEFAULT_WS_PORT}).`,
1247
- );
1320
+ throw new Error(extensionMissing());
1248
1321
  }
1249
1322
 
1250
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.2",
3
+ "version": "1.8.0",
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`