poltertab 1.7.0-beta.3 → 1.8.0-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 +8 -4
- package/mcp-server/bridge.js +242 -28
- package/mcp-server/config.js +27 -3
- package/mcp-server/tools.js +6 -2
- package/package.json +1 -1
- package/skills/browser-navigation-strategy/SKILL.md +3 -1
package/README.md
CHANGED
|
@@ -203,7 +203,8 @@ next use.
|
|
|
203
203
|
|
|
204
204
|
One agent can drive many tabs. Each agent (and each secondary server) has its
|
|
205
205
|
own **current tab**; page-facing tools without `tabId`/`session` act there. An
|
|
206
|
-
explicit `tabId` acts on that tab without changing the current one
|
|
206
|
+
explicit `tabId` acts on that tab without changing the current one; a `tabId`
|
|
207
|
+
Chrome does not have returns `tab_not_found` on any tool that takes one. If the
|
|
207
208
|
current tab is closed, untargeted calls fail with a message naming the closed
|
|
208
209
|
tab instead of quietly acting on another one.
|
|
209
210
|
|
|
@@ -211,7 +212,7 @@ tab instead of quietly acting on another one.
|
|
|
211
212
|
| -------- | ----------- | ------------- |
|
|
212
213
|
| `browser_tab_new` | `url`, `session`, `background` | Open a tab and make it current |
|
|
213
214
|
| `browser_tab_list` | `all` | PolterTab's tabs (or every tab), marking the current one |
|
|
214
|
-
| `browser_tab_switch` | `tabId` or `session`, `focus` | Make another tab current |
|
|
215
|
+
| `browser_tab_switch` | `tabId` or `session`, `focus` | Make another tab current. A tab that does not exist in Chrome returns `tab_not_found`. |
|
|
215
216
|
| `browser_tab_close` | `tabId` or `session`, `force` | Close a tab (default: current). Tabs not opened by PolterTab (including ones adopted via `browser_session_create({tabId})`) return `needs_user_permission` — ask the user, then retry with `force: true`. A tab that does not exist in Chrome returns `tab_not_found`. |
|
|
216
217
|
|
|
217
218
|
### Site memory
|
|
@@ -243,8 +244,11 @@ themselves into **secondary** mode, proxying their commands through the primary,
|
|
|
243
244
|
so concurrent agents share one browser with no extra configuration. The
|
|
244
245
|
primary tags each secondary's commands with its id, and the extension keeps a
|
|
245
246
|
separate current tab per agent, so agents get their own tabs. Up to
|
|
246
|
-
|
|
247
|
-
|
|
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.
|
|
248
252
|
|
|
249
253
|
**Changing the WebSocket Port:**
|
|
250
254
|
Only needed when something that *isn't* PolterTab already holds `7822` —
|
package/mcp-server/bridge.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
const WebSocket = require("ws");
|
|
14
14
|
const crypto = require("crypto");
|
|
15
|
+
const net = require("net");
|
|
15
16
|
const {
|
|
16
17
|
resolvePort,
|
|
17
18
|
DEFAULT_WS_PORT,
|
|
@@ -23,6 +24,9 @@ const {
|
|
|
23
24
|
NETWORK_MAX_BODY_BYTES,
|
|
24
25
|
PROMOTION_DELAY_MS,
|
|
25
26
|
PROMOTION_JITTER_MS,
|
|
27
|
+
PROMOTION_BACKOFF_INITIAL_MS,
|
|
28
|
+
PROMOTION_BACKOFF_MAX_MS,
|
|
29
|
+
PROMOTION_BACKOFF_JITTER,
|
|
26
30
|
MAX_SECONDARIES,
|
|
27
31
|
POLTERTAB_HOME,
|
|
28
32
|
} = require("./config.js");
|
|
@@ -47,6 +51,15 @@ let nodeId = null;
|
|
|
47
51
|
let wss = null;
|
|
48
52
|
let httpServer = null;
|
|
49
53
|
|
|
54
|
+
// Secondary-side retry state. One counter for the whole reconnect/probe/promote
|
|
55
|
+
// cycle, reset only by a Primary's secondary_accepted; `refusal` and
|
|
56
|
+
// `unreachable` say why this node is between connections, so a command sent
|
|
57
|
+
// meanwhile can name the cause instead of a generic "not connected".
|
|
58
|
+
let backoffAttempt = 0;
|
|
59
|
+
let retryTimer = null;
|
|
60
|
+
let refusal = null;
|
|
61
|
+
let unreachable = false;
|
|
62
|
+
|
|
50
63
|
function broadcastSessionState() {
|
|
51
64
|
if (extensionSocket && extensionSocket.readyState === WebSocket.OPEN) {
|
|
52
65
|
const sessions = [];
|
|
@@ -107,6 +120,10 @@ function setupPrimaryServer() {
|
|
|
107
120
|
console.error(
|
|
108
121
|
`[PolterTab MCP] Port ${WS_PORT} in use, switching to Secondary Mode...`,
|
|
109
122
|
);
|
|
123
|
+
// A server that never bound is still a handle; every lost promotion race
|
|
124
|
+
// used to leave one behind.
|
|
125
|
+
httpServer.close();
|
|
126
|
+
httpServer = null;
|
|
110
127
|
startSecondaryMode();
|
|
111
128
|
} else {
|
|
112
129
|
console.error(`[PolterTab MCP] HTTP server error: ${e.message}`);
|
|
@@ -174,7 +191,10 @@ function setupPrimaryServer() {
|
|
|
174
191
|
|
|
175
192
|
function startSecondaryMode() {
|
|
176
193
|
isSecondary = true;
|
|
177
|
-
|
|
194
|
+
// Once per process. The extension keys this agent's current tab by nodeId,
|
|
195
|
+
// so a fresh one after a lost promotion race orphaned that pointer and the
|
|
196
|
+
// agent's next untargeted command landed on the user's focused tab.
|
|
197
|
+
if (!nodeId) nodeId = crypto.randomUUID();
|
|
178
198
|
console.error(
|
|
179
199
|
`[PolterTab MCP] Node ${nodeId} running as Secondary. Connecting to Primary...`,
|
|
180
200
|
);
|
|
@@ -182,19 +202,93 @@ function startSecondaryMode() {
|
|
|
182
202
|
connectToPrimary();
|
|
183
203
|
}
|
|
184
204
|
|
|
205
|
+
// The retry schedule: INITIAL × 2^attempt, capped, ±JITTER so a crowd of
|
|
206
|
+
// refused Secondaries does not come back in step. `rand` is injectable for
|
|
207
|
+
// the tests.
|
|
208
|
+
function _backoffDelay(attempt, rand = Math.random) {
|
|
209
|
+
const base = Math.min(
|
|
210
|
+
PROMOTION_BACKOFF_INITIAL_MS * 2 ** attempt,
|
|
211
|
+
PROMOTION_BACKOFF_MAX_MS,
|
|
212
|
+
);
|
|
213
|
+
const jitter = 1 + PROMOTION_BACKOFF_JITTER * (2 * rand() - 1);
|
|
214
|
+
return Math.min(Math.round(base * jitter), PROMOTION_BACKOFF_MAX_MS);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function scheduleRetry(fn, delay) {
|
|
218
|
+
clearTimeout(retryTimer);
|
|
219
|
+
retryTimer = setTimeout(fn, delay);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Is anything listening on the port? Binding to find out is what leaked a
|
|
223
|
+
// server per attempt; a refused TCP connect is the only answer that means the
|
|
224
|
+
// Primary is gone.
|
|
225
|
+
function probePrimary() {
|
|
226
|
+
return new Promise((resolve) => {
|
|
227
|
+
const sock = net.connect(WS_PORT, "localhost");
|
|
228
|
+
// A listener with a full accept backlog answers with neither connect nor
|
|
229
|
+
// error, which would park this node with no retry scheduled. Treat silence
|
|
230
|
+
// as "something is there": connectToPrimary's handshake timeout then sorts
|
|
231
|
+
// a hung Primary from a live one and keeps the backoff running.
|
|
232
|
+
sock.setTimeout(1000, () => {
|
|
233
|
+
sock.destroy();
|
|
234
|
+
resolve(true);
|
|
235
|
+
});
|
|
236
|
+
sock.once("connect", () => {
|
|
237
|
+
sock.destroy();
|
|
238
|
+
resolve(true);
|
|
239
|
+
});
|
|
240
|
+
sock.once("error", (err) => resolve(err.code !== "ECONNREFUSED"));
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function promoteOrRejoin() {
|
|
245
|
+
if (await probePrimary()) {
|
|
246
|
+
connectToPrimary();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
isSecondary = false;
|
|
250
|
+
setupPrimaryServer();
|
|
251
|
+
}
|
|
252
|
+
|
|
185
253
|
function connectToPrimary() {
|
|
186
|
-
|
|
254
|
+
const ws = new WebSocket(`ws://localhost:${WS_PORT}`, {
|
|
187
255
|
perMessageDeflate: true,
|
|
256
|
+
// A Primary that accepts TCP but never answers the upgrade (event loop
|
|
257
|
+
// blocked, SIGSTOP) would otherwise hold this node forever.
|
|
258
|
+
handshakeTimeout: 5000,
|
|
188
259
|
});
|
|
260
|
+
extensionSocket = ws;
|
|
261
|
+
let opened = false;
|
|
262
|
+
let accepted = false;
|
|
263
|
+
let errCode = null;
|
|
189
264
|
|
|
190
|
-
|
|
265
|
+
ws.on("open", () => {
|
|
266
|
+
opened = true;
|
|
191
267
|
console.error("[PolterTab MCP] Connected to Primary MCP Server.");
|
|
192
|
-
|
|
268
|
+
// The version is what tells a Primary this node understands 4xxx closes.
|
|
269
|
+
ws.send(
|
|
270
|
+
JSON.stringify({
|
|
271
|
+
type: "secondary_mcp",
|
|
272
|
+
nodeId,
|
|
273
|
+
version: OWN_VERSION,
|
|
274
|
+
pid: process.pid,
|
|
275
|
+
parentPid: process.ppid,
|
|
276
|
+
}),
|
|
277
|
+
);
|
|
193
278
|
});
|
|
194
279
|
|
|
195
|
-
|
|
280
|
+
ws.on("message", (message) => {
|
|
196
281
|
try {
|
|
197
282
|
const msg = JSON.parse(message);
|
|
283
|
+
// Frames about this connection carry a type, never a command id, and
|
|
284
|
+
// are handled before the id branch so none can settle a pending command.
|
|
285
|
+
if (msg.type === "secondary_accepted") {
|
|
286
|
+
accepted = true;
|
|
287
|
+
backoffAttempt = 0;
|
|
288
|
+
refusal = null;
|
|
289
|
+
unreachable = false;
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
198
292
|
if (msg.id && pendingCommands.has(msg.id)) {
|
|
199
293
|
const { resolve, reject, timer } = pendingCommands.get(msg.id);
|
|
200
294
|
clearTimeout(timer);
|
|
@@ -214,30 +308,70 @@ function connectToPrimary() {
|
|
|
214
308
|
}
|
|
215
309
|
});
|
|
216
310
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
311
|
+
ws.on("close", (code, reason) => {
|
|
312
|
+
if (extensionSocket === ws) extensionSocket = null;
|
|
313
|
+
// Each branch below sets the one that applies; neither may outlive the
|
|
314
|
+
// close that caused it.
|
|
315
|
+
refusal = null;
|
|
316
|
+
unreachable = false;
|
|
317
|
+
|
|
318
|
+
const refused = code >= 4000 && code <= 4999;
|
|
319
|
+
let info = {};
|
|
320
|
+
if (refused) {
|
|
321
|
+
try {
|
|
322
|
+
info = JSON.parse(reason.toString());
|
|
323
|
+
} catch (_) {}
|
|
324
|
+
}
|
|
222
325
|
|
|
223
326
|
// Reject all pending commands
|
|
327
|
+
const aborted =
|
|
328
|
+
info.reason === "capacity_rejected"
|
|
329
|
+
? `[capacity_rejected limit=${info.limit}] Primary (pid ${info.pid}) refused this node. Command aborted.`
|
|
330
|
+
: "Primary node disconnected. Command aborted.";
|
|
224
331
|
for (const [id, { reject, timer }] of pendingCommands) {
|
|
225
332
|
clearTimeout(timer);
|
|
226
|
-
reject(new Error(
|
|
333
|
+
reject(new Error(aborted));
|
|
227
334
|
pendingCommands.delete(id);
|
|
228
335
|
}
|
|
229
336
|
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
337
|
+
// Refused by a live Primary: it is not dying, so promoting can only fail.
|
|
338
|
+
if (refused) {
|
|
339
|
+
const delay = _backoffDelay(backoffAttempt++);
|
|
340
|
+
refusal = { ...info, retryAt: Date.now() + delay };
|
|
341
|
+
console.error(
|
|
342
|
+
`[PolterTab MCP] Primary refused this node (${code} ${info.reason || "unknown"}). Retrying as Secondary in ${delay}ms.`,
|
|
343
|
+
);
|
|
344
|
+
scheduleRetry(connectToPrimary, delay);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Something holds the port but never completed the handshake: a hung
|
|
349
|
+
// Primary, or not a PolterTab one. Promoting would only hit EADDRINUSE.
|
|
350
|
+
if (!opened && errCode !== "ECONNREFUSED") {
|
|
351
|
+
const delay = _backoffDelay(backoffAttempt++);
|
|
352
|
+
unreachable = true;
|
|
353
|
+
console.error(
|
|
354
|
+
`[PolterTab MCP] Primary on port ${WS_PORT} is not responding (${errCode || code}). Retrying in ${delay}ms.`,
|
|
355
|
+
);
|
|
356
|
+
scheduleRetry(connectToPrimary, delay);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
233
359
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
360
|
+
// The Primary went away, or closed this node without saying why (as a
|
|
361
|
+
// pre-1.8 Primary does at its limit). Probe first; promote only if the port
|
|
362
|
+
// is free. A node that was never accepted stays on the backoff schedule, so
|
|
363
|
+
// even a codeless refusal cannot loop at full speed.
|
|
364
|
+
const delay = accepted
|
|
365
|
+
? PROMOTION_DELAY_MS + Math.floor(Math.random() * PROMOTION_JITTER_MS)
|
|
366
|
+
: _backoffDelay(backoffAttempt++);
|
|
367
|
+
console.error(
|
|
368
|
+
`[PolterTab MCP] Connection to Primary lost. Attempting to become Primary in ${delay}ms...`,
|
|
369
|
+
);
|
|
370
|
+
scheduleRetry(promoteOrRejoin, delay);
|
|
238
371
|
});
|
|
239
372
|
|
|
240
|
-
|
|
373
|
+
ws.on("error", (err) => {
|
|
374
|
+
errCode = err.code || null;
|
|
241
375
|
console.error(`[PolterTab MCP] Secondary WebSocket error: ${err.message}`);
|
|
242
376
|
});
|
|
243
377
|
}
|
|
@@ -270,6 +404,7 @@ async function readNetworkState(params = {}) {
|
|
|
270
404
|
function setupPrimaryWss(wss) {
|
|
271
405
|
wss.on("connection", (ws) => {
|
|
272
406
|
ws.isAlive = true;
|
|
407
|
+
ws.supportsNewCloseCodes = false;
|
|
273
408
|
ws.on("pong", () => {
|
|
274
409
|
ws.isAlive = true;
|
|
275
410
|
});
|
|
@@ -279,18 +414,55 @@ function setupPrimaryWss(wss) {
|
|
|
279
414
|
const msg = JSON.parse(message);
|
|
280
415
|
|
|
281
416
|
if (msg.type === "secondary_mcp") {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
417
|
+
// Only a Secondary that names its version understands a 4xxx close.
|
|
418
|
+
// An older one answers any close by trying to promote — the loop this
|
|
419
|
+
// limit used to trigger — so it is never refused and never counted.
|
|
420
|
+
ws.supportsNewCloseCodes =
|
|
421
|
+
typeof msg.version === "string" && msg.version !== "";
|
|
422
|
+
if (ws.supportsNewCloseCodes) {
|
|
423
|
+
let v2 = 0;
|
|
424
|
+
for (const [id, c] of secondaryClients) {
|
|
425
|
+
if (id !== msg.nodeId && c.supportsNewCloseCodes) v2++;
|
|
426
|
+
}
|
|
427
|
+
if (v2 >= MAX_SECONDARIES) {
|
|
428
|
+
console.error(
|
|
429
|
+
`[PolterTab MCP] Rejecting secondary node ${msg.nodeId} - limit reached.`,
|
|
430
|
+
);
|
|
431
|
+
ws.close(
|
|
432
|
+
4000,
|
|
433
|
+
JSON.stringify({
|
|
434
|
+
reason: "capacity_rejected",
|
|
435
|
+
limit: MAX_SECONDARIES,
|
|
436
|
+
retry_in_ms: PROMOTION_BACKOFF_INITIAL_MS,
|
|
437
|
+
pid: process.pid,
|
|
438
|
+
}),
|
|
439
|
+
);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// Same node, new socket: the old one is dead weight the client may
|
|
444
|
+
// never end, and the close guard below keeps its close harmless.
|
|
445
|
+
const displaced = secondaryClients.get(msg.nodeId);
|
|
446
|
+
if (displaced && displaced !== ws && displaced.readyState === WebSocket.OPEN) {
|
|
447
|
+
displaced.close(1000);
|
|
288
448
|
}
|
|
289
449
|
secondaryClients.set(msg.nodeId, ws);
|
|
290
450
|
ws.nodeId = msg.nodeId;
|
|
451
|
+
ws.version = msg.version || null;
|
|
452
|
+
ws.pid = msg.pid || null;
|
|
453
|
+
ws.parentPid = msg.parentPid || null;
|
|
291
454
|
console.error(
|
|
292
455
|
`[PolterTab MCP] Secondary node connected: ${msg.nodeId}. Total secondaries: ${secondaryClients.size}`,
|
|
293
456
|
);
|
|
457
|
+
if (ws.supportsNewCloseCodes) {
|
|
458
|
+
ws.send(
|
|
459
|
+
JSON.stringify({
|
|
460
|
+
type: "secondary_accepted",
|
|
461
|
+
nodeId: msg.nodeId,
|
|
462
|
+
primaryVersion: OWN_VERSION,
|
|
463
|
+
}),
|
|
464
|
+
);
|
|
465
|
+
}
|
|
294
466
|
broadcastSessionState();
|
|
295
467
|
return;
|
|
296
468
|
}
|
|
@@ -302,6 +474,23 @@ function setupPrimaryWss(wss) {
|
|
|
302
474
|
|
|
303
475
|
if (msg.type === "proxy_command") {
|
|
304
476
|
const { id, action } = msg;
|
|
477
|
+
// No nodeId means no secondary_mcp yet — or a socket refused with
|
|
478
|
+
// 4000 an instant ago. The extension would bucket its command as the
|
|
479
|
+
// Primary agent's and move that agent's current tab; a refused
|
|
480
|
+
// socket would not even get the reply. A well-behaved client's
|
|
481
|
+
// handshake always precedes its first command on the same socket.
|
|
482
|
+
if (!ws.nodeId) {
|
|
483
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
484
|
+
ws.send(
|
|
485
|
+
JSON.stringify({
|
|
486
|
+
id,
|
|
487
|
+
success: false,
|
|
488
|
+
error: "[not_registered] Send secondary_mcp before proxy_command.",
|
|
489
|
+
}),
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
305
494
|
// Stamp which agent is asking, from the socket rather than the
|
|
306
495
|
// message, so the extension keeps this Secondary's current tab apart
|
|
307
496
|
// from the Primary's and every other Secondary's.
|
|
@@ -359,7 +548,15 @@ function setupPrimaryWss(wss) {
|
|
|
359
548
|
}, COMMAND_TIMEOUT_MS);
|
|
360
549
|
|
|
361
550
|
pendingCommands.set(id, { isProxy: true, sourceWs: ws, timer });
|
|
362
|
-
|
|
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),
|
|
557
|
+
id,
|
|
558
|
+
}),
|
|
559
|
+
);
|
|
363
560
|
return;
|
|
364
561
|
}
|
|
365
562
|
|
|
@@ -473,8 +670,12 @@ function setupPrimaryWss(wss) {
|
|
|
473
670
|
console.error(
|
|
474
671
|
`[PolterTab MCP] Secondary node disconnected: ${ws.nodeId}`,
|
|
475
672
|
);
|
|
476
|
-
|
|
477
|
-
|
|
673
|
+
// A node now keeps its id across reconnects, so its new socket may
|
|
674
|
+
// already be registered by the time the old one's close arrives.
|
|
675
|
+
if (secondaryClients.get(ws.nodeId) === ws) {
|
|
676
|
+
secondaryClients.delete(ws.nodeId);
|
|
677
|
+
broadcastSessionState();
|
|
678
|
+
}
|
|
478
679
|
} else if (extensionSocket === ws) {
|
|
479
680
|
console.error("[PolterTab MCP] Chrome extension disconnected.");
|
|
480
681
|
extensionSocket = null;
|
|
@@ -552,6 +753,17 @@ async function sendCommand(action, params) {
|
|
|
552
753
|
|
|
553
754
|
if (isSecondary) {
|
|
554
755
|
if (!isConnected) {
|
|
756
|
+
if (refusal && refusal.reason === "capacity_rejected") {
|
|
757
|
+
const s = Math.max(0, Math.ceil((refusal.retryAt - Date.now()) / 1000));
|
|
758
|
+
throw new Error(
|
|
759
|
+
`[capacity_rejected limit=${refusal.limit}] Primary (pid ${refusal.pid}) has ${refusal.limit} secondaries. Retry in ${s}s.`,
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
if (unreachable) {
|
|
763
|
+
throw new Error(
|
|
764
|
+
`[primary_unreachable port=${WS_PORT}] Primary is not responding. Backing off.`,
|
|
765
|
+
);
|
|
766
|
+
}
|
|
555
767
|
throw new Error("Secondary node not connected to Primary MCP server.");
|
|
556
768
|
}
|
|
557
769
|
|
|
@@ -610,4 +822,6 @@ module.exports = {
|
|
|
610
822
|
isSecondary: () => isSecondary,
|
|
611
823
|
extensionVersion: () => extensionVersion,
|
|
612
824
|
_buildEnvelope,
|
|
825
|
+
_backoffDelay,
|
|
826
|
+
_probePrimary: probePrimary,
|
|
613
827
|
};
|
package/mcp-server/config.js
CHANGED
|
@@ -46,6 +46,18 @@ function resolvePort(argv = process.argv, env = process.env) {
|
|
|
46
46
|
return DEFAULT_WS_PORT;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// The v2 Secondary ceiling: POLTERTAB_MAX_SECONDARIES if it is a whole number
|
|
50
|
+
// ≥ 0, else 50. 0 is honoured — it refuses every versioned Secondary (old ones
|
|
51
|
+
// are still accepted, see MAX_SECONDARIES below) — so an explicit 0 must not
|
|
52
|
+
// fall through to the default the way `parseInt(x) || 50` let it.
|
|
53
|
+
const DEFAULT_MAX_SECONDARIES = 50;
|
|
54
|
+
function resolveMaxSecondaries(env = process.env) {
|
|
55
|
+
const raw = env.POLTERTAB_MAX_SECONDARIES;
|
|
56
|
+
if (raw === undefined || raw.trim() === "") return DEFAULT_MAX_SECONDARIES;
|
|
57
|
+
const n = Number(raw);
|
|
58
|
+
return Number.isInteger(n) && n >= 0 ? n : DEFAULT_MAX_SECONDARIES;
|
|
59
|
+
}
|
|
60
|
+
|
|
49
61
|
module.exports = {
|
|
50
62
|
POLTERTAB_HOME,
|
|
51
63
|
MEMORY_DIR: path.join(POLTERTAB_HOME, "navigation_memory"),
|
|
@@ -53,6 +65,7 @@ module.exports = {
|
|
|
53
65
|
|
|
54
66
|
DEFAULT_WS_PORT,
|
|
55
67
|
resolvePort,
|
|
68
|
+
resolveMaxSecondaries,
|
|
56
69
|
|
|
57
70
|
// See the budget above before changing this.
|
|
58
71
|
COMMAND_TIMEOUT_MS: 35000,
|
|
@@ -74,9 +87,20 @@ module.exports = {
|
|
|
74
87
|
PROMOTION_DELAY_MS: 500,
|
|
75
88
|
PROMOTION_JITTER_MS: 2000,
|
|
76
89
|
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
90
|
+
// A refused or unreachable Secondary retries on this schedule — 2 s, 4 s,
|
|
91
|
+
// 8 s … ±25%, capped — and the whole probe/connect/promote cycle runs under
|
|
92
|
+
// it, reset only when a Primary sends secondary_accepted. Without it a
|
|
93
|
+
// refusal was answered by an immediate reconnect, refused again, forever.
|
|
94
|
+
PROMOTION_BACKOFF_INITIAL_MS: 2000,
|
|
95
|
+
PROMOTION_BACKOFF_MAX_MS: 60000,
|
|
96
|
+
PROMOTION_BACKOFF_JITTER: 0.25,
|
|
97
|
+
|
|
98
|
+
// A safety net against runaway processes, not a usage limit: at 5 it was
|
|
99
|
+
// routinely hit by idle helpers, and every refusal fed the loop above. Only
|
|
100
|
+
// Secondaries that declare a version count; an old one is always accepted,
|
|
101
|
+
// because the only refusal it understands is a close it answers by trying to
|
|
102
|
+
// promote.
|
|
103
|
+
MAX_SECONDARIES: resolveMaxSecondaries(),
|
|
80
104
|
|
|
81
105
|
// Lazy-loaded content arrives after the scroll returns. Long enough for the
|
|
82
106
|
// XHR it triggered to land in the capture buffer, short enough that a
|
package/mcp-server/tools.js
CHANGED
|
@@ -11,7 +11,7 @@ const TARGET = {
|
|
|
11
11
|
tabId: {
|
|
12
12
|
type: "number",
|
|
13
13
|
description:
|
|
14
|
-
"Act on this tab (an id from browser_tab_list) without making it current. Omit to use this agent's current tab: the last one navigated, opened or switched to.",
|
|
14
|
+
"Act on this tab (an id from browser_tab_list) without making it current. Omit to use this agent's current tab: the last one navigated, opened or switched to. A tabId Chrome does not have returns tab_not_found.",
|
|
15
15
|
},
|
|
16
16
|
session: {
|
|
17
17
|
type: "string",
|
|
@@ -498,7 +498,11 @@ const BROWSER_TOOLS = [
|
|
|
498
498
|
inputSchema: {
|
|
499
499
|
type: "object",
|
|
500
500
|
properties: {
|
|
501
|
-
tabId: {
|
|
501
|
+
tabId: {
|
|
502
|
+
type: "number",
|
|
503
|
+
description:
|
|
504
|
+
"Tab to switch to; a tabId Chrome does not have returns tab_not_found",
|
|
505
|
+
},
|
|
502
506
|
session: { type: "string" },
|
|
503
507
|
focus: {
|
|
504
508
|
type: "boolean",
|
package/package.json
CHANGED
|
@@ -204,7 +204,9 @@ commandeer the tab the user is looking at — and later calls reuse it.
|
|
|
204
204
|
For several tabs: `browser_tab_new` opens one and makes it current,
|
|
205
205
|
`browser_tab_list` shows them with their ids, `browser_tab_switch` changes the
|
|
206
206
|
current one, and `browser_tab_close` closes one. Pass `tabId` to act on a tab
|
|
207
|
-
once without switching to it
|
|
207
|
+
once without switching to it — a `tabId` Chrome does not have returns
|
|
208
|
+
`tab_not_found` on any tool that takes one — or name tabs with `session` and
|
|
209
|
+
pass that. If a
|
|
208
210
|
call says your tab was closed, list and switch — do not assume another tab is
|
|
209
211
|
the same page. Close what you opened.
|
|
210
212
|
|