safari-mcp 2.18.1 → 2.19.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
@@ -356,6 +356,9 @@ Prefer stdio (one process per agent) over a persistent daemon? That works too
356
356
  | `SAFARI_MCP_HTTP_PORT` | `9225` | Port for that daemon. |
357
357
  | `SAFARI_PROFILE` | unset | Bind sessions to a named Safari profile. Unset = your ordinary windows. |
358
358
  | `SAFARI_MCP_RAISE_ON_NAVIGATE` | off | Let navigation bring Safari to the front, and stop the focus guard from putting your previous app back. |
359
+ | `SAFARI_MCP_SCREENSHOT_MAX_WIDTH` | unset | Downscale every `safari_screenshot` to this pixel width (Retina captures are 2× the viewport). Per-call `maxWidth` overrides it. |
360
+ | `SAFARI_MCP_KEEPALIVE_TAB` | off | Keep one daemon-served page open in the profile window so Safari never parks the extension worker between commands. |
361
+ | `SAFARI_MCP_OPEN_WINDOW_CMD` | unset | Command run with the profile name when the profile window is absent (e.g. after a reboot). Must open the window without focusing Safari. |
359
362
 
360
363
  `SAFARI_MCP_RAISE_ON_NAVIGATE=1` is for agents whose whole point is *showing* you a page — a voice assistant answering "open YouTube", a demo driver. Everything else should leave it off: by default Safari MCP works in the background and hands focus back to whatever app you were using, so an agent can drive a page while you keep typing somewhere else.
361
364
 
@@ -452,6 +452,13 @@ browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
452
452
  if (Object.keys(msg).length !== 1) return false;
453
453
  if (!Number.isInteger(sender?.tab?.id)) return false;
454
454
  if (!_enabled || _bridgeWorkerSuperseded || _bridgeWorkerRetiring) return false;
455
+ // A page pinging while this worker sits in reconnect backoff is the earliest sign that
456
+ // the bridge is reachable again (the daemon releases wake polls while it has no verified
457
+ // worker). Retry now instead of waiting out a backoff of up to a minute.
458
+ if (!isConnected && !_connecting) {
459
+ if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
460
+ connect();
461
+ }
455
462
  sendResponse({ ok: true });
456
463
  return false;
457
464
  }
@@ -725,7 +732,16 @@ async function pollForCommands(pollGeneration) {
725
732
  _bridgeFetch(`${bridgeUrl}/heartbeat`, { method: "POST" }).catch(() => {});
726
733
  }, 5000);
727
734
  await previousExecution.catch(() => {});
728
- await executeAndReply(msg, bridgeUrl);
735
+ // 7.9.26: a command whose handleCommand never settles (a tab that never answers,
736
+ // a runtime call that hangs) used to park this loop for good: the beat kept the
737
+ // host believing we were alive, /poll went silent for hours, connectToServer
738
+ // waited on the same claim, and only a Safari restart brought the profile back.
739
+ // Bound it: past the host's hard ceiling we answer with an error, release the
740
+ // claim and keep polling; the abandoned promise may settle later into the void.
741
+ await Promise.race([
742
+ executeAndReply(msg, bridgeUrl),
743
+ _wedgedCommandGuard(msg, bridgeUrl),
744
+ ]);
729
745
  }
730
746
  } finally {
731
747
  if (beat) clearInterval(beat);
@@ -757,6 +773,33 @@ async function pollForCommands(pollGeneration) {
757
773
  }
758
774
  }
759
775
 
