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 +76 -15
- package/mcp-server/bridge.js +129 -56
- package/package.json +1 -1
- package/skills/browser-navigation-strategy/SKILL.md +15 -0
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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` —
|
package/mcp-server/bridge.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
// EADDRINUSE handler above
|
|
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,
|
|
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
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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:
|
|
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:
|
|
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
|
@@ -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`
|