safari-mcp 2.22.5 → 2.22.7

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.
Files changed (4) hide show
  1. package/index.js +332 -122
  2. package/ownership-state.js +31 -12
  3. package/package.json +1 -1
  4. package/safari.js +543 -177
package/index.js CHANGED
@@ -228,7 +228,7 @@ async function _evictOldestTab(sessionId) {
228
228
  let open = mine.length;
229
229
  for (const [key, info] of mine) {
230
230
  if (open < MAX_TABS) break;
231
- const outcome = await _closeTrackedTab(info);
231
+ const outcome = await _oneCloseAtATime(() => _closeTrackedTab(info));
232
232
  // It may still be open: keep counting it and retry it at the next new tab. Close the next
233
233
  // only through the extension, which fails fast: an AppleScript close that failed, a page that
234
234
  // never answers the marker check, would cost every tracked tab its own timeout.
@@ -267,6 +267,59 @@ function _untrackClosedTab({ receipt = "", marker = "" }) {
267
267
  }
268
268
  }
269
269
 
270
+ // One tab close at a time in this process. closeTabByMarker() checks a tab's marker and closes the
271
+ // tab in one script, but as two AppleEvents, and a close sent in between renumbered the window: the
272
+ // index the check found then named the next tab, the user's included. Two tab-cap evictions at once
273
+ // could do it (safari_new_tab and run_script newTab, in parallel), as could an eviction next to
274
+ // safari_close_tab. An extension close goes by tab id, but it renumbers the window all the same.
275
+ // ponytail: this orders the closes of one server process (every session of an HTTP daemon); a close
276
+ // by another MCP process can still land between those two events. A slow extension close holds up
277
+ // the rest, shutdown cleanup included, which then leaves the tabs open after its 3s.
278
+ let _closeQueue = Promise.resolve();
279
+ function _oneCloseAtATime(close) {
280
+ const run = _closeQueue.then(() => close());
281
+ _closeQueue = run.catch(() => {});
282
+ return run;
283
+ }
284
+
285
+ // A URL a call takes the session's tab to, and with no scheme its https:// form, which is what
286
+ // safari.js navigate() loads. Strings only: run_script step args are not validated. Closing a tab
287
+ // opened on the URL releases both forms (_claimedForms in ownership-state.js).
288
+ function _claimURL(url) {
289
+ if (typeof url !== "string" || !url) return;
290
+ _addOwnedURL(url);
291
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) _addOwnedURL("https://" + url);
292
+ }
293
+
294
+ // Open a tab through AppleScript as one of the session's own: its URL claimed, and the tab tracked
295
+ // by the marker safari.newTab() mints for it, so the session's tab cap, shutdown cleanup and the
296
+ // memory sweep count and close it, and a close forgets it. The URL the caller asked for stays the
297
+ // session's current one wherever the page redirected: the site chose the redirect's target.
298
+ // newTab() names the marker (onMarker) as soon as the tab exists, and can still throw after that,
299
+ // when marking or reading the new tab fails; the tab is claimed and tracked then too. Left out, it
300
+ // outlived the cap and cleanup, and the next write was refused in it as "not opened by this MCP
301
+ // session". A newTab() that threw before the tab existed named no marker. The marker newTab()
302
+ // names, not the session's after the call: a parallel call of the same session changes that one,
303
+ // and a switch that adopted the user's tab (SAFARI_MCP_ALLOW_USER_TABS) meanwhile got the user's
304
+ // tab recorded among the ones the cap and cleanup close.
305
+ async function _openTabViaAppleScript(url) {
306
+ const mySession = `${SESSION_ID}:${currentSessionId()}`;
307
+ let minted = null;
308
+ try {
309
+ return await safari.newTab(url, { onMarker: (marker) => { minted = marker; } });
310
+ } finally {
311
+ if (minted) {
312
+ _trackTab(safari.getActiveTabIndex(), url, mySession, minted); // the index is only a hint
313
+ if (!url || url === "about:blank") {
314
+ _markBlankTabOpened();
315
+ } else {
316
+ safari.setActiveTabURL(url);
317
+ _claimURL(url);
318
+ }
319
+ }
320
+ }
321
+ }
322
+
270
323
  // Only the extension can tell which tab a receipt names: AppleScript proves a tab by the
271
324
  // marker stamped on it (#112), and a tab the extension opened carries none. A close or switch
272
325
  // by receipt whose extension attempt failed fell back to the session's current tab instead,
