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 +76 -15
- package/mcp-server/bridge.js +106 -10
- 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();
|
|
@@ -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 = [];
|
|
@@ -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 (
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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:
|
|
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:
|
|
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
|
@@ -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`
|