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 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. If the
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
- 5 secondaries are accepted, and if the primary exits one of them promotes itself
247
- to replace it. The extension popup lists whichever agents are connected.
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` —
@@ -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
- nodeId = crypto.randomUUID();
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
- extensionSocket = new WebSocket(`ws://localhost:${WS_PORT}`, {
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
- extensionSocket.on("open", () => {
265
+ ws.on("open", () => {
266
+ opened = true;
191
267
  console.error("[PolterTab MCP] Connected to Primary MCP Server.");
192
- extensionSocket.send(JSON.stringify({ type: "secondary_mcp", nodeId }));
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
- extensionSocket.on("message", (message) => {
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
- extensionSocket.on("close", () => {
218
- console.error(
219
- "[PolterTab MCP] Connection to Primary lost. Attempting to become Primary...",
220
- );
221
- extensionSocket = null;
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("Primary node disconnected. Command aborted."));
333
+ reject(new Error(aborted));
227
334
  pendingCommands.delete(id);
228
335
  }
229
336
 
230
- // Try to become primary with exponential backoff & jitter
231
- const delay =
232
- PROMOTION_DELAY_MS + Math.floor(Math.random() * PROMOTION_JITTER_MS);
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
- setTimeout(() => {
235
- isSecondary = false;
236
- setupPrimaryServer();
237
- }, delay);
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
- extensionSocket.on("error", (err) => {
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
- if (secondaryClients.size >= MAX_SECONDARIES) {
283
- console.error(
284
- `[PolterTab MCP] Rejecting secondary node ${msg.nodeId} - limit reached.`,
285
- );
286
- ws.close();
287
- return;
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
- extensionSocket.send(JSON.stringify({ id, action, ...params }));
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
- secondaryClients.delete(ws.nodeId);
477
- broadcastSessionState();
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
  };
@@ -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
- // More concurrent agents than this sharing one browser stops being useful and
78
- // starts being a way to lose track of which tab belongs to whom.
79
- MAX_SECONDARIES: 5,
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
@@ -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: { type: "number" },
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poltertab",
3
- "version": "1.7.0-beta.3",
3
+ "version": "1.8.0-beta.1",
4
4
  "description": "Let any AI assistant drive and scrape your existing Chrome profile — no headless browser, no complex setup.",
5
5
  "bin": {
6
6
  "poltertab": "./bin/poltertab.js"
@@ -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, or name tabs with `session` and pass that. If a
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