safari-pilot 0.1.33 → 0.1.36

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 (111) hide show
  1. package/bin/Safari Pilot.app/Contents/CodeResources +0 -0
  2. package/bin/Safari Pilot.app/Contents/Info.plist +9 -9
  3. package/bin/Safari Pilot.app/Contents/MacOS/Safari Pilot +0 -0
  4. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Info.plist +8 -8
  5. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/MacOS/Safari Pilot Extension +0 -0
  6. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/background.js +345 -19
  7. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/content-isolated.js +128 -0
  8. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/content-main.js +610 -0
  9. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/lib/cs-readiness.js +58 -0
  10. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/lib/session-filter.js +47 -0
  11. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/lib/tab-url-matcher.js +148 -0
  12. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/locator.js +1088 -0
  13. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/manifest.json +1 -1
  14. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/_CodeSignature/CodeResources +43 -10
  15. package/bin/Safari Pilot.app/Contents/Resources/Assets.car +0 -0
  16. package/bin/Safari Pilot.app/Contents/Resources/Base.lproj/Main.storyboardc/Info.plist +0 -0
  17. package/bin/Safari Pilot.app/Contents/Resources/Base.lproj/Main.storyboardc/MainMenu.nib +0 -0
  18. package/bin/Safari Pilot.app/Contents/Resources/Base.lproj/Main.storyboardc/NSWindowController-B8D-0N-5wS.nib +0 -0
  19. package/bin/Safari Pilot.app/Contents/_CodeSignature/CodeResources +9 -9
  20. package/bin/Safari Pilot.zip +0 -0
  21. package/bin/SafariPilotd +0 -0
  22. package/dist/cli/stats.js +45 -2
  23. package/dist/cli/stats.js.map +1 -1
  24. package/dist/config.d.ts +12 -0
  25. package/dist/config.js +6 -0
  26. package/dist/config.js.map +1 -1
  27. package/dist/engine-selector.js +21 -1
  28. package/dist/engine-selector.js.map +1 -1
  29. package/dist/engines/applescript.d.ts +6 -1
  30. package/dist/engines/applescript.js +43 -12
  31. package/dist/engines/applescript.js.map +1 -1
  32. package/dist/engines/extension.d.ts +2 -0
  33. package/dist/engines/extension.js +91 -9
  34. package/dist/engines/extension.js.map +1 -1
  35. package/dist/engines/js-helpers.d.ts +18 -1
  36. package/dist/engines/js-helpers.js +30 -6
  37. package/dist/engines/js-helpers.js.map +1 -1
  38. package/dist/errors.d.ts +47 -1
  39. package/dist/errors.js +151 -0
  40. package/dist/errors.js.map +1 -1
  41. package/dist/index.js +27 -2
  42. package/dist/index.js.map +1 -1
  43. package/dist/locator.d.ts +60 -0
  44. package/dist/locator.js +29 -0
  45. package/dist/locator.js.map +1 -1
  46. package/dist/security/loop-detector.d.ts +32 -0
  47. package/dist/security/loop-detector.js +109 -0
  48. package/dist/security/loop-detector.js.map +1 -0
  49. package/dist/security/wall-cap.d.ts +32 -0
  50. package/dist/security/wall-cap.js +101 -0
  51. package/dist/security/wall-cap.js.map +1 -0
  52. package/dist/server.d.ts +4 -0
  53. package/dist/server.js +152 -4
  54. package/dist/server.js.map +1 -1
  55. package/dist/tools/auth.js +3 -3
  56. package/dist/tools/auth.js.map +1 -1
  57. package/dist/tools/batch.d.ts +19 -0
  58. package/dist/tools/batch.js +147 -0
  59. package/dist/tools/batch.js.map +1 -0
  60. package/dist/tools/extraction.d.ts +17 -0
  61. package/dist/tools/extraction.js +320 -22
  62. package/dist/tools/extraction.js.map +1 -1
  63. package/dist/tools/file-upload.js +3 -3
  64. package/dist/tools/file-upload.js.map +1 -1
  65. package/dist/tools/final-proof.d.ts +15 -0
  66. package/dist/tools/final-proof.js +139 -0
  67. package/dist/tools/final-proof.js.map +1 -0
  68. package/dist/tools/frames.js +5 -4
  69. package/dist/tools/frames.js.map +1 -1
  70. package/dist/tools/interaction.d.ts +16 -0
  71. package/dist/tools/interaction.js +175 -15
  72. package/dist/tools/interaction.js.map +1 -1
  73. package/dist/tools/navigation.js +19 -0
  74. package/dist/tools/navigation.js.map +1 -1
  75. package/dist/tools/network.js +10 -9
  76. package/dist/tools/network.js.map +1 -1
  77. package/dist/tools/overlays.js +7 -1
  78. package/dist/tools/overlays.js.map +1 -1
  79. package/dist/tools/page-info.d.ts +39 -0
  80. package/dist/tools/page-info.js +126 -0
  81. package/dist/tools/page-info.js.map +1 -0
  82. package/dist/tools/pdf.js +3 -2
  83. package/dist/tools/pdf.js.map +1 -1
  84. package/dist/tools/permissions.js +6 -5
  85. package/dist/tools/permissions.js.map +1 -1
  86. package/dist/tools/playbooks.d.ts +43 -0
  87. package/dist/tools/playbooks.js +193 -0
  88. package/dist/tools/playbooks.js.map +1 -0
  89. package/dist/tools/selector-pack.js +3 -2
  90. package/dist/tools/selector-pack.js.map +1 -1
  91. package/dist/tools/shadow.js +3 -2
  92. package/dist/tools/shadow.js.map +1 -1
  93. package/dist/tools/storage.js +15 -14
  94. package/dist/tools/storage.js.map +1 -1
  95. package/dist/tools/structured-extraction.d.ts +14 -0
  96. package/dist/tools/structured-extraction.js +112 -10
  97. package/dist/tools/structured-extraction.js.map +1 -1
  98. package/dist/tools/wait.d.ts +8 -0
  99. package/dist/tools/wait.js +9 -1
  100. package/dist/tools/wait.js.map +1 -1
  101. package/dist/types.d.ts +17 -1
  102. package/extension/background.js +345 -19
  103. package/extension/content-isolated.js +128 -0
  104. package/extension/content-main.js +610 -0
  105. package/extension/lib/cs-readiness.js +58 -0
  106. package/extension/lib/session-filter.js +47 -0
  107. package/extension/lib/tab-url-matcher.js +148 -0
  108. package/extension/locator.js +1088 -0
  109. package/extension/manifest.json +1 -1
  110. package/package.json +1 -1
  111. package/safari-pilot.config.json +3 -1
