baa-conductor

git clone 

commit
22a3232
parent
fa6290a
author
im_wower
date
2026-03-28 13:30:48 +0800 CST
fix: restore Claude final-message capture in live tabs
4 files changed,  +816, -191
M plugins/baa-firefox/content-script.js
+47, -14
 1@@ -1,3 +1,10 @@
 2+const CONTENT_SCRIPT_RUNTIME_KEY = "__baaFirefoxContentScriptRuntime__";
 3+
 4+const previousContentScriptRuntime = window[CONTENT_SCRIPT_RUNTIME_KEY];
 5+if (previousContentScriptRuntime && typeof previousContentScriptRuntime.dispose === "function") {
 6+  previousContentScriptRuntime.dispose();
 7+}
 8+
 9 function sendBridgeMessage(type, data) {
10   browser.runtime.sendMessage({
11     type,
12@@ -35,32 +42,27 @@ async function handleDeliveryCommand(data = {}) {
13   return await deliveryRuntime.handleCommand(data);
14 }
15 
16-sendBridgeMessage("baa_page_bridge_ready", {
17-  url: location.href,
18-  source: "content-script"
19-});
20-
21-window.addEventListener("__baa_ready__", (event) => {
22+function handlePageReady(event) {
23   sendBridgeMessage("baa_page_bridge_ready", {
24     ...(event.detail || {}),
25     url: event.detail?.url || location.href,
26     source: event.detail?.source || "page-interceptor"
27   });
28-});
29+}
30 
31-window.addEventListener("__baa_net__", (event) => {
32+function handlePageNetwork(event) {
33   sendBridgeMessage("baa_page_network", event.detail);
34-});
35+}
36 
37-window.addEventListener("__baa_sse__", (event) => {
38+function handlePageSse(event) {
39   sendBridgeMessage("baa_page_sse", event.detail);
40-});
41+}
42 
43-window.addEventListener("__baa_proxy_response__", (event) => {
44+function handleProxyResponse(event) {
45   sendBridgeMessage("baa_page_proxy_response", event.detail);
46-});
47+}
48 
49-browser.runtime.onMessage.addListener((message) => {
50+function handleRuntimeMessage(message) {
51   if (!message || typeof message !== "object") return undefined;
52 
53   if (message.type === "baa_page_proxy_request") {
54@@ -83,4 +85,35 @@ browser.runtime.onMessage.addListener((message) => {
55   }
56 
57   return undefined;
58+}
59+
60+window.addEventListener("__baa_ready__", handlePageReady);
61+window.addEventListener("__baa_net__", handlePageNetwork);
62+window.addEventListener("__baa_sse__", handlePageSse);
63+window.addEventListener("__baa_proxy_response__", handleProxyResponse);
64+browser.runtime.onMessage.addListener(handleRuntimeMessage);
65+
66+sendBridgeMessage("baa_page_bridge_ready", {
67+  url: location.href,
68+  source: "content-script"
69 });
70+
71+const contentScriptRuntime = {
72+  dispose() {
73+    window.removeEventListener("__baa_ready__", handlePageReady);
74+    window.removeEventListener("__baa_net__", handlePageNetwork);
75+    window.removeEventListener("__baa_sse__", handlePageSse);
76+    window.removeEventListener("__baa_proxy_response__", handleProxyResponse);
77+    browser.runtime.onMessage.removeListener(handleRuntimeMessage);
78+
79+    if (window[CONTENT_SCRIPT_RUNTIME_KEY] === contentScriptRuntime) {
80+      try {
81+        delete window[CONTENT_SCRIPT_RUNTIME_KEY];
82+      } catch (_) {
83+        window[CONTENT_SCRIPT_RUNTIME_KEY] = null;
84+      }
85+    }
86+  }
87+};
88+
89+window[CONTENT_SCRIPT_RUNTIME_KEY] = contentScriptRuntime;
M plugins/baa-firefox/controller.js
+217, -28
  1@@ -43,6 +43,8 @@ const CONTROL_RETRY_LOG_INTERVAL = 60_000;
  2 const TRACKED_TAB_REFRESH_DELAY = 150;
  3 const SHELL_RUNTIME_HEALTHCHECK_INTERVAL = 30_000;
  4 const CONTROL_STATUS_BODY_LIMIT = 12_000;
  5+const CONTENT_SCRIPT_INJECTION_FILES = ["delivery-adapters.js", "content-script.js"];
  6+const PAGE_INTERCEPTOR_INJECTION_FILES = ["page-interceptor.js"];
  7 const WS_RECONNECT_DELAY = 3_000;
  8 const MANUAL_WS_RECONNECT_DEFAULT_DISCONNECT_MS = 80;
  9 const MANUAL_WS_RECONNECT_MAX_DISCONNECT_MS = 60_000;
 10@@ -3672,11 +3674,18 @@ async function runPluginManagementAction(action, options = {}) {
 11           reason,
 12           action: methodName
 13         });
 14+        const observerRefresh = methodName === "tab_reload"
 15+          ? summarizeObserverRefresh(await reinjectPlatformTabs(target, {
 16+              excludeTabIds: Number.isInteger(tab?.id) ? [tab.id] : [],
 17+              source: methodName
 18+            }))
 19+          : null;
 20         results.push({
 21           platform: target,
 22           ok: true,
 23           tabId: tab?.id ?? null,
 24-          restored: methodName === "tab_open" ? !previousActual.exists : undefined
 25+          restored: methodName === "tab_open" ? !previousActual.exists : undefined,
 26+          observer_refresh: observerRefresh
 27         });
 28       }
 29       break;
 30@@ -4377,30 +4386,47 @@ async function resolveTrackedTab(platform, options = {}) {
 31   }
 32 }
 33 
 34-async function findPlatformTab(platform) {
 35-  const tabs = await browser.tabs.query({ url: PLATFORMS[platform].urlPatterns });
 36-  if (tabs.length === 0) return null;
 37-  tabs.sort((left, right) => {
 38+function sortTabsByRecency(tabs = []) {
 39+  return [...tabs].sort((left, right) => {
 40     if (!!left.active !== !!right.active) return left.active ? -1 : 1;
 41     const leftAccess = Number(left.lastAccessed) || 0;
 42     const rightAccess = Number(right.lastAccessed) || 0;
 43     return rightAccess - leftAccess;
 44   });
 45+}
 46+
 47+function dedupeTabsById(tabs = []) {
 48+  const seen = new Set();
 49+  const unique = [];
 50+
 51+  for (const tab of tabs) {
 52+    if (!Number.isInteger(tab?.id) || seen.has(tab.id)) {
 53+      continue;
 54+    }
 55+
 56+    seen.add(tab.id);
 57+    unique.push(tab);
 58+  }
 59+
 60+  return unique;
 61+}
 62+
 63+async function queryPlatformTabs(platform) {
 64+  return sortTabsByRecency(await browser.tabs.query({ url: PLATFORMS[platform].urlPatterns }));
 65+}
 66+
 67+async function findPlatformTab(platform) {
 68+  const tabs = await queryPlatformTabs(platform);
 69+  if (tabs.length === 0) return null;
 70   return tabs[0];
 71 }
 72 
 73 async function findPlatformShellTab(platform, preferredTabId = null, options = {}) {
 74   const allowFallbackShell = options.allowFallbackShell === true;
 75-  const tabs = await browser.tabs.query({ url: PLATFORMS[platform].urlPatterns });
 76+  const tabs = await queryPlatformTabs(platform);
 77   const shellTabs = tabs.filter((tab) => isPlatformShellUrl(platform, tab.url || "", { allowFallback: allowFallbackShell }));
 78   if (shellTabs.length === 0) return null;
 79 
 80-  shellTabs.sort((left, right) => {
 81-    const leftAccess = Number(left.lastAccessed) || 0;
 82-    const rightAccess = Number(right.lastAccessed) || 0;
 83-    return rightAccess - leftAccess;
 84-  });
 85-
 86   const canonical = Number.isInteger(preferredTabId)
 87     ? (shellTabs.find((tab) => tab.id === preferredTabId) || shellTabs[0])
 88     : shellTabs[0];
 89@@ -4415,6 +4441,123 @@ async function findPlatformShellTab(platform, preferredTabId = null, options = {
 90   return canonical;
 91 }
 92 
 93+async function injectObserverScriptsIntoTab(tabId) {
 94+  const result = {
 95+    tabId,
 96+    ok: false,
 97+    contentScriptInjected: false,
 98+    interceptorInjected: false,
 99+    error: null
100+  };
101+
102+  if (!browser.scripting?.executeScript) {
103+    result.error = "scripting_execute_script_unavailable";
104+    return result;
105+  }
106+
107+  const errors = [];
108+
109+  try {
110+    await browser.scripting.executeScript({
111+      target: { tabId },
112+      files: CONTENT_SCRIPT_INJECTION_FILES
113+    });
114+    result.contentScriptInjected = true;
115+  } catch (error) {
116+    errors.push(`content=${error instanceof Error ? error.message : String(error)}`);
117+  }
118+
119+  try {
120+    await browser.scripting.executeScript({
121+      target: { tabId },
122+      files: PAGE_INTERCEPTOR_INJECTION_FILES,
123+      world: "MAIN"
124+    });
125+    result.interceptorInjected = true;
126+  } catch (error) {
127+    errors.push(`interceptor=${error instanceof Error ? error.message : String(error)}`);
128+  }
129+
130+  result.ok = result.contentScriptInjected && result.interceptorInjected;
131+  result.error = errors.length > 0 ? errors.join("; ") : null;
132+  return result;
133+}
134+
135+function summarizeObserverRefresh(results = []) {
136+  const source = Array.isArray(results) ? results : [];
137+  const refreshed = source.filter((entry) => entry.ok);
138+  const failed = source.filter((entry) => !entry.ok);
139+
140+  return {
141+    attempted_count: source.length,
142+    refreshed_count: refreshed.length,
143+    failed_count: failed.length,
144+    tab_ids: source.map((entry) => entry.tabId),
145+    refreshed_tab_ids: refreshed.map((entry) => entry.tabId),
146+    failed_tab_ids: failed.map((entry) => entry.tabId)
147+  };
148+}
149+
150+async function reinjectPlatformTabs(platform, options = {}) {
151+  if (!PLATFORMS[platform]) return [];
152+
153+  const excludeTabIds = new Set(
154+    Array.isArray(options.excludeTabIds)
155+      ? options.excludeTabIds.filter((tabId) => Number.isInteger(tabId))
156+      : []
157+  );
158+  const allowFallbackShell = cloneDesiredTabState(platform, state.desiredTabs[platform]).exists;
159+  const tabs = dedupeTabsById(
160+    Array.isArray(options.tabs)
161+      ? sortTabsByRecency(options.tabs)
162+      : await queryPlatformTabs(platform)
163+  );
164+  const results = [];
165+
166+  for (const tab of tabs) {
167+    if (!Number.isInteger(tab?.id) || excludeTabIds.has(tab.id)) {
168+      continue;
169+    }
170+
171+    const injection = await injectObserverScriptsIntoTab(tab.id);
172+    results.push({
173+      ...injection,
174+      isShellPage: isPlatformShellUrl(platform, tab.url || "", { allowFallback: allowFallbackShell }),
175+      platform,
176+      url: trimToNull(tab.url)
177+    });
178+  }
179+
180+  if (results.length > 0) {
181+    const summary = summarizeObserverRefresh(results);
182+    addLog(
183+      "info",
184+      `已刷新 ${platformLabel(platform)} 页面观察脚本 ${summary.refreshed_count}/${summary.attempted_count} 个标签页,来源 ${trimToNull(options.source) || "runtime"}`,
185+      false
186+    );
187+
188+    if (summary.failed_count > 0) {
189+      const failedTabs = results
190+        .filter((entry) => !entry.ok)
191+        .map((entry) => `${entry.tabId}:${entry.error || "unknown"}`)
192+        .join(", ");
193+      addLog("warn", `${platformLabel(platform)} 页面观察脚本刷新失败:${failedTabs}`, false);
194+    }
195+  }
196+
197+  return results;
198+}
199+
200+async function reinjectAllOpenPlatformTabs(options = {}) {
201+  const results = [];
202+
203+  for (const platform of PLATFORM_ORDER) {
204+    results.push(...await reinjectPlatformTabs(platform, options));
205+  }
206+
207+  return results;
208+}
209+
210 async function setTrackedTab(platform, tab) {
211   state.trackedTabs[platform] = tab ? tab.id : null;
212   state.actualTabs[platform] = buildActualTabSnapshot(
213@@ -4666,14 +4809,33 @@ function ensureTrackedTabId(platform, tabId, source, senderUrl = "") {
214   return true;
215 }
216 
217+function isSenderShellContext(platform, senderUrl = "") {
218+  const desired = cloneDesiredTabState(platform, state.desiredTabs[platform]);
219+  const adoptableShell = shouldAdoptPlatformTabAsDesired(platform, senderUrl);
220+  const allowFallbackShell = desired.exists || adoptableShell;
221+  return isPlatformShellUrl(platform, senderUrl, { allowFallback: allowFallbackShell });
222+}
223+
224 function getSenderContext(sender, fallbackPlatform = null) {
225   const tabId = sender?.tab?.id;
226   const senderUrl = sender?.tab?.url || "";
227   const senderPlatform = detectPlatformFromUrl(senderUrl);
228   const platform = senderPlatform || fallbackPlatform;
229-  if (!platform) return null;
230-  if (!ensureTrackedTabId(platform, tabId, "message", senderUrl)) return null;
231-  return { platform, tabId };
232+  if (!platform || !Number.isInteger(tabId) || tabId < 0) return null;
233+
234+  const isShellPage = isSenderShellContext(platform, senderUrl);
235+  if (isShellPage) {
236+    if (!ensureTrackedTabId(platform, tabId, "message", senderUrl)) return null;
237+  } else if (senderUrl && !isPlatformUrl(platform, senderUrl)) {
238+    return null;
239+  }
240+
241+  return {
242+    platform,
243+    tabId,
244+    senderUrl,
245+    isShellPage
246+  };
247 }
248 
249 function resolvePlatformFromRequest(details) {
250@@ -5015,18 +5177,16 @@ function handlePageProxyResponse(data, sender) {
251 }
252 
253 function handlePageBridgeReady(data, sender) {
254-  const tabId = sender?.tab?.id;
255   const senderUrl = sender?.tab?.url || data?.url || "";
256-  const platform = detectPlatformFromUrl(senderUrl) || data?.platform || null;
257-  if (!platform || !Number.isInteger(tabId)) return;
258-  if (!ensureTrackedTabId(platform, tabId, "bridge", senderUrl)) return;
259+  const context = getSenderContext(sender, detectPlatformFromUrl(senderUrl) || data?.platform || null);
260+  if (!context) return;
261 
262-  if (platform === "claude") {
263+  if (context.platform === "claude") {
264     updateClaudeState({
265-      tabId,
266-      currentUrl: senderUrl || getPlatformShellUrl(platform),
267+      tabId: context.tabId,
268+      currentUrl: senderUrl || getPlatformShellUrl(context.platform),
269       tabTitle: trimToNull(sender?.tab?.title),
270-      conversationId: isPlatformShellUrl(platform, senderUrl)
271+      conversationId: context.isShellPage
272         ? null
273         : (extractClaudeConversationIdFromPageUrl(senderUrl) || state.claudeState.conversationId)
274     }, {
275@@ -5034,7 +5194,10 @@ function handlePageBridgeReady(data, sender) {
276       render: true
277     });
278   }
279-  addLog("info", `${platformLabel(platform)} 空壳页已就绪,标签页 ${tabId},来源 ${data?.source || "未知"}`);
280+  addLog(
281+    "info",
282+    `${platformLabel(context.platform)} ${context.isShellPage ? "空壳页" : "页面观察"}已就绪,标签页 ${context.tabId},来源 ${data?.source || "未知"}`
283+  );
284 }
285 
286 async function observeCredentialSnapshot(platform, headers, details = {}) {
287@@ -5809,6 +5972,9 @@ async function init() {
288     addLog("warn", "控制页未拿到当前 tabId,跳过 controller_ready 握手", false);
289   }
290 
291+  await reinjectAllOpenPlatformTabs({
292+    source: "startup"
293+  });
294   await refreshTrackedTabsFromBrowser("startup");
295   await collapseRecoveredDesiredTabs();
296   await restoreDesiredTabsOnStartup();
297@@ -5833,7 +5999,30 @@ window.addEventListener("beforeunload", () => {
298   closeWsConnection();
299 });
300 
301-init().catch((error) => {
302-  console.error(error);
303-  addLog("error", error.message, false);
304-});
305+function exposeControllerTestApi() {
306+  const target = globalThis.__BAA_CONTROLLER_TEST_API__;
307+  if (!target || typeof target !== "object") {
308+    return;
309+  }
310+
311+  Object.assign(target, {
312+    getSenderContext,
313+    handlePageBridgeReady,
314+    handlePageNetwork,
315+    handlePageSse,
316+    reinjectAllOpenPlatformTabs,
317+    reinjectPlatformTabs,
318+    runPluginManagementAction,
319+    setDesiredTabState,
320+    state
321+  });
322+}
323+
324+exposeControllerTestApi();
325+
326+if (globalThis.__BAA_SKIP_CONTROLLER_INIT__ !== true) {
327+  init().catch((error) => {
328+    console.error(error);
329+    addLog("error", error.message, false);
330+  });
331+}
M plugins/baa-firefox/page-interceptor.js
+211, -148
  1@@ -1,13 +1,31 @@
  2 (function () {
  3-  if (window.__baaFirefoxIntercepted__) return;
  4-  window.__baaFirefoxIntercepted__ = true;
  5+  const previousRuntime = window.__baaFirefoxIntercepted__;
  6+  const hasManagedRuntime = !!previousRuntime
  7+    && typeof previousRuntime === "object"
  8+    && typeof previousRuntime.teardown === "function";
  9+  const legacyIntercepted = previousRuntime === true
 10+    || (hasManagedRuntime && previousRuntime.legacyIntercepted === true);
 11+
 12+  if (hasManagedRuntime) {
 13+    try {
 14+      previousRuntime.teardown();
 15+    } catch (_) {}
 16+  }
 17 
 18   const BODY_LIMIT = 5000;
 19-  const originalFetch = window.fetch;
 20-  const originalXhrOpen = XMLHttpRequest.prototype.open;
 21-  const originalXhrSend = XMLHttpRequest.prototype.send;
 22-  const originalXhrSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
 23+  const originalFetch = hasManagedRuntime ? previousRuntime.originalFetch : window.fetch;
 24+  const originalXhrOpen = hasManagedRuntime ? previousRuntime.originalXhrOpen : XMLHttpRequest.prototype.open;
 25+  const originalXhrSend = hasManagedRuntime ? previousRuntime.originalXhrSend : XMLHttpRequest.prototype.send;
 26+  const originalXhrSetRequestHeader = hasManagedRuntime ? previousRuntime.originalXhrSetRequestHeader : XMLHttpRequest.prototype.setRequestHeader;
 27   const activeProxyControllers = new Map();
 28+  const cleanupHandlers = [];
 29+
 30+  function addWindowListener(type, listener) {
 31+    window.addEventListener(type, listener);
 32+    cleanupHandlers.push(() => {
 33+      window.removeEventListener(type, listener);
 34+    });
 35+  }
 36 
 37   function hostnameMatches(hostname, hosts) {
 38     return hosts.some((host) => hostname === host || hostname.endsWith(`.${host}`));
 39@@ -419,62 +437,90 @@
 40     }
 41   }
 42 
 43-  window.addEventListener("__baa_proxy_request__", async (event) => {
 44-    let detail = event.detail || {};
 45-    if (typeof detail === "string") {
 46-      try {
 47-        detail = JSON.parse(detail);
 48-      } catch (_) {
 49-        detail = {};
 50+  if (!legacyIntercepted) {
 51+    addWindowListener("__baa_proxy_request__", async (event) => {
 52+      let detail = event.detail || {};
 53+      if (typeof detail === "string") {
 54+        try {
 55+          detail = JSON.parse(detail);
 56+        } catch (_) {
 57+          detail = {};
 58+        }
 59       }
 60-    }
 61 
 62-    const id = detail.id;
 63-    const method = String(detail.method || "GET").toUpperCase();
 64-    const rawPath = detail.path || detail.url || location.href;
 65-    const responseMode = String(detail.response_mode || detail.responseMode || "buffered").toLowerCase();
 66+      const id = detail.id;
 67+      const method = String(detail.method || "GET").toUpperCase();
 68+      const rawPath = detail.path || detail.url || location.href;
 69+      const responseMode = String(detail.response_mode || detail.responseMode || "buffered").toLowerCase();
 70 
 71-    if (!id) return;
 72+      if (!id) return;
 73 
 74-    const proxyAbortController = new AbortController();
 75-    activeProxyControllers.set(id, proxyAbortController);
 76+      const proxyAbortController = new AbortController();
 77+      activeProxyControllers.set(id, proxyAbortController);
 78 
 79-    try {
 80-      const url = new URL(rawPath, location.origin).href;
 81-      const context = getRequestContext(url);
 82-      const headers = new Headers();
 83-      const startedAt = Date.now();
 84-
 85-      for (const [name, value] of Object.entries(detail.headers || {})) {
 86-        if (!name || value == null || value === "") continue;
 87-        if (isForbiddenProxyHeader(name)) continue;
 88-        headers.set(String(name).toLowerCase(), String(value));
 89-      }
 90+      try {
 91+        const url = new URL(rawPath, location.origin).href;
 92+        const context = getRequestContext(url);
 93+        const headers = new Headers();
 94+        const startedAt = Date.now();
 95+
 96+        for (const [name, value] of Object.entries(detail.headers || {})) {
 97+          if (!name || value == null || value === "") continue;
 98+          if (isForbiddenProxyHeader(name)) continue;
 99+          headers.set(String(name).toLowerCase(), String(value));
100+        }
101 
102-      let body = null;
103-      if (method !== "GET" && method !== "HEAD" && Object.prototype.hasOwnProperty.call(detail, "body")) {
104-        if (typeof detail.body === "string") {
105-          body = detail.body;
106-        } else if (detail.body != null) {
107-          if (!headers.has("content-type")) headers.set("content-type", "application/json");
108-          body = JSON.stringify(detail.body);
109+        let body = null;
110+        if (method !== "GET" && method !== "HEAD" && Object.prototype.hasOwnProperty.call(detail, "body")) {
111+          if (typeof detail.body === "string") {
112+            body = detail.body;
113+          } else if (detail.body != null) {
114+            if (!headers.has("content-type")) headers.set("content-type", "application/json");
115+            body = JSON.stringify(detail.body);
116+          }
117         }
118-      }
119 
120-      const response = await originalFetch.call(window, url, {
121-        method,
122-        headers,
123-        body,
124-        credentials: "include",
125-        signal: proxyAbortController.signal
126-      });
127-      const resHeaders = readHeaders(response.headers);
128-      const contentType = response.headers.get("content-type") || "";
129-      const isSse = context ? context.rule.isSse(context.parsed.pathname, contentType) : false;
130-      const reqHeaders = readHeaders(headers);
131-      const reqBody = typeof body === "string" ? trim(body) : null;
132+        const response = await originalFetch.call(window, url, {
133+          method,
134+          headers,
135+          body,
136+          credentials: "include",
137+          signal: proxyAbortController.signal
138+        });
139+        const resHeaders = readHeaders(response.headers);
140+        const contentType = response.headers.get("content-type") || "";
141+        const isSse = context ? context.rule.isSse(context.parsed.pathname, contentType) : false;
142+        const reqHeaders = readHeaders(headers);
143+        const reqBody = typeof body === "string" ? trim(body) : null;
144+
145+        if (responseMode === "sse") {
146+          emitNet({
147+            url,
148+            method,
149+            reqHeaders,
150+            reqBody,
151+            status: response.status,
152+            resHeaders,
153+            resBody: null,
154+            duration: Date.now() - startedAt,
155+            sse: true,
156+            source: "proxy"
157+          }, pageRule);
158+
159+          const replayDetail = {
160+            ...detail,
161+            id,
162+            method,
163+            stream_id: detail.stream_id || detail.streamId || id,
164+            url
165+          };
166+          await streamProxyResponse(replayDetail, response, startedAt, pageRule, reqBody);
167+          return;
168+        }
169+
170+        const responseBody = await response.text();
171+        const trimmedResponseBody = trim(responseBody);
172 
173-      if (responseMode === "sse") {
174         emitNet({
175           url,
176           method,
177@@ -482,123 +528,97 @@
178           reqBody,
179           status: response.status,
180           resHeaders,
181-          resBody: null,
182+          resBody: isSse && pageRule.platform !== "gemini" ? null : trimmedResponseBody,
183           duration: Date.now() - startedAt,
184-          sse: true,
185+          sse: isSse,
186           source: "proxy"
187         }, pageRule);
188 
189-        const replayDetail = {
190-          ...detail,
191-          id,
192-          method,
193-          stream_id: detail.stream_id || detail.streamId || id,
194-          url
195-        };
196-        await streamProxyResponse(replayDetail, response, startedAt, pageRule, reqBody);
197-        return;
198-      }
199-
200-      const responseBody = await response.text();
201-      const trimmedResponseBody = trim(responseBody);
202-
203-      emitNet({
204-        url,
205-        method,
206-        reqHeaders,
207-        reqBody,
208-        status: response.status,
209-        resHeaders,
210-        resBody: isSse && pageRule.platform !== "gemini" ? null : trimmedResponseBody,
211-        duration: Date.now() - startedAt,
212-        sse: isSse,
213-        source: "proxy"
214-      }, pageRule);
215+        if (isSse && trimmedResponseBody) {
216+          emitSse({
217+            url,
218+            method,
219+            reqBody,
220+            chunk: trimmedResponseBody,
221+            ts: Date.now()
222+          }, pageRule);
223+          emitSse({
224+            url,
225+            method,
226+            reqBody,
227+            done: true,
228+            ts: Date.now(),
229+            duration: Date.now() - startedAt
230+          }, pageRule);
231+        }
232 
233-      if (isSse && trimmedResponseBody) {
234-        emitSse({
235+        emit("__baa_proxy_response__", {
236+          id,
237+          platform: pageRule.platform,
238           url,
239           method,
240-          reqBody,
241-          chunk: trimmedResponseBody,
242-          ts: Date.now()
243+          ok: response.ok,
244+          status: response.status,
245+          body: responseBody
246         }, pageRule);
247-        emitSse({
248-          url,
249+      } catch (error) {
250+        emitNet({
251+          url: rawPath,
252           method,
253-          reqBody,
254-          done: true,
255-          ts: Date.now(),
256-          duration: Date.now() - startedAt
257+          reqHeaders: readHeaders(detail.headers || {}),
258+          reqBody: typeof detail.body === "string" ? trim(detail.body) : trimBodyValue(detail.body),
259+          error: error.message,
260+          source: "proxy"
261         }, pageRule);
262-      }
263 
264-      emit("__baa_proxy_response__", {
265-        id,
266-        platform: pageRule.platform,
267-        url,
268-        method,
269-        ok: response.ok,
270-        status: response.status,
271-        body: responseBody
272-      }, pageRule);
273-    } catch (error) {
274-      emitNet({
275-        url: rawPath,
276-        method,
277-        reqHeaders: readHeaders(detail.headers || {}),
278-        reqBody: typeof detail.body === "string" ? trim(detail.body) : trimBodyValue(detail.body),
279-        error: error.message,
280-        source: "proxy"
281-      }, pageRule);
282+        if (responseMode === "sse") {
283+          emitSse({
284+            error: error.message,
285+            id,
286+            method,
287+            reqBody: typeof detail.body === "string" ? trim(detail.body) : trimBodyValue(detail.body),
288+            status: null,
289+            stream_id: detail.stream_id || detail.streamId || id,
290+            ts: Date.now(),
291+            url: rawPath
292+          }, pageRule);
293+          return;
294+        }
295 
296-      if (responseMode === "sse") {
297-        emitSse({
298-          error: error.message,
299+        emit("__baa_proxy_response__", {
300           id,
301+          platform: pageRule.platform,
302+          url: rawPath,
303           method,
304-          reqBody: typeof detail.body === "string" ? trim(detail.body) : trimBodyValue(detail.body),
305-          status: null,
306-          stream_id: detail.stream_id || detail.streamId || id,
307-          ts: Date.now(),
308-          url: rawPath
309+          ok: false,
310+          error: error.message
311         }, pageRule);
312-        return;
313+      } finally {
314+        activeProxyControllers.delete(id);
315       }
316+    });
317 
318-      emit("__baa_proxy_response__", {
319-        id,
320-        platform: pageRule.platform,
321-        url: rawPath,
322-        method,
323-        ok: false,
324-        error: error.message
325-      }, pageRule);
326-    } finally {
327-      activeProxyControllers.delete(id);
328-    }
329-  });
330+    addWindowListener("__baa_proxy_cancel__", (event) => {
331+      let detail = event.detail || {};
332 
333-  window.addEventListener("__baa_proxy_cancel__", (event) => {
334-    let detail = event.detail || {};
335-
336-    if (typeof detail === "string") {
337-      try {
338-        detail = JSON.parse(detail);
339-      } catch (_) {
340-        detail = {};
341+      if (typeof detail === "string") {
342+        try {
343+          detail = JSON.parse(detail);
344+        } catch (_) {
345+          detail = {};
346+        }
347       }
348-    }
349 
350-    const id = detail?.id || detail?.requestId;
351-    if (!id) return;
352+      const id = detail?.id || detail?.requestId;
353+      if (!id) return;
354 
355-    const controller = activeProxyControllers.get(id);
356-    if (!controller) return;
357+      const controller = activeProxyControllers.get(id);
358+      if (!controller) return;
359 
360-    activeProxyControllers.delete(id);
361-    controller.abort(detail?.reason || "browser_request_cancelled");
362-  });
363+      activeProxyControllers.delete(id);
364+      controller.abort(detail?.reason || "browser_request_cancelled");
365+    });
366+  }
367 
368   window.fetch = async function patchedFetch(input, init) {
369     const url = input instanceof Request ? input.url : String(input);
370@@ -755,4 +775,47 @@
371 
372     return originalXhrSend.apply(this, arguments);
373   };
374+
375+  const runtime = {
376+    legacyIntercepted,
377+    originalFetch,
378+    originalXhrOpen,
379+    originalXhrSend,
380+    originalXhrSetRequestHeader,
381+    teardown() {
382+      for (const controller of activeProxyControllers.values()) {
383+        try {
384+          controller.abort("observer_reinject");
385+        } catch (_) {}
386+      }
387+      activeProxyControllers.clear();
388+
389+      window.fetch = originalFetch;
390+      XMLHttpRequest.prototype.open = originalXhrOpen;
391+      XMLHttpRequest.prototype.send = originalXhrSend;
392+      XMLHttpRequest.prototype.setRequestHeader = originalXhrSetRequestHeader;
393+
394+      while (cleanupHandlers.length > 0) {
395+        const cleanup = cleanupHandlers.pop();
396+        try {
397+          cleanup();
398+        } catch (_) {}
399+      }
400+
401+      if (window.__baaFirefoxIntercepted__ === runtime) {
402+        if (legacyIntercepted) {
403+          window.__baaFirefoxIntercepted__ = true;
404+          return;
405+        }
406+
407+        try {
408+          delete window.__baaFirefoxIntercepted__;
409+        } catch (_) {
410+          window.__baaFirefoxIntercepted__ = null;
411+        }
412+      }
413+    }
414+  };
415+
416+  window.__baaFirefoxIntercepted__ = runtime;
417 })();
M tests/browser/browser-control-e2e-smoke.test.mjs
+341, -1
  1@@ -1,9 +1,10 @@
  2 import assert from "node:assert/strict";
  3-import { mkdtempSync, rmSync } from "node:fs";
  4+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
  5 import { createRequire } from "node:module";
  6 import { tmpdir } from "node:os";
  7 import { join } from "node:path";
  8 import test from "node:test";
  9+import vm from "node:vm";
 10 
 11 import { ConductorRuntime } from "../../apps/conductor-daemon/dist/index.js";
 12 
 13@@ -18,6 +19,10 @@ const {
 14   observeSse,
 15   rememberRelay
 16 } = require("../../plugins/baa-firefox/final-message.js");
 17+const controllerSource = readFileSync(
 18+  new URL("../../plugins/baa-firefox/controller.js", import.meta.url),
 19+  "utf8"
 20+);
 21 
 22 function createWebSocketMessageQueue(socket) {
 23   const messages = [];
 24@@ -213,6 +218,263 @@ async function fetchText(url, init) {
 25   };
 26 }
 27 
 28+function createControllerUiElement() {
 29+  return {
 30+    addEventListener() {},
 31+    className: "",
 32+    disabled: false,
 33+    textContent: ""
 34+  };
 35+}
 36+
 37+function wildcardPatternToRegExp(pattern) {
 38+  const escaped = String(pattern || "")
 39+    .replace(/[.+?^${}()|[\]\\]/gu, "\\$&")
 40+    .replace(/\*/gu, ".*");
 41+  return new RegExp(`^${escaped}$`, "u");
 42+}
 43+
 44+function matchesUrlPatterns(url, patterns = []) {
 45+  return patterns.some((pattern) => wildcardPatternToRegExp(pattern).test(url));
 46+}
 47+
 48+function createControllerHarness(options = {}) {
 49+  const executeScriptCalls = [];
 50+  const reloadedTabIds = [];
 51+  const tabs = new Map();
 52+
 53+  for (const tab of options.tabs || []) {
 54+    tabs.set(tab.id, {
 55+      active: false,
 56+      discarded: false,
 57+      hidden: false,
 58+      lastAccessed: 0,
 59+      status: "complete",
 60+      title: "",
 61+      windowId: 1,
 62+      ...tab
 63+    });
 64+  }
 65+
 66+  let nextTabId = Math.max(0, ...tabs.keys()) + 1;
 67+  const storage = {};
 68+  const sentMessages = [];
 69+  const ws = options.ws || {
 70+    readyState: 1,
 71+    send(payload) {
 72+      sentMessages.push(JSON.parse(payload));
 73+    }
 74+  };
 75+  const browser = {
 76+    action: {
 77+      onClicked: {
 78+        addListener() {}
 79+      },
 80+      async setBadgeBackgroundColor() {},
 81+      async setBadgeText() {},
 82+      async setTitle() {}
 83+    },
 84+    runtime: {
 85+      async sendMessage() {
 86+        return { ok: true };
 87+      },
 88+      onMessage: {
 89+        addListener() {}
 90+      }
 91+    },
 92+    scripting: {
 93+      async executeScript(details) {
 94+        executeScriptCalls.push(JSON.parse(JSON.stringify(details)));
 95+        return [];
 96+      }
 97+    },
 98+    storage: {
 99+      local: {
100+        async get(keys) {
101+          if (Array.isArray(keys)) {
102+            return Object.fromEntries(keys.map((key) => [key, storage[key]]));
103+          }
104+
105+          if (typeof keys === "string") {
106+            return {
107+              [keys]: storage[keys]
108+            };
109+          }
110+
111+          return { ...storage };
112+        },
113+        async set(values) {
114+          Object.assign(storage, values || {});
115+        }
116+      },
117+      onChanged: {
118+        addListener() {}
119+      }
120+    },
121+    tabs: {
122+      async create(info = {}) {
123+        const tab = {
124+          active: !!info.active,
125+          discarded: false,
126+          hidden: false,
127+          id: nextTabId,
128+          lastAccessed: Date.now(),
129+          status: "complete",
130+          title: "",
131+          url: info.url || "",
132+          windowId: 1
133+        };
134+        nextTabId += 1;
135+        tabs.set(tab.id, tab);
136+        return { ...tab };
137+      },
138+      async get(tabId) {
139+        const tab = tabs.get(tabId);
140+        if (!tab) {
141+          throw new Error(`missing tab ${tabId}`);
142+        }
143+
144+        return { ...tab };
145+      },
146+      async query(queryInfo = {}) {
147+        const patterns = Array.isArray(queryInfo.url) ? queryInfo.url : [queryInfo.url].filter(Boolean);
148+        const source = [...tabs.values()];
149+        if (patterns.length === 0) {
150+          return source.map((tab) => ({ ...tab }));
151+        }
152+
153+        return source
154+          .filter((tab) => matchesUrlPatterns(tab.url || "", patterns))
155+          .map((tab) => ({ ...tab }));
156+      },
157+      async reload(tabId) {
158+        reloadedTabIds.push(tabId);
159+        const tab = tabs.get(tabId);
160+        if (tab) {
161+          tabs.set(tabId, {
162+            ...tab,
163+            status: "loading"
164+          });
165+        }
166+      },
167+      async remove(tabIds) {
168+        for (const tabId of Array.isArray(tabIds) ? tabIds : [tabIds]) {
169+          tabs.delete(tabId);
170+        }
171+      },
172+      async update(tabId, patch = {}) {
173+        const current = tabs.get(tabId);
174+        if (!current) {
175+          throw new Error(`missing tab ${tabId}`);
176+        }
177+
178+        const next = {
179+          ...current,
180+          ...patch
181+        };
182+        tabs.set(tabId, next);
183+        return { ...next };
184+      },
185+      onActivated: {
186+        addListener() {}
187+      },
188+      onCreated: {
189+        addListener() {}
190+      },
191+      onRemoved: {
192+        addListener() {}
193+      },
194+      onUpdated: {
195+        addListener() {}
196+      }
197+    },
198+    webRequest: {
199+      onBeforeSendHeaders: {
200+        addListener() {}
201+      },
202+      onCompleted: {
203+        addListener() {}
204+      },
205+      onErrorOccurred: {
206+        addListener() {}
207+      }
208+    },
209+    windows: {
210+      async update() {}
211+    }
212+  };
213+  const context = {
214+    AbortController,
215+    Blob,
216+    BAAFinalMessage: options.finalMessageHelpers || null,
217+    Headers,
218+    FormData,
219+    Request,
220+    Response,
221+    TextDecoder,
222+    TextEncoder,
223+    URL,
224+    URLSearchParams,
225+    WebSocket: {
226+      OPEN: 1
227+    },
228+    browser,
229+    clearInterval,
230+    clearTimeout,
231+    console,
232+    crypto: globalThis.crypto,
233+    __BAA_TEST_WS__: ws,
234+    document: {
235+      getElementById() {
236+        return createControllerUiElement();
237+      }
238+    },
239+    fetch: async () => new Response("{}", {
240+      status: 200,
241+      headers: {
242+        "content-type": "application/json"
243+      }
244+    }),
245+    globalThis: null,
246+    location: {
247+      href: "moz-extension://baa/controller.html",
248+      reload() {}
249+    },
250+    performance: {
251+      now() {
252+        return 0;
253+      }
254+    },
255+    setInterval,
256+    setTimeout,
257+    window: null,
258+    __BAA_CONTROLLER_TEST_API__: {},
259+    __BAA_SKIP_CONTROLLER_INIT__: true
260+  };
261+
262+  context.window = context;
263+  context.globalThis = context;
264+  context.addEventListener = () => {};
265+  context.removeEventListener = () => {};
266+  vm.runInNewContext(controllerSource, context, {
267+    filename: "controller.js"
268+  });
269+  vm.runInNewContext(`
270+    __BAA_CONTROLLER_TEST_API__.state.ws = __BAA_TEST_WS__;
271+    __BAA_CONTROLLER_TEST_API__.state.wsConnected = true;
272+  `, context);
273+
274+  const hooks = context.__BAA_CONTROLLER_TEST_API__;
275+
276+  return {
277+    executeScriptCalls,
278+    hooks,
279+    reloadedTabIds,
280+    sentMessages,
281+    tabs
282+  };
283+}
284+
285 function parseSseFrames(text) {
286   return String(text || "")
287     .split(/\n\n+/u)
288@@ -746,6 +1008,84 @@ test("final message relay network observer extracts Claude buffered completion t
289   assert.equal(relay.payload.raw_text, "Buffered Claude reply");
290 });
291 
292+test("controller accepts Claude non-shell page SSE without adopting the chat tab as shell", () => {
293+  const conversationId = "22222222-2222-4222-8222-222222222222";
294+  const harness = createControllerHarness();
295+
296+  harness.hooks.handlePageSse(
297+    {
298+      chunk: [
299+        "event: completion",
300+        'data: {"type":"completion","completion":"Claude final answer from non-shell page","id":"msg-claude-non-shell"}',
301+        ""
302+      ].join("\n"),
303+      done: true,
304+      platform: "claude",
305+      url: `https://claude.ai/api/organizations/11111111-1111-4111-8111-111111111111/chat_conversations/${conversationId}/completion`
306+    },
307+    {
308+      tab: {
309+        id: 42,
310+        title: "Smoke Claude Chat",
311+        url: `https://claude.ai/chat/${conversationId}`
312+      }
313+    }
314+  );
315+
316+  assert.equal(harness.hooks.state.trackedTabs.claude, null);
317+  assert.equal(harness.hooks.state.claudeState.tabId, 42);
318+  assert.equal(harness.hooks.state.claudeState.conversationId, conversationId);
319+  assert.ok(harness.hooks.state.claudeState.lastActivityAt > 0);
320+});
321+
322+test("controller tab_reload refreshes observer scripts on existing Claude chat tabs", async () => {
323+  const conversationId = "33333333-3333-4333-8333-333333333333";
324+  const harness = createControllerHarness({
325+    tabs: [
326+      {
327+        active: false,
328+        id: 11,
329+        lastAccessed: 100,
330+        status: "complete",
331+        title: "Claude Shell",
332+        url: "https://claude.ai/#baa-shell"
333+      },
334+      {
335+        active: true,
336+        id: 12,
337+        lastAccessed: 200,
338+        status: "complete",
339+        title: "Claude Chat",
340+        url: `https://claude.ai/chat/${conversationId}`
341+      }
342+    ]
343+  });
344+
345+  const result = await harness.hooks.runPluginManagementAction("tab_reload", {
346+    platform: "claude",
347+    source: "smoke_test"
348+  });
349+
350+  assert.deepEqual(harness.reloadedTabIds, [11]);
351+  assert.equal(result.action, "tab_reload");
352+  assert.deepEqual(Array.from(result.results[0].observer_refresh.refreshed_tab_ids), [12]);
353+  assert.ok(
354+    harness.executeScriptCalls.some((call) =>
355+      call.target?.tabId === 12
356+      && Array.isArray(call.files)
357+      && call.files.join(",") === "delivery-adapters.js,content-script.js"
358+    )
359+  );
360+  assert.ok(
361+    harness.executeScriptCalls.some((call) =>
362+      call.target?.tabId === 12
363+      && Array.isArray(call.files)
364+      && call.files.join(",") === "page-interceptor.js"
365+      && call.world === "MAIN"
366+    )
367+  );
368+});
369+
370 test("browser control e2e smoke covers metadata read surface plus Claude and ChatGPT relay", async () => {
371   const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-control-e2e-smoke-"));
372   const runtime = new ConductorRuntime(