safari-mcp 2.1.7 → 2.2.0

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.
@@ -957,7 +957,9 @@ async function handleCommand(type, payload) {
957
957
  // Learn profile window from newly created tab
958
958
  if (!_profileWindowId) { _profileWindowId = updated.windowId; browser.storage.local.set({ mcpProfileWindowId: _profileWindowId }).catch(() => {}); }
959
959
  // CRITICAL: Set new tab as the target for this session's subsequent commands
960
- _setSessionTab(sessionId, updated.id, updated.url);
960
+ // Use the requested URL (not updated.url) when page hasn't loaded yet (still about:blank)
961
+ const trackUrl = (updated.url && updated.url !== "about:blank") ? updated.url : (payload.url || updated.url);
962
+ _setSessionTab(sessionId, updated.id, trackUrl);
961
963
  return { title: updated.title, url: updated.url, tabIndex: updated.index + 1 };
962
964
  }
963
965
 
@@ -1049,7 +1051,7 @@ async function handleCommand(type, payload) {
1049
1051
 
1050
1052
  // --- Snapshot (accessibility tree with ref IDs) ---
1051
1053
  case "snapshot": {
1052
- return await execInTab((rootSelector) => {
1054
+ return await execInTab((rootSelector, snapshotGen) => {
1053
1055
  // Clean ALL stale data-mcp-ref attributes from previous snapshots.
1054
1056
  // Without this, old refs remain on DOM and findByRef/CSS selector can target WRONG elements.
1055
1057
  document.querySelectorAll("[data-mcp-ref]").forEach(function(el) { el.removeAttribute("data-mcp-ref"); });
@@ -1099,7 +1101,7 @@ async function handleCommand(type, payload) {
1099
1101
 
1100
1102
  const interactive = isInteractive(el);
1101
1103
  const currentId = id++;
1102
- const refId = "0_" + currentId;
1104
+ const refId = snapshotGen + "_" + currentId;
1103
1105
 
1104
1106
  let attrs = "";
1105
1107
  if (interactive) {
@@ -1209,7 +1211,7 @@ async function handleCommand(type, payload) {
1209
1211
  tree += "\n[WARNING: Snapshot truncated at " + MAX_ELEMENTS + " elements. Use selector parameter to focus on a specific section.]";
1210
1212
  }
1211
1213
  return tree;
1212
- }, [payload.selector || null], tabId);
1214
+ }, [payload.selector || null, payload.gen != null ? payload.gen : 0], tabId);
1213
1215
  }
1214
1216
 
1215
1217
  // --- Double Click ---
@@ -1768,15 +1770,26 @@ async function getActiveTab() {
1768
1770
  return tabs[0];
1769
1771
  }
1770
1772
 
1773
+ // Track which tabs already have the deep query helpers injected
1774
+ const _helpersInjected = new Set();
1775
+ // Clean up when tabs are removed or navigated
1776
+ browser.tabs.onRemoved.addListener((tabId) => { _helpersInjected.delete(tabId); });
1777
+ browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
1778
+ if (changeInfo.status === "loading") _helpersInjected.delete(tabId);
1779
+ });
1780
+
1771
1781
  async function execInTab(func, args = [], tabId = null) {
1772
1782
  const id = tabId || (await getActiveTab()).id;
1773
1783
  try {
1774
- // Auto-inject deep query helpers (idempotent — only defines once per page)
1775
- await browser.scripting.executeScript({
1776
- target: { tabId: id },
1777
- world: "MAIN",
1778
- func: _deepQueryScript,
1779
- }).catch(() => {});
1784
+ // Auto-inject deep query helpers — skip if already injected for this tab+page
1785
+ if (!_helpersInjected.has(id)) {
1786
+ await browser.scripting.executeScript({
1787
+ target: { tabId: id },
1788
+ world: "MAIN",
1789
+ func: _deepQueryScript,
1790
+ }).catch(() => {});
1791
+ _helpersInjected.add(id);
1792
+ }
1780
1793
 
1781
1794
  const results = await browser.scripting.executeScript({
1782
1795
  target: { tabId: id },
package/index.js CHANGED
@@ -81,9 +81,17 @@ let _memoryCheckTimer = null;
81
81
  function _startMemoryMonitor() {
82
82
  _memoryCheckTimer = setInterval(async () => {
83
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();
84
+ // Use pgrep + ps directly — avoids spawning bash, grep, awk pipeline
85
+ let psOut = "0";
86
+ try {
87
+ const pids = execFileSync("pgrep", ["-f", "WebKit|WebContent"], { encoding: "utf8" }).trim();
88
+ if (pids) {
89
+ const pidList = pids.split("\n").join(",");
90
+ psOut = execFileSync("ps", ["-p", pidList, "-o", "rss="], { encoding: "utf8" }).trim();
91
+ const totalKB = psOut.split("\n").reduce((sum, l) => sum + (parseInt(l.trim(), 10) || 0), 0);
92
+ psOut = String(totalKB / 1024);
93
+ }
94
+ } catch { psOut = "0"; }
87
95
  const webkitMB = parseFloat(psOut) || 0;
88
96
  if (webkitMB > WEBKIT_MEMORY_LIMIT_MB && _openedTabs.size > 1) {
89
97
  console.error(`[Safari MCP] ⚠️ WebKit using ${Math.round(webkitMB)}MB (limit: ${WEBKIT_MEMORY_LIMIT_MB}MB) — closing oldest MCP tab`);
@@ -170,8 +178,19 @@ function _setupExtensionListener(ws) {
170
178
  // ========== HTTP POLLING SERVER (for Safari extensions — WebSocket blocked) ==========
171
179
  try {
172
180
  const httpServer = createServer((req, res) => {
173
- // CORS headers for extension
174
- res.setHeader("Access-Control-Allow-Origin", "*");
181
+ // CORS headers — restricted to browser extension origin only.
182
+ // Safari extensions use moz-extension:// or safari-web-extension:// origins.
183
+ // "*" was a security risk: any webpage could POST to localhost:9224 and execute MCP commands.
184
+ const origin = req.headers.origin || "";
185
+ const isSafeOrigin = !origin || origin.startsWith("safari-web-extension://") || origin.startsWith("moz-extension://") || origin.startsWith("chrome-extension://");
186
+ if (isSafeOrigin) {
187
+ res.setHeader("Access-Control-Allow-Origin", origin || "*");
188
+ } else {
189
+ // Block cross-origin requests from web pages
190
+ res.writeHead(403);
191
+ res.end("Forbidden: cross-origin request blocked");
192
+ return;
193
+ }
175
194
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
176
195
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
177
196
 
@@ -480,7 +499,7 @@ const _commandTimeouts = {
480
499
  wait_for: 30000, navigate: 30000, navigate_and_read: 30000, go_back: 10000, go_forward: 10000,
481
500
  reload: 15000, screenshot: 15000, snapshot: 30000, click_and_read: 15000,
482
501
  double_click: 10000, right_click: 10000, clear_field: 5000, select_option: 5000, fill_form: 10000,
483
- get_url: 3000, get_title: 3000,
502
+ replace_editor: 10000, get_url: 3000, get_title: 3000,
484
503
  };
485
504
 
486
505
  // Commands where null result means failure (should fall back to AppleScript)
@@ -617,8 +636,9 @@ server.tool(
617
636
  "PREFERRED way to see page state. Returns accessibility tree with ref IDs for every interactive element. Use refs with click/fill/type instead of CSS selectors. Workflow: snapshot → see refs → click({ref:'0_5'}). PREFER THIS over safari_screenshot (cheaper, structured text vs heavy image) and over safari_read_page (includes interactive refs). Use safari_screenshot only when you need to see visual layout/styling.",
618
637
  { selector: z.string().optional().describe("CSS selector for subtree (default: full page)") },
619
638
  async (args) => {
639
+ const gen = safari.getNextSnapshotGen();
620
640
  const result = await extensionOrFallback(
621
- "snapshot", { selector: args.selector },
641
+ "snapshot", { selector: args.selector, gen },
622
642
  () => safari.takeSnapshot(args)
623
643
  );
624
644
  return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
@@ -1072,20 +1092,36 @@ server.tool(
1072
1092
  // Get current tab list
1073
1093
  const beforeRaw = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
1074
1094
  const beforeTabs = typeof beforeRaw === 'string' ? JSON.parse(beforeRaw) : beforeRaw;
1075
- const beforeUrls = new Set(beforeTabs.map(t => t.url));
1095
+ const beforeIds = new Set(beforeTabs.map(t => `${t.index}:${t.url}`));
1096
+ const beforeCount = beforeTabs.length;
1076
1097
 
1077
- // Poll for new tab
1098
+ // Poll for new tab — detect by count increase + new entries (handles about:blank tabs)
1078
1099
  const deadline = Date.now() + timeoutMs;
1079
1100
  while (Date.now() < deadline) {
1080
1101
  await new Promise(r => setTimeout(r, 500));
1081
1102
  const nowRaw = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
1082
1103
  const nowTabs = typeof nowRaw === 'string' ? JSON.parse(nowRaw) : nowRaw;
1083
- for (const tab of nowTabs) {
1084
- if (!beforeUrls.has(tab.url) && tab.url !== 'about:blank') {
1085
- if (urlContains && !tab.url.includes(urlContains)) continue;
1086
- // Switch to new tab
1087
- await extensionOrFallback("switch_tab", { index: tab.index }, () => safari.switchTab(tab.index));
1088
- return { content: [{ type: "text", text: `Found new tab: ${tab.title} (${tab.url})` }] };
1104
+ if (nowTabs.length > beforeCount) {
1105
+ // Find the new tab(s) — could be about:blank initially (OAuth popups)
1106
+ for (const tab of nowTabs) {
1107
+ if (!beforeIds.has(`${tab.index}:${tab.url}`)) {
1108
+ // Wait briefly for about:blank to resolve to actual URL
1109
+ if (tab.url === 'about:blank') {
1110
+ await new Promise(r => setTimeout(r, 1000));
1111
+ const refreshed = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
1112
+ const refreshedTabs = typeof refreshed === 'string' ? JSON.parse(refreshed) : refreshed;
1113
+ const resolved = refreshedTabs.find(t => t.index === tab.index);
1114
+ if (resolved && resolved.url !== 'about:blank') {
1115
+ if (urlContains && !resolved.url.includes(urlContains)) continue;
1116
+ await extensionOrFallback("switch_tab", { index: resolved.index }, () => safari.switchTab(resolved.index));
1117
+ return { content: [{ type: "text", text: `Found new tab: ${resolved.title} (${resolved.url})` }] };
1118
+ }
1119
+ continue;
1120
+ }
1121
+ if (urlContains && !tab.url.includes(urlContains)) continue;
1122
+ await extensionOrFallback("switch_tab", { index: tab.index }, () => safari.switchTab(tab.index));
1123
+ return { content: [{ type: "text", text: `Found new tab: ${tab.title} (${tab.url})` }] };
1124
+ }
1089
1125
  }
1090
1126
  }
1091
1127
  }
package/mcp-helpers.js ADDED
@@ -0,0 +1,299 @@
1
+ // MCP Click/Interaction Helpers — injected into every page
2
+ // This file is loaded by safari.js at startup, escaped, and injected via AppleScript `do JavaScript`.
3
+ // It is also used by the extension's execInTab for the AppleScript fallback path.
4
+ // IMPORTANT: Keep this file compatible with all browsers — no ES6+ modules, use var where possible.
5
+
6
+ if (window.__mcpVersion !== 5) {
7
+ window.__mcpVersion = 5;
8
+ window.__mcpRefs = window.__mcpRefs || {};
9
+ window.__mcpCachedRoots = null;
10
+ window.__mcpRootsDirty = true;
11
+ if (!window.__mcpRootsObserver) {
12
+ window.__mcpRootsObserver = new MutationObserver(function() { window.__mcpRootsDirty = true; });
13
+ window.__mcpRootsObserver.observe(document.documentElement, { childList: true, subtree: true });
14
+ }
15
+ window.mcpCollectRoots = function() {
16
+ if (!window.__mcpRootsDirty && window.__mcpCachedRoots) return window.__mcpCachedRoots;
17
+ var roots = [];
18
+ function collect(root) {
19
+ roots.push(root);
20
+ var all = root.querySelectorAll('*');
21
+ for (var i = 0; i < all.length; i++) {
22
+ if (all[i].shadowRoot) collect(all[i].shadowRoot);
23
+ }
24
+ }
25
+ collect(document);
26
+ var iframes = document.querySelectorAll('iframe');
27
+ for (var i = 0; i < iframes.length; i++) {
28
+ try { var doc = iframes[i].contentDocument; if (doc) collect(doc); } catch (e) {}
29
+ }
30
+ window.__mcpCachedRoots = roots;
31
+ window.__mcpRootsDirty = false;
32
+ return roots;
33
+ };
34
+ window.mcpQuerySelectorDeep = function(selector) {
35
+ try {
36
+ var direct = document.querySelector(selector);
37
+ if (direct) return direct;
38
+ } catch (e) { return null; }
39
+ var roots = window.mcpCollectRoots();
40
+ for (var i = 1; i < roots.length; i++) {
41
+ try {
42
+ var found = roots[i].querySelector(selector);
43
+ if (found) return found;
44
+ } catch (e) {}
45
+ }
46
+ var iframes = document.querySelectorAll('iframe');
47
+ for (var i = 0; i < iframes.length; i++) {
48
+ try {
49
+ var doc = iframes[i].contentDocument;
50
+ if (doc) { var found = doc.querySelector(selector); if (found) return found; }
51
+ } catch (e) {}
52
+ }
53
+ return null;
54
+ };
55
+ window.mcpElementFromPoint = function(x, y) {
56
+ var el = document.elementFromPoint(x, y);
57
+ while (el && el.shadowRoot) {
58
+ try {
59
+ if (typeof el.shadowRoot.elementFromPoint !== 'function') break;
60
+ var inner = el.shadowRoot.elementFromPoint(x, y);
61
+ if (!inner || inner === el) break;
62
+ el = inner;
63
+ } catch (e) { break; }
64
+ }
65
+ return el;
66
+ };
67
+ window.mcpIsVisible = function(el) {
68
+ if (!el || el.nodeType !== 1 || !el.isConnected) return false;
69
+ var cs = window.getComputedStyle(el);
70
+ if (!cs || cs.display === 'none' || cs.visibility === 'hidden' || cs.visibility === 'collapse' || cs.pointerEvents === 'none' || cs.opacity === '0') return false;
71
+ var r = el.getBoundingClientRect();
72
+ return r.width > 0 && r.height > 0;
73
+ };
74
+ window.mcpIsActionable = function(el) {
75
+ if (!window.mcpIsVisible(el)) return false;
76
+ if (el.disabled || el.getAttribute('aria-disabled') === 'true') return false;
77
+ if (el.matches && el.matches('input[type="hidden"]')) return false;
78
+ return true;
79
+ };
80
+ window.mcpPickActionable = function(el) {
81
+ var node = el && el.nodeType === 1 ? el : (el && el.parentElement) || null;
82
+ while (node) {
83
+ if (window.mcpIsActionable(node) && node.matches && node.matches('a[href],button,input:not([type="hidden"]),textarea,select,summary,label,option,[role],[onclick],[tabindex],[contenteditable=""],[contenteditable="true"]')) return node;
84
+ node = node.parentElement;
85
+ }
86
+ return el && el.nodeType === 1 ? el : null;
87
+ };
88
+ window.mcpFindByAttr = function(attr, value, selector) {
89
+ if (!value) return null;
90
+ var roots = window.mcpCollectRoots();
91
+ var sel = selector || ('[' + attr + ']');
92
+ for (var i = 0; i < roots.length; i++) {
93
+ var els = roots[i].querySelectorAll(sel);
94
+ for (var j = 0; j < els.length; j++) {
95
+ var current = attr === 'href' ? els[j].href : els[j].getAttribute(attr);
96
+ if (current === value && window.mcpIsVisible(els[j])) return els[j];
97
+ }
98
+ }
99
+ return null;
100
+ };
101
+ window.mcpNormalizeText = function(value) {
102
+ return String(value || '').replace(/\s+/g, ' ').trim();
103
+ };
104
+ window.mcpResolveTarget = function(el) {
105
+ var base = window.mcpPickActionable(el) || el;
106
+ if (!base) return null;
107
+ try { base.scrollIntoView({ block: 'center', inline: 'center' }); } catch (e) {}
108
+ var r = base.getBoundingClientRect();
109
+ var dx = Math.min(12, Math.max(4, r.width / 4));
110
+ var dy = Math.min(12, Math.max(4, r.height / 4));
111
+ var pts = [
112
+ [r.left + r.width / 2, r.top + r.height / 2],
113
+ [r.left + dx, r.top + r.height / 2],
114
+ [r.right - dx, r.top + r.height / 2],
115
+ [r.left + r.width / 2, r.top + dy],
116
+ [r.left + r.width / 2, r.bottom - dy]
117
+ ];
118
+ for (var i = 0; i < pts.length; i++) {
119
+ var x = pts[i][0], y = pts[i][1];
120
+ if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) continue;
121
+ var hit = window.mcpElementFromPoint(x, y);
122
+ if (!hit) continue;
123
+ if (base === hit || base.contains(hit) || hit.contains(base)) return window.mcpPickActionable(hit) || hit;
124
+ // Overlay pattern: transparent button/link overlays a text label (e.g. dropdown items).
125
+ // If the hit is an interactive element sharing a nearby common ancestor, prefer it.
126
+ var hitAction = window.mcpPickActionable(hit);
127
+ if (hitAction && hitAction !== base && hitAction.matches && hitAction.matches('button,a[href],[role="button"],[role="option"],[role="menuitem"]')) {
128
+ var p = base.parentElement;
129
+ for (var k = 0; k < 4 && p; k++) {
130
+ if (p.contains(hitAction)) return hitAction;
131
+ p = p.parentElement;
132
+ }
133
+ }
134
+ }
135
+ return base;
136
+ };
137
+ window.mcpClick = function(el) {
138
+ var target = window.mcpResolveTarget(el);
139
+ if (!window.mcpIsActionable(target)) return false;
140
+ var r = target.getBoundingClientRect();
141
+ var x = r.left + r.width / 2, y = r.top + r.height / 2;
142
+ var beforeUrl = location.href;
143
+ var anchor = target.closest ? target.closest('a[href]') : null;
144
+ var href = anchor && anchor.href && !anchor.href.startsWith('javascript:') ? anchor.href : '';
145
+ var s = { bubbles: true, cancelable: true, composed: true, view: window, clientX: x, clientY: y, button: 0, detail: 1 };
146
+ var p = { ...s, pointerId: 1, pointerType: 'mouse', isPrimary: true, width: 1, height: 1, pressure: 0.5 };
147
+ target.dispatchEvent(new PointerEvent('pointerover', { ...p, buttons: 0 }));
148
+ target.dispatchEvent(new MouseEvent('mouseover', { ...s, buttons: 0 }));
149
+ target.dispatchEvent(new PointerEvent('pointerenter', { ...p, buttons: 0 }));
150
+ target.dispatchEvent(new MouseEvent('mouseenter', { ...s, buttons: 0 }));
151
+ target.dispatchEvent(new PointerEvent('pointermove', { ...p, buttons: 0 }));
152
+ target.dispatchEvent(new MouseEvent('mousemove', { ...s, buttons: 0 }));
153
+ target.dispatchEvent(new PointerEvent('pointerdown', { ...p, buttons: 1 }));
154
+ target.dispatchEvent(new MouseEvent('mousedown', { ...s, buttons: 1 }));
155
+ if (target.focus) { try { target.focus({ preventScroll: true }); } catch (e) { try { target.focus(); } catch (_) {} } }
156
+ target.dispatchEvent(new PointerEvent('pointerup', { ...p, buttons: 0, pressure: 0 }));
157
+ target.dispatchEvent(new MouseEvent('mouseup', { ...s, buttons: 0 }));
158
+ try {
159
+ if (typeof target.click === 'function') {
160
+ target.click();
161
+ if (href && href !== beforeUrl) {
162
+ location.href = href;
163
+ }
164
+ }
165
+ } catch (e) {}
166
+ target.dispatchEvent(new MouseEvent('click', { ...s, buttons: 0 }));
167
+ if (href && href !== beforeUrl) {
168
+ location.href = href;
169
+ }
170
+ var form = target.closest ? target.closest('form') : null;
171
+ if (form && (target.type === 'submit' || (target.tagName === 'BUTTON' && target.type !== 'button' && target.type !== 'reset'))) {
172
+ try { form.requestSubmit ? form.requestSubmit(target.type === 'submit' ? target : undefined) : form.submit(); } catch (e) {}
173
+ }
174
+ return true;
175
+ };
176
+ window.mcpReactClick = function(el) {
177
+ var startNode = window.mcpResolveTarget(el) || el;
178
+ if (!startNode) return false;
179
+ function makeSynth(targetNode, type) {
180
+ var r = targetNode.getBoundingClientRect();
181
+ return { type: type || 'click', target: targetNode, currentTarget: targetNode,
182
+ 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,
183
+ preventDefault: function() {}, stopPropagation: function() {}, nativeEvent: new MouseEvent(type || 'click'), persist: function() {}, bubbles: true, cancelable: true };
184
+ }
185
+ // For React radio/checkbox inputs, onChange is the primary handler (not onClick)
186
+ var isToggle = startNode.tagName === 'INPUT' && (startNode.type === 'radio' || startNode.type === 'checkbox');
187
+ function tryOnChange(propsObj, targetNode) {
188
+ if (!isToggle || !propsObj.onChange) return false;
189
+ var newChecked = startNode.type === 'radio' ? true : !startNode.checked;
190
+ propsObj.onChange({ target: { value: startNode.value, checked: newChecked, type: startNode.type, name: startNode.name, id: startNode.id, tagName: 'INPUT' },
191
+ currentTarget: targetNode, preventDefault: function() {}, stopPropagation: function() {}, nativeEvent: new Event('change'), persist: function() {}, bubbles: true, cancelable: true, type: 'change' });
192
+ return true;
193
+ }
194
+ var node = startNode;
195
+ for (var depth = 0; depth < 15 && node; depth++) {
196
+ var pk = Object.keys(node).find(function(k) { return k.startsWith('__reactProps$'); });
197
+ if (pk && node[pk]) {
198
+ var props = node[pk];
199
+ if (tryOnChange(props, node)) return true;
200
+ if (props.onClick) { props.onClick(makeSynth(node, 'click')); return true; }
201
+ if (props.onMouseDown) { props.onMouseDown(makeSynth(node, 'mousedown')); return true; }
202
+ if (props.onPointerDown) { props.onPointerDown(makeSynth(node, 'pointerdown')); return true; }
203
+ }
204
+ var fk = Object.keys(node).find(function(k) { return k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'); });
205
+ if (fk) {
206
+ var f = node[fk];
207
+ while (f) {
208
+ if (f.memoizedProps) {
209
+ if (tryOnChange(f.memoizedProps, node)) return true;
210
+ if (f.memoizedProps.onClick) { f.memoizedProps.onClick(makeSynth(node, 'click')); return true; }
211
+ if (f.memoizedProps.onMouseDown) { f.memoizedProps.onMouseDown(makeSynth(node, 'mousedown')); return true; }
212
+ if (f.memoizedProps.onPointerDown) { f.memoizedProps.onPointerDown(makeSynth(node, 'pointerdown')); return true; }
213
+ }
214
+ f = f.return;
215
+ }
216
+ }
217
+ node = node.parentElement;
218
+ }
219
+ return false;
220
+ };
221
+ window.mcpClickWithReact = function(el) {
222
+ var target = window.mcpResolveTarget(el) || el;
223
+ var reactFired = false;
224
+ try { reactFired = window.mcpReactClick(target); } catch (e) {}
225
+ var anchor = target && target.closest ? target.closest('a[href]') : null;
226
+ if (!reactFired || anchor) window.mcpClick(target);
227
+ return target;
228
+ };
229
+ window.mcpFindRef = function(ref) {
230
+ var el = window.mcpQuerySelectorDeep('[data-mcp-ref="' + ref + '"]');
231
+ if (el) return el;
232
+ if (!window.__mcpRefs || !window.__mcpRefs[ref]) return null;
233
+ var m = window.__mcpRefs[ref];
234
+ if (m.id) { el = window.mcpFindByAttr('id', m.id); if (el) return el; }
235
+ if (m.testid) { el = window.mcpFindByAttr('data-testid', m.testid); if (el) return el; }
236
+ if (m.nameAttr) { el = window.mcpFindByAttr('name', m.nameAttr); if (el) return el; }
237
+ if (m.href) { el = window.mcpFindByAttr('href', m.href, 'a[href]'); if (el) return el; }
238
+ if (m.al) { el = window.mcpFindByAttr('aria-label', m.al); if (el) return el; }
239
+ if (m.ph) { el = window.mcpFindByAttr('placeholder', m.ph); if (el) return el; }
240
+ if (m.text) {
241
+ el = window.mcpFindText(m.text, true) || window.mcpFindText(m.text, false);
242
+ if (el) return el;
243
+ }
244
+ if (m.cx !== undefined && m.cy !== undefined) {
245
+ try {
246
+ window.scrollTo(window.scrollX, Math.max(0, m.cy - window.innerHeight / 2));
247
+ } catch (e) {}
248
+ el = window.mcpElementFromPoint(m.cx - window.scrollX, m.cy - window.scrollY);
249
+ if (el) return window.mcpPickActionable(el) || el;
250
+ }
251
+ return null;
252
+ };
253
+ window.mcpFindText = function(text, exact) {
254
+ var needle = window.mcpNormalizeText(text);
255
+ var best = null, bestScore = Infinity;
256
+ function consider(node) {
257
+ var target = window.mcpPickActionable(node) || node;
258
+ if (!window.mcpIsVisible(target)) return;
259
+ var r = target.getBoundingClientRect();
260
+ var area = r.width * r.height;
261
+ var interactive = target.matches && target.matches('a[href],button,input:not([type="hidden"]),textarea,select,summary,label,[role=button],[role=link],[role=tab],[onclick],[tabindex]');
262
+ var score = area + (interactive ? 0 : 1000000);
263
+ if (score < bestScore) { best = target; bestScore = score; }
264
+ }
265
+ var roots = window.mcpCollectRoots();
266
+ for (var i = 0; i < roots.length; i++) {
267
+ var attrEls = roots[i].querySelectorAll('[aria-label],[placeholder],[title],[data-testid],[alt]');
268
+ for (var j = 0; j < attrEls.length; j++) {
269
+ var a = attrEls[j];
270
+ var vals = [a.getAttribute('aria-label'), a.getAttribute('placeholder'), a.getAttribute('title'), a.getAttribute('data-testid'), a.getAttribute('alt')];
271
+ for (var k = 0; k < vals.length; k++) {
272
+ var val = vals[k];
273
+ if (!val) continue;
274
+ var normalized = window.mcpNormalizeText(val);
275
+ if (exact ? normalized === needle : normalized.includes(needle)) { consider(a); break; }
276
+ }
277
+ }
278
+ var tw = document.createTreeWalker(roots[i], NodeFilter.SHOW_TEXT, null);
279
+ while (tw.nextNode()) {
280
+ var n = tw.currentNode;
281
+ var t = window.mcpNormalizeText(n.textContent);
282
+ if (!t) continue;
283
+ if (exact ? (t !== needle) : !t.includes(needle)) continue;
284
+ if (n.parentElement) consider(n.parentElement);
285
+ }
286
+ }
287
+ if (!best) {
288
+ var allEls = document.querySelectorAll('*');
289
+ for (var i = 0; i < allEls.length; i++) {
290
+ var el = allEls[i];
291
+ var it = window.mcpNormalizeText(el.innerText);
292
+ if (!it) continue;
293
+ if (exact ? (it !== needle) : !it.includes(needle)) continue;
294
+ consider(el);
295
+ }
296
+ }
297
+ return best;
298
+ };
299
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.1.7",
3
+ "version": "2.2.0",
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",
@@ -49,6 +49,7 @@
49
49
  "files": [
50
50
  "index.js",
51
51
  "safari.js",
52
+ "mcp-helpers.js",
52
53
  "safari-helper",
53
54
  "safari-helper.swift",
54
55
  "extension/",
package/safari.js CHANGED
@@ -8,6 +8,7 @@ import { promisify } from "node:util";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join, dirname } from "node:path";
10
10
  import { readFile, writeFile, unlink, appendFile } from "node:fs/promises";
11
+ import { readFileSync } from "node:fs";
11
12
  import { fileURLToPath } from "node:url";
12
13
  // Extension bridge is handled by index.js (WebSocket server on port 9223)
13
14
 
@@ -650,7 +651,7 @@ export async function reload(hardReload = false) {
650
651
 
651
652
  export async function readPage({ selector, maxLength = 50000 } = {}) {
652
653
  if (selector) {
653
- const sel = selector.replace(/'/g, "\\'");
654
+ const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
654
655
  return runJS(
655
656
  `(function(){
656
657
  var el = document.querySelector('${sel}');
@@ -673,7 +674,12 @@ export async function getPageSource({ maxLength = 200000 } = {}) {
673
674
 
674
675
  // Inject click helpers ONCE per page (cached on window.__mcp)
675
676
  // Includes: mcpClick (full event sequence), mcpReactClick (React Fiber), mcpFindText (fast TreeWalker)
676
- const INJECT_MCP_HELPERS = `if(window.__mcpVersion!==5){window.__mcpVersion=5;
677
+ // Loaded from mcp-helpers.js at startup — enables syntax highlighting, linting, and easier maintenance
678
+ const INJECT_MCP_HELPERS = readFileSync(join(__dirname, 'mcp-helpers.js'), 'utf8');
679
+
680
+ // NOTE: Helper code previously inlined here (~300 lines) is now in mcp-helpers.js
681
+ /* eslint-disable */
682
+ const _LEGACY_INLINE_START = `if(window.__mcpVersion!==5){window.__mcpVersion=5;
677
683
  window.__mcpRefs=window.__mcpRefs||{};
678
684
  window.__mcpCachedRoots=null;
679
685
  window.__mcpRootsDirty=true;
@@ -1044,14 +1050,14 @@ export async function click({ selector, text, x, y, ref }) {
1044
1050
  }
1045
1051
 
1046
1052
  if (selector) {
1047
- const sel = selector.replace(/'/g, "\\'");
1053
+ const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1048
1054
  return clickWithRetry(
1049
1055
  `(function(){var el=mcpQuerySelectorDeep('${sel}');if(!el)return 'Element not found: ${sel}';var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+((target.innerText||target.textContent)?(' "'+(target.innerText||target.textContent).trim().substring(0,50)+'"'):'');})()`
1050
1056
  );
1051
1057
  }
1052
1058
 
1053
1059
  if (text) {
1054
- const safeText = text.replace(/'/g, "\\'");
1060
+ const safeText = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1055
1061
  return clickWithRetry(
1056
1062
  `(function(){var el=mcpFindText('${safeText}',true)||mcpFindText('${safeText}',false);if(!el)return 'Element not found with text: ${safeText}';var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+' "'+((target.innerText||target.textContent)||'').trim().substring(0,50)+'"';})()`
1057
1063
  );
@@ -1068,7 +1074,7 @@ export async function click({ selector, text, x, y, ref }) {
1068
1074
  export async function doubleClick({ selector, x, y, ref }) {
1069
1075
  if (ref) selector = refSelector(ref);
1070
1076
  if (selector) {
1071
- const sel = selector.replace(/'/g, "\\'");
1077
+ const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1072
1078
  return runJS(
1073
1079
  `(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found: ${sel}';el.scrollIntoView({block:'center'});el.dispatchEvent(new MouseEvent('dblclick',{bubbles:true,cancelable:true}));return 'Double-clicked: '+el.tagName;})()`
1074
1080
  );
@@ -1083,7 +1089,7 @@ export async function doubleClick({ selector, x, y, ref }) {
1083
1089
 
1084
1090
  export async function rightClick({ selector, x, y }) {
1085
1091
  if (selector) {
1086
- const sel = selector.replace(/'/g, "\\'");
1092
+ const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1087
1093
  return runJS(
1088
1094
  `(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found: ${sel}';el.scrollIntoView({block:'center'});el.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,cancelable:true,button:2}));return 'Right-clicked: '+el.tagName;})()`
1089
1095
  );
@@ -1122,7 +1128,7 @@ export async function nativeClick({ selector, text, x, y, ref, doubleClick = fal
1122
1128
  });
1123
1129
  })()`;
1124
1130
  } else if (selector) {
1125
- const sel = selector.replace(/'/g, "\\'");
1131
+ const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1126
1132
  jsExpr = `(function(){
1127
1133
  var el = document.querySelector('${sel}');
1128
1134
  if (!el) return JSON.stringify({error: 'Element not found: ${sel}'});
@@ -1136,7 +1142,7 @@ export async function nativeClick({ selector, text, x, y, ref, doubleClick = fal
1136
1142
  });
1137
1143
  })()`;
1138
1144
  } else {
1139
- const safeText = text.replace(/'/g, "\\'");
1145
+ const safeText = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1140
1146
  jsExpr = `(function(){
1141
1147
  var el = mcpFindText('${safeText}', true) || mcpFindText('${safeText}', false);
1142
1148
  if (!el) return JSON.stringify({error: 'Element not found with text: ${safeText}'});
@@ -1194,7 +1200,7 @@ export async function nativeClick({ selector, text, x, y, ref, doubleClick = fal
1194
1200
 
1195
1201
  export async function fill({ selector, value, ref }) {
1196
1202
  if (ref) selector = refSelector(ref);
1197
- const sel = selector.replace(/'/g, "\\'");
1203
+ const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1198
1204
  // Proper escaping order: backslashes first, then quotes
1199
1205
  const val = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
1200
1206
  const result = await runJS(
@@ -1248,14 +1254,14 @@ export async function fill({ selector, value, ref }) {
1248
1254
  }
1249
1255
 
1250
1256
  export async function clearField({ selector }) {
1251
- const sel = selector.replace(/'/g, "\\'");
1257
+ const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1252
1258
  return runJS(
1253
1259
  `(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found: ${sel}';if(el.isContentEditable){el.focus();document.execCommand('selectAll');document.execCommand('delete');el.dispatchEvent(new Event('input',{bubbles:true}));return 'Cleared (contenteditable)';}var t=el._valueTracker;if(t)t.setValue('x');var p=el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;var d=Object.getOwnPropertyDescriptor(p,'value');if(d&&d.set)d.set.call(el,'');else el.value='';el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));el.dispatchEvent(new Event('blur',{bubbles:true}));return 'Cleared';})()`
1254
1260
  );
1255
1261
  }
1256
1262
 
1257
1263
  export async function selectOption({ selector, value }) {
1258
- const sel = selector.replace(/'/g, "\\'");
1264
+ const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1259
1265
  const val = value.replace(/'/g, "\\'");
1260
1266
  return runJS(
1261
1267
  `(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found';el.focus();var t=el._valueTracker;if(t)t.setValue('');var d=Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype,'value');if(d&&d.set){d.set.call(el,'${val}');}else{el.value='${val}';}var m=false;for(var i=0;i<el.options.length;i++){if(el.options[i].value==='${val}'){el.selectedIndex=i;m=true;break;}}if(!m||el.value!=='${val}'){var norm=function(s){return s.replace(/[\\u200B-\\u200F\\u202A-\\u202E\\u2066-\\u2069\\uFEFF]/g,'').replace(/[\\u2010-\\u2015\\u2212\\uFE58\\uFE63\\uFF0D]/g,'-').replace(/\\s*-\\s*/g,'-').replace(/\\s+/g,' ').trim();};var cv=norm('${val}');for(var i=0;i<el.options.length;i++){if(norm(el.options[i].value)===cv||norm(el.options[i].text)===cv){el.selectedIndex=i;if(d&&d.set){d.set.call(el,el.options[i].value);}else{el.value=el.options[i].value;}m=true;break;}}if(!m){for(var i=0;i<el.options.length;i++){var nv=norm(el.options[i].value),nt=norm(el.options[i].text);if(nv.indexOf(cv)>=0||nt.indexOf(cv)>=0||cv.indexOf(nv)>=0||cv.indexOf(nt)>=0){if(i===0&&el.options.length>1)continue;el.selectedIndex=i;if(d&&d.set){d.set.call(el,el.options[i].value);}else{el.value=el.options[i].value;}m=true;break;}}}}el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));el.dispatchEvent(new Event('blur',{bubbles:true}));return 'Selected: '+el.value+' (index '+el.selectedIndex+')';})()`
@@ -1423,7 +1429,7 @@ export async function pressKey({ key, modifiers = [] }) {
1423
1429
  export async function typeText({ text, selector, ref }) {
1424
1430
  if (ref) selector = refSelector(ref);
1425
1431
  if (selector) {
1426
- const sel = selector.replace(/'/g, "\\'");
1432
+ const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1427
1433
  await runJS(`document.querySelector('${sel}')?.focus()`);
1428
1434
  // Quick poll for focus to settle (was 200ms fixed sleep)
1429
1435
  await new Promise((r) => setTimeout(r, 30));
@@ -1706,7 +1712,7 @@ export async function screenshot({ fullPage = false } = {}) {
1706
1712
  // ========== ELEMENT SCREENSHOT ==========
1707
1713
 
1708
1714
  export async function screenshotElement({ selector }) {
1709
- const sel = selector.replace(/'/g, "\\'");
1715
+ const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1710
1716
  // Use html2canvas-like approach: capture element via SVG foreignObject
1711
1717
  const result = await runJS(
1712
1718
  `(async function(){
@@ -1773,7 +1779,7 @@ export async function screenshotElement({ selector }) {
1773
1779
  await execFileAsync("screencapture", ["-l" + windowId, "-o", "-x", tmpFile]);
1774
1780
  const { x, y, w, h } = JSON.parse(bounds);
1775
1781
  // Use sips to crop (macOS built-in)
1776
- const toolbarHeight = 52; // Safari toolbar approximate height
1782
+ const toolbarHeight = 74; // Safari toolbar approximate height (matches _getSafariWindowGeometry)
1777
1783
  const cropFile = join(tmpdir(), `safari-el-crop-${Date.now()}.png`);
1778
1784
  await execFileAsync("sips", [
1779
1785
  "-c", String(h), String(w),
@@ -2010,14 +2016,14 @@ export async function evaluate({ script }) {
2010
2016
  // ========== ELEMENT INFO ==========
2011
2017
 
2012
2018
  export async function getElementInfo({ selector }) {
2013
- const sel = selector.replace(/'/g, "\\'");
2019
+ const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
2014
2020
  return runJS(
2015
2021
  `(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found';var r=el.getBoundingClientRect();return JSON.stringify({tag:el.tagName,text:el.textContent.trim().substring(0,200),href:el.href||'',value:el.value||'',visible:r.width>0&&r.height>0,rect:{x:Math.round(r.x),y:Math.round(r.y),w:Math.round(r.width),h:Math.round(r.height)},attrs:Object.fromEntries([...el.attributes].map(function(a){return[a.name,a.value.substring(0,100)]}))})})()`
2016
2022
  );
2017
2023
  }
2018
2024
 
2019
2025
  export async function querySelectorAll({ selector, limit = 20 }) {
2020
- const sel = selector.replace(/'/g, "\\'");
2026
+ const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
2021
2027
  return runJS(
2022
2028
  `JSON.stringify([...document.querySelectorAll('${sel}')].slice(0,${Number(limit)}).map(function(el,i){return{index:i,tag:el.tagName,text:el.textContent.trim().substring(0,100),href:el.href||undefined,value:el.value||undefined}}))`
2023
2029
  );
@@ -2028,7 +2034,7 @@ export async function querySelectorAll({ selector, limit = 20 }) {
2028
2034
  export async function hover({ selector, x, y, ref }) {
2029
2035
  if (ref) selector = refSelector(ref);
2030
2036
  if (selector) {
2031
- const sel = selector.replace(/'/g, "\\'");
2037
+ const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
2032
2038
  return runJS(
2033
2039
  `(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found';el.scrollIntoView({block:'center'});el.dispatchEvent(new MouseEvent('mouseover',{bubbles:true}));el.dispatchEvent(new MouseEvent('mouseenter',{bubbles:true}));return 'Hovered: '+el.tagName;})()`
2034
2040
  );
@@ -2045,7 +2051,7 @@ export async function hover({ selector, x, y, ref }) {
2045
2051
 
2046
2052
  export async function handleDialog({ action = "accept", text }) {
2047
2053
  if (text !== undefined) {
2048
- const safeText = text.replace(/'/g, "\\'");
2054
+ const safeText = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
2049
2055
  await runJS(
2050
2056
  `window.__mcp_dialog_response='${safeText}';window.__origPrompt=window.prompt;window.prompt=function(){var r=window.__mcp_dialog_response;window.prompt=window.__origPrompt;return r;}`
2051
2057
  );
@@ -2159,7 +2165,7 @@ export async function uploadFile({ selector, filePath }) {
2159
2165
  end tell`
2160
2166
  ).catch(() => {}); // Ignore if no dialog open
2161
2167
 
2162
- const sel = selector.replace(/'/g, "\\'");
2168
+ const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
2163
2169
  const { basename, extname } = await import("node:path");
2164
2170
  const fileName = basename(filePath);
2165
2171
  const ext = extname(filePath).toLowerCase().replace(".", "");
@@ -2439,6 +2445,7 @@ export async function savePDF({ path: pdfPath }) {
2439
2445
  // instead of guessing CSS selectors. Much faster, no hallucination risk.
2440
2446
 
2441
2447
  let _snapshotGen = 0;
2448
+ export function getNextSnapshotGen() { return _snapshotGen++; }
2442
2449
 
2443
2450
  export async function takeSnapshot({ selector } = {}) {
2444
2451
  const gen = _snapshotGen++;
@@ -2793,7 +2800,13 @@ export async function importStorageState({ state }) {
2793
2800
  const parsed = typeof state === "string" ? JSON.parse(state) : state;
2794
2801
  const esc = (s) => String(s).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "");
2795
2802
  const cmds = [];
2796
- if (parsed.cookies) cmds.push(`document.cookie='${esc(parsed.cookies)}'`);
2803
+ // Cookies must be set one at a time — document.cookie only accepts one cookie per assignment
2804
+ if (parsed.cookies) {
2805
+ const cookiePairs = String(parsed.cookies).split(/;\s*/);
2806
+ for (const pair of cookiePairs) {
2807
+ if (pair.trim()) cmds.push(`document.cookie='${esc(pair.trim())}'`);
2808
+ }
2809
+ }
2797
2810
  if (parsed.localStorage) {
2798
2811
  for (const [k, v] of Object.entries(parsed.localStorage)) {
2799
2812
  cmds.push(`localStorage.setItem('${esc(k)}','${esc(v)}')`);
@@ -3181,7 +3194,7 @@ export async function overrideGeolocation({ latitude, longitude, accuracy = 100
3181
3194
  // ========== COMPUTED STYLES ==========
3182
3195
 
3183
3196
  export async function getComputedStyles({ selector, properties }) {
3184
- const sel = selector.replace(/'/g, "\\'");
3197
+ const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
3185
3198
  const propsFilter = properties
3186
3199
  ? `.filter(function(p){return [${properties.map((p) => `'${p}'`).join(",")}].includes(p)})`
3187
3200
  : "";
@@ -3320,14 +3333,14 @@ export async function detectForms() {
3320
3333
 
3321
3334
  export async function scrollToElement({ selector, text, block = "center", timeout = 10000 }) {
3322
3335
  if (selector) {
3323
- const sel = selector.replace(/'/g, "\\'");
3336
+ const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
3324
3337
  return runJS(
3325
3338
  `(function(){var el=document.querySelector('${sel}');if(!el)return 'Element not found: ${sel}';el.scrollIntoView({behavior:'smooth',block:'${block}'});var r=el.getBoundingClientRect();return 'Scrolled to: '+el.tagName+' at y='+Math.round(r.y);})()`
3326
3339
  );
3327
3340
  }
3328
3341
  if (text) {
3329
3342
  // Virtual DOM scroll: scroll down repeatedly until text appears (for Airtable, etc.)
3330
- const safeText = text.replace(/'/g, "\\'");
3343
+ const safeText = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
3331
3344
  return runJS(
3332
3345
  `(async function(){
3333
3346
  var deadline = Date.now() + ${Number(timeout)};
@@ -3368,8 +3381,9 @@ export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
3368
3381
  const safeUrl = targetUrl.replace(/"/g, '\\"');
3369
3382
  const navTarget = _activeTabIndex ? `tab ${_activeTabIndex} of ${getTargetWindowRef()}` : getFallbackTarget();
3370
3383
  await osascriptFast(`tell application "Safari" to set URL of ${navTarget} to "${safeUrl}"`);
3384
+ _activeTabURL = targetUrl;
3371
3385
  // Single JS call: poll readyState + return page data — 1 call instead of 30+
3372
- return runJS(
3386
+ const navResult = await runJS(
3373
3387
  `(async function(){
3374
3388
  for(var i=0;i<60;i++){
3375
3389
  if(document.readyState==='complete')break;
@@ -3379,6 +3393,12 @@ export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
3379
3393
  })()`,
3380
3394
  { timeout: 30000 }
3381
3395
  );
3396
+ // Update _activeTabURL with the actual URL after navigation
3397
+ try {
3398
+ const parsed = JSON.parse(navResult);
3399
+ if (parsed.url && parsed.url !== 'about:blank') _activeTabURL = parsed.url;
3400
+ } catch {}
3401
+ return navResult;
3382
3402
  }
3383
3403
 
3384
3404
  // Click + wait for navigation or element — common after clicking a link/button
@@ -3474,11 +3494,6 @@ export async function analyzePage() {
3474
3494
  }
3475
3495
 
3476
3496
  // ========== GRACEFUL SHUTDOWN ==========
3477
- function cleanup() {
3478
- if (_helperProc) { try { _helperProc.kill(); } catch {} _helperProc = null; }
3479
- _drainHelperQueue("shutting down");
3480
- }
3481
-
3482
- process.on("exit", cleanup);
3483
- process.on("SIGINT", () => { cleanup(); process.exit(0); });
3484
- process.on("SIGTERM", () => { cleanup(); process.exit(0); });
3497
+ // NOTE: Signal handlers are registered at the top of the file (cleanupHelper + process.exit).
3498
+ // _drainHelperQueue is called from cleanupHelper via process.on("exit").
3499
+ process.on("exit", () => { _drainHelperQueue("shutting down"); });