@@ -23,29 +23,29 @@
23
23
  <key>CFBundlePackageType</key>
24
24
  <string>APPL</string>
25
25
  <key>CFBundleShortVersionString</key>
26
- <string>0.1.33</string>
26
+ <string>0.1.36</string>
27
27
  <key>CFBundleSupportedPlatforms</key>
28
28
  <array>
29
29
  <string>MacOSX</string>
30
30
  </array>
31
31
  <key>CFBundleVersion</key>
32
- <string>202605121922</string>
32
+ <string>202605190102</string>
33
33
  <key>DTCompiler</key>
34
34
  <string>com.apple.compilers.llvm.clang.1_0</string>
35
35
  <key>DTPlatformBuild</key>
36
- <string>25E251</string>
36
+ <string>25F70</string>
37
37
  <key>DTPlatformName</key>
38
38
  <string>macosx</string>
39
39
  <key>DTPlatformVersion</key>
40
- <string>26.4</string>
40
+ <string>26.5</string>
41
41
  <key>DTSDKBuild</key>
42
- <string>25E251</string>
42
+ <string>25F70</string>
43
43
  <key>DTSDKName</key>
44
- <string>macosx26.4</string>
44
+ <string>macosx26.5</string>
45
45
  <key>DTXcode</key>
46
- <string>2641</string>
46
+ <string>2650</string>
47
47
  <key>DTXcodeBuild</key>
48
- <string>17E202</string>
48
+ <string>17F42</string>
49
49
  <key>LSMinimumSystemVersion</key>
50
50
  <string>26.4</string>
51
51
  <key>NSMainStoryboardFile</key>
@@ -53,6 +53,6 @@
53
53
  <key>NSPrincipalClass</key>
54
54
  <string>NSApplication</string>
55
55
  <key>SFSafariWebExtensionConverterVersion</key>
56
- <string>26.4.1</string>
56
+ <string>26.5</string>
57
57
  </dict>
58
58
  </plist>
@@ -19,29 +19,29 @@
19
19
  <key>CFBundlePackageType</key>
20
20
  <string>XPC!</string>
21
21
  <key>CFBundleShortVersionString</key>
22
- <string>0.1.33</string>
22
+ <string>0.1.36</string>
23
23
  <key>CFBundleSupportedPlatforms</key>
24
24
  <array>
25
25
  <string>MacOSX</string>
26
26
  </array>
27
27
  <key>CFBundleVersion</key>
28
- <string>202605121922</string>
28
+ <string>202605190102</string>
29
29
  <key>DTCompiler</key>