@@ -296,7 +349,7 @@ async function _cleanupTabs() {
296
349
  // with no index would close only this session's active tab (#68).
297
350
  for (const info of [..._openedTabs.values()]) {
298
351
  try {
299
- await safari.closeTabByMarker(info.marker);
352
+ await _oneCloseAtATime(() => safari.closeTabByMarker(info.marker));
300
353
  } catch {}
301
354
  }
302
355
  _openedTabs.clear();
@@ -362,7 +415,7 @@ async function _closeOldestMCPTab() {
362
415
  if (oldestIdx !== null) {
363
416
  // By its identity, like the tab cap: an extension-opened tab carries a receipt, not a
364
417
  // marker, and a marker-only close forgot it here without freeing anything.
365
- await _closeTrackedTab(_openedTabs.get(oldestIdx));
418
+ await _oneCloseAtATime(() => _closeTrackedTab(_openedTabs.get(oldestIdx)));
366
419
  _untrackTab(oldestIdx);
367
420
  }
368
421
  }
@@ -1808,13 +1861,34 @@ function _safeUrlForOutput(rawUrl) {
1808
1861
  // Receipts this daemon rotated on the caller's behalf (a cross-origin safari_navigate) stay
1809
1862
  // usable under their old name: callers routinely keep the first receipt they saw and ignore
1810
1863
  // the fresh one in the navigate result (nine consecutive "not valid for this origin" failures
1811
- // in one session on 4.9.26). Whoever held the old capability held the tab.
1864
+ // in one session on 4.9.26). Whoever held the old capability held the tab. Every session that
1865
+ // held it follows the alias, other clients of an HTTP daemon included (_getActiveReceipt), so an
1866
+ // alias may only join two receipts of one tab: each rotation names the receipt it replaces.
1812
1867
  const _receiptAliases = new Map(); // old token → newer token
1813
1868
  function _aliasReceipt(oldToken, newToken) {
1814
1869
  if (!oldToken || !newToken || oldToken === newToken) return;
1870
+ // Every older name of the tab points straight at its newest receipt. As a chain of one link per
1871
+ // rotation, a receipt stopped resolving after eight (_receiptToken's hop limit): a handful of
1872
+ // cross-origin navigations in one tab.
1873
+ for (const [older, newer] of _receiptAliases) if (newer === oldToken) _receiptAliases.set(older, newToken);
1815
1874
  _receiptAliases.set(oldToken, newToken);
1816
1875
  if (_receiptAliases.size > 500) _receiptAliases.delete(_receiptAliases.keys().next().value);
1817
1876
  }
1877
+
1878
+ // The origin a receipt is valid on: the page the extension reported with it (_sanitizeTabResult).
1879
+ // safari_navigate rotates a receipt only when the tab lands off that origin. Judged by the URL
1880
+ // safari.js holds instead (another tab's when the call names its tab by receipt, unknown after a
1881
+ // switch the extension served, stale after a navigation it served), a navigate that stayed on the
1882
+ // receipt's origin counted as a change and retired the receipt the caller kept.
1883
+ const _receiptOrigins = new Map(); // token → origin
1884
+ function _noteReceiptOrigin(receipt, url) {
1885
+ const token = _receiptToken(receipt);
1886
+ const origin = _originOf(url);
1887
+ if (!token || !origin) return;
1888
+ _receiptOrigins.set(token, origin);
1889
+ if (_receiptOrigins.size > 500) _receiptOrigins.delete(_receiptOrigins.keys().next().value);
1890
+ }
1891
+
1818
1892
  function _receiptToken(value) {
1819
1893
  const raw = String(value || "");
1820
1894
  let token = "";
@@ -1852,8 +1926,10 @@ const _activeReceipts = new Map();
1852
1926
  function _receiptSessionKey() {
1853
1927
  return `${SESSION_ID}:${currentSessionId()}`;
1854
1928
  }
1929
+ // Resolved on every read: another client of an HTTP daemon can rotate this session's receipt
1930
+ // after it was stored, and the extension then knows only the new one.
1855
1931
  function _getActiveReceipt() {
1856
- return _activeReceipts.get(_receiptSessionKey()) || "";
1932
+ return _receiptToken(_activeReceipts.get(_receiptSessionKey()));
1857
1933
  }
1858
1934
  function _setActiveReceipt(receipt) {
1859
1935
  _activeReceipts.set(_receiptSessionKey(), _receiptToken(receipt));
@@ -1890,6 +1966,8 @@ function _sanitizeTabResult(value) {
1890
1966
  if (!normalized || typeof normalized !== "object") return normalized;
1891
1967
  const safeUrl = _safeUrlForOutput(normalized.safeUrl || normalized.url || normalized.requestedUrl || "");
1892
1968
  const receipt = _receiptToken(normalized.receipt || normalized.receiptUrl || "");
1969
+ // The extension hands every receipt out here, with the page it is valid on.
1970
+ _noteReceiptOrigin(receipt, safeUrl);
1893
1971
  return {
1894
1972
  ...(normalized.index !== undefined ? { index: normalized.index } : {}),
1895
1973
  ...(normalized.tabIndex !== undefined ? { tabIndex: normalized.tabIndex } : {}),
@@ -1997,12 +2075,20 @@ async function _runExtensionBatchAction(action, args = {}) {
1997
2075
  return _sanitizeTabResult(await extensionOrFallback("list_tabs", {}, () => safari.listTabs()));
1998
2076
 
1999
2077
  case "getReceipt": {
2000
- const previous = _receiptToken(args.receipt || "") || _getActiveReceipt();
2078
+ // A receipt that is not one (a token cut short, the advice's "<old receipt>" left in) fell
2079
+ // through to the current tab's receipt, and the step rotated that tab instead.
2080
+ if (args.receipt && !_receiptToken(args.receipt)) {
2081
+ throw new Error("Tab safety: getReceipt requires an extension-issued receipt");
2082
+ }
2083
+ const previous = _receiptToken(args.receipt || _getActiveReceipt());
2084
+ // The payload names the receipt aliased below. extensionOrFallback would otherwise attach the
2085
+ // session's receipt as it stands after its own awaits, which a call running alongside can change.
2001
2086
  const value = normalize(await extensionOrFallback(
2002
- "get_tab_receipt", {
2003
- ...(_receiptToken(args.receipt || "") ? { receipt: _receiptToken(args.receipt) } : {}),
2004
- },
2005
- () => { throw new Error("getReceipt requires the verified Safari extension"); }
2087
+ "get_tab_receipt", previous ? { receipt: previous } : {},
2088
+ () => {
2089
+ throw new Error("getReceipt: the Safari extension did not hand back a new receipt (the server log has its " +
2090
+ "reason; safari_doctor shows whether it is connected). AppleScript touched no tab.");
2091
+ }
2006
2092
  ));
2007
2093
  const safeValue = _sanitizeTabResult(value);
2008
2094
  // The rotated tab is now the current one. Its URL stays unknown in safari.js: a receipt
@@ -2011,7 +2097,7 @@ async function _runExtensionBatchAction(action, args = {}) {
2011
2097
  if (safeValue?.receipt) {
2012
2098
  // Rotation retires the old token. Keep it resolving to the new one, as navigate does,
2013
2099
  // so the tab stays reachable — and closable by the tab cap — by the name it was opened with.
2014
- _aliasReceipt(previous, safeValue.receipt);
2100
+ _aliasReceipt(_receiptToken(previous), safeValue.receipt); // the receipt extensionOrFallback sent
2015
2101
  _setActiveReceipt(safeValue.receipt);
2016
2102
  }
2017
2103
  return safeValue;
@@ -2051,7 +2137,6 @@ async function _runExtensionBatchAction(action, args = {}) {
2051
2137
  case "navigate": {
2052
2138
  const url = String(args.url || "");
2053
2139
  if (!url) throw new Error("navigate requires url");
2054
- _addOwnedURL(url);
2055
2140
  const raw = await extensionOrFallback(
2056
2141
  "navigate", { url, timeout: args.timeout },
2057
2142
  () => safari.navigate(url)
@@ -2062,7 +2147,6 @@ async function _runExtensionBatchAction(action, args = {}) {
2062
2147
  case "navigateAndRead": {
2063
2148
  const url = String(args.url || "");
2064
2149
  if (!url) throw new Error("navigateAndRead requires url");
2065
- _addOwnedURL(url);
2066
2150
  const raw = await extensionOrFallback(
2067
2151
  "navigate_and_read", { url, maxLength: args.maxLength, timeout: args.timeout },
2068
2152
  async () => { await safari.navigate(url); return safari.readPage({ maxLength: args.maxLength }); }
@@ -2214,8 +2298,8 @@ async function _runExtensionBatchAction(action, args = {}) {
2214
2298
  // Tab-ownership assertion — shared by extensionOrFallback AND the tools that bypass it
2215
2299
  // (safari_run_script, native_*). Throws if the operation would land on a tab this MCP
2216
2300
  // session didn't open. Read-only / tab-management ops (in _noOwnershipCheck) are exempt.
2217
- // Once any tab has been opened via new_tab, ALL subsequent page-mutating ops must target
2218
- // an owned tab — this is what prevents navigating/clicking in the user's tabs.
2301
+ // A session with no tab of its own may only read: without one, AppleScript acts on the
2302
+ // front document, the tab the user is looking at.
2219
2303
  function _assertTabOwnership(opType, extensionPayload = {}) {
2220
2304
  if (_noOwnershipCheck.has(opType)) return;
2221
2305
  // Closing is the one op the opt-in never unlocks (#92, condition 2). Adoption makes a
@@ -2232,26 +2316,36 @@ function _assertTabOwnership(opType, extensionPayload = {}) {
2232
2316
  // presented after a stateless reconnect; only the extension can validate its exact
2233
2317
  // tab binding, freshness, digest, and origin.
2234
2318
  if (_preferAppleScript && _receiptToken(extensionPayload.receipt || _getActiveReceipt())) return;
2319
+ // A rotation that names its receipt touches no page and never falls back to AppleScript, so the
2320
+ // extension alone judges it, as a profile does every receipt. Without a profile, the URL safari.js
2321
+ // holds can be where a redirect took the tab during an AppleScript load, which the session never
2322
+ // registered, or, after safari_navigate, the URL the tab left, which the navigation released: this
2323
+ // check refused the rotation on both.
2324
+ if (opType === "get_tab_receipt" && _receiptToken(extensionPayload.receipt)) return;
2235
2325
  const currentUrl = safari.getActiveTabURL();
2236
2326
  // An adopted tab (#92) is the session's target even though the session opened nothing.
2237
2327
  if (safari.isActiveTabAdopted()) {
2238
2328
  console.error(`[Safari MCP] "${opType}" on ${_safeUrlForOutput(currentUrl)} (user tab, opted-in)`);
2239
2329
  return;
2240
2330
  }
2241
- if (_ownedTabURLs.size === 0 && _openedTabs.size === 0) {
2242
- // No tabs opened yet — block everything except read-only ops
2243
- const msg = `⚠️ Tab safety: no tabs opened yet. Call safari_new_tab first before "${opType}".`;
2331
+ // Asked of THIS session: whether it opened, claimed or adopted a tab (safari.js records that per
2332
+ // session, for AppleScript's tabs and the extension's alike), or names one by the receipt passed
2333
+ // with this call — a session with no state of its own yet, such as a subagent or the same agent
2334
+ // after a reconnect. The extension applies the same rule on its own path (handleCommand in
2335
+ // extension/background.js). It was asked of _ownedTabURLs and _openedTabs, which hold every
2336
+ // session's tabs in this process, and through owned-tabs.json every process's, so a tab opened
2337
+ // anywhere on the machine let a session that had none write, into the user's tab.
2338
+ if (!_receiptToken(extensionPayload.receipt) && !safari.hasOwnedTab()) {
2339
+ const msg = `⚠️ Tab safety: refusing "${opType}" — this MCP session has no tab of its own, so it would act on the tab in front, which is the user's. Open one with safari_new_tab, or pass the receipt of a tab you opened, with the call or to safari_switch_tab (a receipt survives reconnects and restarts).`;
2244
2340
  console.error(`[Safari MCP] ${msg}`);
2245
2341
  throw new Error(msg);
2246
2342
  }
2247
- if (currentUrl && !_isURLOwned(currentUrl)) {
2248
- // about:blank tabs are owned if we have any tracked tabs (new_tab creates them at about:blank)
2249
- const isBlankOwned = (currentUrl === 'about:blank' || currentUrl === 'missing value') && (_openedTabs.size > 0 || _ownedTabURLs.has(BLANK_TAB_SENTINEL));
2250
- if (!isBlankOwned) {
2251
- const msg = `⚠️ Tab safety: refusing "${opType}" — current tab (${_safeUrlForOutput(currentUrl)}) was not opened by this MCP session. Use safari_new_tab or safari_switch_tab to target your own tab.`;
2252
- console.error(`[Safari MCP] ${msg}`);
2253
- throw new Error(msg);
2254
- }
2343
+ // A blank tab has no URL to own; the current tab of a session that has one is its own.
2344
+ const blank = currentUrl === 'about:blank' || currentUrl === 'missing value';
2345
+ if (currentUrl && !blank && !_isURLOwned(currentUrl)) {
2346
+ const msg = `⚠️ Tab safety: refusing "${opType}" — current tab (${_safeUrlForOutput(currentUrl)}) was not opened by this MCP session. Use safari_new_tab or safari_switch_tab to target your own tab.`;
2347
+ console.error(`[Safari MCP] ${msg}`);
2348
+ throw new Error(msg);
2255
2349
  }
2256
2350
  }
2257
2351
 
@@ -2261,6 +2355,11 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
2261
2355
  _refuseUnnamedTab(extensionType, extensionPayload);
2262
2356
  // Tab-ownership guard — extracted to _assertTabOwnership so run_script / native_* share it.
2263
2357
  _assertTabOwnership(extensionType, extensionPayload);
2358
+ // A navigation claims its destination before it starts, so a load that throws halfway still
2359
+ // leaves the tab reachable (safari_switch_tab recovery). Only once the guard has passed: the
2360
+ // callers used to claim it first, and the destination vouched for itself — on a clean machine
2361
+ // it was the one owned URL the guard looked for, and a refused call left it in owned-tabs.json.
2362
+ if (extensionType === "navigate" || extensionType === "navigate_and_read") _addOwnedURL(extensionPayload.url);
2264
2363
 
2265
2364
  // Safari may terminate an otherwise healthy profile worker between two tool
2266
2365
  // calls. If no command has been sent yet, waiting for its verified reconnect is
@@ -2297,9 +2396,13 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
2297
2396
  const reloadHandoff = extensionType === "reload_extension" && _isExtensionHost
2298
2397
  ? _prepareReloadHttpWorkerHandoff()
2299
2398
  : null;
2399
+ const namedReceipt = _receiptToken(extensionPayload.receipt);
2300
2400
  const payload = {
2301
2401
  ...(attachActiveReceipt && activeReceipt ? { receipt: activeReceipt } : {}),
2302
2402
  ...extensionPayload,
2403
+ // A receipt named when the call started follows a rotation of its tab made while the call
2404
+ // waited above (by another client of an HTTP daemon): an alias joins receipts of one tab.
2405
+ ...(namedReceipt ? { receipt: namedReceipt } : {}),
2303
2406
  ...(reloadHandoff ? { reloadHandoff: reloadHandoff.token } : {}),
2304
2407
  sessionId: `${SESSION_ID}:${currentSessionId()}`,
2305
2408
  };
@@ -2346,8 +2449,10 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
2346
2449
  // the extension's tab-id proof and could switch to or mutate a user's tab.
2347
2450
  if (String(err?.message || err).includes("Tab safety:")) {
2348
2451
  if (/not valid for this origin/.test(err.message)) {
2452
+ // The receipt goes in "args": the tool's schema drops a key beside "action", and
2453
+ // getReceipt then rotated the session's current tab, or was refused after a reconnect.
2349
2454
  err.message += " Likely cause: the tab moved to another origin after the receipt was minted (redirect/login). " +
2350
- "Rotate it with safari_run_script [{\"action\":\"getReceipt\",\"receipt\":\"<old receipt>\"}], or reopen with safari_new_tab.";
2455
+ "Rotate it with safari_run_script [{\"action\":\"getReceipt\",\"args\":{\"receipt\":\"<old receipt>\"}}], or reopen with safari_new_tab.";
2351
2456
  }
2352
2457
  throw err;
2353
2458
  }
@@ -2460,13 +2565,12 @@ server.tool(
2460
2565
  throw new Error(`safari_navigate only opens http(s) URLs in a Safari profile ("${url.slice(0, 40)}" would leave the tab unreachable). To free the tab, use safari_close_tab.`);
2461
2566
  }
2462
2567
  const oldUrl = safari.getActiveTabURL();
2463
- // Pre-register the destination as owned BEFORE navigating. We are navigating OUR
2464
- // tab, so the target URL is ours even if navigate() throws mid-load on a slow SPA.
2465
- // Without this, a slow/failed navigate left the new URL unowned and locked the
2466
- // switch_tab recovery out — the very recovery the lock error tells you to use.
2467
- _addOwnedURL(url);
2568
+ // extensionOrFallback claims the destination for this session once its guard passes.
2569
+ // Read once, before anything waits: a call alongside that names another tab makes that tab the
2570
+ // session's current one, and the rotation below has to name the tab this call navigated.
2571
+ const usedReceipt = _receiptToken(receipt || _getActiveReceipt());
2468
2572
  let result = await extensionOrFallback(
2469
- "navigate", { url, ..._explicitReceipt({ receipt }) },
2573
+ "navigate", { url, ..._explicitReceipt({ receipt: usedReceipt }) },
2470
2574
  () => safari.navigate(url)
2471
2575
  );
2472
2576
  // Tab kept its identity, just changed URL — drop the stale old URL from ownership.
@@ -2476,14 +2580,15 @@ server.tool(
2476
2580
  // origin", ~80×/week). The caller chose the destination, so rotating here is safe —
2477
2581
  // hand back the new receipt instead of letting the caller discover the trap.
2478
2582
  const landed = result && typeof result === "object" ? result.url : "";
2479
- const usedReceipt = _receiptToken(receipt || _getActiveReceipt());
2480
- if (landed && _originOf(landed) !== _originOf(oldUrl) && usedReceipt) {
2583
+ // The origin of the receipt the navigation carried: extensionOrFallback sends the newest one.
2584
+ if (landed && _originOf(landed) !== (_receiptOrigins.get(_receiptToken(usedReceipt)) || _originOf(oldUrl)) && usedReceipt) {
2481
2585
  try {
2586
+ // Named for the alias below, as run_script getReceipt names it (see _receiptAliases).
2482
2587
  const fresh = _sanitizeTabResult(await extensionOrFallback(
2483
- "get_tab_receipt", { ..._explicitReceipt({ receipt }) }, () => null
2588
+ "get_tab_receipt", { ..._explicitReceipt({ receipt: usedReceipt }) }, () => null
2484
2589
  ));
2485
2590
  if (fresh?.receipt) {
2486
- _aliasReceipt(usedReceipt, fresh.receipt);
2591
+ _aliasReceipt(_receiptToken(usedReceipt), fresh.receipt); // the receipt extensionOrFallback sent
2487
2592
  _setActiveReceipt(fresh.receipt);
2488
2593
  result = { ...result, receipt: fresh.receipt };
2489
2594
  }
@@ -2580,13 +2685,10 @@ server.tool(
2580
2685
  maxLength: z.coerce.number().optional().describe("Max chars to return (default: 50000)"),
2581
2686
  timeout: z.coerce.number().optional().describe("Load timeout in ms (default: 30000)"),
2582
2687
  },
2583
- async ({ url, maxLength, timeout }) => {
2688
+ async ({ url, maxLength, timeout, receipt }) => {
2584
2689
  const oldUrl = safari.getActiveTabURL();
2585
- // Pre-register destination as owned BEFORE navigating (see safari_navigate) so a
2586
- // slow/throwing navigate on a heavy SPA cannot lock switch_tab recovery out.
2587
- _addOwnedURL(url);
2588
2690
  const result = await extensionOrFallback(
2589
- "navigate_and_read", { url, maxLength, timeout },
2691
+ "navigate_and_read", { url, maxLength, timeout, ..._explicitReceipt({ receipt }) },
2590
2692
  async () => {
2591
2693
  await safari.navigate(url);
2592
2694
  return safari.readPage({ maxLength });
@@ -2848,8 +2950,11 @@ server.tool(
2848
2950
  value: z.string().describe("Option value or visible label to select"),
2849
2951
  },
2850
2952
  async (args) => {
2851
- // The engine calls below can bypass extensionOrFallback — assert ownership here.
2852
- _assertTabOwnership("select_option");
2953
+ // The receipt first: for a caller with no state of its own it is what names the tab, and the
2954
+ // guard has to see it. The engine calls below can bypass extensionOrFallback — assert
2955
+ // ownership here.
2956
+ const pin = _explicitReceipt(args);
2957
+ _assertTabOwnership("select_option", pin);
2853
2958
  // ref path: resolve via mcpFindRef (reaches iframes/shadow DOM) on the AppleScript
2854
2959
  // engine — the extension's select_option handler is selector-only.
2855
2960
  if (args.ref) {
@@ -2857,7 +2962,7 @@ server.tool(
2857
2962
  return { content: [{ type: "text", text: typeof refResult === 'string' ? refResult : JSON.stringify(refResult) }] };
2858
2963
  }
2859
2964
  let result = await extensionOrFallback(
2860
- "select_option", { selector: args.selector, value: args.value, ..._explicitReceipt(args) },
2965
+ "select_option", { selector: args.selector, value: args.value, ...pin },
2861
2966
  () => safari.selectOption(args)
2862
2967
  );
2863
2968
  // If extension returned "Selected: " with empty/default value, fuzzy match may have failed.
@@ -3112,7 +3217,7 @@ server.tool(
3112
3217
  let viaAppleScript = false;
3113
3218
  const rawResult = await extensionOrFallback(
3114
3219
  "new_tab", { url: requestedUrl },
3115
- () => { viaAppleScript = true; return safari.newTab(requestedUrl); }
3220
+ () => { viaAppleScript = true; return _openTabViaAppleScript(requestedUrl); }
3116
3221
  );
3117
3222
  // AppleScript fallback returns a JSON string; extension returns an object — normalize
3118
3223
  let result = rawResult;
@@ -3124,9 +3229,8 @@ server.tool(
3124
3229
  // remains internal and is never copied into the MCP response.
3125
3230
  if (safeResult?.tabIndex) {
3126
3231
  safari.setActiveTabIndex(safeResult.tabIndex);
3127
- // safari.js holds the marker of the last tab AppleScript opened, so it names THIS tab
3128
- // only when AppleScript just opened it.
3129
- _trackTab(safeResult.tabIndex, requestedUrl, mySession, viaAppleScript ? safari.getActiveTabMarker() : "", safeResult.receipt);
3232
+ // A tab AppleScript opened is tracked by _openTabViaAppleScript, by its marker.
3233
+ if (!viaAppleScript) _trackTab(safeResult.tabIndex, requestedUrl, mySession, "", safeResult.receipt);
3130
3234
  // The new tab is the current one, with or without a receipt.
3131
3235
  _setActiveReceipt(safeResult.receipt);
3132
3236
  }
@@ -3162,18 +3266,23 @@ server.tool(
3162
3266
  // tab's receipt, and the tab the caller was still working in closed instead of the one it named.
3163
3267
  const token = supplied ? _receiptToken(supplied) : _getActiveReceipt();
3164
3268
  if (supplied && !token) return errorResult("Tab safety: invalid tab receipt");
3165
- const closesCurrent = !token || token === _getActiveReceipt();
3166
- const marker = safari.getActiveTabMarker();
3269
+ let closesCurrent = false, marker = null;
3167
3270
  let viaAppleScript = false;
3168
- const result = await extensionOrFallback(
3169
- "close_tab",
3170
- token ? { receipt: token } : {},
3171
- () => {
3172
- if (token) throw _receiptNeedsExtension("close_tab");
3173
- viaAppleScript = true;
3174
- return safari.closeTab();
3175
- }
3176
- );
3271
+ // One close at a time (_oneCloseAtATime). Which tab is current is read when this close's turn
3272
+ // comes: a parallel call can switch tabs while it waits, and AppleScript closes the one current then.
3273
+ const result = await _oneCloseAtATime(() => {
3274
+ closesCurrent = !token || token === _getActiveReceipt();
3275
+ marker = safari.getActiveTabMarker();
3276
+ return extensionOrFallback(
3277
+ "close_tab",
3278
+ token ? { receipt: token } : {},
3279
+ () => {
3280
+ if (token) throw _receiptNeedsExtension("close_tab");
3281
+ viaAppleScript = true;
3282
+ return safari.closeTab();
3283
+ }
3284
+ );
3285
+ });
3177
3286
  // The extension closed the receipt's tab; AppleScript, the tab carrying the session's marker.
3178
3287
  _untrackClosedTab(viaAppleScript ? { marker } : { receipt: token });
3179
3288
  // Closing another tab by its receipt leaves the current one current.
@@ -3271,16 +3380,19 @@ server.tool(
3271
3380
  return claimed;
3272
3381
  }
3273
3382
  );
3274
- // Adopted just now, or earlier: switchTab() keeps an adopted tab's marker.
3383
+ // Adopted just now, or earlier: switchTab() keeps an adopted tab in the adoption family.
3275
3384
  const adopted = viaAppleScript && safari.isActiveTabAdopted();
3276
3385
  const safeResult = _sanitizeTabResult(result);
3277
3386
  // Sync safari.js state so AppleScript fallback targets the correct tab
3278
3387
  const resolvedIndex = safeResult?.tabIndex || index;
3279
3388
  if (resolvedIndex) safari.setActiveTabIndex(resolvedIndex);
3280
- if (safeResult?.safeUrl) safari.setActiveTabURL(safeResult.safeUrl);
3281
- // …including the marker, which still names the last tab AppleScript claimed.
3389
+ if (viaAppleScript && safeResult?.safeUrl) safari.setActiveTabURL(safeResult.safeUrl);
3390
+ // …including the marker, which still names the last tab AppleScript claimed. The URL is left
3391
+ // unknown, as run_script's switchTab leaves it: the extension reports where the tab is now,
3392
+ // which after a redirect or a login bounce is a page this session never registered, and the
3393
+ // ownership guard refused every write after it, though the extension had proven the tab by its id.
3282
3394
  if (!viaAppleScript && safeResult && typeof safeResult === "object") {
3283
- safari.setActiveTabFromExtension(resolvedIndex, safeResult.safeUrl);
3395
+ safari.setActiveTabFromExtension(resolvedIndex, null);
3284
3396
  }
3285
3397
  if (safeResult?.receipt || token) _setActiveReceipt(safeResult?.receipt || token);
3286
3398
  // A switch by INDEX gets no receipt back, and the session was still holding the one it
@@ -3326,39 +3438,50 @@ server.tool(
3326
3438
  },
3327
3439
  async ({ timeout, urlContains }) => {
3328
3440
  const timeoutMs = timeout || 10000;
3329
- // Every listing says where it came from. AppleScript lists one window, the one its first
3330
- // listing read (`win`), and a claim through AppleScript goes to that window: listings of
3331
- // whichever window was in front made a tab of the user's other window look new, and the
3332
- // claim stamped the session's own marker on it. A listing from the other source starts the
3333
- // comparison over instead of comparing tabs across them.
3441
+ // Every listing says where it came from. AppleScript lists one window by id (`win`): the one
3442
+ // this session's own tab is in, proven by its marker when AppleScript first lists, and a claim
3443
+ // through AppleScript goes to that window. Listings of whichever window was in front made a
3444
+ // tab of the user's other window look new, and the claim stamped the session's own marker on
3445
+ // it. A listing from the other source starts the comparison over instead of comparing tabs
3446
+ // across them.
3334
3447
  let win = null;
3335
3448
  const list = async () => {
3336
3449
  let via = "extension";
3337
3450
  const raw = await extensionOrFallback("list_tabs", {}, async () => {
3338
3451
  via = "applescript";
3452
+ if (!win) win = await safari.sessionTabWindow();
3339
3453
  const listed = await safari.listWindowTabs(win);
3340
3454
  win = listed.win;
3341
3455
  return listed.tabs;
3342
3456
  });
3343
3457
  return { via, tabs: typeof raw === "string" ? JSON.parse(raw) : raw };
3344
3458
  };
3459
+ // A listed tab's URL: AppleScript lists `url`, the extension `safeUrl` (origin and path).
3460
+ const urlOf = (t) => t.url ?? t.safeUrl;
3345
3461
  // Switch to the new tab and own it for THIS session, else the next interaction trips the
3346
3462
  // tab-safety guard. Its marker names it only if AppleScript made the switch (and stamped
3347
- // it); the extension answers with the tab's receipt instead. AppleScript claims only a tab
3348
- // its own listing of the pinned window saw: an index the extension listed is a position in
3349
- // the extension's window, not in the one AppleScript would stamp.
3463
+ // it); the extension answers with the tab's receipt instead. A tab AppleScript listed is
3464
+ // claimed through AppleScript, in the pinned window, at once: its index is a position there,
3465
+ // not in the extension's window, and waiting out the extension's switch_tab first (30 s
3466
+ // while its worker is busy) left time for another tab to slide under that index. AppleScript
3467
+ // never claims a tab only the extension's listing saw.
3350
3468
  const adopt = async (t, via) => {
3351
- let viaAppleScript = false;
3352
- const switched = _sanitizeTabResult(await extensionOrFallback(
3353
- "switch_tab", { index: t.index },
3354
- () => {
3355
- viaAppleScript = true;
3356
- if (via !== "applescript" || !win) {
3357
- throw new Error("Tab safety: the new tab was seen by the Safari extension, which could not switch to it, and AppleScript cannot tell which tab of its own window that is. Retry safari_wait_for_new_tab.");
3358
- }
3359
- return safari.switchTab(t.index, { claim: true, win });
3469
+ const viaAppleScript = via === "applescript";
3470
+ let switched;
3471
+ if (viaAppleScript) {
3472
+ if (!win) throw new Error("Tab safety: AppleScript did not say which window it listed, so it cannot tell which tab opened, and this session claimed none. The tab stays open, and another safari_wait_for_new_tab will not report it: that call starts from a listing that already has it.");
3473
+ switched = _sanitizeTabResult(await safari.switchTab(t.index, { claim: true, win, expectUrl: t.url }));
3474
+ } else {
3475
+ switched = _sanitizeTabResult(await extensionOrFallback("switch_tab", { index: t.index }, () => {
3476
+ throw new Error("Tab safety: a new tab opened, but the Safari extension could not switch to it and AppleScript cannot tell which tab of its own window that is, so this session did not claim it. The tab stays open, and another safari_wait_for_new_tab will not report it: that call starts from a listing that already has it. Check the extension with safari_doctor.");
3477
+ }));
3478
+ // The extension switches to whatever tab sits at the index when it runs the command, and
3479
+ // answers "Tab not found" as if it had switched: a tab that closed or moved in between
3480
+ // handed the session another tab of its own, or none, under the new tab's name.
3481
+ if (switched?.safeUrl !== _safeUrlForOutput(urlOf(t))) {
3482
+ throw Object.assign(new Error("Tab safety: the Safari extension switched to another tab than the one the listing saw (a tab closed or moved in between), so this session did not claim it."), { moved: true });
3360
3483
  }
3361
- ));
3484
+ }
3362
3485
  safari.setActiveTabIndex(t.index);
3363
3486
  safari.setActiveTabURL(t.url);
3364
3487
  if (!viaAppleScript) safari.setActiveTabFromExtension(t.index, t.url);
@@ -3369,49 +3492,102 @@ server.tool(
3369
3492
  _trackTab(t.index, t.url, `${SESSION_ID}:${currentSessionId()}`, viaAppleScript ? safari.getActiveTabMarker() : "", switched?.receipt);
3370
3493
  }
3371
3494
  _setActiveReceipt(switched?.receipt);
3372
- return { content: [{ type: "text", text: `Found new tab: ${t.title} (${t.url})` }] };
3495
+ return { content: [{ type: "text", text: `Found new tab: ${t.title} (${urlOf(t)})` }] };
3496
+ };
3497
+ // Whether every tab of `was` is still there on its URL, in order, in `is`: only then can a tab
3498
+ // `is` added be told from the old ones. The first `index:url` pair not seen before was often a
3499
+ // tab of the user's that had navigated, or had moved when another closed, and the claim stamped
3500
+ // the session's own marker on it.
3501
+ // ponytail: tabs are told apart by URL alone (AppleScript has no tab id and no opener), so any
3502
+ // tab the user opens in the window during the wait counts as new, a new tab that opens on the
3503
+ // very URL a tab of the user's leaves in the same poll can pass for it (as can a tab of the
3504
+ // user's that lands on the URL of a new tab closing next to it, while another tab opens further
3505
+ // along, which leaves the same listings as that new tab staying put), and the claim stamps
3506
+ // whatever tab sits at the listed index a script later unless switchTab checks `expectUrl`.
3507
+ const intact = (was, is) => {
3508
+ const seen = new Set(was.map(urlOf));
3509
+ const kept = is.filter((t) => seen.has(urlOf(t)));
3510
+ return kept.length === was.length && kept.every((t, i) => urlOf(t) === urlOf(was[i]));
3511
+ };
3512
+ // Where the new tabs `last` showed are in `is`: each still on its URL, shown by no more and no
3513
+ // fewer tabs than before, and in its place while the other tabs only navigated (as many tabs as
3514
+ // before), or still there while the others only closed (the rest on their URLs, in order) and
3515
+ // none next to it. A tab that went next to a new one could instead have landed on its URL as the
3516
+ // new one went, and a tab that joins it on its URL could be taken for it; a neighbour counts as
3517
+ // still there only while as many tabs as before show its URL, or a twin could stand in for it.
3518
+ // Null when unsure.
3519
+ const stillNew = (last, urls, is) => {
3520
+ const count = (tabs, u) => tabs.filter((t) => urlOf(t) === u).length;
3521
+ const at = (tabs, u) => tabs.findIndex((t) => urlOf(t) === u);
3522
+ if (urls.some((u) => count(is, u) !== count(last, u))) return null;
3523
+ if (is.length === last.length) return urls.some((u) => at(is, u) !== at(last, u)) ? null : urls.map((u) => is[at(is, u)]);
3524
+ const there = new Set();
3525
+ let j = 0;
3526
+ last.forEach((t, k) => { if (j < is.length && urlOf(is[j]) === urlOf(t)) { there.add(k); j++; } });
3527
+ const stays = (k) => count(is, urlOf(last[k])) === count(last, urlOf(last[k]));
3528
+ const neighbours = (u) => [at(last, u) - 1, at(last, u) + 1].every((k) => k < 0 || k >= last.length || (there.has(k) && stays(k)));
3529
+ return j === is.length && urls.every(neighbours) ? urls.map((u) => is[at(is, u)]) : null;
3373
3530
  };
3374
3531
  // Get current tab list
3375
3532
  let before = await list();
3376
- let beforeIds = new Set(before.tabs.map(t => `${t.index}:${t.url}`));
3377
-
3378
- // Poll for new tab — detect by count increase + new entries (handles about:blank tabs)
3533
+ // Every URL a baseline showed. A new tab is one on a URL none of them showed, so a tab that
3534
+ // left the window and came back (reopened, or dragged out and in again) is not new either.
3535
+ const shown = new Set(before.tabs.map(urlOf));
3536
+ // The new tabs not claimed yet (still on about:blank, not matching urlContains, or refused),
3537
+ // by URL, and the listing that showed them; and whether any tab opened at all.
3538
+ let pending = { last: before.tabs, urls: [] };
3539
+ let opened = false;
3540
+
3541
+ // Poll for new tab — detect by count increase + tabs on new URLs
3379
3542
  const deadline = Date.now() + timeoutMs;
3380
3543
  while (Date.now() < deadline) {
3381
3544
  await new Promise(r => setTimeout(r, 500));
3382
- const now = await list();
3383
- if (now.via !== before.via) {
3384
- before = now;
3385
- beforeIds = new Set(now.tabs.map(t => `${t.index}:${t.url}`));
3545
+ const now = await list().catch((err) => {
3546
+ // A listing that fails (the window pin refused, AppleScript failed) takes the wait's record
3547
+ // of a tab that opened with it: say so, as the timeout would.
3548
+ if (opened && err instanceof Error) err.message += " A tab opened during the wait; if it is still open, another safari_wait_for_new_tab will not report it.";
3549
+ throw err;
3550
+ });
3551
+ // A listing where a tab closed or navigated becomes the baseline, so a tab of the user's that
3552
+ // navigated meanwhile is not new once one opens; so does one from the other source. A new
3553
+ // tab not claimed yet stays out of it while the other tabs only navigated in place or only
3554
+ // closed: taken into it, it was never new again, and the wait timed out on a popup that
3555
+ // was still loading when another tab changed. Any other change, or one as a tab opened,
3556
+ // takes every tab in: held against the older baseline, whichever of two tabs that changed
3557
+ // navigated first looked new, the user's too.
3558
+ if (now.via !== before.via || !intact(before.tabs, now.tabs)) {
3559
+ const same = now.via === before.via;
3560
+ const kept = !pending.urls.length ? [] : same ? stillNew(pending.last, pending.urls, now.tabs) : null;
3561
+ // Only AppleScript lists one window by id: the extension can list whichever window is in front.
3562
+ if (same && now.via === "applescript" && now.tabs.length > before.tabs.length) opened = true;
3563
+ before = { ...now, tabs: now.tabs.filter((t) => !kept?.includes(t)) };
3564
+ for (const t of before.tabs) shown.add(urlOf(t));
3565
+ pending = { last: now.tabs, urls: kept ? pending.urls : [] };
3386
3566
  continue;
3387
3567
  }
3388
- if (now.tabs.length > before.tabs.length) {
3389
- // Find the new tab(s) — could be about:blank initially (OAuth popups)
3390
- for (const tab of now.tabs) {
3391
- if (!beforeIds.has(`${tab.index}:${tab.url}`)) {
3392
- // Wait for about:blank to resolve to actual URL — dynamic polling instead of fixed delay
3393
- if (tab.url === 'about:blank') {
3394
- let resolved = null;
3395
- for (let attempt = 0; attempt < 10; attempt++) {
3396
- await new Promise(r => setTimeout(r, 300)); // 300ms intervals, max 3s total
3397
- const refreshed = await list();
3398
- resolved = refreshed.via === now.via ? refreshed.tabs.find(t => t.index === tab.index) : null;
3399
- if (resolved && resolved.url !== 'about:blank') break;
3400
- resolved = null;
3401
- }
3402
- if (resolved && resolved.url !== 'about:blank') {
3403
- if (urlContains && !resolved.url.includes(urlContains)) continue;
3404
- return await adopt(resolved, now.via);
3405
- }
3406
- continue;
3407
- }
3408
- if (urlContains && !tab.url.includes(urlContains)) continue;
3409
- return await adopt(tab, now.via);
3410
- }
3568
+ const old = new Set(before.tabs.map(urlOf));
3569
+ pending = { last: now.tabs, urls: now.tabs.map(urlOf).filter((u) => !old.has(u)) };
3570
+ if (pending.urls.length) opened = true;
3571
+ for (const tab of now.tabs) {
3572
+ // A new tab still on about:blank (an OAuth popup before it loads) is claimed at a later
3573
+ // poll, once it shows its URL: a later listing found it again by index, and by then that
3574
+ // could be another tab.
3575
+ if (urlOf(tab) === 'about:blank' || shown.has(urlOf(tab))) continue;
3576
+ if (urlContains && !String(urlOf(tab)).includes(urlContains)) continue;
3577
+ try {
3578
+ return await adopt(tab, now.via);
3579
+ } catch (err) {
3580
+ // A tab another session opened in this window meanwhile, whose marker the claim refuses
3581
+ // to overwrite (`otherSession`), or a tab that no longer shows the page the listing saw
3582
+ // (`moved`: one closed in between and slid another under the index). Neither is the
3583
+ // tab this waits for, and a later listing can still show it.
3584
+ if (!err?.otherSession && !err?.moved) throw err;
3411
3585
  }
3412
3586
  }
3413
3587
  }
3414
- return { content: [{ type: "text", text: "TIMEOUT: no new tab appeared" }] };
3588
+ return { content: [{ type: "text", text: opened
3589
+ ? "TIMEOUT: a tab opened during the wait, but none this call could claim: it could not be told apart from a tab that changed at the same time, urlContains ruled it out, or its claim was refused. If it is still open, another safari_wait_for_new_tab will not report it: open it again during a new wait, or ask the user."
3590
+ : "TIMEOUT: no new tab appeared" }] };
3415
3591
  }
3416
3592
  );
3417
3593
 
@@ -3689,7 +3865,7 @@ server.tool(
3689
3865
 
3690
3866
  server.tool(
3691
3867
  "safari_run_script",
3692
- "Batch Safari actions in one MCP session. Named profiles use the verified extension and support: newTab, switchTab, getReceipt, listTabs, closeTab, navigate, navigateAndRead, readPage, snapshot, getElementInfo, querySelectorAll, waitFor, waitForTime, click, clickAndOpenPopup, fill, fillForm, clearField, typeText, selectOption, pressKey, scroll, scrollTo, scrollToElement, hover, evaluate, reload, goBack, goForward. switchTab accepts an index or an opaque receipt; a valid receipt recovers the exact owned tab across Safari windows without focusing one. clickAndOpenPopup takes exactly one selector or snapshot ref, targets one exact frame, performs one click, captures a blocked HTTP(S) window.open, and opens it as a background tab without focusing Safari; it refuses CAPTCHA/challenge targets and never returns URL query/hash data. Non-profile mode retains the legacy action set; there, closeTab and switchTab take a tab index, not a receipt. Use getReceipt after a cross-origin redirect.",
3868
+ "Batch Safari actions in one MCP session. Named profiles use the verified extension and support: newTab, switchTab, getReceipt, listTabs, closeTab, navigate, navigateAndRead, readPage, snapshot, getElementInfo, querySelectorAll, waitFor, waitForTime, click, clickAndOpenPopup, fill, fillForm, clearField, typeText, selectOption, pressKey, scroll, scrollTo, scrollToElement, hover, evaluate, reload, goBack, goForward. switchTab accepts an index or an opaque receipt; a valid receipt recovers the exact owned tab across Safari windows without focusing one. clickAndOpenPopup takes exactly one selector or snapshot ref, targets one exact frame, performs one click, captures a blocked HTTP(S) window.open, and opens it as a background tab without focusing Safari; it refuses CAPTCHA/challenge targets and never returns URL query/hash data. Non-profile mode retains the legacy action set plus getReceipt; there, closeTab and switchTab take a tab index, not a receipt, and getReceipt needs the tab's receipt in args. After a cross-origin redirect, rotate the tab's receipt with {\"action\":\"getReceipt\",\"args\":{\"receipt\":\"<old receipt>\"}}.",
3693
3869
  {
3694
3870
  steps: z.array(z.object({
3695
3871
  action: z.string().describe("Action name (e.g. 'navigate', 'click', 'fill')"),
@@ -3702,15 +3878,16 @@ server.tool(
3702
3878
  // AppleScript and loses the verified profile/tab-id boundary; they use the
3703
3879
  // extension-only dispatcher below.
3704
3880
  if (!process.env.SAFARI_PROFILE) {
3881
+ const mySession = `${SESSION_ID}:${currentSessionId()}`;
3705
3882
  const onStep = (action, stepArgs) => {
3706
- if (action === "newTab") { _markBlankTabOpened(); return; }
3883
+ // getReceipt is guarded in extensionOrFallback, which sees the receipt the step names and leaves
3884
+ // a rotation that names one to the extension. The check below is given no payload, so it would not
3885
+ // see that receipt and would judge the URL safari.js holds: after a redirect during an AppleScript
3886
+ // load, one the session never registered.
3887
+ if (action === "getReceipt") return;
3707
3888
  if (action === "navigate" || action === "navigateAndRead") {
3708
3889
  _assertTabOwnership(`run_script:${action}`);
3709
- const url = stepArgs && typeof stepArgs.url === "string" ? stepArgs.url : null;
3710
- if (url) {
3711
- _addOwnedURL(url);
3712
- if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) _addOwnedURL("https://" + url);
3713
- }
3890
+ _claimURL(stepArgs?.url);
3714
3891
  return;
3715
3892
  }
3716
3893
  // These steps run through AppleScript here, which cannot tell which tab a receipt names:
@@ -3747,7 +3924,40 @@ server.tool(
3747
3924
  return _runExtensionBatchAction("switchTab", { index: Number(index) });
3748
3925
  }
3749
3926
  };
3750
- return textResult(await safari.runScript({ steps, onStep, actions: { switchTab } }));
3927
+ // A newTab step opens its tab as safari_new_tab does: under the session's tab cap, which first
3928
+ // closes the session's oldest tab when it already has MAX_TABS, and as one of the session's
3929
+ // own tabs, claimed and tracked (_openTabViaAppleScript). It used to claim nothing, so every
3930
+ // write step after newTab{url} was refused on the session's own tab, and to track nothing, so
3931
+ // a batch's tabs escaped the cap, shutdown cleanup and the memory sweep.
3932
+ const newTab = async ({ url }) => {
3933
+ const evicted = await _evictOldestTab(mySession);
3934
+ const result = await _openTabViaAppleScript(String(url || ""));
3935
+ if (!evicted) return result;
3936
+ // Name the tab the cap closed, as safari_new_tab does.
3937
+ let tab = {};
3938
+ try { tab = JSON.parse(result); } catch {}
3939
+ return { ...tab, ..._evictionReport(evicted) };
3940
+ };
3941
+ // A closeTab step forgets the tab it closed, as safari_close_tab does after an AppleScript
3942
+ // close: closeOwnTab() closes only the tab that carries the session's marker. One close at a
3943
+ // time (_oneCloseAtATime).
3944
+ const closeTab = ({ index }) => _oneCloseAtATime(async () => {
3945
+ const marker = safari.getActiveTabMarker();
3946
+ const result = await safari.closeOwnTab(index);
3947
+ _untrackClosedTab({ marker });
3948
+ return result;
3949
+ });
3950
+ // Only the extension can rotate a receipt, and the legacy action table had no getReceipt, so
3951
+ // the step the origin refusal advises came back "Unknown action" and the tab stayed stranded.
3952
+ // It has to name its tab here: this batch's AppleScript steps (newTab, switchTab) move to a
3953
+ // tab without touching the session's receipt, which then names a tab the batch has left.
3954
+ const getReceipt = (stepArgs) => {
3955
+ if (!stepArgs?.receipt) {
3956
+ throw new Error("Tab safety: without SAFARI_PROFILE, getReceipt needs the receipt of the tab it rotates, in args.receipt.");
3957
+ }
3958
+ return _runExtensionBatchAction("getReceipt", stepArgs);
3959
+ };
3960
+ return textResult(await safari.runScript({ steps, onStep, actions: { switchTab, newTab, closeTab, getReceipt } }));
3751
3961
  }
3752
3962
 
3753
3963
  // Keep every step in one MCP request/session and route it through