safari-mcp 2.1.5 → 2.1.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.
package/README.md CHANGED
@@ -9,13 +9,14 @@
9
9
  [![Node.js](https://img.shields.io/badge/Node.js-18%2B-green)](https://nodejs.org/)
10
10
  [![macOS](https://img.shields.io/badge/macOS-only-blue)](https://www.apple.com/macos/)
11
11
  [![MCP](https://img.shields.io/badge/MCP-compatible-purple)](https://modelcontextprotocol.io)
12
+ [![CI](https://github.com/achiya-automation/safari-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/achiya-automation/safari-mcp/actions/workflows/ci.yml)
12
13
  [![npm downloads](https://img.shields.io/npm/dm/safari-mcp)](https://www.npmjs.com/package/safari-mcp)
13
14
  [![GitHub stars](https://img.shields.io/github/stars/achiya-automation/safari-mcp?style=social)](https://github.com/achiya-automation/safari-mcp/stargazers)
14
15
  [![achiya-automation/safari-mcp MCP server](https://glama.ai/mcp/servers/achiya-automation/safari-mcp/badges/score.svg)](https://glama.ai/mcp/servers/achiya-automation/safari-mcp)
15
16
 
16
17
  **80 tools** · **Zero dependencies** · **~5ms per command** · **60% less CPU than Chrome**
17
18
 
18
- [Quick Start](#quick-start) · [All 80 Tools](#tools-80) · [Why Safari MCP?](#safari-mcp-vs-alternatives) · [Architecture](#architecture)
19
+ [Quick Start](#quick-start) · [All 80 Tools](#tools-80) · [Examples](examples/) · [Why Safari MCP?](#safari-mcp-vs-alternatives) · [Architecture](#architecture) · [Changelog](CHANGELOG.md)
19
20
 
20
21
  ![Safari MCP Demo](https://github.com/achiya-automation/safari-mcp/releases/download/v2.1.2/safari-mcp-showcase.gif)
21
22
 
@@ -63,6 +64,12 @@
63
64
 
64
65
  ### Install
65
66
 
67
+ **Option A — npm (recommended):**
68
+ ```bash
69
+ npm install -g safari-mcp
70
+ ```
71
+
72
+ **Option B — from source:**
66
73
  ```bash
67
74
  git clone https://github.com/achiya-automation/safari-mcp.git
68
75
  cd safari-mcp
@@ -447,7 +454,9 @@ Safari MCP works with any MCP-compatible client:
447
454
 
448
455
  ## Contributing
449
456
 
450
- PRs welcome! The codebase is two files:
457
+ PRs welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions.
458
+
459
+ The codebase is two files:
451
460
  - `safari.js` — Safari automation layer (AppleScript + JavaScript)
452
461
  - `index.js` — MCP server with tool definitions
453
462
 
@@ -92,6 +92,11 @@ async function connect() {
92
92
  updateBadge("OFF");
93
93
  return; // Do NOT poll — let the correct profile's extension handle commands
94
94
  }
95
+ // Notify server that we passed profile verification
96
+ await fetch(`${HTTP_URL}/extension-verified`, {
97
+ method: "POST",
98
+ signal: AbortSignal.timeout(3000),
99
+ }).catch(() => {});
95
100
  await _discoverProfileWindow();
96
101
  }
97
102
  isConnected = true;
@@ -1548,6 +1553,8 @@ async function handleCommand(type, payload) {
1548
1553
  const _sessionTabCache = new Map();
1549
1554
  const TAB_CACHE_MS = 3000; // Re-verify tab URL match every 3s
1550
1555
  const _DEFAULT_SESSION = "__default__"; // Fallback for commands without sessionId
1556
+ const SESSION_MAX_AGE_MS = 5 * 60 * 1000; // 5 min — prune stale sessions
1557
+ const MAX_SESSIONS = 50; // Hard cap on session cache size
1551
1558
 
1552
1559
  function _getSessionCache(sessionId) {
1553
1560
  const sid = sessionId || _DEFAULT_SESSION;
@@ -1557,6 +1564,29 @@ function _getSessionCache(sessionId) {
1557
1564
  return _sessionTabCache.get(sid);
1558
1565
  }
1559
1566
 
1567
+ // Prune stale sessions — runs every 60s
1568
+ function _pruneSessionCache() {
1569
+ const now = Date.now();
1570
+ for (const [sid, cache] of _sessionTabCache) {
1571
+ if (sid === _DEFAULT_SESSION) continue;
1572
+ // Remove sessions with no active tab that haven't been used in 5 min
1573
+ if (!cache.tabId && (now - cache.time) > SESSION_MAX_AGE_MS) {
1574
+ _sessionTabCache.delete(sid);
1575
+ }
1576
+ }
1577
+ // Hard cap: if still too many, remove oldest
1578
+ if (_sessionTabCache.size > MAX_SESSIONS) {
1579
+ const sorted = [..._sessionTabCache.entries()]
1580
+ .filter(([sid]) => sid !== _DEFAULT_SESSION)
1581
+ .sort((a, b) => a[1].time - b[1].time);
1582
+ while (_sessionTabCache.size > MAX_SESSIONS && sorted.length) {
1583
+ const [sid] = sorted.shift();
1584
+ _sessionTabCache.delete(sid);
1585
+ }
1586
+ }
1587
+ }
1588
+ setInterval(_pruneSessionCache, 60000);
1589
+
1560
1590
  function _setSessionTab(sessionId, tabId, tabUrl) {
1561
1591
  const cache = _getSessionCache(sessionId);
1562
1592
  cache.tabId = tabId;
package/glama.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
+ "$schema": "https://glama.ai/mcp/schemas/server.json",
2
3
  "name": "Safari MCP Server",
3
4
  "description": "Native Safari browser automation for AI agents. 80+ tools via AppleScript — zero overhead, keeps logins, runs silently in background.",
4
5
  "repository": "https://github.com/achiya-automation/safari-mcp",
5
6
  "homepage": "https://github.com/achiya-automation/safari-mcp",
6
7
  "author": "achiya-automation",
8
+ "maintainers": ["achiya-automation"],
7
9
  "license": "MIT",
8
10
  "categories": ["browser-automation", "web-scraping", "developer-tools"],
9
11
  "tags": ["safari", "macos", "browser", "automation", "applescript", "mcp"],
package/index.js CHANGED
@@ -46,6 +46,77 @@ try {
46
46
  // ========== SESSION ID (unique per MCP process — enables per-session tab tracking) ==========
47
47
  const SESSION_ID = randomUUID().slice(0, 8);
48
48
 
49
+ // ========== MEMORY GUARD: track & auto-close MCP-opened tabs ==========
50
+ const MAX_TABS = parseInt(process.env.MCP_MAX_TABS || "6", 10);
51
+ const MEMORY_CHECK_INTERVAL_MS = parseInt(process.env.MCP_MEMORY_CHECK_MS || "60000", 10);
52
+ const WEBKIT_MEMORY_LIMIT_MB = parseInt(process.env.MCP_WEBKIT_LIMIT_MB || "3000", 10);
53
+
54
+ // Track tabs opened by THIS session (index → {url, openedAt})
55
+ const _openedTabs = new Map();
56
+
57
+ function _trackTab(tabIndex, url) {
58
+ _openedTabs.set(tabIndex, { url: url || "", openedAt: Date.now() });
59
+ }
60
+
61
+ function _untrackTab(tabIndex) {
62
+ _openedTabs.delete(tabIndex);
63
+ }
64
+
65
+ // Close all MCP-opened tabs on process exit
66
+ async function _cleanupTabs() {
67
+ if (_openedTabs.size === 0) return;
68
+ console.error(`[Safari MCP] Cleanup: closing ${_openedTabs.size} MCP-opened tabs`);
69
+ const indices = [..._openedTabs.keys()].sort((a, b) => b - a);
70
+ for (const idx of indices) {
71
+ try {
72
+ safari.setActiveTabIndex(idx);
73
+ await safari.closeTab();
74
+ } catch {}
75
+ }
76
+ _openedTabs.clear();
77
+ }
78
+
79
+ // Periodic memory check — if WebKit exceeds limit, close oldest MCP tabs
80
+ let _memoryCheckTimer = null;
81
+ function _startMemoryMonitor() {
82
+ _memoryCheckTimer = setInterval(async () => {
83
+ try {
84
+ const psOut = execFileSync("bash", ["-c",
85
+ "ps aux | grep -i 'WebKit\\|WebContent' | grep -v grep | awk '{sum+=$6} END {print sum/1024}'"
86
+ ], { encoding: "utf8" }).trim();
87
+ const webkitMB = parseFloat(psOut) || 0;
88
+ if (webkitMB > WEBKIT_MEMORY_LIMIT_MB && _openedTabs.size > 1) {
89
+ console.error(`[Safari MCP] ⚠️ WebKit using ${Math.round(webkitMB)}MB (limit: ${WEBKIT_MEMORY_LIMIT_MB}MB) — closing oldest MCP tab`);
90
+ let oldestIdx = null, oldestTime = Infinity;
91
+ for (const [idx, info] of _openedTabs) {
92
+ if (info.openedAt < oldestTime) { oldestTime = info.openedAt; oldestIdx = idx; }
93
+ }
94
+ if (oldestIdx !== null) {
95
+ try {
96
+ safari.setActiveTabIndex(oldestIdx);
97
+ await safari.closeTab();
98
+ } catch {}
99
+ _untrackTab(oldestIdx);
100
+ }
101
+ }
102
+ } catch {}
103
+ }, MEMORY_CHECK_INTERVAL_MS);
104
+ _memoryCheckTimer.unref();
105
+ }
106
+
107
+ // Cleanup on exit
108
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
109
+ process.on(sig, async () => {
110
+ await _cleanupTabs();
111
+ process.exit(0);
112
+ });
113
+ }
114
+ process.on("exit", () => {
115
+ if (_openedTabs.size > 0) {
116
+ console.error(`[Safari MCP] Exit: ${_openedTabs.size} tabs were tracked for cleanup`);
117
+ }
118
+ });
119
+
49
120
  // ========== EXTENSION FOCUS SAFETY ==========
50
121
  // When SAFARI_PROFILE is set, the extension's browser.scripting.executeScript()
51
122
  // can steal window focus in Safari (bringing the automation window to front).
@@ -164,7 +235,9 @@ try {
164
235
 
165
236
  // POST /connect — extension announces it's alive
166
237
  if (req.method === "POST" && req.url === "/connect") {
167
- if (!_extensionConnected) {
238
+ // When SAFARI_PROFILE is set, don't mark as connected until profile is verified.
239
+ // A personal-profile extension connecting first would incorrectly set the flag.
240
+ if (!process.env.SAFARI_PROFILE && !_extensionConnected) {
168
241
  _extensionConnected = true;
169
242
  console.error("[Safari MCP] Extension connected via HTTP polling");
170
243
  }
@@ -174,6 +247,18 @@ try {
174
247
  return;
175
248
  }
176
249
 
250
+ // POST /extension-verified — extension confirmed it's in the correct profile
251
+ if (req.method === "POST" && req.url === "/extension-verified") {
252
+ if (!_extensionConnected) {
253
+ _extensionConnected = true;
254
+ console.error("[Safari MCP] Extension connected and profile-verified via HTTP polling");
255
+ }
256
+ _extensionLastPollTime = Date.now();
257
+ res.writeHead(200, { "Content-Type": "application/json" });
258
+ res.end(JSON.stringify({ status: "verified" }));
259
+ return;
260
+ }
261
+
177
262
  // POST /verify-profile — extension asks server to check which profile has a nonce tab
178
263
  if (req.method === "POST" && req.url === "/verify-profile") {
179
264
  let body = "";
@@ -894,6 +979,22 @@ server.tool(
894
979
  "Open a new tab, optionally with a URL",
895
980
  { url: z.string().optional().describe("URL to open (empty for blank tab)") },
896
981
  async ({ url }) => {
982
+ // Enforce tab limit — close oldest MCP tab if at max
983
+ if (_openedTabs.size >= MAX_TABS) {
984
+ let oldestIdx = null, oldestTime = Infinity;
985
+ for (const [idx, info] of _openedTabs) {
986
+ if (info.openedAt < oldestTime) { oldestTime = info.openedAt; oldestIdx = idx; }
987
+ }
988
+ if (oldestIdx !== null) {
989
+ console.error(`[Safari MCP] Tab limit (${MAX_TABS}) reached — closing oldest tab #${oldestIdx}`);
990
+ try {
991
+ safari.setActiveTabIndex(oldestIdx);
992
+ await safari.closeTab();
993
+ } catch {}
994
+ _untrackTab(oldestIdx);
995
+ }
996
+ }
997
+
897
998
  const result = await extensionOrFallback(
898
999
  "new_tab", { url },
899
1000
  () => safari.newTab(url)
@@ -901,6 +1002,7 @@ server.tool(
901
1002
  // Sync safari.js tracking when extension handled new_tab
902
1003
  if (result?.tabIndex) {
903
1004
  safari.setActiveTabIndex(result.tabIndex);
1005
+ _trackTab(result.tabIndex, url);
904
1006
  }
905
1007
  if (result?.url) {
906
1008
  safari.setActiveTabURL(result.url);
@@ -914,7 +1016,9 @@ server.tool(
914
1016
  "Close the current tab",
915
1017
  {},
916
1018
  async () => {
1019
+ const activeIdx = safari.getActiveTabIndex();
917
1020
  const result = await extensionOrFallback("close_tab", {}, () => safari.closeTab());
1021
+ if (activeIdx !== null) _untrackTab(activeIdx);
918
1022
  return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
919
1023
  }
920
1024
  );
@@ -1720,5 +1824,6 @@ server.tool(
1720
1824
 
1721
1825
  // ========== START SERVER ==========
1722
1826
 
1827
+ _startMemoryMonitor();
1723
1828
  const transport = new StdioServerTransport();
1724
1829
  await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.1.5",
3
+ "version": "2.1.7",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
5
  "description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 80 tools via AppleScript + JavaScript.",
6
6
  "type": "module",
package/safari.js CHANGED
@@ -7,7 +7,7 @@ import { execFile, spawn } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join, dirname } from "node:path";
10
- import { readFile, writeFile, unlink } from "node:fs/promises";
10
+ import { readFile, writeFile, unlink, appendFile } from "node:fs/promises";
11
11
  import { fileURLToPath } from "node:url";
12
12
  // Extension bridge is handled by index.js (WebSocket server on port 9223)
13
13
 
@@ -99,6 +99,16 @@ let _activeTabURL = null; // URL-based tracking (stable even when tabs shift)
99
99
  let _lastResolveTime = 0; // Cache: skip resolve if verified recently
100
100
  const RESOLVE_CACHE_MS = 1500; // Re-verify tab every 1.5 seconds (was 5s — too stale)
101
101
 
102
+ // ========== DIAGNOSTIC LOG ==========
103
+ // File-based log for profile/focus issues — survives MCP restart, visible to user
104
+ const _LOG_FILE = '/tmp/safari-mcp-profile.log';
105
+ function _logProfile(msg) {
106
+ const ts = new Date().toISOString().replace('T', ' ').substring(0, 19);
107
+ const line = `[${ts}] ${msg}\n`;
108
+ console.error(`[Safari MCP] ${msg}`);
109
+ appendFile(_LOG_FILE, line).catch(() => {});
110
+ }
111
+
102
112
  // ========== PROFILE TARGETING ==========
103
113
  // Set SAFARI_PROFILE env var to target a specific Safari profile window.
104
114
  // Safari shows profile windows as: "ProfileName — Tab Title"
@@ -106,7 +116,7 @@ const SAFARI_PROFILE = process.env.SAFARI_PROFILE || null;
106
116
  let _targetWindowRef = null; // null = not yet discovered. Updated by refreshTargetWindow()
107
117
  let _targetWindowId = null; // Numeric window ID (for CGEvent window targeting)
108
118
  let _targetWindowCacheTime = 0;
109
- const TARGET_WINDOW_CACHE_MS = 5000;
119
+ const TARGET_WINDOW_CACHE_MS = 2000; // Reduced from 5s — faster detection of window changes
110
120
  let _profileWindowMissing = false; // True when profile window is not found
111
121
 
112
122
  // Get the target window ref, falling back to 'front window' ONLY when no profile is configured
@@ -125,14 +135,16 @@ async function refreshTargetWindow(force = false) {
125
135
  const now = Date.now();
126
136
  if (!force && _targetWindowRef && (now - _targetWindowCacheTime) < TARGET_WINDOW_CACHE_MS) return;
127
137
  const safeProfile = SAFARI_PROFILE.replace(/"/g, '\\"');
138
+ // Find profile window by name AND verify the window ID still matches
128
139
  const result = await osascriptFast(
129
- `tell application "Safari"\n repeat with w in every window\n if name of w starts with "${safeProfile} \u2014" then return id of w\n end repeat\n return 0\nend tell`
130
- ).catch(() => '0');
131
- const id = Number(result);
140
+ `tell application "Safari"\n repeat with w in every window\n if name of w starts with "${safeProfile} \u2014" then return (id of w as text) & "|" & name of w\n end repeat\n return "0|"\nend tell`
141
+ ).catch(() => '0|');
142
+ const [idStr, windowName] = String(result).split('|');
143
+ const id = Number(idStr);
132
144
  if (id > 0) {
133
145
  const newRef = `window id ${id}`;
134
146
  if (_targetWindowRef && _targetWindowRef !== newRef) {
135
- console.error(`[Safari MCP] Profile window changed: ${_targetWindowRef} → ${newRef}`);
147
+ _logProfile(`Profile window changed: ${_targetWindowRef} → ${newRef} ("${windowName}")`);
136
148
  }
137
149
  _targetWindowRef = newRef;
138
150
  _targetWindowId = id;
@@ -144,18 +156,46 @@ async function refreshTargetWindow(force = false) {
144
156
  _targetWindowId = null;
145
157
  _targetWindowCacheTime = 0;
146
158
  _profileWindowMissing = true;
147
- console.error(`[Safari MCP] WARNING: Profile "${SAFARI_PROFILE}" window not found — refusing to use front window`);
159
+ _logProfile(`WARNING: Profile "${SAFARI_PROFILE}" window not found — refusing to use front window`);
148
160
  }
149
161
  }
150
162
 
163
+ // Background verification: periodically check that cached window ID still belongs to profile
164
+ if (SAFARI_PROFILE) {
165
+ setInterval(async () => {
166
+ if (!_targetWindowRef || !_targetWindowId) return;
167
+ try {
168
+ const name = await osascriptFast(
169
+ `tell application "Safari" to return name of ${_targetWindowRef}`
170
+ ).catch(() => '');
171
+ if (name && !name.startsWith(`${SAFARI_PROFILE} \u2014`)) {
172
+ // Window ID no longer belongs to profile — invalidate cache immediately
173
+ _logProfile(`SAFETY: Window ${_targetWindowRef} no longer belongs to profile "${SAFARI_PROFILE}" (name: "${name}") — invalidating`);
174
+ _targetWindowRef = null;
175
+ _targetWindowId = null;
176
+ _targetWindowCacheTime = 0;
177
+ _profileWindowMissing = true;
178
+ // Try to rediscover immediately
179
+ await refreshTargetWindow(true);
180
+ }
181
+ } catch {
182
+ // Window might have been closed — invalidate and rediscover
183
+ _targetWindowRef = null;
184
+ _targetWindowId = null;
185
+ _targetWindowCacheTime = 0;
186
+ await refreshTargetWindow(true);
187
+ }
188
+ }, 3000); // Check every 3 seconds
189
+ }
190
+
151
191
  // Initialize profile window at startup (ES module top-level await)
152
192
  if (SAFARI_PROFILE) {
153
193
  await new Promise(r => setTimeout(r, 50)); // Let helper process initialize
154
194
  await refreshTargetWindow(true);
155
195
  if (_targetWindowRef) {
156
- console.error(`[Safari MCP] Profile "${SAFARI_PROFILE}" → targeting ${_targetWindowRef}`);
196
+ _logProfile(`Startup: Profile "${SAFARI_PROFILE}" → targeting ${_targetWindowRef}`);
157
197
  } else {
158
- console.error(`[Safari MCP] WARNING: Profile "${SAFARI_PROFILE}" window NOT found at startup`);
198
+ _logProfile(`WARNING: Profile "${SAFARI_PROFILE}" window NOT found at startup`);
159
199
  }
160
200
  }
161
201
 
@@ -811,11 +851,21 @@ const INJECT_MCP_HELPERS = `if(window.__mcpVersion!==5){window.__mcpVersion=5;
811
851
  clientX:r.left+r.width/2,clientY:r.top+r.height/2,pageX:r.left+r.width/2+window.scrollX,pageY:r.top+r.height/2+window.scrollY,
812
852
  preventDefault:function(){},stopPropagation:function(){},nativeEvent:new MouseEvent(type||'click'),persist:function(){},bubbles:true,cancelable:true};
813
853
  }
854
+ // For React radio/checkbox inputs, onChange is the primary handler (not onClick)
855
+ var isToggle=startNode.tagName==='INPUT'&&(startNode.type==='radio'||startNode.type==='checkbox');
856
+ function tryOnChange(propsObj,targetNode){
857
+ if(!isToggle||!propsObj.onChange)return false;
858
+ var newChecked=startNode.type==='radio'?true:!startNode.checked;
859
+ propsObj.onChange({target:{value:startNode.value,checked:newChecked,type:startNode.type,name:startNode.name,id:startNode.id,tagName:'INPUT'},
860
+ currentTarget:targetNode,preventDefault:function(){},stopPropagation:function(){},nativeEvent:new Event('change'),persist:function(){},bubbles:true,cancelable:true,type:'change'});
861
+ return true;
862
+ }
814
863
  var node=startNode;
815
864
  for(var depth=0;depth<15&&node;depth++){
816
865
  var pk=Object.keys(node).find(function(k){return k.startsWith('__reactProps$')});
817
866
  if(pk&&node[pk]){
818
867
  var props=node[pk];
868
+ if(tryOnChange(props,node))return true;
819
869
  if(props.onClick){props.onClick(makeSynth(node,'click'));return true}
820
870
  if(props.onMouseDown){props.onMouseDown(makeSynth(node,'mousedown'));return true}
821
871
  if(props.onPointerDown){props.onPointerDown(makeSynth(node,'pointerdown'));return true}
@@ -825,6 +875,7 @@ const INJECT_MCP_HELPERS = `if(window.__mcpVersion!==5){window.__mcpVersion=5;
825
875
  var f=node[fk];
826
876
  while(f){
827
877
  if(f.memoizedProps){
878
+ if(tryOnChange(f.memoizedProps,node))return true;
828
879
  if(f.memoizedProps.onClick){f.memoizedProps.onClick(makeSynth(node,'click'));return true}
829
880
  if(f.memoizedProps.onMouseDown){f.memoizedProps.onMouseDown(makeSynth(node,'mousedown'));return true}
830
881
  if(f.memoizedProps.onPointerDown){f.memoizedProps.onPointerDown(makeSynth(node,'pointerdown'));return true}
@@ -1527,30 +1578,35 @@ export async function replaceEditorContent({ text }) {
1527
1578
  export async function screenshot({ fullPage = false } = {}) {
1528
1579
  await refreshTargetWindow();
1529
1580
  const tmpFile = join(tmpdir(), `safari-screenshot-${Date.now()}.png`);
1530
- let savedTabIdx = null;
1531
1581
  try {
1532
- // If working on a background tab, temporarily switch to it for screenshot
1582
+ // Check if target tab is a background tab — if so, use JS screenshot to avoid tab jumping
1583
+ let isBackgroundTab = false;
1533
1584
  if (_activeTabIndex) {
1534
1585
  try {
1535
1586
  const currentIdx = await osascriptFast(
1536
1587
  `tell application "Safari" to return index of current tab of ${getTargetWindowRef()}`
1537
1588
  );
1538
- if (Number(currentIdx) !== _activeTabIndex) {
1539
- savedTabIdx = Number(currentIdx);
1540
- await osascriptFast(
1541
- `tell application "Safari" to set current tab of ${getTargetWindowRef()} to tab ${_activeTabIndex} of ${getTargetWindowRef()}`
1542
- );
1543
- await new Promise(r => setTimeout(r, 200)); // Let Safari render the tab
1544
- }
1589
+ isBackgroundTab = Number(currentIdx) !== _activeTabIndex;
1545
1590
  } catch (_) {}
1546
1591
  }
1547
- const skipScreencapture = false; // Always try screencapture now
1592
+ // When on a background tab, go straight to JS-based screenshot (no tab switch, no focus steal)
1593
+ const skipScreencapture = isBackgroundTab;
1548
1594
 
1549
1595
  // Try screencapture — use osascript's do shell script to bypass VS Code permission issue
1550
1596
  const windowId = !skipScreencapture ? await osascript(
1551
1597
  `tell application "Safari" to return id of ${getTargetWindowRef()}`
1552
1598
  ).catch(() => null) : null;
1553
1599
 
1600
+ // Save frontmost app BEFORE screencapture (it may steal focus on macOS Tahoe)
1601
+ let previousApp = null;
1602
+ if (windowId) {
1603
+ try {
1604
+ previousApp = (await osascriptFast(
1605
+ `tell application "System Events" to return name of first application process whose frontmost is true`
1606
+ )).trim();
1607
+ } catch (_) {}
1608
+ }
1609
+
1554
1610
  if (windowId) {
1555
1611
  try {
1556
1612
  if (fullPage) {
@@ -1585,6 +1641,12 @@ export async function screenshot({ fullPage = false } = {}) {
1585
1641
  );
1586
1642
  }
1587
1643
  }
1644
+ // Restore focus if screencapture stole it (common on macOS Tahoe)
1645
+ if (previousApp && previousApp !== "Safari") {
1646
+ await osascriptFast(
1647
+ `tell application "${previousApp}" to activate`
1648
+ ).catch(() => {});
1649
+ }
1588
1650
  // Compress: convert PNG to JPEG (50% quality) + resize to max 1200px width
1589
1651
  // Cuts ~600KB PNG → ~60KB JPEG — critical for staying under 20MB context limit
1590
1652
  const jpgFile = tmpFile.replace('.png', '.jpg');
@@ -1638,14 +1700,6 @@ export async function screenshot({ fullPage = false } = {}) {
1638
1700
  throw new Error("screencapture failed — Screen Recording permission may have been lost. Grant permission in System Settings → Privacy & Security → Screen Recording, then restart Safari.");
1639
1701
  } finally {
1640
1702
  await unlink(tmpFile).catch(() => {});
1641
- // Restore user's tab if we switched
1642
- if (savedTabIdx) {
1643
- try {
1644
- await osascriptFast(
1645
- `tell application "Safari" to set current tab of ${getTargetWindowRef()} to tab ${savedTabIdx} of ${getTargetWindowRef()}`
1646
- );
1647
- } catch (_) {}
1648
- }
1649
1703
  }
1650
1704
  }
1651
1705