30
30
  <string>com.apple.compilers.llvm.clang.1_0</string>
31
31
  <key>DTPlatformBuild</key>
32
- <string>25E251</string>
32
+ <string>25F70</string>
33
33
  <key>DTPlatformName</key>
34
34
  <string>macosx</string>
35
35
  <key>DTPlatformVersion</key>
36
- <string>26.4</string>
36
+ <string>26.5</string>
37
37
  <key>DTSDKBuild</key>
38
- <string>25E251</string>
38
+ <string>25F70</string>
39
39
  <key>DTSDKName</key>
40
- <string>macosx26.4</string>
40
+ <string>macosx26.5</string>
41
41
  <key>DTXcode</key>
42
- <string>2641</string>
42
+ <string>2650</string>
43
43
  <key>DTXcodeBuild</key>
44
- <string>17E202</string>
44
+ <string>17F42</string>
45
45
  <key>LSMinimumSystemVersion</key>
46
46
  <string>10.14</string>
47
47
  <key>NSExtension</key>
@@ -63,29 +63,110 @@ async function saveTabCache() {
63
63
  }
64
64
  }
65
65
 
66
+ // v0.1.36 F1.2 dashboard-URL handshake (2026-05-18 evening rework).
67
+ //
68
+ // The daemon identifies each MCP session by the URL of its dashboard tab
69
+ // (http://127.0.0.1:19475/session?id=sess_<n>). Every daemon-side command
70
+ // carries that URL in `cmd.sessionDashboardUrl`. The extension watches
71
+ // tabs.onUpdated / onCreated for that URL pattern and records
72
+ // dashboardUrl → tab.windowId (WebExtension namespace) so the candidate
73
+ // filter can resolve session → window in the SAME namespace the cache
74
+ // uses. The previous design (passing AppleScript window IDs) silently
75
+ // dropped every candidate because the two ID schemes never matched.
76
+ const SESSION_DASHBOARD_URL_PREFIX = 'http://127.0.0.1:19475/session?id=';
77
+ const sessionDashboardUrlToWindowId = new Map();
78
+ // Reverse mapping (tabId → dashboardUrl) so tabs.onRemoved can drop the
79
+ // forward entry without iterating the whole Map.
80
+ const dashboardTabIdToUrl = new Map();
81
+
82
+ function registerDashboardTab(tab) {
83
+ if (!tab || tab.id == null || tab.windowId == null) return;
84
+ const url = tab.url;
85
+ if (typeof url !== 'string' || !url.startsWith(SESSION_DASHBOARD_URL_PREFIX)) return;
86
+ const prevUrl = dashboardTabIdToUrl.get(tab.id);
87
+ if (prevUrl && prevUrl !== url) {
88
+ // Tab navigated from one dashboard URL to another (or away then back).
89
+ // Drop the stale forward entry. The new one is rewritten below.
90
+ if (sessionDashboardUrlToWindowId.get(prevUrl) === tab.windowId) {
91
+ sessionDashboardUrlToWindowId.delete(prevUrl);
92
+ }
93
+ }
94
+ sessionDashboardUrlToWindowId.set(url, tab.windowId);
95
+ dashboardTabIdToUrl.set(tab.id, url);
96
+ }
97
+
98
+ // MV3 event pages are recycled aggressively. If Safari already has session
99
+ // dashboard tabs open when the extension wakes up — common when a session
100
+ // was created before the extension's event page was alive, or when the
101
+ // page was unloaded and re-loaded mid-session — the tabs.onCreated /
102
+ // onUpdated events for those tabs already fired and won't fire again. The
103
+ // map would be empty, and spFilterBySession would fall back to its
104
+ // startup-race "return all candidates" path → cross-session pollution
105
+ // becomes possible.
106
+ //
107
+ // Solution: every time the event page loads, scan existing tabs once and
108
+ // populate the map for any dashboard URLs already open. Idempotent —
109
+ // re-registering the same (tab, url, windowId) triple is a no-op.
110
+ async function populateSessionMapFromExistingTabs() {
111
+ try {
112
+ const tabs = await browser.tabs.query({});
113
+ for (const t of tabs) {
114
+ registerDashboardTab(t);
115
+ }
116
+ } catch {
117
+ // browser.tabs.query can fail in event-page wake contexts where the
118
+ // host hasn't fully initialised. The tabs.onCreated/onUpdated paths
119
+ // remain as the fallback registration mechanism.
120
+ }
121
+ }
122
+ populateSessionMapFromExistingTabs();
123
+
66
124
  // Top-level tab lifecycle listeners — MUST be registered synchronously at script