776
+ // Ceiling for one command. The host's own hard deadline is max(4×timeout, 180s) and its
777
+ // longest tool timeout is 75s, so nothing legitimate is still awaited after five minutes.
778
+ const _WEDGED_COMMAND_MS = 330000;
779
+ function _wedgedCommandGuard(msg, bridgeUrl) {
780
+ return new Promise((resolve) => {
781
+ setTimeout(async () => {
782
+ console.log("Safari MCP: command wedged, releasing the poll loop", msg && msg.type);
783
+ try {
784
+ await _bridgeFetch(`${bridgeUrl}/result`, {
785
+ method: "POST",
786
+ headers: { "Content-Type": "application/json" },
787
+ body: JSON.stringify({
788
+ type: "response",
789
+ id: msg.id,
790
+ result: null,
791
+ error: `command ${msg.type} did not settle within ${_WEDGED_COMMAND_MS}ms`,
792
+ }),
793
+ signal: AbortSignal.timeout(5000),
794
+ });
795
+ } catch (e) {
796
+ /* the host most likely expired it already */
797
+ }
798
+ resolve();
799
+ }, _WEDGED_COMMAND_MS);
800
+ });
801
+ }
802
+
760
803
  // ========== SHARED: Execute command and send response ==========
761
804
 
762
805
  async function executeAndReply(msg, bridgeUrl = HTTP_URL) {
@@ -2,7 +2,7 @@
2
2
  "manifest_version": 3,
3
3
  "name": "Safari MCP Bridge",
4
4
  "description": "Connects AI agents to Safari — fast JS execution, screenshots, clicks with your real cookies/logins",
5
- "version": "2.10.8",
5
+ "version": "2.10.10",
6
6
  "icons": {
7
7
  "48": "images/icon-48.png",
8
8
  "96": "images/icon-96.png",
package/index.js CHANGED
@@ -417,6 +417,10 @@ const _HTTP_WORKER_TTL_MS = 60 * 1000;
417
417
  const _HTTP_RELOAD_HANDOFF_TTL_MS = 30 * 1000;
418
418
  const _HTTP_WORKER_SUCCESSOR_GRACE_MS = 20 * 1000;
419
419
  const _HTTP_WORKER_DISCONNECT_MS = 30 * 1000;
420
+ // Safari parks an idle profile worker every ~30s and its 1-minute alarm wakes it. Declaring
421
+ // it gone in between bought nothing (a profile has no fallback) and cost a full
422
+ // re-verification per minute — 61 flaps in two hours. Wait one alarm cycle plus margin.
423
+ const _HTTP_WORKER_PARK_GRACE_MS = 75 * 1000;
420
424
  let _extensionLastPollTime = 0;
421
425
  let _extensionLastHeartbeat = 0;
422
426
  let _reloadHttpWorkerHandoff = null;
@@ -742,6 +746,16 @@ try {
742
746
  return;
743
747
  }
744
748
 
749
+ // GET /keepalive — a plain page for the keepalive tab (SAFARI_MCP_KEEPALIVE_TAB=1).
750
+ // Any http(s) page of the profile hosts the content script, whose wake long-poll and
751
+ // runtime pings are what keep Safari's MV3 worker from parking; this page exists so
752
+ // there is always one, even after every owned tab was closed or Safari was relaunched.
753
+ if (req.method === "GET" && req.url.split("?")[0].split("#")[0] === "/keepalive") {
754
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
755
+ res.end(KEEPALIVE_HTML);
756
+ return;
757
+ }
758
+
745
759
  // Every Safari-extension HTTP endpoint is authenticated in addition to its
746
760
  // CORS check. Origin alone is not authority: another extension has its own
747
761
  // extension-scheme origin, and a local process can forge or omit Origin.
@@ -880,6 +894,7 @@ try {
880
894
  _extensionConnected = true;
881
895
  console.error("[Safari MCP] Extension connected via HTTP polling");
882
896
  }
897
+ _scheduleKeepaliveTab();
883
898
  }
884
899
  if (!process.env.SAFARI_PROFILE) _extensionLastPollTime = Date.now();
885
900
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -893,15 +908,23 @@ try {
893
908
  const heartbeatingWorkerId = _requireActiveHttpWorker(req, res);
894
909
  if (!heartbeatingWorkerId) return;
895
910
  _extensionLastHeartbeat = Date.now();
896
- _extensionLastPollTime = Date.now();
897
911
  // Re-arm in-flight deadlines: the worker told us it is still on the command.
898
912
  // hardDeadline inside armTimer keeps this from extending indefinitely.
913
+ let liveInFlight = false;
899
914
  for (const pending of _pendingRequests.values()) {
900
915
  if (pending.dispatchedWorkerId !== heartbeatingWorkerId) continue;
901
916
  if (!pending.armTimer || Date.now() >= pending.hardDeadline) continue;
917
+ liveInFlight = true;
902
918
  clearTimeout(pending.timer);
903
919
  pending.timer = pending.armTimer();
904
920
  }
921
+ // A beat proves liveness only while the worker is on a command we still wait for.
922
+ // 7.9.26: a worker wedged inside one command (its handleCommand never settled) kept
923
+ // beating every 5s for hours after that command's hard deadline had expired; each
924
+ // beat refreshed the stale clock, so "HTTP poll timeout" never fired, no successor
925
+ // could take the lease, and every tool call died with "Extension timeout". Once
926
+ // nothing dispatched to this worker is in flight, its beats no longer count as polls.
927
+ if (liveInFlight) _extensionLastPollTime = Date.now();
905
928
  res.writeHead(204);
906
929
  res.end();
907
930
  return;
@@ -933,6 +956,7 @@ try {
933
956
  console.error("[Safari MCP] Extension connected and profile-verified via HTTP polling");
934
957
  }
935
958
  _extensionLastPollTime = Date.now();
959
+ _scheduleKeepaliveTab();
936
960
  res.writeHead(200, { "Content-Type": "application/json" });
937
961
  res.end(JSON.stringify({ status: "verified" }));
938
962
  return;
@@ -1164,8 +1188,16 @@ async function _proxyToExtension(type, payload, timeoutMs = 30000) {
1164
1188
  // Detect stale HTTP connection (no poll in 30s = disconnected)
1165
1189
  // Only applies to primary instance (extension host) — not proxy mode
1166
1190
  const _staleHttpTimer = setInterval(() => {
1191
+ // After a daemon restart the profile worker can sit in its reconnect backoff for minutes
1192
+ // (measured 9 min on 4.9.26) while pages of the profile are already long-polling us for
1193
+ // wake-ups. Releasing those polls makes each page ping the worker, and a pinged worker
1194
+ // retries /connect at once (extension ≥2.10.9) instead of waiting out the backoff.
1195
+ if (_isExtensionHost && process.env.SAFARI_PROFILE && !_profileExtensionVerified && _contentWakePolls.size) {
1196
+ _releaseContentWakePolls();
1197
+ }
1167
1198
  if (_isExtensionHost && _extensionConnected && !_extensionWs && _extensionLastPollTime > 0) {
1168
1199
  if (Date.now() - _extensionLastPollTime > _HTTP_WORKER_DISCONNECT_MS) {
1200
+ if (process.env.SAFARI_PROFILE && Date.now() - _extensionLastPollTime <= _HTTP_WORKER_PARK_GRACE_MS) return;
1169
1201
  // A command already delivered to this worker is an at-most-once fence.
1170
1202
  // Its own bounded request timer releases the fence; disconnect cleanup must
1171
1203
  // not make an ordinary successor eligible while the old worker may finish it.
@@ -1195,6 +1227,18 @@ _staleHttpTimer.unref(); // stale-detection must not keep the Node process aliv
1195
1227
 
1196
1228
  // ========== SHARED EXTENSION LOGIC ==========
1197
1229
 
1230
+ // A worker that keeps beating but never finishes a command holds the profile lease
1231
+ // hostage. Releasing it is the same bookkeeping the stale timer does on poll timeout.
1232
+ function _dropWedgedHttpWorker(reason) {
1233
+ if (!_extensionConnected && !_activeHttpWorkerId) return;
1234
+ _extensionConnected = false;
1235
+ _activeHttpWorkerId = "";
1236
+ _connectingHttpWorkers.clear();
1237
+ _reloadHttpWorkerHandoff = null;
1238
+ _drainOnDisconnect(`wedged worker: ${reason}`);
1239
+ console.error(`[Safari MCP] Extension worker dropped as wedged (${reason})`);
1240
+ }
1241
+
1198
1242
  // Drain pending requests and command queue on disconnect — allows fast fallback to AppleScript
1199
1243
  function _drainOnDisconnect(reason) {
1200
1244
  if (process.env.SAFARI_PROFILE) {
@@ -1277,6 +1321,7 @@ function sendToExtension(type, payload = {}, timeoutMs = 30000) {
1277
1321
  // hardDeadline is the ceiling, so a worker that beats forever cannot hang us.
1278
1322
  const hardDeadline = Date.now() + Math.max(timeoutMs * 4, 180000);
1279
1323
  const expire = () => {
1324
+ const pending = _pendingRequests.get(id);
1280
1325
  _pendingRequests.delete(id);
1281
1326
  if (reloadHandoff) _cancelReloadHttpWorkerHandoff(reloadHandoff);
1282
1327
  // Also drop it from the HTTP poll queue — otherwise the extension could poll this command
@@ -1284,6 +1329,15 @@ function sendToExtension(type, payload = {}, timeoutMs = 30000) {
1284
1329
  const qi = _commandQueue.findIndex(c => c.id === id);
1285
1330
  if (qi >= 0) _commandQueue.splice(qi, 1);
1286
1331
  reject(new Error(`Extension timeout after ${timeoutMs}ms`));
1332
+ // The worker took this command and never answered, even past the hard ceiling: it is
1333
+ // wedged (see /heartbeat). Drop its lease now instead of waiting for the stale timer,
1334
+ // so a fresh worker — or Safari re-spawning this one — can connect at once.
1335
+ if (
1336
+ pending && pending.dispatchedWorkerId && pending.dispatchedWorkerId === _activeHttpWorkerId &&
1337
+ Date.now() >= pending.hardDeadline
1338
+ ) {
1339
+ _dropWedgedHttpWorker(`${type} exceeded its hard deadline`);
1340
+ }
1287
1341
  };
1288
1342
  const armTimer = () => setTimeout(expire, Math.min(timeoutMs, Math.max(0, hardDeadline - Date.now())));
1289
1343
  _pendingRequests.set(id, {
@@ -1329,6 +1383,44 @@ const _commandTimeouts = {
1329
1383
  // that typically arrive first after idle must outlive that cycle instead of failing at 30s.
1330
1384
  if (process.env.SAFARI_PROFILE) { _commandTimeouts.new_tab = 75000; _commandTimeouts.list_tabs = 75000; }
1331
1385
 
1386
+ // ========== KEEPALIVE TAB (opt-in) ==========
1387
+ // Safari parks an idle extension worker; the first command afterwards then waits for the
1388
+ // 1-minute alarm (measured: new_tab 18s, or a hard failure before the queue fix). A page of
1389
+ // the profile that carries the content script relays wake-ups instantly and keeps the worker
1390
+ // alive — so keep exactly one such page open, served by this daemon itself.
1391
+ const KEEPALIVE_TAB_ENABLED = process.env.SAFARI_MCP_KEEPALIVE_TAB === "1";
1392
+ const KEEPALIVE_HTML = '<!doctype html><html lang="he" dir="rtl"><head><meta charset="utf-8"><title>Safari MCP · keepalive</title><meta name="robots" content="noindex"><style>body{font-family:-apple-system,system-ui,sans-serif;margin:3rem auto;max-width:36rem;padding:0 1rem;color:#1a2230;background:#f3f5f8;line-height:1.6}code{background:#ddeff2;padding:.1em .4em;border-radius:3px}</style></head><body><h1>Safari MCP</h1><p>הטאב הזה משאיר את ההרחבה ערה ברקע, כדי שהפקודה הראשונה אחרי הפסקה תרוץ מיד. אפשר להשאיר אותו פתוח; אם סוגרים אותו, הוא ייפתח שוב לבד.</p><p>לביטול: <code>SAFARI_MCP_KEEPALIVE_TAB=0</code> בהגדרות הדמון.</p></body></html>';
1393
+ const KEEPALIVE_URL = `http://127.0.0.1:${HTTP_PORT}/keepalive`;
1394
+ const KEEPALIVE_SESSION = `${SESSION_ID}:keepalive`;
1395
+ let _keepaliveBusy = false;
1396
+ let _keepaliveTimer = null;
1397
+ async function _ensureKeepaliveTab() {
1398
+ if (!KEEPALIVE_TAB_ENABLED || _keepaliveBusy || !_isExtensionHost || !_extensionConnected) return;
1399
+ if (_preferAppleScript && !_profileExtensionVerified) return;
1400
+ _keepaliveBusy = true;
1401
+ try {
1402
+ const tabs = await sendToExtension("list_tabs", { sessionId: KEEPALIVE_SESSION }, 30000);
1403
+ const list = Array.isArray(tabs) ? tabs : [];
1404
+ // No window at all → new_tab would have to create one, and a WebExtension-created
1405
+ // window is not guaranteed to land in this profile. Wait for a real window instead.
1406
+ if (!list.length) return;
1407
+ if (list.some((t) => String(t?.safeUrl || t?.url || "").startsWith(KEEPALIVE_URL))) return;
1408
+ await sendToExtension("new_tab", { url: KEEPALIVE_URL, sessionId: KEEPALIVE_SESSION }, 45000);
1409
+ console.error(`[Safari MCP] keepalive tab opened (${KEEPALIVE_URL})`);
1410
+ } catch (err) {
1411
+ console.error(`[Safari MCP] keepalive tab check skipped: ${err.message}`);
1412
+ } finally {
1413
+ _keepaliveBusy = false;
1414
+ }
1415
+ }
1416
+ function _scheduleKeepaliveTab(delayMs = 3000) {
1417
+ if (!KEEPALIVE_TAB_ENABLED) return;
1418
+ if (_keepaliveTimer) clearTimeout(_keepaliveTimer);
1419
+ _keepaliveTimer = setTimeout(() => { _keepaliveTimer = null; void _ensureKeepaliveTab(); }, delayMs);
1420
+ _keepaliveTimer.unref();
1421
+ }
1422
+ if (KEEPALIVE_TAB_ENABLED) setInterval(() => void _ensureKeepaliveTab(), 10 * 60 * 1000).unref();
1423
+
1332
1424
  // Commands where null result means failure (should fall back to AppleScript)
1333
1425
  const _nullMeansFailure = new Set([
1334
1426
  "click", "double_click", "right_click", "fill",
@@ -1426,11 +1518,26 @@ function _safeUrlForOutput(rawUrl) {
1426
1518
  }
1427
1519
  }
1428
1520
 
1521
+ // Receipts this daemon rotated on the caller's behalf (a cross-origin safari_navigate) stay
1522
+ // usable under their old name: callers routinely keep the first receipt they saw and ignore
1523
+ // the fresh one in the navigate result (nine consecutive "not valid for this origin" failures
1524
+ // in one session on 4.9.26). Whoever held the old capability held the tab.
1525
+ const _receiptAliases = new Map(); // old token → newer token
1526
+ function _aliasReceipt(oldToken, newToken) {
1527
+ if (!oldToken || !newToken || oldToken === newToken) return;
1528
+ _receiptAliases.set(oldToken, newToken);
1529
+ if (_receiptAliases.size > 500) _receiptAliases.delete(_receiptAliases.keys().next().value);
1530
+ }
1429
1531
  function _receiptToken(value) {
1430
1532
  const raw = String(value || "");
1431
- if (/^[A-Za-z0-9_-]{24,}$/.test(raw)) return raw;
1432
- const legacy = raw.match(/(?:[?#&])mcp-tab=([A-Za-z0-9_-]{24,})(?:[&#]|$)/);
1433
- return legacy ? legacy[1] : "";
1533
+ let token = "";
1534
+ if (/^[A-Za-z0-9_-]{24,}$/.test(raw)) token = raw;
1535
+ else {
1536
+ const legacy = raw.match(/(?:[?#&])mcp-tab=([A-Za-z0-9_-]{24,})(?:[&#]|$)/);
1537
+ token = legacy ? legacy[1] : "";
1538
+ }
1539
+ for (let hops = 0; token && _receiptAliases.has(token) && hops < 8; hops++) token = _receiptAliases.get(token);
1540
+ return token;
1434
1541
  }
1435
1542
 
1436
1543
  // A caller-supplied receipt is the documented way for a stateless caller to name its
@@ -1936,12 +2043,17 @@ server.tool(
1936
2043
  // origin", ~80×/week). The caller chose the destination, so rotating here is safe —
1937
2044
  // hand back the new receipt instead of letting the caller discover the trap.
1938
2045
  const landed = result && typeof result === "object" ? result.url : "";
1939
- if (landed && _originOf(landed) !== _originOf(oldUrl) && _receiptToken(receipt || _getActiveReceipt())) {
2046
+ const usedReceipt = _receiptToken(receipt || _getActiveReceipt());
2047
+ if (landed && _originOf(landed) !== _originOf(oldUrl) && usedReceipt) {
1940
2048
  try {
1941
2049
  const fresh = _sanitizeTabResult(await extensionOrFallback(
1942
2050
  "get_tab_receipt", { ..._explicitReceipt({ receipt }) }, () => null
1943
2051
  ));
1944
- if (fresh?.receipt) { _setActiveReceipt(fresh.receipt); result = { ...result, receipt: fresh.receipt }; }
2052
+ if (fresh?.receipt) {
2053
+ _aliasReceipt(usedReceipt, fresh.receipt);
2054
+ _setActiveReceipt(fresh.receipt);
2055
+ result = { ...result, receipt: fresh.receipt };
2056
+ }
1945
2057
  } catch { /* the navigation itself succeeded — a failed rotation must not fail the tool */ }
1946
2058
  }
1947
2059
  return textResult(result);
@@ -2568,6 +2680,7 @@ server.tool(
2568
2680
  // session now gets its own MAX_TABS and can only evict its own.
2569
2681
  const mySession = `${SESSION_ID}:${currentSessionId()}`;
2570
2682
  const myTabs = [..._openedTabs].filter(([, info]) => (info.sessionId || "") === mySession);
2683
+ let evictedTab = null;
2571
2684
  if (myTabs.length >= MAX_TABS) {
2572
2685
  let oldestIdx = null, oldestTime = Infinity;
2573
2686
  for (const [idx, info] of myTabs) {
@@ -2583,6 +2696,7 @@ server.tool(
2583
2696
  );
2584
2697
  } catch {}
2585
2698
  _untrackTab(oldestIdx);
2699
+ evictedTab = oldestIdx;
2586
2700
  }
2587
2701
  }
2588
2702
 
@@ -2610,7 +2724,12 @@ server.tool(
2610
2724
  }
2611
2725
  if (!requestedUrl) _markBlankTabOpened();
2612
2726
  if (safeResult?.receipt) _setActiveReceipt(safeResult.receipt);
2613
- return { content: [{ type: "text", text: typeof safeResult === 'string' ? safeResult : JSON.stringify(safeResult) }] };
2727
+ // The eviction used to be stderr-only, so a session learned about its closed tab from
2728
+ // the next "receipt … stale" error. Say it in the response instead.
2729
+ const out = evictedTab !== null && safeResult && typeof safeResult === "object"
2730
+ ? { ...safeResult, evictedTab, note: `Tab cap ${MAX_TABS}/session reached — your oldest tab #${evictedTab} was closed. Close tabs you are done with (safari_close_tab).` }
2731
+ : safeResult;
2732
+ return { content: [{ type: "text", text: typeof out === 'string' ? out : JSON.stringify(out) }] };
2614
2733
  }
2615
2734
  );
2616
2735
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.18.1",
3
+ "version": "2.19.1",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
5
  "description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 97 tools via AppleScript + JavaScript.",
6
6
  "type": "module",
package/safari.js CHANGED
@@ -304,6 +304,7 @@ async function refreshTargetWindow(force = false) {
304
304
  _targetWindowCacheTime = 0;
305
305
  _profileWindowMissing = true;
306
306
  _profileMisses++;
307
+ _maybeOpenProfileWindow();
307
308
  // Log on transition into the missing state, then at most once per 5 minutes —
308
309
  // this steady state used to append one line per poll, unbounded (#81).
309
310
  if (_profileMisses === 1 || now - _lastMissingLogTime > 300000) {
@@ -313,6 +314,34 @@ async function refreshTargetWindow(force = false) {
313
314
  }
314
315
  }
315
316
 
317
+ // ponytail: opt-in self-heal for the "profile window is closed" steady state (198 refusals in
318
+ // one log, every morning after a reboot). SAFARI_MCP_OPEN_WINDOW_CMD is run with the profile
319
+ // name once absence is established (3 consecutive misses), at most every 2 minutes; the poll
320
+ // below then rediscovers the window. The command must open the window WITHOUT focusing
321
+ // Safari (e.g. ~/bin/safari-bg-window, which presses the File-menu item via Accessibility).
322
+ const OPEN_WINDOW_CMD = process.env.SAFARI_MCP_OPEN_WINDOW_CMD || "";
323
+ let _lastOpenWindowAttempt = 0;
324
+ function _maybeOpenProfileWindow() {
325
+ if (!OPEN_WINDOW_CMD || !SAFARI_PROFILE || _profileMisses < 3) return;
326
+ const now = Date.now();
327
+ if (now - _lastOpenWindowAttempt < 120000) return;
328
+ _lastOpenWindowAttempt = now;
329
+ _logProfile(`Profile "${SAFARI_PROFILE}" window absent — running ${OPEN_WINDOW_CMD}`);
330
+ // The opener presses a File-menu item, which needs Safari running and NOT hidden
331
+ // (`open -g -j` leaves the menu bar inaccessible). Launch it in the background first.
332
+ const ensureRunning = execFileAsync("/usr/bin/pgrep", ["-x", "Safari"]).then(() => null, () =>
333
+ execFileAsync("/usr/bin/open", ["-g", "-a", "Safari"])
334
+ .then(() => new Promise((r) => setTimeout(r, 5000)))
335
+ .catch(() => null));
336
+ ensureRunning
337
+ .then(() => execFileAsync(OPEN_WINDOW_CMD, [SAFARI_PROFILE], { timeout: 30000 }))
338
+ .then(() => {
339
+ _profileMisses = 0; // re-arm the subprocess retry for the fresh window
340
+ setTimeout(() => { refreshTargetWindow(true).catch(() => {}); }, 3000).unref();
341
+ })
342
+ .catch((err) => _logProfile(`Open-window command failed: ${err.message}`));
343
+ }
344
+
316
345
  // Background verification: periodically check that cached window ID still belongs to profile
317
346
  if (SAFARI_PROFILE) {
318
347
  // Self-scheduling poll with exponential backoff (#81): 3s while the window is