67
125
  // load time so Safari wakes the event page when tabs change.
68
126
  browser.tabs.onCreated.addListener((tab) => {
69
127
  if (tab.id != null) {
70
- tabCacheMap.set(tab.id, { url: tab.url || '', title: tab.title || '' });
128
+ // v0.1.36 reviewer F1.2 store windowId so findTargetTab can filter
129
+ // candidates by the originating MCP session's window. Cross-session
130
+ // matchers would otherwise route one agent's command into another
131
+ // agent's tab when two MCP sessions share a Safari instance (typical
132
+ // at bench concurrency).
133
+ tabCacheMap.set(tab.id, {
134
+ url: tab.url || '',
135
+ title: tab.title || '',
136
+ windowId: tab.windowId,
137
+ });
71
138
  saveTabCache();
139
+ registerDashboardTab(tab);
72
140
  }
73
141
  });
74
142
 
75
143
  browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
76
- const entry = tabCacheMap.get(tabId) || { url: '', title: '' };
144
+ const entry = tabCacheMap.get(tabId) || { url: '', title: '', windowId: undefined };
77
145
  if (changeInfo.url !== undefined) entry.url = changeInfo.url;
78
146
  if (changeInfo.title !== undefined) entry.title = changeInfo.title;
79
147
  // Also pick up from the full tab object if changeInfo is sparse
80
148
  if (tab.url && !entry.url) entry.url = tab.url;
81
149
  if (tab.title && !entry.title) entry.title = tab.title;
150
+ // F1.2: keep windowId fresh from the full tab object; tabs.onUpdated does
151
+ // not emit a windowId changeInfo even when the tab is moved between
152
+ // windows, so always sync from `tab.windowId`.
153
+ if (tab.windowId !== undefined) entry.windowId = tab.windowId;
82
154
  tabCacheMap.set(tabId, entry);
83
155
  saveTabCache();
156
+ registerDashboardTab(tab);
84
157
  });
85
158
 
86
159
  browser.tabs.onRemoved.addListener((tabId) => {
87
160
  tabCacheMap.delete(tabId);
88
161
  saveTabCache();
162
+ // F1.2: clean up dashboard URL mapping if this was a session dashboard
163
+ // tab. Leaving stale entries would mean a later command targeting the
164
+ // closed session's URL would still try to filter by its dead windowId.
165
+ const url = dashboardTabIdToUrl.get(tabId);
166
+ if (url) {
167
+ dashboardTabIdToUrl.delete(tabId);
168
+ sessionDashboardUrlToWindowId.delete(url);
169
+ }
89
170
  });
90
171
 
91
172
  // T79: clear tab-scoped selectorPack storage on tab close. Keys live under
@@ -236,10 +317,191 @@ async function removePendingEntry(commandId) {
236
317
  }
237
318
 
238
319
  // ─── Command execution ───────────────────────────────────────────────────────
239
- async function findTargetTab(tabUrl) {
240
- if (tabUrl) {
241
- const target = tabUrl.replace(/\/$/, '');
242
320
 
321
+ // v0.1.36 Track A Fix 3 — content-script readiness map (inlined from
322
+ // extension/lib/cs-readiness.js; tested in test/unit/extension/
323
+ // cs-readiness.test.ts). Content scripts write a heartbeat to
324
+ // `sp_cs_ready_<tabId>` on load; we mirror it into spCsReadyMap and use
325
+ // it to choose between a fast-fail (5s) vs. normal (30s) storage-bus
326
+ // timeout. Without this, dispatching to a freshly opened/navigated tab
327
+ // blocks the full 30s before the agent can recover.
328
+ const SP_CS_READY_MAX_AGE_MS = 60_000;
329
+ const SP_CS_NOT_READY_FAST_FAIL_MS = 10_000;
330
+ const spCsReadyMap = new Map();
331
+ function spRecordCsReady(tabId, now) { spCsReadyMap.set(tabId, { timestamp: now }); }
332
+ function spIsCsReady(tabId, now, maxAgeMs) {
333
+ const entry = spCsReadyMap.get(tabId);
334
+ if (!entry) return false;
335
+ return (now - entry.timestamp) <= (maxAgeMs ?? SP_CS_READY_MAX_AGE_MS);
336
+ }
337
+ function spDecideStorageBusTimeout(tabId, now, callerDefaultMs) {
338
+ if (spIsCsReady(tabId, now)) {
339
+ return { timeoutMs: callerDefaultMs, reason: 'cs_ready' };
340
+ }
341
+ return {
342
+ timeoutMs: Math.min(callerDefaultMs, SP_CS_NOT_READY_FAST_FAIL_MS),
343
+ reason: 'cs_not_ready',
344
+ };
345
+ }
346
+ // Storage listener: mirror `sp_cs_ready_<tabId>` keys into the in-memory map.
347
+ // Content-isolated.js writes one on load; on navigation, the new content
348
+ // script writes a fresh timestamp, refreshing the readiness window.
349
+ browser.storage.onChanged.addListener((changes, area) => {
350
+ if (area !== 'local') return;
351
+ for (const k of Object.keys(changes)) {
352
+ if (!k.startsWith('sp_cs_ready_')) continue;
353
+ const tabId = parseInt(k.slice('sp_cs_ready_'.length), 10);
354
+ const nv = changes[k].newValue;
355
+ if (!Number.isFinite(tabId)) continue;
356
+ if (nv && typeof nv.ts === 'number') spRecordCsReady(tabId, nv.ts);
357
+ if (nv === undefined || nv === null) spCsReadyMap.delete(tabId);
358
+ }
359
+ });
360
+ // Cleanup readiness state when a tab closes.
361
+ browser.tabs.onRemoved.addListener((tabId) => {
362
+ spCsReadyMap.delete(tabId);
363
+ try { browser.storage.local.remove('sp_cs_ready_' + tabId).catch(() => {}); } catch { /* shrug */ }
364
+ });
365
+ // Rehydrate readiness map from storage on event-page startup. MV3 wakes the
366
+ // event page repeatedly — any heartbeats written while it was asleep would be
367
+ // invisible to the in-memory map without this scan.
368
+ (async () => {
369
+ try {
370
+ const stored = await browser.storage.local.get(null);
371
+ for (const k of Object.keys(stored)) {
372
+ if (!k.startsWith('sp_cs_ready_')) continue;
373
+ const tabId = parseInt(k.slice('sp_cs_ready_'.length), 10);
374
+ const v = stored[k];
375
+ if (!Number.isFinite(tabId)) continue;
376
+ if (v && typeof v.ts === 'number') spRecordCsReady(tabId, v.ts);
377
+ }
378
+ } catch { /* storage transiently unavailable — gate falls back to fast-fail */ }
379
+ })();
380
+ // On navigation completion, prior heartbeat is stale: a new content script
381
+ // will load and post a fresh one. We DON'T evict eagerly here — we let the
382
+ // fresh write overwrite, and the max-age window covers the rare gap.
383
+
384
+ // v0.1.36 Track A Fix 1 — tolerant URL matcher (inlined from
385
+ // extension/lib/tab-url-matcher.js; tested in test/unit/extension/
386
+ // tab-url-matcher.test.ts). MV3 background can't import ES modules, so the
387
+ // implementation is duplicated here. Keep behaviour in sync with the lib.
388
+ const SP_TRACKING_PARAM_PREFIXES = ['utm_'];
389
+ const SP_TRACKING_PARAM_EXACT = new Set([
390
+ 'gclid', 'fbclid', 'msclkid', 'mc_eid', 'mc_cid',
391
+ 'ref', 'referrer', 'source', 'campaign',
392
+ '_ga', '_gl', 'igshid', 'yclid', 'twclid', 'dclid',
393
+ ]);
394
+ function spStripTrailingSlash(s) { return s.length > 1 && s.endsWith('/') ? s.slice(0, -1) : s; }
395
+ function spNormalizeForMatch(url) {
396
+ if (typeof url !== 'string' || url.length === 0) return url || '';
397
+ let u;
398
+ try { u = new URL(url); } catch { return spStripTrailingSlash(url); }
399
+ const scheme = u.protocol.toLowerCase();
400
+ let host = u.hostname.toLowerCase();
401
+ if (host.startsWith('www.')) host = host.slice(4);
402
+ const params = new URLSearchParams();
403
+ for (const [k, v] of u.searchParams) {
404
+ const lk = k.toLowerCase();
405
+ if (SP_TRACKING_PARAM_EXACT.has(lk)) continue;
406
+ if (SP_TRACKING_PARAM_PREFIXES.some((p) => lk.startsWith(p))) continue;
407
+ params.append(k, v);
408
+ }
409
+ const queryStr = params.toString();
410
+ const port = u.port ? ':' + u.port : '';
411
+ const path = spStripTrailingSlash(u.pathname || '/');
412
+ return `${scheme}//${host}${port}${path}${queryStr ? '?' + queryStr : ''}`;
413
+ }
414
+ function spOriginAndPath(url) {
415
+ try {
416
+ const u = new URL(url);
417
+ let host = u.hostname.toLowerCase();
418
+ if (host.startsWith('www.')) host = host.slice(4);
419
+ return {
420
+ origin: `${u.protocol.toLowerCase()}//${host}${u.port ? ':' + u.port : ''}`,
421
+ path: spStripTrailingSlash(u.pathname || '/'),
422
+ };
423
+ } catch { return null; }
424
+ }
425
+ function spPathIsPrefix(requestedPath, candidatePath) {
426
+ if (candidatePath === requestedPath) return true;
427
+ if (!candidatePath.startsWith(requestedPath)) return false;
428
+ return candidatePath.charAt(requestedPath.length) === '/';
429
+ }
430
+ /** Returns the matched candidate's id (whatever the caller passes in `id`),
431
+ * or null if no tier matches. Candidates: Array<{id, url}>. */
432
+ function spMatchTabUrl(requestedUrl, candidates) {
433
+ if (typeof requestedUrl !== 'string' || requestedUrl.length === 0) return null;
434
+ if (!Array.isArray(candidates) || candidates.length === 0) return null;
435
+ // Tier 0 — exact (trailing-slash tolerant).
436
+ const targetExact = spStripTrailingSlash(requestedUrl);
437
+ for (const c of candidates) {
438
+ if (spStripTrailingSlash(c.url || '') === targetExact) return c.id;
439
+ }
440
+ // Tier 1 — normalized (ambiguity guard, F1.1). First-match-wins routed
441
+ // commands into stale or dead tabs when two candidates normalize-identically
442
+ // (live SPA-drifted tab + stale closed-tab leftover). Mirror Tier 2's
443
+ // ambiguity contract: return id only when exactly one candidate matches.
444
+ const targetNorm = spNormalizeForMatch(requestedUrl);
445
+ let tier1Id = null;
446
+ let tier1Count = 0;
447
+ for (const c of candidates) {
448
+ if (spNormalizeForMatch(c.url || '') === targetNorm) {
449
+ tier1Id = c.id;
450
+ tier1Count += 1;
451
+ }
452
+ }
453
+ if (tier1Count === 1) return tier1Id;
454
+ // Tier 2 — origin + path-prefix (longest unambiguous).
455
+ const reqOriginPath = spOriginAndPath(requestedUrl);
456
+ if (!reqOriginPath) return null;
457
+ let bestId = null;
458
+ let bestLen = -1;
459
+ let bestCount = 0;
460
+ for (const c of candidates) {
461
+ const cop = spOriginAndPath(c.url || '');
462
+ if (!cop) continue;
463
+ if (cop.origin !== reqOriginPath.origin) continue;
464
+ if (!spPathIsPrefix(reqOriginPath.path, cop.path)) continue;
465
+ if (cop.path.length > bestLen) {
466
+ bestLen = cop.path.length; bestId = c.id; bestCount = 1;
467
+ } else if (cop.path.length === bestLen) {
468
+ bestCount += 1;
469
+ }
470
+ }
471
+ return bestId !== null && bestCount === 1 ? bestId : null;
472
+ }
473
+
474
+ // v0.1.36 reviewer F1.2 — session-scoped candidate filter. Pre-filters the
475
+ // tab list to only those belonging to the originating MCP session's window
476
+ // BEFORE the URL matcher fires, so cross-session tabs cannot be matched.
477
+ // Logic mirrors extension/lib/session-filter.js (tested in
478
+ // test/unit/extension/session-filter.test.ts). Inlined because MV3
479
+ // background can't import ES modules.
480
+ //
481
+ // 2026-05-18 evening rework: signature changed from `sessionWindowId`
482
+ // (AppleScript int — wrong namespace) to `sessionDashboardUrl` (stable
483
+ // string identifier). The Map sessionDashboardUrlToWindowId is populated
484
+ // by tabs.onUpdated / onCreated above whenever a tab loads a URL matching
485
+ // SESSION_DASHBOARD_URL_PREFIX. Filtering happens in the WebExtension
486
+ // API's windowId namespace where the cache entries also live.
487
+ function spFilterBySession(candidates, sessionDashboardUrl) {
488
+ if (sessionDashboardUrl === undefined || sessionDashboardUrl === null) {
489
+ return candidates;
490
+ }
491
+ const wid = sessionDashboardUrlToWindowId.get(sessionDashboardUrl);
492
+ if (wid === undefined) {
493
+ // Startup race or unknown session — fail OPEN (per spec). The TS-side
494
+ // TabOwnershipRegistry still enforces per-session isolation by URL.
495
+ return candidates;
496
+ }
497
+ return candidates.filter((c) => c.windowId === wid);
498
+ }
499
+
500
+ async function findTargetTab(tabUrl, opts) {
501
+ const sessionDashboardUrl = (opts && opts.sessionDashboardUrl !== undefined)
502
+ ? opts.sessionDashboardUrl
503
+ : undefined;
504
+ if (tabUrl) {
243
505
  // Test-only escape hatch: if `__sp_test_skip_tabs_query__` is set in
244
506
  // storage, the tabs.query primary path is skipped. Used by e2e tests to
245
507
  // simulate Safari's alarm-wake context where tabs.query({}) returns [].
@@ -249,22 +511,32 @@ async function findTargetTab(tabUrl) {
249
511
 
250
512
 
251
513
  if (!skipTabsQuery) {
252
- // Primary: browser.tabs.query (works when event page is fully active)
514
+ // Primary: browser.tabs.query, run through the 3-tier matcher so SPA
515
+ // URL drift / www-prefix / tracking-params don't trigger TAB_NOT_FOUND.
253
516
  const all = await browser.tabs.query({});
254
517
  if (all.length > 0) {
255
- const match = all.find((t) => (t.url || '').replace(/\/$/, '') === target);
256
- if (match) return match;
518
+ const liveCandidates = all.map((t) => ({ id: t.id, url: t.url || '', windowId: t.windowId }));
519
+ const sessionScoped = spFilterBySession(liveCandidates, sessionDashboardUrl);
520
+ const matchedId = spMatchTabUrl(tabUrl, sessionScoped);
521
+ if (matchedId != null) {
522
+ const t = all.find((x) => x.id === matchedId);
523
+ if (t) return t;
524
+ }
257
525
  }
258
526
  }
259
527
 
260
528
  // Fallback: persistent tab cache (works when tabs.query returns [] in
261
529
  // alarm-triggered wake context — Safari event page lifecycle limitation).
262
530
  if (tabCacheMap.size > 0) {
531
+ const cacheList = [];
263
532
  for (const [tabId, info] of tabCacheMap) {
264
- if ((info.url || '').replace(/\/$/, '') === target) {
265
- // Return a minimal tab-like object with the id for scripting API
266
- return { id: tabId, url: info.url, title: info.title };
267
- }
533
+ cacheList.push({ id: tabId, url: info.url || '', windowId: info.windowId });
534
+ }
535
+ const sessionScoped = spFilterBySession(cacheList, sessionDashboardUrl);
536
+ const matchedId = spMatchTabUrl(tabUrl, sessionScoped);
537
+ if (matchedId != null) {
538
+ const info = tabCacheMap.get(matchedId);
539
+ return { id: matchedId, url: info.url, title: info.title };
268
540
  }
269
541
  }
270
542
 
@@ -294,13 +566,46 @@ async function executeCommand(cmd) {
294
566
  return result;
295
567
  }
296
568
 
297
- const tab = await findTargetTab(cmd.tabUrl);
569
+ // F1.2: scope candidates to the originating MCP session's Safari window
570
+ // when the command carries a sessionDashboardUrl. Commands without one
571
+ // (legacy callers, health probes, startup-race) keep pre-F1.2
572
+ // cross-session behaviour — see spFilterBySession header above.
573
+ const tab = await findTargetTab(cmd.tabUrl, { sessionDashboardUrl: cmd.sessionDashboardUrl });
298
574
  if (!tab || tab.id == null) {
299
575
  // T27: structured error so the daemon's ExtensionBridge.handleResult
300
576
  // lifts `name` into StructuredError.code. The TS-side ExtensionEngine
301
577
  // round-trips that as the error code, surfacing TAB_NOT_FOUND to MCP.
578
+ //
579
+ // v0.1.36 Fix 1: enrich the error with the closest same-origin
580
+ // candidate URL so the agent can update its stored tabUrl on retry.
581
+ // (Tier 2 matching already covers most drift cases; this branch only
582
+ // fires when even the path-prefix tier missed — e.g. agent's URL is
583
+ // on origin X but the only X-origin tab is at an unrelated path.)
584
+ let hint = '';
585
+ if (cmd.tabUrl) {
586
+ try {
587
+ const u = new URL(cmd.tabUrl);
588
+ const reqOrigin = u.protocol + '//' + u.hostname.replace(/^www\./, '');
589
+ const seen = new Set();
590
+ const sameOriginUrls = [];
591
+ for (const [, info] of tabCacheMap) {
592
+ if (!info.url) continue;
593
+ try {
594
+ const cu = new URL(info.url);
595
+ const co = cu.protocol + '//' + cu.hostname.replace(/^www\./, '');
596
+ if (co === reqOrigin && !seen.has(info.url)) {
597
+ seen.add(info.url);
598
+ sameOriginUrls.push(info.url);
599
+ }
600
+ } catch { /* skip unparsable */ }
601
+ }
602
+ if (sameOriginUrls.length > 0) {
603
+ hint = ` Same-origin tabs in cache: ${sameOriginUrls.slice(0, 3).join(', ')}. Update tabUrl in subsequent calls.`;
604
+ }
605
+ } catch { /* unparsable requested URL — no hint */ }
606
+ }
302
607
  const error = cmd.tabUrl
303
- ? { name: 'TAB_NOT_FOUND', message: `No agent-owned tab matches url="${cmd.tabUrl}" (extension cache miss)` }
608
+ ? { name: 'TAB_NOT_FOUND', message: `No agent-owned tab matches url="${cmd.tabUrl}" (extension cache miss).${hint}` }
304
609
  : { message: `No target tab for url="${cmd.tabUrl}"` };
305
610
  const result = { ok: false, error };
306
611
  await updatePendingEntry(commandId, { status: 'completed', result });
@@ -723,7 +1028,16 @@ async function executeCommand(cmd) {
723
1028
  const cmdKey = 'sp_cmd_' + commandId;
724
1029
  const resultKey = 'sp_result_' + commandId;
725
1030
  const isFrameTargeted = cmd.frameId != null && cmd.frameId !== 0;
726
- const TIMEOUT_MS = isFrameTargeted ? 10000 : 30000;
1031
+ // v0.1.36 Fix 3 (initial-soft) gate timeout on content-script readiness.
1032
+ // Initial shipping behaviour: track heartbeats for telemetry but do NOT
1033
+ // fast-fail. The in-memory readiness map is wiped whenever Safari restarts
1034
+ // the MV3 event page, which falsely flags long-lived tabs as not-ready.
1035
+ // The tighter fast-fail behaviour will return in v0.1.37 once heartbeat
1036
+ // rehydration from storage is robust.
1037
+ const baseTimeout = isFrameTargeted ? 10000 : 30000;
1038
+ const isCsReadyNow = spIsCsReady(tab.id, Date.now());
1039
+ const TIMEOUT_MS = baseTimeout;
1040
+ const timeoutReason = isCsReadyNow ? 'cs_ready' : 'cs_not_ready_observed';
727
1041
  const storageCmd = {
728
1042
  commandId,
729
1043
  tabId: tab.id,
@@ -750,10 +1064,22 @@ async function executeCommand(cmd) {
750
1064
  const resultTimeout = setTimeout(() => {
751
1065
  clearInterval(keepAlive);
752
1066
  browser.storage.onChanged.removeListener(resultListener);
753
- const errorCode = isFrameTargeted ? 'FRAME_UNREACHABLE' : 'STORAGE_BUS_TIMEOUT';
754
- const errorMessage = isFrameTargeted
755
- ? `Frame ${cmd.frameId} unreachable content script did not respond within ${TIMEOUT_MS}ms (sandbox/CSP/injection failure?)`
756
- : `Storage bus timeout (${TIMEOUT_MS}ms) — content script may not be loaded on target tab`;
1067
+ // v0.1.36 Fix 3 emit CONTENT_SCRIPT_NOT_READY when the timeout was
1068
+ // gated short because no recent heartbeat existed. This is a recoverable
1069
+ // error: agent should call safari_wait_for or safari_navigate, then retry.
1070
+ let errorCode;
1071
+ let errorMessage;
1072
+ if (isFrameTargeted) {
1073
+ errorCode = 'FRAME_UNREACHABLE';
1074
+ errorMessage = `Frame ${cmd.frameId} unreachable — content script did not respond within ${TIMEOUT_MS}ms (sandbox/CSP/injection failure?)`;
1075
+ } else if (timeoutReason === 'cs_not_ready_observed') {
1076
+ // Heartbeat absent at decision time AND full timeout elapsed.
1077
+ errorCode = 'CONTENT_SCRIPT_NOT_READY';
1078
+ errorMessage = `Content script did not respond within ${TIMEOUT_MS}ms; no readiness heartbeat observed for this tab. Page may still be loading; call safari_wait_for with selector="body" before retrying.`;
1079
+ } else {
1080
+ errorCode = 'STORAGE_BUS_TIMEOUT';
1081
+ errorMessage = `Storage bus timeout (${TIMEOUT_MS}ms) — content script registered but did not respond in time`;
1082
+ }
757
1083
  resultResolver({ ok: false, error: { name: errorCode, message: errorMessage } });
758
1084
  }, TIMEOUT_MS);
759
1085