baa-conductor


commit
1d74e50
parent
f2ba058
author
codex@macbookpro
date
2026-04-03 15:25:49 +0800 CST
Merge remote-tracking branch 'origin/feat/safari-chatgpt-final-message'
45 files changed,  +6737, -94
Raw patch view.
   1diff --git a/apps/conductor-daemon/src/firefox-ws.ts b/apps/conductor-daemon/src/firefox-ws.ts
   2index 0190a19e5415f42475b68c58f243f59e70299f1b..f764b8a1574ef70b286124ce1775a68fafdc0acb 100644
   3--- a/apps/conductor-daemon/src/firefox-ws.ts
   4+++ b/apps/conductor-daemon/src/firefox-ws.ts
   5@@ -38,8 +38,9 @@ import {
   6 } from "./renewal/conversations.js";
   7 
   8 const FIREFOX_WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
   9+const BROWSER_WS_PROTOCOL = "baa.browser.local";
  10 const FIREFOX_WS_PROTOCOL = "baa.firefox.local";
  11-const FIREFOX_WS_PROTOCOL_VERSION = 1;
  12+const BROWSER_WS_PROTOCOL_VERSION = 1;
  13 const FIREFOX_WS_STATE_POLL_INTERVAL_MS = 2_000;
  14 const FIREFOX_WS_LOGIN_STATE_STALE_AFTER_MS = 45_000;
  15 const FIREFOX_WS_LOGIN_STATE_LOST_AFTER_MS = 120_000;
  16@@ -50,6 +51,7 @@ const NORMAL_CLOSE_CODE = 1000;
  17 const UNSUPPORTED_DATA_CLOSE_CODE = 1003;
  18 const MESSAGE_TOO_LARGE_CLOSE_CODE = 1009;
  19 
  20+export const BROWSER_WS_PATH = "/ws/browser";
  21 export const FIREFOX_WS_PATH = "/ws/firefox";
  22 
  23 type IntervalHandle = ReturnType<typeof globalThis.setInterval>;
  24@@ -163,6 +165,33 @@ function normalizeNonEmptyString(value: unknown): string | null {
  25   return normalized === "" ? null : normalized;
  26 }
  27 
  28+function inferNodePlatformFromClientId(clientId: string): string | null {
  29+  const normalized = clientId.trim().toLowerCase();
  30+
  31+  if (normalized.startsWith("firefox-")) {
  32+    return "firefox";
  33+  }
  34+
  35+  if (normalized.startsWith("safari-")) {
  36+    return "safari";
  37+  }
  38+
  39+  return null;
  40+}
  41+
  42+function resolveNodePlatform(input: {
  43+  clientId: string;
  44+  nodePlatform: string | null;
  45+}): string | null {
  46+  const explicit = normalizeNonEmptyString(input.nodePlatform);
  47+
  48+  if (explicit != null) {
  49+    return explicit;
  50+  }
  51+
  52+  return inferNodePlatformFromClientId(input.clientId);
  53+}
  54+
  55 function readFirstString(
  56   input: Record<string, unknown>,
  57   keys: readonly string[]
  58@@ -672,6 +701,21 @@ function buildFrame(opcode: number, payload: Buffer = Buffer.alloc(0)): Buffer {
  59   return Buffer.concat([header, payload]);
  60 }
  61 
  62+export function buildBrowserWebSocketUrl(localApiBase: string | null | undefined): string | null {
  63+  const normalized = normalizeNonEmptyString(localApiBase);
  64+
  65+  if (normalized == null) {
  66+    return null;
  67+  }
  68+
  69+  const url = new URL(normalized);
  70+  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
  71+  url.pathname = BROWSER_WS_PATH;
  72+  url.search = "";
  73+  url.hash = "";
  74+  return url.toString();
  75+}
  76+
  77 export function buildFirefoxWebSocketUrl(localApiBase: string | null | undefined): string | null {
  78   const normalized = normalizeNonEmptyString(localApiBase);
  79 
  80@@ -1091,6 +1135,10 @@ export class ConductorFirefoxWebSocketServer {
  81   }
  82 
  83   getUrl(): string | null {
  84+    return buildBrowserWebSocketUrl(this.baseUrlLoader());
  85+  }
  86+
  87+  getFirefoxCompatUrl(): string | null {
  88     return buildFirefoxWebSocketUrl(this.baseUrlLoader());
  89   }
  90 
  91@@ -1149,7 +1197,7 @@ export class ConductorFirefoxWebSocketServer {
  92   handleUpgrade(request: IncomingMessage, socket: Socket, head: Buffer): boolean {
  93     const pathname = normalizePathname(new URL(request.url ?? "/", "http://127.0.0.1").pathname);
  94 
  95-    if (pathname !== FIREFOX_WS_PATH) {
  96+    if (pathname !== BROWSER_WS_PATH && pathname !== FIREFOX_WS_PATH) {
  97       socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
  98       socket.destroy();
  99       return false;
 100@@ -1276,7 +1324,7 @@ export class ConductorFirefoxWebSocketServer {
 101         this.sendError(
 102           connection,
 103           "unsupported_message_type",
 104-          "api_request is a server-initiated Firefox bridge command and cannot be sent by the client."
 105+          "api_request is a server-initiated browser bridge command and cannot be sent by the client."
 106         );
 107         return;
 108       case "api_response":
 109@@ -1314,7 +1362,10 @@ export class ConductorFirefoxWebSocketServer {
 110     connection.setClientMetadata({
 111       clientId,
 112       nodeCategory: readFirstString(message, ["nodeCategory", "node_category"]),
 113-      nodePlatform: readFirstString(message, ["nodePlatform", "node_platform"]),
 114+      nodePlatform: resolveNodePlatform({
 115+        clientId,
 116+        nodePlatform: readFirstString(message, ["nodePlatform", "node_platform"])
 117+      }),
 118       nodeType: readFirstString(message, ["nodeType", "node_type"])
 119     });
 120     this.connectionsByClientId.set(clientId, connection);
 121@@ -1323,9 +1374,11 @@ export class ConductorFirefoxWebSocketServer {
 122       type: "hello_ack",
 123       clientId,
 124       localApiBase: this.snapshotLoader().controlApi.localApiBase ?? null,
 125-      protocol: FIREFOX_WS_PROTOCOL,
 126-      version: FIREFOX_WS_PROTOCOL_VERSION,
 127+      protocol: BROWSER_WS_PROTOCOL,
 128+      protocol_compat: [FIREFOX_WS_PROTOCOL],
 129+      version: BROWSER_WS_PROTOCOL_VERSION,
 130       wsUrl: this.getUrl(),
 131+      wsCompatUrls: [this.getFirefoxCompatUrl()].filter((entry): entry is string => entry != null),
 132       supports: {
 133         inbound: [
 134           "hello",
 135@@ -1570,7 +1623,10 @@ export class ConductorFirefoxWebSocketServer {
 136     const runtime = this.snapshotLoader();
 137     await this.repository.upsertBrowserLoginState({
 138       account: summary.account,
 139-      browser: connection.session.nodePlatform ?? "firefox",
 140+      browser:
 141+        connection.session.nodePlatform
 142+        ?? inferNodePlatformFromClientId(clientId)
 143+        ?? "firefox",
 144       capturedAt,
 145       clientId,
 146       credentialFingerprint: fingerprint,
 147@@ -2108,11 +2164,11 @@ export class ConductorFirefoxWebSocketServer {
 148         scheduler_enabled: runtime.daemon.schedulerEnabled,
 149         started: runtime.runtime.started,
 150         started_at: toUnixMilliseconds(runtime.runtime.startedAt),
 151-        ws_path: FIREFOX_WS_PATH,
 152+        ws_path: BROWSER_WS_PATH,
 153         ws_url: this.getUrl()
 154       },
 155       system: await buildSystemStateData(this.repository),
 156-      version: FIREFOX_WS_PROTOCOL_VERSION
 157+      version: BROWSER_WS_PROTOCOL_VERSION
 158     };
 159   }
 160 
 161@@ -2145,7 +2201,7 @@ export class ConductorFirefoxWebSocketServer {
 162         recent_executes: [],
 163         recent_ingests: []
 164       },
 165-      ws_path: FIREFOX_WS_PATH,
 166+      ws_path: BROWSER_WS_PATH,
 167       ws_url: this.getUrl()
 168     };
 169   }
 170diff --git a/apps/conductor-daemon/src/index.test.js b/apps/conductor-daemon/src/index.test.js
 171index a84d995c6e5c88ca7b9516659145216c5ea669de..94f1762dad24e1b166da3a7a02e59d6bbe497569 100644
 172--- a/apps/conductor-daemon/src/index.test.js
 173+++ b/apps/conductor-daemon/src/index.test.js
 174@@ -228,6 +228,7 @@ async function createLocalApiFixture(options = {}) {
 175     },
 176     controlApi: {
 177       baseUrl: "https://control.example.test",
 178+      browserWsUrl: "ws://127.0.0.1:4317/ws/browser",
 179       firefoxWsUrl: "ws://127.0.0.1:4317/ws/firefox",
 180       hasSharedToken: true,
 181       localApiBase: "http://127.0.0.1:4317",
 182@@ -2288,8 +2289,8 @@ function createBrowserBridgeStub() {
 183         shell_runtime: [buildShellRuntime("claude")]
 184       }
 185     ],
 186-    ws_path: "/ws/firefox",
 187-    ws_url: "ws://127.0.0.1:4317/ws/firefox"
 188+    ws_path: "/ws/browser",
 189+    ws_url: "ws://127.0.0.1:4317/ws/browser"
 190   };
 191 
 192   const buildApiResponse = ({ body, clientId = "firefox-claude", error = null, id = null, status = 200 }) => ({
 193@@ -2857,7 +2858,7 @@ async function fetchJson(url, init) {
 194   };
 195 }
 196 
 197-async function connectFirefoxBridgeClient(wsUrl, clientId) {
 198+async function connectBrowserBridgeClient(wsUrl, clientId, nodePlatform = "firefox") {
 199   const socket = new WebSocket(wsUrl);
 200   const queue = createWebSocketMessageQueue(socket);
 201 
 202@@ -2868,7 +2869,7 @@ async function connectFirefoxBridgeClient(wsUrl, clientId) {
 203       clientId,
 204       nodeType: "browser",
 205       nodeCategory: "proxy",
 206-      nodePlatform: "firefox"
 207+      nodePlatform
 208     })
 209   );
 210 
 211@@ -2891,6 +2892,10 @@ async function connectFirefoxBridgeClient(wsUrl, clientId) {
 212   };
 213 }
 214 
 215+async function connectFirefoxBridgeClient(wsUrl, clientId) {
 216+  return await connectBrowserBridgeClient(wsUrl, clientId, "firefox");
 217+}
 218+
 219 test("writeHttpResponse stops waiting for body backpressure when the client closes", async () => {
 220   const response = new MockWritableResponse((_chunk, writeCount, writableResponse) => {
 221     assert.equal(writeCount, 1);
 222@@ -6977,7 +6982,7 @@ test("handleConductorHttpRequest returns browser_request_timeout with timeout de
 223   assert.equal(payload.details.bridge_request_id, "browser-timeout-1");
 224 });
 225 
 226-test("handleConductorHttpRequest returns a clear 503 for Claude browser actions without an active Firefox client", async () => {
 227+test("handleConductorHttpRequest returns a clear 503 for Claude browser actions without an active browser client", async () => {
 228   const { repository, snapshot } = await createLocalApiFixture();
 229 
 230   const response = await handleConductorHttpRequest(
 231@@ -6991,8 +6996,8 @@ test("handleConductorHttpRequest returns a clear 503 for Claude browser actions
 232         active_connection_id: null,
 233         client_count: 0,
 234         clients: [],
 235-        ws_path: "/ws/firefox",
 236-        ws_url: snapshot.controlApi.firefoxWsUrl
 237+        ws_path: "/ws/browser",
 238+        ws_url: snapshot.controlApi.browserWsUrl
 239       }),
 240       repository,
 241       snapshotLoader: () => snapshot
 242@@ -7673,6 +7678,7 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
 243     assert.equal(snapshot.daemon.schedulerEnabled, true);
 244     assert.equal(snapshot.codexd.localApiBase, codexd.baseUrl);
 245     assert.match(snapshot.controlApi.localApiBase, /^http:\/\/127\.0\.0\.1:\d+$/u);
 246+    assert.match(snapshot.controlApi.browserWsUrl, /^ws:\/\/127\.0\.0\.1:\d+\/ws\/browser$/u);
 247     assert.match(snapshot.controlApi.firefoxWsUrl, /^ws:\/\/127\.0\.0\.1:\d+\/ws\/firefox$/u);
 248 
 249     const baseUrl = snapshot.controlApi.localApiBase;
 250@@ -7704,6 +7710,7 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
 251     const payload = await runtimeResponse.json();
 252     assert.equal(payload.ok, true);
 253     assert.equal(payload.data.identity, "mini-main@mini(primary)");
 254+    assert.equal(payload.data.controlApi.browserWsUrl, snapshot.controlApi.browserWsUrl);
 255     assert.equal(payload.data.controlApi.firefoxWsUrl, snapshot.controlApi.firefoxWsUrl);
 256     assert.equal(payload.data.controlApi.localApiBase, baseUrl);
 257     assert.equal(payload.data.runtime.started, true);
 258@@ -8383,6 +8390,7 @@ test("ConductorRuntime awaits daemon shutdown when local API startup fails", asy
 259     },
 260     stop: async () => {},
 261     getBaseUrl: () => "http://127.0.0.1:0",
 262+    getBrowserWebSocketUrl: () => null,
 263     getFirefoxWebSocketUrl: () => null
 264   };
 265 
 266@@ -8441,6 +8449,7 @@ test("ConductorRuntime.stop waits for daemon shutdown before closing the local A
 267       localApiStopSawDaemonResolved = daemonStopResolved;
 268     },
 269     getBaseUrl: () => "http://127.0.0.1:0",
 270+    getBrowserWebSocketUrl: () => null,
 271     getFirefoxWebSocketUrl: () => null
 272   };
 273 
 274@@ -8608,7 +8617,9 @@ test("ConductorRuntime exposes a local Firefox websocket bridge over the local A
 275 
 276       const helloAck = await queue.next((message) => message.type === "hello_ack");
 277       assert.equal(helloAck.clientId, "firefox-test");
 278-      assert.equal(helloAck.wsUrl, wsUrl);
 279+      assert.equal(helloAck.protocol, "baa.browser.local");
 280+      assert.equal(helloAck.wsUrl, snapshot.controlApi.browserWsUrl);
 281+      assert.deepEqual(helloAck.wsCompatUrls, [snapshot.controlApi.firefoxWsUrl]);
 282 
 283       const initialSnapshot = await queue.next(
 284         (message) => message.type === "state_snapshot" && message.reason === "hello"
 285@@ -8719,6 +8730,43 @@ test("ConductorRuntime exposes a local Firefox websocket bridge over the local A
 286   });
 287 });
 288 
 289+test("ConductorRuntime accepts Safari bridge clients on the canonical browser websocket path", async () => {
 290+  await withRuntimeFixture(async ({ runtime }) => {
 291+    let client = null;
 292+
 293+    try {
 294+      const snapshot = await runtime.start();
 295+      const wsUrl = snapshot.controlApi.browserWsUrl;
 296+      const baseUrl = snapshot.controlApi.localApiBase;
 297+
 298+      client = await connectBrowserBridgeClient(wsUrl, "safari-test", "safari");
 299+
 300+      assert.equal(client.helloAck.clientId, "safari-test");
 301+      assert.equal(client.helloAck.protocol, "baa.browser.local");
 302+      assert.equal(client.helloAck.wsUrl, snapshot.controlApi.browserWsUrl);
 303+      assert.deepEqual(client.helloAck.wsCompatUrls, [snapshot.controlApi.firefoxWsUrl]);
 304+      assert.equal(client.initialSnapshot.snapshot.browser.clients[0]?.node_platform, "safari");
 305+      assert.equal(client.initialSnapshot.snapshot.browser.ws_path, "/ws/browser");
 306+
 307+      const browserStatus = await fetch(`${baseUrl}/v1/browser`);
 308+      assert.equal(browserStatus.status, 200);
 309+      const browserPayload = await browserStatus.json();
 310+      assert.equal(browserPayload.data.bridge.transport, "local_browser_ws");
 311+      assert.equal(browserPayload.data.bridge.ws_path, "/ws/browser");
 312+      assert.equal(browserPayload.data.bridge.ws_url, snapshot.controlApi.browserWsUrl);
 313+      assert.equal(browserPayload.data.current_client.client_id, "safari-test");
 314+      assert.equal(browserPayload.data.current_client.node_platform, "safari");
 315+    } finally {
 316+      client?.queue.stop();
 317+      client?.socket.close();
 318+
 319+      if (client?.socket) {
 320+        await waitForWebSocketClose(client.socket);
 321+      }
 322+    }
 323+  });
 324+});
 325+
 326 test("ConductorRuntime exposes Firefox outbound bridge commands and api request responses", async () => {
 327   const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-firefox-bridge-"));
 328   const runtime = new ConductorRuntime(
 329@@ -10146,6 +10194,7 @@ test("persistent live ingest survives restart and /v1/browser restores recent hi
 330         },
 331         controlApi: {
 332           baseUrl: "https://control.example.test",
 333+          browserWsUrl: "ws://127.0.0.1:4317/ws/browser",
 334           firefoxWsUrl: "ws://127.0.0.1:4317/ws/firefox",
 335           hasSharedToken: true,
 336           localApiBase: "http://127.0.0.1:4317",
 337diff --git a/apps/conductor-daemon/src/index.ts b/apps/conductor-daemon/src/index.ts
 338index 8bbf934e0f7899725ae43847d55546a8734c8bbf..bab2759a5ccc256b450d272bc6c3e52a4ad1c733 100644
 339--- a/apps/conductor-daemon/src/index.ts
 340+++ b/apps/conductor-daemon/src/index.ts
 341@@ -28,6 +28,7 @@ import {
 342   type ConductorHttpResponse
 343 } from "./http-types.js";
 344 import {
 345+  buildBrowserWebSocketUrl,
 346   ConductorFirefoxWebSocketServer,
 347   buildFirefoxWebSocketUrl
 348 } from "./firefox-ws.js";
 349@@ -252,6 +253,7 @@ export interface ConductorRuntimeSnapshot {
 350   paths: ConductorRuntimePaths;
 351   controlApi: {
 352     baseUrl: string;
 353+    browserWsUrl: string | null;
 354     firefoxWsUrl: string | null;
 355     localApiBase: string | null;
 356     hasSharedToken: boolean;
 357@@ -848,6 +850,10 @@ class ConductorLocalHttpServer {
 358   }
 359 
 360   getFirefoxWebSocketUrl(): string | null {
 361+    return this.firefoxWebSocketServer.getFirefoxCompatUrl();
 362+  }
 363+
 364+  getBrowserWebSocketUrl(): string | null {
 365     return this.firefoxWebSocketServer.getUrl();
 366   }
 367 
 368@@ -2165,6 +2171,7 @@ function formatConfigText(config: ResolvedConductorRuntimeConfig): string {
 369     `codexd_local_api_base: ${config.codexdLocalApiBase ?? "not-configured"}`,
 370     `code_root_dir: ${config.codeRootDir}`,
 371     `local_api_base: ${config.localApiBase ?? "not-configured"}`,
 372+    `browser_ws_url: ${buildBrowserWebSocketUrl(config.localApiBase) ?? "not-configured"}`,
 373     `firefox_ws_url: ${buildFirefoxWebSocketUrl(config.localApiBase) ?? "not-configured"}`,
 374     `local_api_allowed_hosts: ${config.localApiAllowedHosts.join(",") || "loopback-only"}`,
 375     `priority: ${config.priority}`,
 376@@ -2436,6 +2443,8 @@ export class ConductorRuntime {
 377 
 378   getRuntimeSnapshot(now: number = this.now()): ConductorRuntimeSnapshot {
 379     const localApiBase = this.localApiServer?.getBaseUrl() ?? this.config.localApiBase;
 380+    const browserWsUrl =
 381+      this.localApiServer?.getBrowserWebSocketUrl() ?? buildBrowserWebSocketUrl(localApiBase);
 382     const firefoxWsUrl =
 383       this.localApiServer?.getFirefoxWebSocketUrl() ?? buildFirefoxWebSocketUrl(localApiBase);
 384 
 385@@ -2446,6 +2455,7 @@ export class ConductorRuntime {
 386       paths: { ...this.config.paths },
 387       controlApi: {
 388         baseUrl: this.config.publicApiBase,
 389+        browserWsUrl,
 390         firefoxWsUrl,
 391         localApiBase,
 392         hasSharedToken: this.config.sharedToken != null,
 393diff --git a/apps/conductor-daemon/src/local-api.ts b/apps/conductor-daemon/src/local-api.ts
 394index 1a2370d743d09a829aa0da6ab91f4122d0378c64..447f523769bb11533b1ad26b31c2afad137402f8 100644
 395--- a/apps/conductor-daemon/src/local-api.ts
 396+++ b/apps/conductor-daemon/src/local-api.ts
 397@@ -345,6 +345,7 @@ export interface ConductorRuntimeApiSnapshot {
 398   };
 399   controlApi: {
 400     baseUrl: string;
 401+    browserWsUrl?: string | null;
 402     firefoxWsUrl?: string | null;
 403     hasSharedToken: boolean;
 404     localApiBase: string | null;
 405@@ -616,7 +617,7 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
 406     lifecycle: "legacy",
 407     method: "POST",
 408     pathPattern: "/v1/browser/claude/send",
 409-    summary: "legacy 包装:通过本地 Firefox bridge 发起一轮 Claude 对话"
 410+    summary: "legacy 包装:通过本地 browser bridge 发起一轮 Claude 对话"
 411   },
 412   {
 413     id: "browser.claude.current",
 414@@ -633,7 +634,7 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
 415     lifecycle: "legacy",
 416     method: "POST",
 417     pathPattern: "/v1/browser/chatgpt/send",
 418-    summary: "legacy 包装:通过本地 Firefox bridge 发起一轮 ChatGPT 对话"
 419+    summary: "legacy 包装:通过本地 browser bridge 发起一轮 ChatGPT 对话"
 420   },
 421   {
 422     id: "browser.chatgpt.current",
 423@@ -650,7 +651,7 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
 424     lifecycle: "legacy",
 425     method: "POST",
 426     pathPattern: "/v1/browser/gemini/send",
 427-    summary: "legacy 包装:通过本地 Firefox bridge 发起一轮 Gemini 对话"
 428+    summary: "legacy 包装:通过本地 browser bridge 发起一轮 Gemini 对话"
 429   },
 430   {
 431     id: "browser.gemini.current",
 432@@ -2179,8 +2180,8 @@ function createEmptyBrowserState(snapshot: ConductorRuntimeApiSnapshot): Browser
 433       lastSession: null
 434     },
 435     instruction_ingest: createEmptyBrowserInstructionIngestSnapshot(),
 436-    ws_path: "/ws/firefox",
 437-    ws_url: snapshot.controlApi.firefoxWsUrl ?? null
 438+    ws_path: "/ws/browser",
 439+    ws_url: snapshot.controlApi.browserWsUrl ?? snapshot.controlApi.firefoxWsUrl ?? null
 440   };
 441 }
 442 
 443@@ -2578,14 +2579,14 @@ function createBrowserBridgeHttpError(action: string, error: unknown): LocalApiH
 444       return new LocalApiHttpError(
 445         503,
 446         "browser_bridge_unavailable",
 447-        "No active Firefox bridge client is connected.",
 448+        "No active browser bridge client is connected.",
 449         details
 450       );
 451     case "client_not_found":
 452       return new LocalApiHttpError(
 453         409,
 454         "browser_client_not_found",
 455-        "The requested Firefox bridge client is not connected.",
 456+        "The requested browser bridge client is not connected.",
 457         details
 458       );
 459     case "duplicate_request_id":
 460@@ -2625,14 +2626,14 @@ function createBrowserBridgeHttpError(action: string, error: unknown): LocalApiH
 461       return new LocalApiHttpError(
 462         503,
 463         "browser_bridge_unavailable",
 464-        `The Firefox bridge became unavailable while processing ${action}.`,
 465+        `The browser bridge became unavailable while processing ${action}.`,
 466         details
 467       );
 468     default:
 469       return new LocalApiHttpError(
 470         502,
 471         "browser_bridge_error",
 472-        `Failed to execute ${action} through the Firefox bridge.`,
 473+        `Failed to execute ${action} through the browser bridge.`,
 474         details
 475       );
 476   }
 477@@ -2715,14 +2716,14 @@ function ensureBrowserClientReady(
 478     throw new LocalApiHttpError(
 479       503,
 480       "browser_bridge_unavailable",
 481-      `No active Firefox bridge client is connected for ${platform} requests.`
 482+      `No active browser bridge client is connected for ${platform} requests.`
 483     );
 484   }
 485 
 486   throw new LocalApiHttpError(
 487     409,
 488     "browser_client_not_found",
 489-    `Firefox bridge client "${normalizedRequestedClientId}" is not connected.`,
 490+    `Browser bridge client "${normalizedRequestedClientId}" is not connected.`,
 491     compactJsonObject({
 492       client_id: normalizedRequestedClientId,
 493       platform
 494@@ -2738,7 +2739,7 @@ function ensureClaudeBridgeReady(
 495     throw new LocalApiHttpError(
 496       503,
 497       "browser_bridge_unavailable",
 498-      "No active Firefox bridge client is connected for Claude actions."
 499+      "No active browser bridge client is connected for Claude actions."
 500     );
 501   }
 502 
 503@@ -2746,7 +2747,7 @@ function ensureClaudeBridgeReady(
 504     throw new LocalApiHttpError(
 505       409,
 506       "claude_credentials_unavailable",
 507-      "Claude credentials are not available yet on the selected Firefox bridge client.",
 508+      "Claude credentials are not available yet on the selected browser bridge client.",
 509       compactJsonObject({
 510         client_id: selection.client.client_id,
 511         requested_client_id: normalizeOptionalString(requestedClientId),
 512@@ -3811,7 +3812,7 @@ async function buildBrowserStatusData(context: LocalApiRequestContext): Promise<
 513       client_count: browserState.client_count,
 514       clients: browserState.clients.map(serializeBrowserClientSnapshot),
 515       status: browserState.client_count > 0 ? "connected" : "disconnected",
 516-      transport: "local_firefox_ws",
 517+      transport: "local_browser_ws",
 518       ws_path: browserState.ws_path,
 519       ws_url: browserState.ws_url
 520     },
 521@@ -4106,9 +4107,11 @@ async function requestCodexd(
 522 }
 523 
 524 function buildFirefoxWebSocketData(snapshot: ConductorRuntimeApiSnapshot): JsonObject {
 525+  const canonicalUrl = snapshot.controlApi.browserWsUrl ?? snapshot.controlApi.firefoxWsUrl ?? null;
 526+
 527   return {
 528     auth_mode: "local_network_only",
 529-    enabled: snapshot.controlApi.firefoxWsUrl != null,
 530+    enabled: canonicalUrl != null,
 531     inbound_messages: [
 532       "hello",
 533       "state_request",
 534@@ -4140,10 +4143,12 @@ function buildFirefoxWebSocketData(snapshot: ConductorRuntimeApiSnapshot): JsonO
 535       "reload",
 536       "error"
 537     ],
 538-    path: "/ws/firefox",
 539-    purpose: "local Firefox extension bridge",
 540+    compat_paths: ["/ws/firefox"],
 541+    compat_url: snapshot.controlApi.firefoxWsUrl ?? null,
 542+    path: "/ws/browser",
 543+    purpose: "local browser extension bridge",
 544     reconnect: "client auto-reconnect is expected and supported",
 545-    url: snapshot.controlApi.firefoxWsUrl ?? null
 546+    url: canonicalUrl
 547   };
 548 }
 549 
 550@@ -4155,7 +4160,7 @@ function buildBrowserActionContract(origin: string): JsonObject {
 551         "必填字符串。当前正式支持 plugin_status、request_credentials、tab_open、tab_focus、tab_reload、tab_restore、ws_reconnect、controller_reload。",
 552       platform:
 553         "tab_open、tab_focus、tab_reload、request_credentials、tab_restore 建议带非空平台字符串;当前正式 shell / credential 管理平台已覆盖 claude 和 chatgpt,Gemini 仍留在下一波。",
 554-      clientId: "可选字符串;指定目标 Firefox bridge client。",
 555+      clientId: "可选字符串;指定目标 browser bridge client。",
 556       reason: "可选字符串;request_credentials、tab_reload、tab_restore、ws_reconnect、controller_reload 会原样透传给浏览器侧。",
 557       disconnectMs:
 558         `仅 ws_reconnect 可选;断开后保持离线的毫秒数。支持 disconnect_ms / delayMs / delay_ms 别名;范围 0-${MAX_BROWSER_WS_RECONNECT_DISCONNECT_MS}。`,
 559@@ -4195,7 +4200,7 @@ function buildBrowserActionContract(origin: string): JsonObject {
 560       }
 561     ],
 562     error_semantics: [
 563-      "503 browser_bridge_unavailable: 当前没有可用 Firefox bridge client。",
 564+      "503 browser_bridge_unavailable: 当前没有可用 browser bridge client。",
 565       "409 browser_client_not_found: 指定的 clientId 当前未连接。",
 566       "504 browser_action_timeout: 插件未在约定时间内回传结构化 action_result。"
 567     ]
 568@@ -4208,7 +4213,7 @@ function buildBrowserRequestContract(origin: string): JsonObject {
 569     request_body: {
 570       platform:
 571         "必填字符串;当前正式支持 claude、chatgpt 和 gemini。claude 额外支持省略 path + prompt 的兼容模式;chatgpt / gemini 当前只支持显式 path 的 raw proxy 请求。",
 572-      clientId: "可选字符串;指定目标 Firefox bridge client。",
 573+      clientId: "可选字符串;指定目标 browser bridge client。",
 574       requestId: "可选字符串;用于 trace 和未来 cancel 对齐。缺省时由 conductor 生成。",
 575       method: "可选字符串;默认 GET。若携带 requestBody 或 prompt 且未显式指定,则默认 POST。",
 576       path: "raw proxy 模式下必填;直接转发给浏览器本地 HTTP 代理路径。",
 577@@ -4262,7 +4267,7 @@ function buildBrowserRequestContract(origin: string): JsonObject {
 578     error_semantics: [
 579       "400 invalid_request: 缺字段或组合不合法;例如既没有 path,也不是 platform=claude + prompt 的兼容模式。",
 580       "409 claude_credentials_unavailable: Claude prompt 模式还没有捕获到可用凭证。",
 581-      "503 browser_bridge_unavailable: 当前没有活跃 Firefox bridge client。",
 582+      "503 browser_bridge_unavailable: 当前没有活跃 browser bridge client。",
 583       "4xx/5xx browser_upstream_error: 浏览器本地代理已返回上游 HTTP 错误;responseMode=sse 时会在事件流里交付 stream_error。"
 584     ]
 585   };
 586@@ -4278,12 +4283,12 @@ function buildBrowserRequestCancelContract(): JsonObject {
 587       reason: "可选字符串;取消原因。"
 588     },
 589     current_state: "active",
 590-    implementation_status: "会把 cancel 请求转发给当前执行中的 Firefox bridge client,并让原始 request 尽快以取消错误结束。",
 591+    implementation_status: "会把 cancel 请求转发给当前执行中的 browser bridge client,并让原始 request 尽快以取消错误结束。",
 592     error_semantics: [
 593       "400 invalid_request: requestId 或 platform 缺失。",
 594       "404 browser_request_not_found: 对应 requestId 当前不在执行中。",
 595       "409 browser_request_client_mismatch: 指定了 clientId,但与实际执行中的 client 不一致。",
 596-      "503 browser_bridge_unavailable: 当前执行中的 Firefox bridge client 已断开。"
 597+      "503 browser_bridge_unavailable: 当前执行中的 browser bridge client 已断开。"
 598     ]
 599   };
 600 }
 601@@ -4303,7 +4308,7 @@ function buildBrowserLegacyRouteData(): JsonObject[] {
 602 
 603 function buildBrowserHttpData(snapshot: ConductorRuntimeApiSnapshot, origin: string): JsonObject {
 604   return {
 605-    enabled: snapshot.controlApi.firefoxWsUrl != null,
 606+    enabled: (snapshot.controlApi.browserWsUrl ?? snapshot.controlApi.firefoxWsUrl) != null,
 607     legacy_helper_platform: BROWSER_CLAUDE_PLATFORM,
 608     legacy_helper_platforms: [
 609       BROWSER_CLAUDE_PLATFORM,
 610@@ -4326,12 +4331,12 @@ function buildBrowserHttpData(snapshot: ConductorRuntimeApiSnapshot, origin: str
 611     legacy_routes: buildBrowserLegacyRouteData(),
 612     transport: {
 613       http: snapshot.controlApi.localApiBase ?? null,
 614-      websocket: snapshot.controlApi.firefoxWsUrl ?? null
 615+      websocket: snapshot.controlApi.browserWsUrl ?? snapshot.controlApi.firefoxWsUrl ?? null
 616     },
 617     notes: [
 618       "Business-facing browser work now lands on POST /v1/browser/request; browser/plugin management lands on POST /v1/browser/actions.",
 619       "GET /v1/browser remains the shared read model for login-state metadata, plugin connectivity, shell_runtime, and the latest structured action_result per client.",
 620-      "The generic browser HTTP request surface now formally supports Claude prompt/raw relay plus ChatGPT and Gemini raw relay, and expects a local Firefox bridge client.",
 621+      "The generic browser HTTP request surface now formally supports Claude prompt/raw relay plus ChatGPT and Gemini raw relay, and expects a local browser bridge client.",
 622       "Claude keeps the prompt shortcut when path is omitted; ChatGPT and Gemini require an explicit path and a real browser login context captured on the selected client.",
 623       "Gemini raw relay can additionally use conversationId to prefer a conversation-matched persisted send template when the plugin has one.",
 624       "The legacy helper surface now also exposes /v1/browser/chatgpt/* and /v1/browser/gemini/* wrappers for BAA target compatibility.",
 625@@ -4849,7 +4854,7 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
 626       "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN>; missing or wrong tokens return 401 JSON.",
 627       "These routes read and mutate the mini node's local truth source directly.",
 628       "GET /healthz, /readyz, /rolez and /v1/runtime remain available as low-level diagnostics.",
 629-      "The optional Firefox bridge WS reuses the same local listener and upgrades on /ws/firefox."
 630+      "The optional browser bridge WS reuses the same local listener and upgrades on /ws/browser, with /ws/firefox kept as a compatibility path."
 631     ]
 632   });
 633 }
 634@@ -6023,7 +6028,7 @@ async function handleBrowserRequestCancel(context: LocalApiRequestContext): Prom
 635       throw new LocalApiHttpError(
 636         409,
 637         "browser_request_client_mismatch",
 638-        `Browser request "${requestId}" is not running on the requested Firefox bridge client.`,
 639+        `Browser request "${requestId}" is not running on the requested browser bridge client.`,
 640         compactJsonObject({
 641           client_id: clientId,
 642           platform,
 643diff --git a/docs/api/README.md b/docs/api/README.md
 644index c36e7831b6a005551bac9024bc63a3df572c9b76..d372ce7594643fb09b127dafeaf91119ce66aba6 100644
 645--- a/docs/api/README.md
 646+++ b/docs/api/README.md
 647@@ -31,7 +31,7 @@
 648 | conductor public host | `https://conductor.makefile.so` | 唯一公网入口;VPS Nginx 回源到同一个 `conductor-daemon` local-api |
 649 | conductor-daemon local-api | `BAA_CONDUCTOR_LOCAL_API`,默认可用值如 `http://127.0.0.1:4317` | 本地真相源;承接 describe/health/version/capabilities/status/browser/system/controllers/tasks/codex/host-ops |
 650 | codexd local-api | `BAA_CODEXD_LOCAL_API_BASE`,默认可用值如 `http://127.0.0.1:4319` | 独立 `codexd` 本地服务;支持 `GET /describe` 自描述;`conductor-daemon` 的 `/v1/codex/*` 只代理到这里 |
 651-| conductor-daemon local-firefox-ws | 由 `BAA_CONDUCTOR_LOCAL_API` 派生,例如 `ws://127.0.0.1:4317/ws/firefox` | 本地 Firefox 插件双向 bridge;复用同一个 listener,不单独开公网端口 |
 652+| conductor-daemon local-browser-ws | 由 `BAA_CONDUCTOR_LOCAL_API` 派生,例如 `ws://127.0.0.1:4317/ws/browser` | 本地 browser 插件双向 bridge;复用同一个 listener,不单独开公网端口,`/ws/firefox` 保留兼容路径 |
 653 | status-api local view | `http://127.0.0.1:4318` | 本地只读状态兼容包装层;继续提供 `/describe`、`/v1/status`、`/v1/status/ui` 和 `/ui`,默认从 `BAA_CONDUCTOR_LOCAL_API` 的 `/v1/system/state` 取数,不承担公网入口角色 |
 654 
 655 ## Describe First
 656@@ -45,7 +45,7 @@
 657 5. 按需查看 `browser`、`controllers`、`tasks`、`codex`
 658 6. 如果要做本机 shell / 文件操作,先读 `GET ${BAA_CONDUCTOR_LOCAL_API}/describe/control` 返回里的 `host_operations`,并准备 `Authorization: Bearer <BAA_SHARED_TOKEN>`
 659 7. 只有在明确需要写操作时,再调用 `pause` / `resume` / `drain` 或 `host-ops`
 660-8. 只有在明确需要浏览器双向通讯时,再手动连接 `/ws/firefox`
 661+8. 只有在明确需要浏览器双向通讯时,再手动连接 `/ws/browser`
 662 
 663 如果是直接调用 `codexd`:
 664 
 665@@ -134,7 +134,7 @@
 666 
 667 | 方法 | 路径 | 说明 |
 668 | --- | --- | --- |
 669-| `GET` | `/v1/browser` | 读取活跃 Firefox bridge、插件在线状态、最新 `shell_runtime` / `last_action_result`、持久化登录态记录和 `fresh` / `stale` / `lost` 状态 |
 670+| `GET` | `/v1/browser` | 读取活跃 browser bridge、插件在线状态、最新 `shell_runtime` / `last_action_result`、持久化登录态记录和 `fresh` / `stale` / `lost` 状态 |
 671 | `POST` | `/v1/browser/actions` | 派发通用 browser/plugin 管理动作;当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`、`plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore` |
 672 | `POST` | `/v1/browser/request` | 发起通用 browser HTTP 代发请求;当前正式支持 Claude 的 prompt/raw buffered / SSE 请求,以及 ChatGPT 的 path-based buffered / SSE 请求 |
 673 | `POST` | `/v1/browser/request/cancel` | 取消请求或流;会向对应 Firefox client 派发 `request_cancel` |
 674@@ -159,10 +159,10 @@ Browser 面约定:
 675 - SSE 响应固定用 `stream_open`、`stream_event`、`stream_end`、`stream_error` 作为 event name;`stream_event` 带递增 `seq`
 676 - `POST /v1/browser/actions` 当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`、`plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore`,并返回结构化 `action_result`
 677 - `GET /v1/browser` 会回显当前风控默认值、最新 `shell_runtime` / `last_action_result` 和运行时 target/platform 状态,便于观察抖动、限流、退避和熔断
 678-- `/ws/firefox` 只在本地 listener 上可用,不是公网产品接口
 679-- `request` 的 Claude prompt 模式和 `current` 辅助读只有在 `mini` 上已有活跃 Firefox bridge client,且 Claude 页面已捕获有效凭证和 endpoint 时才可用
 680+- `/ws/browser` 只在本地 listener 上可用,不是公网产品接口;`/ws/firefox` 仅作兼容路径
 681+- `request` 的 Claude prompt 模式和 `current` 辅助读只有在 `mini` 上已有活跃 browser bridge client,且 Claude 页面已捕获有效凭证和 endpoint 时才可用
 682 - ChatGPT / Gemini raw relay 同样依赖真实浏览器里已捕获到的有效登录态 / header;建议先用 `GET /v1/browser?platform=chatgpt&status=fresh`、`GET /v1/browser?platform=gemini&status=fresh` 确认再发请求
 683-- 如果当前没有活跃 Firefox client,会返回清晰的 `503` JSON 错误
 684+- 如果当前没有活跃 browser client,会返回清晰的 `503` JSON 错误
 685 - 如果已连接 client 还没拿到 Claude 凭证,会返回 `409` JSON 错误并提示先在浏览器里完成一轮真实请求
 686 
 687 ### 最小 Claude Curl
 688@@ -316,18 +316,19 @@ host-ops 约定:
 689 | `GET` | `/rolez` | 当前 `leader` / `standby` 视图 |
 690 | `GET` | `/v1/runtime` | daemon runtime 快照 |
 691 
 692-## Firefox Local WS
 693+## Browser Local WS
 694 
 695-本地 Firefox bridge 复用 `conductor-daemon` 的同一个监听地址,不额外引入第二个端口:
 696+本地 browser bridge 复用 `conductor-daemon` 的同一个监听地址,不额外引入第二个端口:
 697 
 698 - HTTP base: `http://127.0.0.1:4317`
 699-- Firefox WS: `ws://127.0.0.1:4317/ws/firefox`
 700+- Browser WS: `ws://127.0.0.1:4317/ws/browser`
 701+- Firefox compat WS: `ws://127.0.0.1:4317/ws/firefox`
 702 
 703 当前约定:
 704 
 705 - 只服务本地 / loopback / 明确允许的 Tailscale `100.x` 地址
 706 - 不是公网通道,不单独暴露到 `conductor.makefile.so`
 707-- Firefox 插件默认同时使用本地 WS 和同一个本地 HTTP listener
 708+- Firefox / Safari 插件默认同时使用本地 WS 和同一个本地 HTTP listener
 709 - `state_snapshot.system` 直接复用 `/v1/system/state` 的字段结构
 710 - `action_request` 支持 `pause` / `resume` / `drain`
 711 - 浏览器发来的 `credentials` / `api_endpoints` 会被转换成 `account`、凭证指纹、端点元数据和 `fresh/stale/lost` 持久化记录
 712diff --git a/docs/api/business-interfaces.md b/docs/api/business-interfaces.md
 713index a141e4687f970ba1237fc51301e86704effb0cd6..068ea2ed8a118a31773a94cade892e9135d83eec 100644
 714--- a/docs/api/business-interfaces.md
 715+++ b/docs/api/business-interfaces.md
 716@@ -69,15 +69,15 @@
 717 
 718 | 方法 | 路径 | 作用 |
 719 | --- | --- | --- |
 720-| `POST` | `/v1/browser/request` | 通过 `conductor -> /ws/firefox -> 插件页面内 HTTP 代理` 发起通用 browser request;当前正式支持 Claude 的 prompt/raw buffered / SSE 请求,以及 ChatGPT 的 path-based buffered / SSE 请求 |
 721-| `POST` | `/v1/browser/request/cancel` | 取消 request 或流;会向对应 Firefox client 下发正式 `request_cancel` |
 722+| `POST` | `/v1/browser/request` | 通过 `conductor -> /ws/browser -> 插件页面内 HTTP 代理` 发起通用 browser request;当前正式支持 Claude 的 prompt/raw buffered / SSE 请求,以及 ChatGPT 的 path-based buffered / SSE 请求 |
 723+| `POST` | `/v1/browser/request/cancel` | 取消 request 或流;会向对应 browser client 下发正式 `request_cancel` |
 724 | `POST` | `/v1/browser/claude/send` | legacy 包装:等价映射到 `POST /v1/browser/request` |
 725 
 726 说明:
 727 
 728 - `GET /v1/browser` 是当前正式浏览器桥接读面;支持按 `platform`、`account`、`client_id`、`host`、`status` 过滤
 729 - 这个读面只返回 `account`、凭证指纹、端点元数据和时间戳状态;不会暴露原始 `cookie`、`token` 或 header 值
 730-- `GET /v1/browser` 现在会额外返回 `automation_conversations`,按 `platform + remote_conversation_id` 暴露当前对话的 `automation_status`、`pause_reason` 和 active link,供 Firefox 浮层和调试读面同步统一自动化状态
 731+- `GET /v1/browser` 现在会额外返回 `automation_conversations`,按 `platform + remote_conversation_id` 暴露当前对话的 `automation_status`、`pause_reason` 和 active link,供 Firefox / Safari 浮层和调试读面同步统一自动化状态
 732 - `records[].view` 会区分活跃连接与仅持久化记录,`status` 会暴露 `fresh`、`stale`、`lost`
 733 - 当前浏览器代发面正式支持 `claude`、`chatgpt` 和 `gemini`
 734 - `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式;`platform=chatgpt` / `platform=gemini` 当前都必须显式带 `path`
 735@@ -100,7 +100,7 @@
 736   - `repeated_renewal_count`
 737 - `conductor` 启动时会自动释放异常退出遗留的 `execution_state`,并把 `status=running` 的 renewal job 安全回排为 `pending`
 738 - ChatGPT / Gemini raw relay 仍依赖浏览器里真实捕获到的登录态 / header;建议先看 `GET /v1/browser?platform=chatgpt&status=fresh`、`GET /v1/browser?platform=gemini&status=fresh`
 739-- 如果没有活跃 Firefox bridge client,会返回 `503`
 740+- 如果没有活跃 browser bridge client,会返回 `503`
 741 - 如果 client 还没有 Claude 凭证快照,会返回 `409`
 742 - 打开、聚焦、重载标签页等 browser/plugin 管理动作已经移到 [`control-interfaces.md`](./control-interfaces.md) 和 `GET /describe/control`
 743 
 744diff --git a/docs/api/control-interfaces.md b/docs/api/control-interfaces.md
 745index eb72fef2b821396567bc49718f21c5f8fa2d1a6e..0decd63642d27de3e3bcf57438d3e087637fea7c 100644
 746--- a/docs/api/control-interfaces.md
 747+++ b/docs/api/control-interfaces.md
 748@@ -69,7 +69,7 @@
 749 
 750 | 方法 | 路径 | 作用 |
 751 | --- | --- | --- |
 752-| `GET` | `/v1/browser` | 返回 Firefox bridge 在线状态、插件摘要、最新 `shell_runtime` / `last_action_result`、浏览器登录态持久化记录,以及浮层同步所需的 `automation_conversations` |
 753+| `GET` | `/v1/browser` | 返回 browser bridge 在线状态、插件摘要、最新 `shell_runtime` / `last_action_result`、浏览器登录态持久化记录,以及浮层同步所需的 `automation_conversations` |
 754 | `GET` | `/v1/system/state` | 返回当前 automation mode、leader、queue、active runs |
 755 
 756 ### Browser / Plugin 管理
 757@@ -83,7 +83,7 @@
 758 browser/plugin 管理约定:
 759 
 760 - `GET /v1/browser` 是 plugin 状态和登录态元数据的共享读面;不是单独的第三层 describe
 761-- `GET /v1/browser` 现在也会暴露 `automation_conversations`,把当前 active link 对应的 `automation_status`、`pause_reason` 和 `remote_conversation_id` 提供给 Firefox 浮层同步
 762+- `GET /v1/browser` 现在也会暴露 `automation_conversations`,把当前 active link 对应的 `automation_status`、`pause_reason` 和 `remote_conversation_id` 提供给 Firefox / Safari 浮层同步
 763 - `POST /v1/browser/actions` 当前正式支持:
 764   - `request_credentials`
 765   - `tab_open`
 766@@ -98,7 +98,7 @@ browser/plugin 管理约定:
 767   - `repeatCount` / `repeat_count`
 768   - `repeatIntervalMs` / `repeat_interval_ms` / `intervalMs` / `interval_ms`
 769 - 当前正式 shell / credential 管理平台仍覆盖 `claude` 和 `chatgpt`;Gemini raw relay 已转入 business 面,但 control 面的 shell 管理合同没有在这轮一起扩面
 770-- 如果没有活跃 Firefox bridge client,会返回 `503`
 771+- 如果没有活跃 browser bridge client,会返回 `503`
 772 - 如果指定了不存在的 `clientId`,会返回 `409`
 773 - `POST /v1/browser/actions` 会等待插件回传结构化 `action_result`,返回 `accepted` / `completed` / `failed` / `reason` / `target` / `result` / `shell_runtime`
 774 - 对 `browser.proxy_delivery` 一类动作,`action_result.results[*]` 现在可额外带 `delivery_ack`,用于表达下游确认层级;首版已稳定提供 Level 1 `status_code`
 775@@ -137,7 +137,7 @@ system 控制约定:
 776   - `GET /v1/renewal/jobs?local_conversation_id=...`
 777 - `paused` 不会删除任务,只会阻止 dispatcher 继续推进待执行 job
 778 - `manual` 和 `auto` / `paused` 共用同一份 `local_conversations` 后端状态,不存在插件侧单独影子开关
 779-- renewal REST 写接口修改状态后,Firefox bridge 的 `state_snapshot.browser.automation_conversations` 会在下一轮 WS 推送中同步更新,供浮层实时刷新
 780+- renewal REST 写接口修改状态后,browser bridge 的 `state_snapshot.browser.automation_conversations` 会在下一轮 WS 推送中同步更新,供浮层实时刷新
 781 - conductor 启动时会自动清理上次异常退出遗留的 `execution_state`,并把 `status=running` 的 renewal job 重新排回 `pending`
 782 - `GET /v1/renewal/conversations/:local_conversation_id` 现在会额外暴露:
 783   - `pause_reason`
 784diff --git a/docs/api/firefox-local-ws.md b/docs/api/firefox-local-ws.md
 785index 53b4895930b48e84c1a378ab2525cb287eb20e3e..da4f6996a4f4aa82adb8482160827fc08fdcbc27 100644
 786--- a/docs/api/firefox-local-ws.md
 787+++ b/docs/api/firefox-local-ws.md
 788@@ -1,10 +1,10 @@
 789-# Firefox Local WS
 790+# Browser Local WS
 791 
 792-`conductor-daemon` 现在在本地 HTTP listener 上正式支持 Firefox bridge WebSocket。
 793+`conductor-daemon` 现在在本地 HTTP listener 上正式支持 browser bridge WebSocket。
 794 
 795 目标:
 796 
 797-- 给本地 Firefox 插件一个正式、稳定、可重连的双向入口
 798+- 给本地 Firefox / Safari 插件一个正式、稳定、可重连的双向入口
 799 - 复用 `mini` 本地 control plane 的 system state / action write 能力
 800 - 不再把这条链路当成公网或 Cloudflare Worker 通道
 801 
 802@@ -14,19 +14,20 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
 803 
 804 例子:
 805 
 806-- `BAA_CONDUCTOR_LOCAL_API=http://100.71.210.78:4317` -> `ws://100.71.210.78:4317/ws/firefox`
 807-- `BAA_CONDUCTOR_LOCAL_API=http://127.0.0.1:4317` -> `ws://127.0.0.1:4317/ws/firefox`
 808+- `BAA_CONDUCTOR_LOCAL_API=http://100.71.210.78:4317` -> `ws://100.71.210.78:4317/ws/browser`
 809+- `BAA_CONDUCTOR_LOCAL_API=http://127.0.0.1:4317` -> `ws://127.0.0.1:4317/ws/browser`
 810 
 811 约束:
 812 
 813-- path 固定是 `/ws/firefox`
 814+- canonical path 是 `/ws/browser`
 815+- `/ws/firefox` 仍保留为兼容路径
 816 - 只应监听 loopback 或显式允许的 Tailscale `100.x` 地址
 817 - 不是公网入口
 818 - 当前正式浏览器代发 HTTP 面只支持 `claude`;但 `GET /v1/browser` 的元数据读面可以返回所有已上报 `platform`
 819 
 820 ## 自动重连语义
 821 
 822-- Firefox 客户端断开后可以直接重连到同一个 URL
 823+- browser 客户端断开后可以直接重连到同一个 URL
 824 - server 会接受新的连接并继续推送最新 snapshot
 825 - 如果同一个 `clientId` 建立了新连接,旧连接会被替换
 826 - `state_snapshot` 会在 `hello`、browser metadata 变化、system state 变化时重新推送
 827@@ -37,7 +38,7 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
 828 
 829 | `type` | 说明 |
 830 | --- | --- |
 831-| `hello` | 注册当前 Firefox client,声明 `clientId`、`nodeType`、`nodeCategory`、`nodePlatform` |
 832+| `hello` | 注册当前 browser client,声明 `clientId`、`nodeType`、`nodeCategory`、`nodePlatform` |
 833 | `state_request` | 主动请求最新 snapshot |
 834 | `action_request` | 请求执行 `pause` / `resume` / `drain` |
 835 | `action_result` | 回传 browser/plugin 管理动作的结构化执行结果;带 `accepted` / `completed` / `failed` / `reason` / `result` / `shell_runtime` |
 836@@ -73,10 +74,10 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
 837 ```json
 838 {
 839   "type": "hello",
 840-  "clientId": "firefox-ab12cd",
 841+  "clientId": "safari-ab12cd",
 842   "nodeType": "browser",
 843   "nodeCategory": "proxy",
 844-  "nodePlatform": "firefox"
 845+  "nodePlatform": "safari"
 846 }
 847 ```
 848 
 849@@ -85,10 +86,12 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
 850 ```json
 851 {
 852   "type": "hello_ack",
 853-  "clientId": "firefox-ab12cd",
 854-  "protocol": "baa.firefox.local",
 855+  "clientId": "safari-ab12cd",
 856+  "protocol": "baa.browser.local",
 857+  "protocol_compat": ["baa.firefox.local"],
 858   "version": 1,
 859-  "wsUrl": "ws://100.71.210.78:4317/ws/firefox",
 860+  "wsUrl": "ws://100.71.210.78:4317/ws/browser",
 861+  "wsCompatUrls": ["ws://100.71.210.78:4317/ws/firefox"],
 862   "localApiBase": "http://100.71.210.78:4317",
 863   "supports": {
 864     "inbound": ["hello", "state_request", "action_request", "action_result", "credentials", "api_endpoints", "client_log", "browser.final_message", "api_response", "stream_open", "stream_event", "stream_end", "stream_error"],
 865@@ -108,8 +111,8 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
 866     "server": {
 867       "identity": "mini-main@mini(primary)",
 868       "local_api_base": "http://100.71.210.78:4317",
 869-      "ws_path": "/ws/firefox",
 870-      "ws_url": "ws://100.71.210.78:4317/ws/firefox"
 871+      "ws_path": "/ws/browser",
 872+      "ws_url": "ws://100.71.210.78:4317/ws/browser"
 873     },
 874     "system": {
 875       "mode": "running",
 876@@ -142,8 +145,8 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
 877       "client_count": 1,
 878       "clients": [
 879         {
 880-          "client_id": "firefox-ab12cd",
 881-          "node_platform": "firefox",
 882+          "client_id": "safari-ab12cd",
 883+          "node_platform": "safari",
 884           "credentials": [],
 885           "final_messages": [],
 886           "request_hooks": []
 887@@ -159,7 +162,7 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
 888 - `snapshot.system` 直接复用 `GET /v1/system/state` 的合同
 889 - `snapshot.browser.clients[].credentials` 只回传 `account`、`credential_fingerprint`、`freshness`、`header_count` 和时间戳
 890 - `snapshot.browser.clients[].final_messages` 只保留当前活跃 bridge client 最近观测到的最终消息,不写入当前持久化表
 891-- `snapshot.browser.automation_conversations` 会按 active link 暴露当前页面/对话的 `automation_status`、`pause_reason`、`local_conversation_id` 和 `remote_conversation_id`,供 Firefox 浮层同步统一自动化状态
 892+- `snapshot.browser.automation_conversations` 会按 active link 暴露当前页面/对话的 `automation_status`、`pause_reason`、`local_conversation_id` 和 `remote_conversation_id`,供 Firefox / Safari 浮层同步统一自动化状态
 893 - `snapshot.browser.instruction_ingest` 暴露 live ingest / execute 的持久化读面:
 894   - `last_ingest`
 895   - `last_execute`
 896@@ -494,7 +497,7 @@ SSE 请求示例:
 897 
 898 说明:
 899 
 900-- 可显式指定 `clientId` 目标;未指定时 server 会选最近活跃的 Firefox client
 901+- 可显式指定 `clientId` 目标;未指定时 server 会选最近活跃的 browser client
 902 - 当前插件按 `platform` 激活或拉起对应页面
 903 
 904 ### `request_credentials`
 905@@ -569,7 +572,7 @@ SSE 请求示例:
 906 
 907 当前非目标:
 908 
 909-- 不把 `/ws/firefox` 直接暴露成公网产品接口
 910+- 不把 `/ws/browser` 或兼容路径 `/ws/firefox` 直接暴露成公网产品接口
 911 - 不把页面对话 UI、聊天 DOM 自动化或多标签会话编排写成正式 bridge 能力
 912 - 正式 HTTP 面已经收口到 `GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`;`/v1/browser/claude/*` 只保留 legacy 包装与 Claude 辅助读
 913 - 本文仍只讨论 WS transport、client registry 和 request-response 基础能力
 914@@ -580,7 +583,7 @@ SSE 请求示例:
 915 
 916 ```bash
 917 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://100.71.210.78:4317}"
 918-WS_URL="$(node --input-type=module -e 'const u = new URL(process.argv[1]); u.protocol = u.protocol === \"https:\" ? \"wss:\" : \"ws:\"; u.pathname = \"/ws/firefox\"; u.search = \"\"; u.hash = \"\"; console.log(u.toString());' "$LOCAL_API_BASE")"
 919+WS_URL="$(node --input-type=module -e 'const u = new URL(process.argv[1]); u.protocol = u.protocol === \"https:\" ? \"wss:\" : \"ws:\"; u.pathname = \"/ws/browser\"; u.search = \"\"; u.hash = \"\"; console.log(u.toString());' "$LOCAL_API_BASE")"
 920 
 921 WS_URL="$WS_URL" node --input-type=module <<'EOF'
 922 const socket = new WebSocket(process.env.WS_URL);
 923@@ -588,10 +591,10 @@ const socket = new WebSocket(process.env.WS_URL);
 924 socket.addEventListener("open", () => {
 925   socket.send(JSON.stringify({
 926     type: "hello",
 927-    clientId: "smoke-client",
 928+    clientId: "safari-smoke-client",
 929     nodeType: "browser",
 930     nodeCategory: "proxy",
 931-    nodePlatform: "firefox"
 932+    nodePlatform: "safari"
 933   }));
 934 
 935   socket.send(JSON.stringify({
 936@@ -607,7 +610,7 @@ EOF
 937 
 938 ### 端到端 Claude HTTP smoke
 939 
 940-如果要验证 `conductor HTTP -> /ws/firefox -> Claude 页面内 HTTP 代理` 这条最小闭环,直接运行:
 941+如果要验证 `conductor HTTP -> /ws/browser -> Claude 页面内 HTTP 代理` 这条最小闭环,直接运行:
 942 
 943 ```bash
 944 ./scripts/runtime/browser-control-e2e-smoke.sh
 945@@ -623,4 +626,4 @@ EOF
 946 - `POST /v1/browser/request`
 947 - `POST /v1/browser/request/cancel`
 948 - 正式 SSE 与 Claude legacy wrapper
 949-- 以及 `/ws/firefox` 上的 `open_tab`、`request_cancel`、`api_request` / `api_response` / `stream_*`
 950+- 以及 `/ws/browser` 上的 `open_tab`、`request_cancel`、`api_request` / `api_response` / `stream_*`
 951diff --git a/plugins/baa-safari/README.md b/plugins/baa-safari/README.md
 952new file mode 100644
 953index 0000000000000000000000000000000000000000..4eba862f8ddf515b33e275e349aa1537c14c59f2
 954--- /dev/null
 955+++ b/plugins/baa-safari/README.md
 956@@ -0,0 +1,120 @@
 957+# BAA Safari
 958+
 959+`plugins/baa-safari/` 当前收口到 Safari Phase 1 的页面桥接与 ChatGPT final-message relay。
 960+
 961+这一版完成的是:
 962+
 963+- Safari Web Extension 基础壳和 Xcode 宿主 App
 964+- content script 注入 `page-interceptor.js`
 965+- 复用 Firefox shared helper 的 `final-message.js`
 966+- 与 Firefox 对齐的页面事件协议:
 967+  - `__baa_ready__`
 968+  - `__baa_net__`
 969+  - `__baa_sse__`
 970+  - `__baa_proxy_request__`
 971+  - `__baa_proxy_response__`
 972+- ChatGPT 回复完成后上送 `browser.final_message`
 973+- 本地 browser WS bridge:默认连接 `ws://127.0.0.1:4317/ws/browser`
 974+- ChatGPT 的脱敏登录态元数据基础:
 975+  - `credential_fingerprint`
 976+  - `endpoint_metadata`
 977+  - `account_hint`
 978+- controller 页面的权限和注入诊断
 979+
 980+这一版刻意不做:
 981+
 982+- Safari `proxy_delivery` 正式闭环
 983+- Claude / Gemini 的 Safari 业务能力
 984+
 985+## 当前支持边界
 986+
 987+- `chatgpt`
 988+  已实现页面桥接、SSE/network 事件转发、`browser.final_message` relay、endpoint metadata 和脱敏登录态元数据采集。
 989+- `claude`
 990+  只保留页面桥接基础和平台抽象占位,不宣称已支持 final-message 或 proxy_delivery。
 991+- `gemini`
 992+  只保留页面桥接基础和平台抽象占位,不宣称已支持 final-message 或 proxy_delivery。
 993+
 994+## Runtime 合同
 995+
 996+- `controller.html`
 997+  控制页和长期 runtime owner 视图;负责展示 bridge 健康度、权限提示和 metadata 快照。
 998+- `background.js`
 999+  聚合 content-script / page-interceptor 事件、`webRequest` 脱敏元数据、ChatGPT final-message observer 和本地 browser WS。
1000+- `final-message.js`
1001+  与 Firefox 保持合同一致的 pure-JS helper;Safari 直接复用其 SSE/network 归并和 dedupe 语义。
1002+- `content-script.js`
1003+  页面入口;注入 `page-interceptor.js`,并把页面事件转成 runtime message。
1004+- `page-interceptor.js`
1005+  运行在页面主世界;复用 Firefox 风格的 fetch/XHR 拦截和 `__baa_proxy_request__` 事件协议。
1006+
1007+## 关键观测点
1008+
1009+打开 ChatGPT 页面后,控制页应该至少能看到:
1010+
1011+- `Content Script` 卡片有最近页面回报
1012+- `Page Bridge` 卡片进入 ready
1013+- `Local WS` 卡片进入已连接
1014+- `ChatGPT Metadata` 卡片出现 `credential_fingerprint` 或 `endpoint_count > 0`
1015+- `Final Message` 卡片在回复完成后出现最近一次 relay
1016+- `最近诊断日志` 里能看到 `page_bridge_ready`、`endpoint_metadata_seen` 或 `credential_snapshot_seen`
1017+
1018+## Website Access 排查
1019+
1020+Safari 最容易踩坑的是网站授权。
1021+
1022+如果控制页一直显示:
1023+
1024+- 没有 `Content Script` 回报
1025+- 没有 `Page Bridge` ready
1026+- `permissionHints` 提示 `website_access_or_content_script_missing`
1027+
1028+优先检查:
1029+
1030+1. `Safari -> Settings -> Extensions`
1031+2. 启用 `BAA Safari Extension`
1032+3. 在 `Website Access` 给 `chatgpt.com` / `chat.openai.com` 授权
1033+4. 刷新目标页面后再看控制页
1034+
1035+如果已经有 `Content Script` 回报,但一直没有 `Page Bridge` ready,通常是:
1036+
1037+- `page-interceptor.js` 注入失败
1038+- 页面 CSP 或主世界注入时机有问题
1039+
1040+这时看控制页里的 `最近诊断日志`,重点找:
1041+
1042+- `page_interceptor_script_error`
1043+- `page_interceptor_timeout`
1044+- `page_bridge_ready`
1045+
1046+## 目录
1047+
1048+- [manifest.json](/Users/george/code/baa-conductor-safari-plugin-safari-page-bridge/plugins/baa-safari/manifest.json)
1049+- [background.js](/Users/george/code/baa-conductor-safari-plugin-safari-page-bridge/plugins/baa-safari/background.js)
1050+- [final-message.js](/Users/george/code/baa-conductor-safari-plugin-safari-chatgpt-final-message/plugins/baa-safari/final-message.js)
1051+- [content-script.js](/Users/george/code/baa-conductor-safari-plugin-safari-page-bridge/plugins/baa-safari/content-script.js)
1052+- [page-interceptor.js](/Users/george/code/baa-conductor-safari-plugin-safari-page-bridge/plugins/baa-safari/page-interceptor.js)
1053+- [controller.html](/Users/george/code/baa-conductor-safari-plugin-safari-page-bridge/plugins/baa-safari/controller.html)
1054+- [controller.js](/Users/george/code/baa-conductor-safari-plugin-safari-page-bridge/plugins/baa-safari/controller.js)
1055+- [baa-safari-host](/Users/george/code/baa-conductor-safari-plugin-safari-page-bridge/plugins/baa-safari/baa-safari-host)
1056+
1057+## 本地构建
1058+
1059+```bash
1060+cd /Users/george/code/baa-conductor-safari-plugin-safari-page-bridge
1061+env LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 \
1062+  xcodebuild \
1063+  -project plugins/baa-safari/baa-safari-host/BAASafariHost.xcodeproj \
1064+  -scheme BAASafariHost \
1065+  -configuration Debug build
1066+```
1067+
1068+## 首次启用
1069+
1070+1. 先构建并启动 `BAASafariHost.app`。
1071+2. 打开 Safari。
1072+3. 进入 `Safari -> Settings -> Extensions`。
1073+4. 启用 `BAA Safari Extension`。
1074+5. 在 `Website Access` 给 ChatGPT 授权。
1075+6. 点击工具栏扩展按钮,让 `background.js` 打开 [controller.html](/Users/george/code/baa-conductor-safari-plugin-safari-page-bridge/plugins/baa-safari/controller.html)。
1076+7. 打开 ChatGPT 页面,观察 controller 里的 bridge 和 metadata 状态。
1077diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost Extension/BAASafariHost_Extension.entitlements b/plugins/baa-safari/baa-safari-host/BAASafariHost Extension/BAASafariHost_Extension.entitlements
1078new file mode 100644
1079index 0000000000000000000000000000000000000000..f2ef3ae0265b40c475e8ef90e3a311c31786c594
1080--- /dev/null
1081+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost Extension/BAASafariHost_Extension.entitlements	
1082@@ -0,0 +1,10 @@
1083+<?xml version="1.0" encoding="UTF-8"?>
1084+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1085+<plist version="1.0">
1086+<dict>
1087+    <key>com.apple.security.app-sandbox</key>
1088+    <true/>
1089+    <key>com.apple.security.files.user-selected.read-only</key>
1090+    <true/>
1091+</dict>
1092+</plist>
1093diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost Extension/Info.plist b/plugins/baa-safari/baa-safari-host/BAASafariHost Extension/Info.plist
1094new file mode 100644
1095index 0000000000000000000000000000000000000000..9ee504dc51f3d3c54be0fb0038a89b13b2250107
1096--- /dev/null
1097+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost Extension/Info.plist	
1098@@ -0,0 +1,13 @@
1099+<?xml version="1.0" encoding="UTF-8"?>
1100+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1101+<plist version="1.0">
1102+<dict>
1103+	<key>NSExtension</key>
1104+	<dict>
1105+		<key>NSExtensionPointIdentifier</key>
1106+		<string>com.apple.Safari.web-extension</string>
1107+		<key>NSExtensionPrincipalClass</key>
1108+		<string>$(PRODUCT_MODULE_NAME).SafariWebExtensionHandler</string>
1109+	</dict>
1110+</dict>
1111+</plist>
1112diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost Extension/SafariWebExtensionHandler.swift b/plugins/baa-safari/baa-safari-host/BAASafariHost Extension/SafariWebExtensionHandler.swift
1113new file mode 100644
1114index 0000000000000000000000000000000000000000..12ffd9f1016a8d8b23f75a9851d70af46ece0569
1115--- /dev/null
1116+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost Extension/SafariWebExtensionHandler.swift	
1117@@ -0,0 +1,42 @@
1118+//
1119+//  SafariWebExtensionHandler.swift
1120+//  BAASafariHost Extension
1121+//
1122+//  Created by george on 2026/4/2.
1123+//
1124+
1125+import SafariServices
1126+import os.log
1127+
1128+class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
1129+
1130+    func beginRequest(with context: NSExtensionContext) {
1131+        let request = context.inputItems.first as? NSExtensionItem
1132+
1133+        let profile: UUID?
1134+        if #available(iOS 17.0, macOS 14.0, *) {
1135+            profile = request?.userInfo?[SFExtensionProfileKey] as? UUID
1136+        } else {
1137+            profile = request?.userInfo?["profile"] as? UUID
1138+        }
1139+
1140+        let message: Any?
1141+        if #available(iOS 15.0, macOS 11.0, *) {
1142+            message = request?.userInfo?[SFExtensionMessageKey]
1143+        } else {
1144+            message = request?.userInfo?["message"]
1145+        }
1146+
1147+        os_log(.default, "Received message from browser.runtime.sendNativeMessage: %@ (profile: %@)", String(describing: message), profile?.uuidString ?? "none")
1148+
1149+        let response = NSExtensionItem()
1150+        if #available(iOS 15.0, macOS 11.0, *) {
1151+            response.userInfo = [ SFExtensionMessageKey: [ "echo": message ] ]
1152+        } else {
1153+            response.userInfo = [ "message": [ "echo": message ] ]
1154+        }
1155+
1156+        context.completeRequest(returningItems: [ response ], completionHandler: nil)
1157+    }
1158+
1159+}
1160diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost.xcodeproj/project.pbxproj b/plugins/baa-safari/baa-safari-host/BAASafariHost.xcodeproj/project.pbxproj
1161new file mode 100644
1162index 0000000000000000000000000000000000000000..8540cae8d467d55ed8433a2f4e2f738116838c6d
1163--- /dev/null
1164+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost.xcodeproj/project.pbxproj
1165@@ -0,0 +1,857 @@
1166+// !$*UTF8*$!
1167+{
1168+	archiveVersion = 1;
1169+	classes = {
1170+	};
1171+	objectVersion = 77;
1172+	objects = {
1173+
1174+/* Begin PBXBuildFile section */
1175+		5EF028BD2F7E7177006A58E2 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EF028BC2F7E7177006A58E2 /* AppDelegate.swift */; };
1176+		5EF028C12F7E7177006A58E2 /* Main.html in Resources */ = {isa = PBXBuildFile; fileRef = 5EF028BF2F7E7177006A58E2 /* Main.html */; };
1177+		5EF028C32F7E7177006A58E2 /* Icon.png in Resources */ = {isa = PBXBuildFile; fileRef = 5EF028C22F7E7177006A58E2 /* Icon.png */; };
1178+		5EF028C52F7E7177006A58E2 /* Style.css in Resources */ = {isa = PBXBuildFile; fileRef = 5EF028C42F7E7177006A58E2 /* Style.css */; };
1179+		5EF028C72F7E7177006A58E2 /* Script.js in Resources */ = {isa = PBXBuildFile; fileRef = 5EF028C62F7E7177006A58E2 /* Script.js */; };
1180+		5EF028C92F7E7177006A58E2 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EF028C82F7E7177006A58E2 /* ViewController.swift */; };
1181+		5EF028CC2F7E7177006A58E2 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 5EF028CA2F7E7177006A58E2 /* Main.storyboard */; };
1182+		5EF028CE2F7E7177006A58E2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5EF028CD2F7E7177006A58E2 /* Assets.xcassets */; };
1183+		5EF028DB2F7E7177006A58E2 /* BAASafariHostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EF028DA2F7E7177006A58E2 /* BAASafariHostTests.swift */; };
1184+		5EF028E52F7E7177006A58E2 /* BAASafariHostUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EF028E42F7E7177006A58E2 /* BAASafariHostUITests.swift */; };
1185+		5EF028E72F7E7177006A58E2 /* BAASafariHostUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EF028E62F7E7177006A58E2 /* BAASafariHostUITestsLaunchTests.swift */; };
1186+		5EF028ED2F7E7177006A58E2 /* BAASafariHost Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5EF028EC2F7E7177006A58E2 /* BAASafariHost Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
1187+		5EF028F22F7E7177006A58E2 /* SafariWebExtensionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EF028F12F7E7177006A58E2 /* SafariWebExtensionHandler.swift */; };
1188+		5EF0290E2F7E7178006A58E2 /* background.js in Resources */ = {isa = PBXBuildFile; fileRef = 5EF029052F7E7178006A58E2 /* background.js */; };
1189+		5EF0290F2F7E7178006A58E2 /* controller.css in Resources */ = {isa = PBXBuildFile; fileRef = 5EF029062F7E7178006A58E2 /* controller.css */; };
1190+		5EF029102F7E7178006A58E2 /* controller.js in Resources */ = {isa = PBXBuildFile; fileRef = 5EF029072F7E7178006A58E2 /* controller.js */; };
1191+		5EF029112F7E7178006A58E2 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 5EF029082F7E7178006A58E2 /* README.md */; };
1192+		5EF029202F7E7178006A58E2 /* final-message.js in Resources */ = {isa = PBXBuildFile; fileRef = 5EF029212F7E7178006A58E2 /* final-message.js */; };
1193+		5EF029122F7E7178006A58E2 /* controller.html in Resources */ = {isa = PBXBuildFile; fileRef = 5EF029092F7E7178006A58E2 /* controller.html */; };
1194+		5EF029132F7E7178006A58E2 /* manifest.json in Resources */ = {isa = PBXBuildFile; fileRef = 5EF0290A2F7E7178006A58E2 /* manifest.json */; };
1195+		5EF029142F7E7178006A58E2 /* content-script.js in Resources */ = {isa = PBXBuildFile; fileRef = 5EF0290B2F7E7178006A58E2 /* content-script.js */; };
1196+		5EF029162F7E7178006A58E2 /* page-interceptor.js in Resources */ = {isa = PBXBuildFile; fileRef = 5EF0290D2F7E7178006A58E2 /* page-interceptor.js */; };
1197+		5EF029172F7E7178006A58E2 /* 48.png in Resources */ = {isa = PBXBuildFile; fileRef = 5EF029182F7E7178006A58E2 /* 48.png */; };
1198+		5EF029192F7E7178006A58E2 /* 96.png in Resources */ = {isa = PBXBuildFile; fileRef = 5EF0291A2F7E7178006A58E2 /* 96.png */; };
1199+		5EF0291B2F7E7178006A58E2 /* 128.png in Resources */ = {isa = PBXBuildFile; fileRef = 5EF0291C2F7E7178006A58E2 /* 128.png */; };
1200+/* End PBXBuildFile section */
1201+
1202+/* Begin PBXContainerItemProxy section */
1203+		5EF028D72F7E7177006A58E2 /* PBXContainerItemProxy */ = {
1204+			isa = PBXContainerItemProxy;
1205+			containerPortal = 5EF028B12F7E7177006A58E2 /* Project object */;
1206+			proxyType = 1;
1207+			remoteGlobalIDString = 5EF028B82F7E7177006A58E2;
1208+			remoteInfo = BAASafariHost;
1209+		};
1210+		5EF028E12F7E7177006A58E2 /* PBXContainerItemProxy */ = {
1211+			isa = PBXContainerItemProxy;
1212+			containerPortal = 5EF028B12F7E7177006A58E2 /* Project object */;
1213+			proxyType = 1;
1214+			remoteGlobalIDString = 5EF028B82F7E7177006A58E2;
1215+			remoteInfo = BAASafariHost;
1216+		};
1217+		5EF028EE2F7E7177006A58E2 /* PBXContainerItemProxy */ = {
1218+			isa = PBXContainerItemProxy;
1219+			containerPortal = 5EF028B12F7E7177006A58E2 /* Project object */;
1220+			proxyType = 1;
1221+			remoteGlobalIDString = 5EF028EB2F7E7177006A58E2;
1222+			remoteInfo = "BAASafariHost Extension";
1223+		};
1224+/* End PBXContainerItemProxy section */
1225+
1226+/* Begin PBXCopyFilesBuildPhase section */
1227+		5EF028FA2F7E7177006A58E2 /* Embed Foundation Extensions */ = {
1228+			isa = PBXCopyFilesBuildPhase;
1229+			buildActionMask = 2147483647;
1230+			dstPath = "";
1231+			dstSubfolderSpec = 13;
1232+			files = (
1233+				5EF028ED2F7E7177006A58E2 /* BAASafariHost Extension.appex in Embed Foundation Extensions */,
1234+			);
1235+			name = "Embed Foundation Extensions";
1236+			runOnlyForDeploymentPostprocessing = 0;
1237+		};
1238+/* End PBXCopyFilesBuildPhase section */
1239+
1240+/* Begin PBXFileReference section */
1241+		5EF028B92F7E7177006A58E2 /* BAASafariHost.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BAASafariHost.app; sourceTree = BUILT_PRODUCTS_DIR; };
1242+		5EF028BC2F7E7177006A58E2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
1243+		5EF028C02F7E7177006A58E2 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = Base; path = Base.lproj/Main.html; sourceTree = "<group>"; };
1244+		5EF028C22F7E7177006A58E2 /* Icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = Icon.png; sourceTree = "<group>"; };
1245+		5EF028C42F7E7177006A58E2 /* Style.css */ = {isa = PBXFileReference; lastKnownFileType = text.css; path = Style.css; sourceTree = "<group>"; };
1246+		5EF028C62F7E7177006A58E2 /* Script.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = Script.js; sourceTree = "<group>"; };
1247+		5EF028C82F7E7177006A58E2 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = "<group>"; };
1248+		5EF028CB2F7E7177006A58E2 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
1249+		5EF028CD2F7E7177006A58E2 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
1250+		5EF028CF2F7E7177006A58E2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
1251+		5EF028D02F7E7177006A58E2 /* BAASafariHost.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BAASafariHost.entitlements; sourceTree = "<group>"; };
1252+		5EF028D12F7E7177006A58E2 /* BAASafariHost.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BAASafariHost.entitlements; sourceTree = "<group>"; };
1253+		5EF028D62F7E7177006A58E2 /* BAASafariHostTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BAASafariHostTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
1254+		5EF028DA2F7E7177006A58E2 /* BAASafariHostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BAASafariHostTests.swift; sourceTree = "<group>"; };
1255+		5EF028E02F7E7177006A58E2 /* BAASafariHostUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BAASafariHostUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
1256+		5EF028E42F7E7177006A58E2 /* BAASafariHostUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BAASafariHostUITests.swift; sourceTree = "<group>"; };
1257+		5EF028E62F7E7177006A58E2 /* BAASafariHostUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BAASafariHostUITestsLaunchTests.swift; sourceTree = "<group>"; };
1258+		5EF028EC2F7E7177006A58E2 /* BAASafariHost Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "BAASafariHost Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
1259+		5EF028F12F7E7177006A58E2 /* SafariWebExtensionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariWebExtensionHandler.swift; sourceTree = "<group>"; };
1260+		5EF028F32F7E7177006A58E2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
1261+		5EF028F42F7E7177006A58E2 /* BAASafariHost_Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BAASafariHost_Extension.entitlements; sourceTree = "<group>"; };
1262+		5EF029052F7E7178006A58E2 /* background.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; name = background.js; path = ../../background.js; sourceTree = "<group>"; };
1263+		5EF029062F7E7178006A58E2 /* controller.css */ = {isa = PBXFileReference; lastKnownFileType = text.css; name = controller.css; path = ../../controller.css; sourceTree = "<group>"; };
1264+		5EF029072F7E7178006A58E2 /* controller.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; name = controller.js; path = ../../controller.js; sourceTree = "<group>"; };
1265+		5EF029082F7E7178006A58E2 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../../README.md; sourceTree = "<group>"; };
1266+		5EF029212F7E7178006A58E2 /* final-message.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; name = "final-message.js"; path = "../../final-message.js"; sourceTree = "<group>"; };
1267+		5EF029092F7E7178006A58E2 /* controller.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = controller.html; path = ../../controller.html; sourceTree = "<group>"; };
1268+		5EF0290A2F7E7178006A58E2 /* manifest.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = manifest.json; path = ../../manifest.json; sourceTree = "<group>"; };
1269+		5EF0290B2F7E7178006A58E2 /* content-script.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; name = "content-script.js"; path = "../../content-script.js"; sourceTree = "<group>"; };
1270+		5EF0290D2F7E7178006A58E2 /* page-interceptor.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; name = "page-interceptor.js"; path = "../../page-interceptor.js"; sourceTree = "<group>"; };
1271+		5EF029182F7E7178006A58E2 /* 48.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = 48.png; path = ../../icons/48.png; sourceTree = "<group>"; };
1272+		5EF0291A2F7E7178006A58E2 /* 96.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = 96.png; path = ../../icons/96.png; sourceTree = "<group>"; };
1273+		5EF0291C2F7E7178006A58E2 /* 128.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = 128.png; path = ../../icons/128.png; sourceTree = "<group>"; };
1274+/* End PBXFileReference section */
1275+
1276+/* Begin PBXFrameworksBuildPhase section */
1277+		5EF028B62F7E7177006A58E2 /* Frameworks */ = {
1278+			isa = PBXFrameworksBuildPhase;
1279+			buildActionMask = 2147483647;
1280+			files = (
1281+			);
1282+			runOnlyForDeploymentPostprocessing = 0;
1283+		};
1284+		5EF028D32F7E7177006A58E2 /* Frameworks */ = {
1285+			isa = PBXFrameworksBuildPhase;
1286+			buildActionMask = 2147483647;
1287+			files = (
1288+			);
1289+			runOnlyForDeploymentPostprocessing = 0;
1290+		};
1291+		5EF028DD2F7E7177006A58E2 /* Frameworks */ = {
1292+			isa = PBXFrameworksBuildPhase;
1293+			buildActionMask = 2147483647;
1294+			files = (
1295+			);
1296+			runOnlyForDeploymentPostprocessing = 0;
1297+		};
1298+		5EF028E92F7E7177006A58E2 /* Frameworks */ = {
1299+			isa = PBXFrameworksBuildPhase;
1300+			buildActionMask = 2147483647;
1301+			files = (
1302+			);
1303+			runOnlyForDeploymentPostprocessing = 0;
1304+		};
1305+/* End PBXFrameworksBuildPhase section */
1306+
1307+/* Begin PBXGroup section */
1308+		5EF028B02F7E7177006A58E2 = {
1309+			isa = PBXGroup;
1310+			children = (
1311+				5EF028BB2F7E7177006A58E2 /* BAASafariHost */,
1312+				5EF028D92F7E7177006A58E2 /* BAASafariHostTests */,
1313+				5EF028E32F7E7177006A58E2 /* BAASafariHostUITests */,
1314+				5EF028F02F7E7177006A58E2 /* BAASafariHost Extension */,
1315+				5EF028BA2F7E7177006A58E2 /* Products */,
1316+			);
1317+			sourceTree = "<group>";
1318+		};
1319+		5EF028BA2F7E7177006A58E2 /* Products */ = {
1320+			isa = PBXGroup;
1321+			children = (
1322+				5EF028B92F7E7177006A58E2 /* BAASafariHost.app */,
1323+				5EF028D62F7E7177006A58E2 /* BAASafariHostTests.xctest */,
1324+				5EF028E02F7E7177006A58E2 /* BAASafariHostUITests.xctest */,
1325+				5EF028EC2F7E7177006A58E2 /* BAASafariHost Extension.appex */,
1326+			);
1327+			name = Products;
1328+			sourceTree = "<group>";
1329+		};
1330+		5EF028BB2F7E7177006A58E2 /* BAASafariHost */ = {
1331+			isa = PBXGroup;
1332+			children = (
1333+				5EF028BC2F7E7177006A58E2 /* AppDelegate.swift */,
1334+				5EF028C82F7E7177006A58E2 /* ViewController.swift */,
1335+				5EF028CA2F7E7177006A58E2 /* Main.storyboard */,
1336+				5EF028CD2F7E7177006A58E2 /* Assets.xcassets */,
1337+				5EF028CF2F7E7177006A58E2 /* Info.plist */,
1338+				5EF028D02F7E7177006A58E2 /* BAASafariHost.entitlements */,
1339+				5EF028D12F7E7177006A58E2 /* BAASafariHost.entitlements */,
1340+				5EF028BE2F7E7177006A58E2 /* Resources */,
1341+			);
1342+			path = BAASafariHost;
1343+			sourceTree = "<group>";
1344+		};
1345+		5EF028BE2F7E7177006A58E2 /* Resources */ = {
1346+			isa = PBXGroup;
1347+			children = (
1348+				5EF028BF2F7E7177006A58E2 /* Main.html */,
1349+				5EF028C22F7E7177006A58E2 /* Icon.png */,
1350+				5EF028C42F7E7177006A58E2 /* Style.css */,
1351+				5EF028C62F7E7177006A58E2 /* Script.js */,
1352+			);
1353+			path = Resources;
1354+			sourceTree = "<group>";
1355+		};
1356+		5EF028D92F7E7177006A58E2 /* BAASafariHostTests */ = {
1357+			isa = PBXGroup;
1358+			children = (
1359+				5EF028DA2F7E7177006A58E2 /* BAASafariHostTests.swift */,
1360+			);
1361+			path = BAASafariHostTests;
1362+			sourceTree = "<group>";
1363+		};
1364+		5EF028E32F7E7177006A58E2 /* BAASafariHostUITests */ = {
1365+			isa = PBXGroup;
1366+			children = (
1367+				5EF028E42F7E7177006A58E2 /* BAASafariHostUITests.swift */,
1368+				5EF028E62F7E7177006A58E2 /* BAASafariHostUITestsLaunchTests.swift */,
1369+			);
1370+			path = BAASafariHostUITests;
1371+			sourceTree = "<group>";
1372+		};
1373+		5EF028F02F7E7177006A58E2 /* BAASafariHost Extension */ = {
1374+			isa = PBXGroup;
1375+			children = (
1376+				5EF029042F7E7178006A58E2 /* Resources */,
1377+				5EF028F12F7E7177006A58E2 /* SafariWebExtensionHandler.swift */,
1378+				5EF028F32F7E7177006A58E2 /* Info.plist */,
1379+				5EF028F42F7E7177006A58E2 /* BAASafariHost_Extension.entitlements */,
1380+			);
1381+			path = "BAASafariHost Extension";
1382+			sourceTree = "<group>";
1383+		};
1384+		5EF029042F7E7178006A58E2 /* Resources */ = {
1385+			isa = PBXGroup;
1386+			children = (
1387+				5EF029052F7E7178006A58E2 /* background.js */,
1388+				5EF029062F7E7178006A58E2 /* controller.css */,
1389+				5EF029072F7E7178006A58E2 /* controller.js */,
1390+				5EF029082F7E7178006A58E2 /* README.md */,
1391+				5EF029212F7E7178006A58E2 /* final-message.js */,
1392+				5EF029092F7E7178006A58E2 /* controller.html */,
1393+				5EF0290A2F7E7178006A58E2 /* manifest.json */,
1394+				5EF0290B2F7E7178006A58E2 /* content-script.js */,
1395+				5EF0290D2F7E7178006A58E2 /* page-interceptor.js */,
1396+				5EF029182F7E7178006A58E2 /* 48.png */,
1397+				5EF0291A2F7E7178006A58E2 /* 96.png */,
1398+				5EF0291C2F7E7178006A58E2 /* 128.png */,
1399+			);
1400+			name = Resources;
1401+			path = "BAASafariHost Extension";
1402+			sourceTree = SOURCE_ROOT;
1403+		};
1404+/* End PBXGroup section */
1405+
1406+/* Begin PBXNativeTarget section */
1407+		5EF028B82F7E7177006A58E2 /* BAASafariHost */ = {
1408+			isa = PBXNativeTarget;
1409+			buildConfigurationList = 5EF028FB2F7E7177006A58E2 /* Build configuration list for PBXNativeTarget "BAASafariHost" */;
1410+			buildPhases = (
1411+				5EF028B52F7E7177006A58E2 /* Sources */,
1412+				5EF028B62F7E7177006A58E2 /* Frameworks */,
1413+				5EF028B72F7E7177006A58E2 /* Resources */,
1414+				5EF028FA2F7E7177006A58E2 /* Embed Foundation Extensions */,
1415+			);
1416+			buildRules = (
1417+			);
1418+			dependencies = (
1419+				5EF028EF2F7E7177006A58E2 /* PBXTargetDependency */,
1420+			);
1421+			name = BAASafariHost;
1422+			packageProductDependencies = (
1423+			);
1424+			productName = BAASafariHost;
1425+			productReference = 5EF028B92F7E7177006A58E2 /* BAASafariHost.app */;
1426+			productType = "com.apple.product-type.application";
1427+		};
1428+		5EF028D52F7E7177006A58E2 /* BAASafariHostTests */ = {
1429+			isa = PBXNativeTarget;
1430+			buildConfigurationList = 5EF028FE2F7E7177006A58E2 /* Build configuration list for PBXNativeTarget "BAASafariHostTests" */;
1431+			buildPhases = (
1432+				5EF028D22F7E7177006A58E2 /* Sources */,
1433+				5EF028D32F7E7177006A58E2 /* Frameworks */,
1434+				5EF028D42F7E7177006A58E2 /* Resources */,
1435+			);
1436+			buildRules = (
1437+			);
1438+			dependencies = (
1439+				5EF028D82F7E7177006A58E2 /* PBXTargetDependency */,
1440+			);
1441+			name = BAASafariHostTests;
1442+			packageProductDependencies = (
1443+			);
1444+			productName = BAASafariHostTests;
1445+			productReference = 5EF028D62F7E7177006A58E2 /* BAASafariHostTests.xctest */;
1446+			productType = "com.apple.product-type.bundle.unit-test";
1447+		};
1448+		5EF028DF2F7E7177006A58E2 /* BAASafariHostUITests */ = {
1449+			isa = PBXNativeTarget;
1450+			buildConfigurationList = 5EF029012F7E7177006A58E2 /* Build configuration list for PBXNativeTarget "BAASafariHostUITests" */;
1451+			buildPhases = (
1452+				5EF028DC2F7E7177006A58E2 /* Sources */,
1453+				5EF028DD2F7E7177006A58E2 /* Frameworks */,
1454+				5EF028DE2F7E7177006A58E2 /* Resources */,
1455+			);
1456+			buildRules = (
1457+			);
1458+			dependencies = (
1459+				5EF028E22F7E7177006A58E2 /* PBXTargetDependency */,
1460+			);
1461+			name = BAASafariHostUITests;
1462+			packageProductDependencies = (
1463+			);
1464+			productName = BAASafariHostUITests;
1465+			productReference = 5EF028E02F7E7177006A58E2 /* BAASafariHostUITests.xctest */;
1466+			productType = "com.apple.product-type.bundle.ui-testing";
1467+		};
1468+		5EF028EB2F7E7177006A58E2 /* BAASafariHost Extension */ = {
1469+			isa = PBXNativeTarget;
1470+			buildConfigurationList = 5EF028F72F7E7177006A58E2 /* Build configuration list for PBXNativeTarget "BAASafariHost Extension" */;
1471+			buildPhases = (
1472+				5EF028E82F7E7177006A58E2 /* Sources */,
1473+				5EF028E92F7E7177006A58E2 /* Frameworks */,
1474+				5EF028EA2F7E7177006A58E2 /* Resources */,
1475+			);
1476+			buildRules = (
1477+			);
1478+			dependencies = (
1479+			);
1480+			name = "BAASafariHost Extension";
1481+			packageProductDependencies = (
1482+			);
1483+			productName = "BAASafariHost Extension";
1484+			productReference = 5EF028EC2F7E7177006A58E2 /* BAASafariHost Extension.appex */;
1485+			productType = "com.apple.product-type.app-extension";
1486+		};
1487+/* End PBXNativeTarget section */
1488+
1489+/* Begin PBXProject section */
1490+		5EF028B12F7E7177006A58E2 /* Project object */ = {
1491+			isa = PBXProject;
1492+			attributes = {
1493+				BuildIndependentTargetsInParallel = 1;
1494+				LastSwiftUpdateCheck = 1620;
1495+				LastUpgradeCheck = 1620;
1496+				TargetAttributes = {
1497+					5EF028B82F7E7177006A58E2 = {
1498+						CreatedOnToolsVersion = 16.2;
1499+					};
1500+					5EF028D52F7E7177006A58E2 = {
1501+						CreatedOnToolsVersion = 16.2;
1502+						TestTargetID = 5EF028B82F7E7177006A58E2;
1503+					};
1504+					5EF028DF2F7E7177006A58E2 = {
1505+						CreatedOnToolsVersion = 16.2;
1506+						TestTargetID = 5EF028B82F7E7177006A58E2;
1507+					};
1508+					5EF028EB2F7E7177006A58E2 = {
1509+						CreatedOnToolsVersion = 16.2;
1510+					};
1511+				};
1512+			};
1513+			buildConfigurationList = 5EF028B42F7E7177006A58E2 /* Build configuration list for PBXProject "BAASafariHost" */;
1514+			developmentRegion = en;
1515+			hasScannedForEncodings = 0;
1516+			knownRegions = (
1517+				en,
1518+				Base,
1519+			);
1520+			mainGroup = 5EF028B02F7E7177006A58E2;
1521+			minimizedProjectReferenceProxies = 1;
1522+			preferredProjectObjectVersion = 77;
1523+			productRefGroup = 5EF028BA2F7E7177006A58E2 /* Products */;
1524+			projectDirPath = "";
1525+			projectRoot = "";
1526+			targets = (
1527+				5EF028B82F7E7177006A58E2 /* BAASafariHost */,
1528+				5EF028D52F7E7177006A58E2 /* BAASafariHostTests */,
1529+				5EF028DF2F7E7177006A58E2 /* BAASafariHostUITests */,
1530+				5EF028EB2F7E7177006A58E2 /* BAASafariHost Extension */,
1531+			);
1532+		};
1533+/* End PBXProject section */
1534+
1535+/* Begin PBXResourcesBuildPhase section */
1536+		5EF028B72F7E7177006A58E2 /* Resources */ = {
1537+			isa = PBXResourcesBuildPhase;
1538+			buildActionMask = 2147483647;
1539+			files = (
1540+				5EF028C32F7E7177006A58E2 /* Icon.png in Resources */,
1541+				5EF028CC2F7E7177006A58E2 /* Main.storyboard in Resources */,
1542+				5EF028C72F7E7177006A58E2 /* Script.js in Resources */,
1543+				5EF028C12F7E7177006A58E2 /* Main.html in Resources */,
1544+				5EF028CE2F7E7177006A58E2 /* Assets.xcassets in Resources */,
1545+				5EF028C52F7E7177006A58E2 /* Style.css in Resources */,
1546+			);
1547+			runOnlyForDeploymentPostprocessing = 0;
1548+		};
1549+		5EF028D42F7E7177006A58E2 /* Resources */ = {
1550+			isa = PBXResourcesBuildPhase;
1551+			buildActionMask = 2147483647;
1552+			files = (
1553+			);
1554+			runOnlyForDeploymentPostprocessing = 0;
1555+		};
1556+		5EF028DE2F7E7177006A58E2 /* Resources */ = {
1557+			isa = PBXResourcesBuildPhase;
1558+			buildActionMask = 2147483647;
1559+			files = (
1560+			);
1561+			runOnlyForDeploymentPostprocessing = 0;
1562+		};
1563+		5EF028EA2F7E7177006A58E2 /* Resources */ = {
1564+			isa = PBXResourcesBuildPhase;
1565+			buildActionMask = 2147483647;
1566+			files = (
1567+				5EF029142F7E7178006A58E2 /* content-script.js in Resources */,
1568+				5EF0290E2F7E7178006A58E2 /* background.js in Resources */,
1569+				5EF029102F7E7178006A58E2 /* controller.js in Resources */,
1570+				5EF029202F7E7178006A58E2 /* final-message.js in Resources */,
1571+				5EF029122F7E7178006A58E2 /* controller.html in Resources */,
1572+				5EF029162F7E7178006A58E2 /* page-interceptor.js in Resources */,
1573+				5EF029172F7E7178006A58E2 /* 48.png in Resources */,
1574+				5EF029192F7E7178006A58E2 /* 96.png in Resources */,
1575+				5EF0291B2F7E7178006A58E2 /* 128.png in Resources */,
1576+				5EF029112F7E7178006A58E2 /* README.md in Resources */,
1577+				5EF029132F7E7178006A58E2 /* manifest.json in Resources */,
1578+				5EF0290F2F7E7178006A58E2 /* controller.css in Resources */,
1579+			);
1580+			runOnlyForDeploymentPostprocessing = 0;
1581+		};
1582+/* End PBXResourcesBuildPhase section */
1583+
1584+/* Begin PBXSourcesBuildPhase section */
1585+		5EF028B52F7E7177006A58E2 /* Sources */ = {
1586+			isa = PBXSourcesBuildPhase;
1587+			buildActionMask = 2147483647;
1588+			files = (
1589+				5EF028C92F7E7177006A58E2 /* ViewController.swift in Sources */,
1590+				5EF028BD2F7E7177006A58E2 /* AppDelegate.swift in Sources */,
1591+			);
1592+			runOnlyForDeploymentPostprocessing = 0;
1593+		};
1594+		5EF028D22F7E7177006A58E2 /* Sources */ = {
1595+			isa = PBXSourcesBuildPhase;
1596+			buildActionMask = 2147483647;
1597+			files = (
1598+				5EF028DB2F7E7177006A58E2 /* BAASafariHostTests.swift in Sources */,
1599+			);
1600+			runOnlyForDeploymentPostprocessing = 0;
1601+		};
1602+		5EF028DC2F7E7177006A58E2 /* Sources */ = {
1603+			isa = PBXSourcesBuildPhase;
1604+			buildActionMask = 2147483647;
1605+			files = (
1606+				5EF028E52F7E7177006A58E2 /* BAASafariHostUITests.swift in Sources */,
1607+				5EF028E72F7E7177006A58E2 /* BAASafariHostUITestsLaunchTests.swift in Sources */,
1608+			);
1609+			runOnlyForDeploymentPostprocessing = 0;
1610+		};
1611+		5EF028E82F7E7177006A58E2 /* Sources */ = {
1612+			isa = PBXSourcesBuildPhase;
1613+			buildActionMask = 2147483647;
1614+			files = (
1615+				5EF028F22F7E7177006A58E2 /* SafariWebExtensionHandler.swift in Sources */,
1616+			);
1617+			runOnlyForDeploymentPostprocessing = 0;
1618+		};
1619+/* End PBXSourcesBuildPhase section */
1620+
1621+/* Begin PBXTargetDependency section */
1622+		5EF028D82F7E7177006A58E2 /* PBXTargetDependency */ = {
1623+			isa = PBXTargetDependency;
1624+			target = 5EF028B82F7E7177006A58E2 /* BAASafariHost */;
1625+			targetProxy = 5EF028D72F7E7177006A58E2 /* PBXContainerItemProxy */;
1626+		};
1627+		5EF028E22F7E7177006A58E2 /* PBXTargetDependency */ = {
1628+			isa = PBXTargetDependency;
1629+			target = 5EF028B82F7E7177006A58E2 /* BAASafariHost */;
1630+			targetProxy = 5EF028E12F7E7177006A58E2 /* PBXContainerItemProxy */;
1631+		};
1632+		5EF028EF2F7E7177006A58E2 /* PBXTargetDependency */ = {
1633+			isa = PBXTargetDependency;
1634+			target = 5EF028EB2F7E7177006A58E2 /* BAASafariHost Extension */;
1635+			targetProxy = 5EF028EE2F7E7177006A58E2 /* PBXContainerItemProxy */;
1636+		};
1637+/* End PBXTargetDependency section */
1638+
1639+/* Begin PBXVariantGroup section */
1640+		5EF028BF2F7E7177006A58E2 /* Main.html */ = {
1641+			isa = PBXVariantGroup;
1642+			children = (
1643+				5EF028C02F7E7177006A58E2 /* Base */,
1644+			);
1645+			name = Main.html;
1646+			sourceTree = "<group>";
1647+		};
1648+		5EF028CA2F7E7177006A58E2 /* Main.storyboard */ = {
1649+			isa = PBXVariantGroup;
1650+			children = (
1651+				5EF028CB2F7E7177006A58E2 /* Base */,
1652+			);
1653+			name = Main.storyboard;
1654+			sourceTree = "<group>";
1655+		};
1656+/* End PBXVariantGroup section */
1657+
1658+/* Begin XCBuildConfiguration section */
1659+		5EF028F52F7E7177006A58E2 /* Debug */ = {
1660+			isa = XCBuildConfiguration;
1661+			buildSettings = {
1662+				ALWAYS_SEARCH_USER_PATHS = NO;
1663+				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
1664+				CLANG_ANALYZER_NONNULL = YES;
1665+				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
1666+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
1667+				CLANG_ENABLE_MODULES = YES;
1668+				CLANG_ENABLE_OBJC_ARC = YES;
1669+				CLANG_ENABLE_OBJC_WEAK = YES;
1670+				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
1671+				CLANG_WARN_BOOL_CONVERSION = YES;
1672+				CLANG_WARN_COMMA = YES;
1673+				CLANG_WARN_CONSTANT_CONVERSION = YES;
1674+				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
1675+				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
1676+				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
1677+				CLANG_WARN_EMPTY_BODY = YES;
1678+				CLANG_WARN_ENUM_CONVERSION = YES;
1679+				CLANG_WARN_INFINITE_RECURSION = YES;
1680+				CLANG_WARN_INT_CONVERSION = YES;
1681+				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
1682+				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
1683+				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
1684+				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
1685+				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
1686+				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
1687+				CLANG_WARN_STRICT_PROTOTYPES = YES;
1688+				CLANG_WARN_SUSPICIOUS_MOVE = YES;
1689+				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
1690+				CLANG_WARN_UNREACHABLE_CODE = YES;
1691+				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
1692+				COPY_PHASE_STRIP = NO;
1693+				DEBUG_INFORMATION_FORMAT = dwarf;
1694+				ENABLE_STRICT_OBJC_MSGSEND = YES;
1695+				ENABLE_TESTABILITY = YES;
1696+				ENABLE_USER_SCRIPT_SANDBOXING = YES;
1697+				GCC_C_LANGUAGE_STANDARD = gnu17;
1698+				GCC_DYNAMIC_NO_PIC = NO;
1699+				GCC_NO_COMMON_BLOCKS = YES;
1700+				GCC_OPTIMIZATION_LEVEL = 0;
1701+				GCC_PREPROCESSOR_DEFINITIONS = (
1702+					"DEBUG=1",
1703+					"$(inherited)",
1704+				);
1705+				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
1706+				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
1707+				GCC_WARN_UNDECLARED_SELECTOR = YES;
1708+				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
1709+				GCC_WARN_UNUSED_FUNCTION = YES;
1710+				GCC_WARN_UNUSED_VARIABLE = YES;
1711+				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
1712+				MACOSX_DEPLOYMENT_TARGET = 15.1;
1713+				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
1714+				MTL_FAST_MATH = YES;
1715+				ONLY_ACTIVE_ARCH = YES;
1716+				SDKROOT = macosx;
1717+				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
1718+				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
1719+			};
1720+			name = Debug;
1721+		};
1722+		5EF028F62F7E7177006A58E2 /* Release */ = {
1723+			isa = XCBuildConfiguration;
1724+			buildSettings = {
1725+				ALWAYS_SEARCH_USER_PATHS = NO;
1726+				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
1727+				CLANG_ANALYZER_NONNULL = YES;
1728+				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
1729+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
1730+				CLANG_ENABLE_MODULES = YES;
1731+				CLANG_ENABLE_OBJC_ARC = YES;
1732+				CLANG_ENABLE_OBJC_WEAK = YES;
1733+				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
1734+				CLANG_WARN_BOOL_CONVERSION = YES;
1735+				CLANG_WARN_COMMA = YES;
1736+				CLANG_WARN_CONSTANT_CONVERSION = YES;
1737+				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
1738+				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
1739+				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
1740+				CLANG_WARN_EMPTY_BODY = YES;
1741+				CLANG_WARN_ENUM_CONVERSION = YES;
1742+				CLANG_WARN_INFINITE_RECURSION = YES;
1743+				CLANG_WARN_INT_CONVERSION = YES;
1744+				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
1745+				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
1746+				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
1747+				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
1748+				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
1749+				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
1750+				CLANG_WARN_STRICT_PROTOTYPES = YES;
1751+				CLANG_WARN_SUSPICIOUS_MOVE = YES;
1752+				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
1753+				CLANG_WARN_UNREACHABLE_CODE = YES;
1754+				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
1755+				COPY_PHASE_STRIP = NO;
1756+				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
1757+				ENABLE_NS_ASSERTIONS = NO;
1758+				ENABLE_STRICT_OBJC_MSGSEND = YES;
1759+				ENABLE_USER_SCRIPT_SANDBOXING = YES;
1760+				GCC_C_LANGUAGE_STANDARD = gnu17;
1761+				GCC_NO_COMMON_BLOCKS = YES;
1762+				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
1763+				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
1764+				GCC_WARN_UNDECLARED_SELECTOR = YES;
1765+				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
1766+				GCC_WARN_UNUSED_FUNCTION = YES;
1767+				GCC_WARN_UNUSED_VARIABLE = YES;
1768+				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
1769+				MACOSX_DEPLOYMENT_TARGET = 15.1;
1770+				MTL_ENABLE_DEBUG_INFO = NO;
1771+				MTL_FAST_MATH = YES;
1772+				SDKROOT = macosx;
1773+				SWIFT_COMPILATION_MODE = wholemodule;
1774+			};
1775+			name = Release;
1776+		};
1777+		5EF028F82F7E7177006A58E2 /* Debug */ = {
1778+			isa = XCBuildConfiguration;
1779+			buildSettings = {
1780+				CODE_SIGN_ENTITLEMENTS = "BAASafariHost Extension/BAASafariHost_Extension.entitlements";
1781+				CODE_SIGN_STYLE = Automatic;
1782+				CURRENT_PROJECT_VERSION = 1;
1783+				ENABLE_HARDENED_RUNTIME = YES;
1784+				GENERATE_INFOPLIST_FILE = YES;
1785+				INFOPLIST_FILE = "BAASafariHost Extension/Info.plist";
1786+				INFOPLIST_KEY_CFBundleDisplayName = "BAASafariHost Extension";
1787+				INFOPLIST_KEY_NSHumanReadableCopyright = "";
1788+				LD_RUNPATH_SEARCH_PATHS = (
1789+					"$(inherited)",
1790+					"@executable_path/../Frameworks",
1791+					"@executable_path/../../../../Frameworks",
1792+				);
1793+				MACOSX_DEPLOYMENT_TARGET = 10.14;
1794+				MARKETING_VERSION = 1.0;
1795+				OTHER_LDFLAGS = (
1796+					"-framework",
1797+					SafariServices,
1798+				);
1799+				PRODUCT_BUNDLE_IDENTIFIER = com.baa.safari.BAASafariHost.Extension;
1800+				PRODUCT_NAME = "$(TARGET_NAME)";
1801+				SKIP_INSTALL = YES;
1802+				SWIFT_EMIT_LOC_STRINGS = YES;
1803+				SWIFT_VERSION = 5.0;
1804+			};
1805+			name = Debug;
1806+		};
1807+		5EF028F92F7E7177006A58E2 /* Release */ = {
1808+			isa = XCBuildConfiguration;
1809+			buildSettings = {
1810+				CODE_SIGN_ENTITLEMENTS = "BAASafariHost Extension/BAASafariHost_Extension.entitlements";
1811+				CODE_SIGN_STYLE = Automatic;
1812+				CURRENT_PROJECT_VERSION = 1;
1813+				ENABLE_HARDENED_RUNTIME = YES;
1814+				GENERATE_INFOPLIST_FILE = YES;
1815+				INFOPLIST_FILE = "BAASafariHost Extension/Info.plist";
1816+				INFOPLIST_KEY_CFBundleDisplayName = "BAASafariHost Extension";
1817+				INFOPLIST_KEY_NSHumanReadableCopyright = "";
1818+				LD_RUNPATH_SEARCH_PATHS = (
1819+					"$(inherited)",
1820+					"@executable_path/../Frameworks",
1821+					"@executable_path/../../../../Frameworks",
1822+				);
1823+				MACOSX_DEPLOYMENT_TARGET = 10.14;
1824+				MARKETING_VERSION = 1.0;
1825+				OTHER_LDFLAGS = (
1826+					"-framework",
1827+					SafariServices,
1828+				);
1829+				PRODUCT_BUNDLE_IDENTIFIER = com.baa.safari.BAASafariHost.Extension;
1830+				PRODUCT_NAME = "$(TARGET_NAME)";
1831+				SKIP_INSTALL = YES;
1832+				SWIFT_EMIT_LOC_STRINGS = YES;
1833+				SWIFT_VERSION = 5.0;
1834+			};
1835+			name = Release;
1836+		};
1837+		5EF028FC2F7E7177006A58E2 /* Debug */ = {
1838+			isa = XCBuildConfiguration;
1839+			buildSettings = {
1840+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
1841+				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
1842+				CODE_SIGN_ENTITLEMENTS = BAASafariHost/BAASafariHost.entitlements;
1843+				CODE_SIGN_STYLE = Automatic;
1844+				COMBINE_HIDPI_IMAGES = YES;
1845+				CURRENT_PROJECT_VERSION = 1;
1846+				ENABLE_HARDENED_RUNTIME = YES;
1847+				GENERATE_INFOPLIST_FILE = YES;
1848+				INFOPLIST_FILE = BAASafariHost/Info.plist;
1849+				INFOPLIST_KEY_CFBundleDisplayName = BAASafariHost;
1850+				INFOPLIST_KEY_NSHumanReadableCopyright = "";
1851+				INFOPLIST_KEY_NSMainStoryboardFile = Main;
1852+				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
1853+				LD_RUNPATH_SEARCH_PATHS = (
1854+					"$(inherited)",
1855+					"@executable_path/../Frameworks",
1856+				);
1857+				MACOSX_DEPLOYMENT_TARGET = 10.14;
1858+				MARKETING_VERSION = 1.0;
1859+				OTHER_LDFLAGS = (
1860+					"-framework",
1861+					SafariServices,
1862+					"-framework",
1863+					WebKit,
1864+				);
1865+				PRODUCT_BUNDLE_IDENTIFIER = com.baa.safari.BAASafariHost;
1866+				PRODUCT_NAME = "$(TARGET_NAME)";
1867+				SWIFT_EMIT_LOC_STRINGS = YES;
1868+				SWIFT_VERSION = 5.0;
1869+			};
1870+			name = Debug;
1871+		};
1872+		5EF028FD2F7E7177006A58E2 /* Release */ = {
1873+			isa = XCBuildConfiguration;
1874+			buildSettings = {
1875+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
1876+				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
1877+				CODE_SIGN_ENTITLEMENTS = BAASafariHost/BAASafariHost.entitlements;
1878+				CODE_SIGN_STYLE = Automatic;
1879+				COMBINE_HIDPI_IMAGES = YES;
1880+				CURRENT_PROJECT_VERSION = 1;
1881+				ENABLE_HARDENED_RUNTIME = YES;
1882+				GENERATE_INFOPLIST_FILE = YES;
1883+				INFOPLIST_FILE = BAASafariHost/Info.plist;
1884+				INFOPLIST_KEY_CFBundleDisplayName = BAASafariHost;
1885+				INFOPLIST_KEY_NSHumanReadableCopyright = "";
1886+				INFOPLIST_KEY_NSMainStoryboardFile = Main;
1887+				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
1888+				LD_RUNPATH_SEARCH_PATHS = (
1889+					"$(inherited)",
1890+					"@executable_path/../Frameworks",
1891+				);
1892+				MACOSX_DEPLOYMENT_TARGET = 10.14;
1893+				MARKETING_VERSION = 1.0;
1894+				OTHER_LDFLAGS = (
1895+					"-framework",
1896+					SafariServices,
1897+					"-framework",
1898+					WebKit,
1899+				);
1900+				PRODUCT_BUNDLE_IDENTIFIER = com.baa.safari.BAASafariHost;
1901+				PRODUCT_NAME = "$(TARGET_NAME)";
1902+				SWIFT_EMIT_LOC_STRINGS = YES;
1903+				SWIFT_VERSION = 5.0;
1904+			};
1905+			name = Release;
1906+		};
1907+		5EF028FF2F7E7177006A58E2 /* Debug */ = {
1908+			isa = XCBuildConfiguration;
1909+			buildSettings = {
1910+				BUNDLE_LOADER = "$(TEST_HOST)";
1911+				CODE_SIGN_STYLE = Automatic;
1912+				CURRENT_PROJECT_VERSION = 1;
1913+				GENERATE_INFOPLIST_FILE = YES;
1914+				MACOSX_DEPLOYMENT_TARGET = 10.14;
1915+				MARKETING_VERSION = 1.0;
1916+				PRODUCT_BUNDLE_IDENTIFIER = com.baa.safari.BAASafariHostTests;
1917+				PRODUCT_NAME = "$(TARGET_NAME)";
1918+				SWIFT_EMIT_LOC_STRINGS = NO;
1919+				SWIFT_VERSION = 5.0;
1920+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BAASafariHost.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/BAASafariHost";
1921+			};
1922+			name = Debug;
1923+		};
1924+		5EF029002F7E7177006A58E2 /* Release */ = {
1925+			isa = XCBuildConfiguration;
1926+			buildSettings = {
1927+				BUNDLE_LOADER = "$(TEST_HOST)";
1928+				CODE_SIGN_STYLE = Automatic;
1929+				CURRENT_PROJECT_VERSION = 1;
1930+				GENERATE_INFOPLIST_FILE = YES;
1931+				MACOSX_DEPLOYMENT_TARGET = 10.14;
1932+				MARKETING_VERSION = 1.0;
1933+				PRODUCT_BUNDLE_IDENTIFIER = com.baa.safari.BAASafariHostTests;
1934+				PRODUCT_NAME = "$(TARGET_NAME)";
1935+				SWIFT_EMIT_LOC_STRINGS = NO;
1936+				SWIFT_VERSION = 5.0;
1937+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BAASafariHost.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/BAASafariHost";
1938+			};
1939+			name = Release;
1940+		};
1941+		5EF029022F7E7177006A58E2 /* Debug */ = {
1942+			isa = XCBuildConfiguration;
1943+			buildSettings = {
1944+				CODE_SIGN_STYLE = Automatic;
1945+				CURRENT_PROJECT_VERSION = 1;
1946+				GENERATE_INFOPLIST_FILE = YES;
1947+				MARKETING_VERSION = 1.0;
1948+				PRODUCT_BUNDLE_IDENTIFIER = com.baa.safari.BAASafariHostUITests;
1949+				PRODUCT_NAME = "$(TARGET_NAME)";
1950+				SWIFT_EMIT_LOC_STRINGS = NO;
1951+				SWIFT_VERSION = 5.0;
1952+				TEST_TARGET_NAME = BAASafariHost;
1953+			};
1954+			name = Debug;
1955+		};
1956+		5EF029032F7E7177006A58E2 /* Release */ = {
1957+			isa = XCBuildConfiguration;
1958+			buildSettings = {
1959+				CODE_SIGN_STYLE = Automatic;
1960+				CURRENT_PROJECT_VERSION = 1;
1961+				GENERATE_INFOPLIST_FILE = YES;
1962+				MARKETING_VERSION = 1.0;
1963+				PRODUCT_BUNDLE_IDENTIFIER = com.baa.safari.BAASafariHostUITests;
1964+				PRODUCT_NAME = "$(TARGET_NAME)";
1965+				SWIFT_EMIT_LOC_STRINGS = NO;
1966+				SWIFT_VERSION = 5.0;
1967+				TEST_TARGET_NAME = BAASafariHost;
1968+			};
1969+			name = Release;
1970+		};
1971+/* End XCBuildConfiguration section */
1972+
1973+/* Begin XCConfigurationList section */
1974+		5EF028B42F7E7177006A58E2 /* Build configuration list for PBXProject "BAASafariHost" */ = {
1975+			isa = XCConfigurationList;
1976+			buildConfigurations = (
1977+				5EF028F52F7E7177006A58E2 /* Debug */,
1978+				5EF028F62F7E7177006A58E2 /* Release */,
1979+			);
1980+			defaultConfigurationIsVisible = 0;
1981+			defaultConfigurationName = Release;
1982+		};
1983+		5EF028F72F7E7177006A58E2 /* Build configuration list for PBXNativeTarget "BAASafariHost Extension" */ = {
1984+			isa = XCConfigurationList;
1985+			buildConfigurations = (
1986+				5EF028F82F7E7177006A58E2 /* Debug */,
1987+				5EF028F92F7E7177006A58E2 /* Release */,
1988+			);
1989+			defaultConfigurationIsVisible = 0;
1990+			defaultConfigurationName = Release;
1991+		};
1992+		5EF028FB2F7E7177006A58E2 /* Build configuration list for PBXNativeTarget "BAASafariHost" */ = {
1993+			isa = XCConfigurationList;
1994+			buildConfigurations = (
1995+				5EF028FC2F7E7177006A58E2 /* Debug */,
1996+				5EF028FD2F7E7177006A58E2 /* Release */,
1997+			);
1998+			defaultConfigurationIsVisible = 0;
1999+			defaultConfigurationName = Release;
2000+		};
2001+		5EF028FE2F7E7177006A58E2 /* Build configuration list for PBXNativeTarget "BAASafariHostTests" */ = {
2002+			isa = XCConfigurationList;
2003+			buildConfigurations = (
2004+				5EF028FF2F7E7177006A58E2 /* Debug */,
2005+				5EF029002F7E7177006A58E2 /* Release */,
2006+			);
2007+			defaultConfigurationIsVisible = 0;
2008+			defaultConfigurationName = Release;
2009+		};
2010+		5EF029012F7E7177006A58E2 /* Build configuration list for PBXNativeTarget "BAASafariHostUITests" */ = {
2011+			isa = XCConfigurationList;
2012+			buildConfigurations = (
2013+				5EF029022F7E7177006A58E2 /* Debug */,
2014+				5EF029032F7E7177006A58E2 /* Release */,
2015+			);
2016+			defaultConfigurationIsVisible = 0;
2017+			defaultConfigurationName = Release;
2018+		};
2019+/* End XCConfigurationList section */
2020+	};
2021+	rootObject = 5EF028B12F7E7177006A58E2 /* Project object */;
2022+}
2023diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/plugins/baa-safari/baa-safari-host/BAASafariHost.xcodeproj/project.xcworkspace/contents.xcworkspacedata
2024new file mode 100644
2025index 0000000000000000000000000000000000000000..919434a6254f0e9651f402737811be6634a03e9c
2026--- /dev/null
2027+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost.xcodeproj/project.xcworkspace/contents.xcworkspacedata
2028@@ -0,0 +1,7 @@
2029+<?xml version="1.0" encoding="UTF-8"?>
2030+<Workspace
2031+   version = "1.0">
2032+   <FileRef
2033+      location = "self:">
2034+   </FileRef>
2035+</Workspace>
2036diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/AppDelegate.swift b/plugins/baa-safari/baa-safari-host/BAASafariHost/AppDelegate.swift
2037new file mode 100644
2038index 0000000000000000000000000000000000000000..b75d6d6405b5fd844d648740581701f2e06f997a
2039--- /dev/null
2040+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/AppDelegate.swift
2041@@ -0,0 +1,21 @@
2042+//
2043+//  AppDelegate.swift
2044+//  BAASafariHost
2045+//
2046+//  Created by george on 2026/4/2.
2047+//
2048+
2049+import Cocoa
2050+
2051+@main
2052+class AppDelegate: NSObject, NSApplicationDelegate {
2053+
2054+    func applicationDidFinishLaunching(_ notification: Notification) {
2055+        // Override point for customization after application launch.
2056+    }
2057+
2058+    func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
2059+        return true
2060+    }
2061+
2062+}
2063diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/AccentColor.colorset/Contents.json b/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/AccentColor.colorset/Contents.json
2064new file mode 100644
2065index 0000000000000000000000000000000000000000..eb8789700816459c1e1480e0b34781d9fb78a1ca
2066--- /dev/null
2067+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/AccentColor.colorset/Contents.json
2068@@ -0,0 +1,11 @@
2069+{
2070+  "colors" : [
2071+    {
2072+      "idiom" : "universal"
2073+    }
2074+  ],
2075+  "info" : {
2076+    "author" : "xcode",
2077+    "version" : 1
2078+  }
2079+}
2080diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/AppIcon.appiconset/Contents.json b/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/AppIcon.appiconset/Contents.json
2081new file mode 100644
2082index 0000000000000000000000000000000000000000..3f00db43ec3c8b462759505d635dc5545d4e8e50
2083--- /dev/null
2084+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/AppIcon.appiconset/Contents.json
2085@@ -0,0 +1,58 @@
2086+{
2087+  "images" : [
2088+    {
2089+      "idiom" : "mac",
2090+      "scale" : "1x",
2091+      "size" : "16x16"
2092+    },
2093+    {
2094+      "idiom" : "mac",
2095+      "scale" : "2x",
2096+      "size" : "16x16"
2097+    },
2098+    {
2099+      "idiom" : "mac",
2100+      "scale" : "1x",
2101+      "size" : "32x32"
2102+    },
2103+    {
2104+      "idiom" : "mac",
2105+      "scale" : "2x",
2106+      "size" : "32x32"
2107+    },
2108+    {
2109+      "idiom" : "mac",
2110+      "scale" : "1x",
2111+      "size" : "128x128"
2112+    },
2113+    {
2114+      "idiom" : "mac",
2115+      "scale" : "2x",
2116+      "size" : "128x128"
2117+    },
2118+    {
2119+      "idiom" : "mac",
2120+      "scale" : "1x",
2121+      "size" : "256x256"
2122+    },
2123+    {
2124+      "idiom" : "mac",
2125+      "scale" : "2x",
2126+      "size" : "256x256"
2127+    },
2128+    {
2129+      "idiom" : "mac",
2130+      "scale" : "1x",
2131+      "size" : "512x512"
2132+    },
2133+    {
2134+      "idiom" : "mac",
2135+      "scale" : "2x",
2136+      "size" : "512x512"
2137+    }
2138+  ],
2139+  "info" : {
2140+    "author" : "xcode",
2141+    "version" : 1
2142+  }
2143+}
2144diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/Contents.json b/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/Contents.json
2145new file mode 100644
2146index 0000000000000000000000000000000000000000..73c00596a7fca3f3d4bdd64053b69d86745f9e10
2147--- /dev/null
2148+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/Contents.json
2149@@ -0,0 +1,6 @@
2150+{
2151+  "info" : {
2152+    "author" : "xcode",
2153+    "version" : 1
2154+  }
2155+}
2156diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/LargeIcon.imageset/Contents.json b/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/LargeIcon.imageset/Contents.json
2157new file mode 100644
2158index 0000000000000000000000000000000000000000..a19a5492203a8d30fc85ccc30497536a9500155d
2159--- /dev/null
2160+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/Assets.xcassets/LargeIcon.imageset/Contents.json
2161@@ -0,0 +1,20 @@
2162+{
2163+  "images" : [
2164+    {
2165+      "idiom" : "universal",
2166+      "scale" : "1x"
2167+    },
2168+    {
2169+      "idiom" : "universal",
2170+      "scale" : "2x"
2171+    },
2172+    {
2173+      "idiom" : "universal",
2174+      "scale" : "3x"
2175+    }
2176+  ],
2177+  "info" : {
2178+    "author" : "xcode",
2179+    "version" : 1
2180+  }
2181+}
2182diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/BAASafariHost.entitlements b/plugins/baa-safari/baa-safari-host/BAASafariHost/BAASafariHost.entitlements
2183new file mode 100644
2184index 0000000000000000000000000000000000000000..625af03d99b2ea991b4a6dc8eed9db088e5afba1
2185--- /dev/null
2186+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/BAASafariHost.entitlements
2187@@ -0,0 +1,12 @@
2188+<?xml version="1.0" encoding="UTF-8"?>
2189+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2190+<plist version="1.0">
2191+<dict>
2192+	<key>com.apple.security.app-sandbox</key>
2193+	<true/>
2194+	<key>com.apple.security.files.user-selected.read-only</key>
2195+	<true/>
2196+	<key>com.apple.security.network.client</key>
2197+	<true/>
2198+</dict>
2199+</plist>
2200diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/Base.lproj/Main.storyboard b/plugins/baa-safari/baa-safari-host/BAASafariHost/Base.lproj/Main.storyboard
2201new file mode 100644
2202index 0000000000000000000000000000000000000000..f31b8241388f186e0882183ea04dbe6af071083a
2203--- /dev/null
2204+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/Base.lproj/Main.storyboard
2205@@ -0,0 +1,124 @@
2206+<?xml version="1.0" encoding="UTF-8"?>
2207+<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="19085" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" initialViewController="B8D-0N-5wS">
2208+    <dependencies>
2209+        <plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="19085"/>
2210+        <plugIn identifier="com.apple.WebKit2IBPlugin" version="19085"/>
2211+        <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
2212+    </dependencies>
2213+    <scenes>
2214+        <!--Application-->
2215+        <scene sceneID="JPo-4y-FX3">
2216+            <objects>
2217+                <application id="hnw-xV-0zn" sceneMemberID="viewController">
2218+                    <menu key="mainMenu" title="Main Menu" systemMenu="main" id="AYu-sK-qS6">
2219+                        <items>
2220+                            <menuItem title="BAASafariHost" id="1Xt-HY-uBw">
2221+                                <modifierMask key="keyEquivalentModifierMask"/>
2222+                                <menu key="submenu" title="BAASafariHost" systemMenu="apple" id="uQy-DD-JDr">
2223+                                    <items>
2224+                                        <menuItem title="About BAASafariHost" id="5kV-Vb-QxS">
2225+                                            <modifierMask key="keyEquivalentModifierMask"/>
2226+                                            <connections>
2227+                                                <action selector="orderFrontStandardAboutPanel:" target="Ady-hI-5gd" id="Exp-CZ-Vem"/>
2228+                                            </connections>
2229+                                        </menuItem>
2230+                                        <menuItem isSeparatorItem="YES" id="VOq-y0-SEH"/>
2231+                                        <menuItem title="Hide BAASafariHost" keyEquivalent="h" id="Olw-nP-bQN">
2232+                                            <connections>
2233+                                                <action selector="hide:" target="Ady-hI-5gd" id="PnN-Uc-m68"/>
2234+                                            </connections>
2235+                                        </menuItem>
2236+                                        <menuItem title="Hide Others" keyEquivalent="h" id="Vdr-fp-XzO">
2237+                                            <modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
2238+                                            <connections>
2239+                                                <action selector="hideOtherApplications:" target="Ady-hI-5gd" id="VT4-aY-XCT"/>
2240+                                            </connections>
2241+                                        </menuItem>
2242+                                        <menuItem title="Show All" id="Kd2-mp-pUS">
2243+                                            <modifierMask key="keyEquivalentModifierMask"/>
2244+                                            <connections>
2245+                                                <action selector="unhideAllApplications:" target="Ady-hI-5gd" id="Dhg-Le-xox"/>
2246+                                            </connections>
2247+                                        </menuItem>
2248+                                        <menuItem isSeparatorItem="YES" id="kCx-OE-vgT"/>
2249+                                        <menuItem title="Quit BAASafariHost" keyEquivalent="q" id="4sb-4s-VLi">
2250+                                            <connections>
2251+                                                <action selector="terminate:" target="Ady-hI-5gd" id="Te7-pn-YzF"/>
2252+                                            </connections>
2253+                                        </menuItem>
2254+                                    </items>
2255+                                </menu>
2256+                            </menuItem>
2257+                            <menuItem title="Help" id="wpr-3q-Mcd">
2258+                                <modifierMask key="keyEquivalentModifierMask"/>
2259+                                <menu key="submenu" title="Help" systemMenu="help" id="F2S-fz-NVQ">
2260+                                    <items>
2261+                                        <menuItem title="BAASafariHost Help" keyEquivalent="?" id="FKE-Sm-Kum">
2262+                                            <connections>
2263+                                                <action selector="showHelp:" target="Ady-hI-5gd" id="y7X-2Q-9no"/>
2264+                                            </connections>
2265+                                        </menuItem>
2266+                                    </items>
2267+                                </menu>
2268+                            </menuItem>
2269+                        </items>
2270+                    </menu>
2271+                    <connections>
2272+                        <outlet property="delegate" destination="Voe-Tx-rLC" id="PrD-fu-P6m"/>
2273+                    </connections>
2274+                </application>
2275+                <customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModuleProvider="target"/>
2276+                <customObject id="YLy-65-1bz" customClass="NSFontManager"/>
2277+                <customObject id="Ady-hI-5gd" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
2278+            </objects>
2279+            <point key="canvasLocation" x="76" y="-134"/>
2280+        </scene>
2281+        <!--Window Controller-->
2282+        <scene sceneID="R2V-B0-nI4">
2283+            <objects>
2284+                <windowController showSeguePresentationStyle="single" id="B8D-0N-5wS" sceneMemberID="viewController">
2285+                    <window key="window" title="BAASafariHost" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" restorable="NO" releasedWhenClosed="NO" animationBehavior="default" id="IQv-IB-iLA">
2286+                        <windowStyleMask key="styleMask" titled="YES" closable="YES"/>
2287+                        <windowCollectionBehavior key="collectionBehavior" fullScreenNone="YES"/>
2288+                        <rect key="contentRect" x="196" y="240" width="425" height="325"/>
2289+                        <rect key="screenRect" x="0.0" y="0.0" width="1680" height="1027"/>
2290+                        <connections>
2291+                            <outlet property="delegate" destination="B8D-0N-5wS" id="98r-iN-zZc"/>
2292+                        </connections>
2293+                    </window>
2294+                    <connections>
2295+                        <segue destination="XfG-lQ-9wD" kind="relationship" relationship="window.shadowedContentViewController" id="cq2-FE-JQM"/>
2296+                    </connections>
2297+                </windowController>
2298+                <customObject id="Oky-zY-oP4" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
2299+            </objects>
2300+            <point key="canvasLocation" x="75" y="250"/>
2301+        </scene>
2302+        <!--View Controller-->
2303+        <scene sceneID="hIz-AP-VOD">
2304+            <objects>
2305+                <viewController id="XfG-lQ-9wD" customClass="ViewController" customModuleProvider="target" sceneMemberID="viewController">
2306+                    <view key="view" id="m2S-Jp-Qdl">
2307+                        <rect key="frame" x="0.0" y="0.0" width="425" height="325"/>
2308+                        <autoresizingMask key="autoresizingMask"/>
2309+                        <subviews>
2310+                            <wkWebView wantsLayer="YES" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="eOr-cG-IQY">
2311+                                <rect key="frame" x="0.0" y="0.0" width="425" height="325"/>
2312+                                <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
2313+                                <wkWebViewConfiguration key="configuration">
2314+                                    <audiovisualMediaTypes key="mediaTypesRequiringUserActionForPlayback" none="YES"/>
2315+                                    <wkPreferences key="preferences"/>
2316+                                </wkWebViewConfiguration>
2317+                            </wkWebView>
2318+                        </subviews>
2319+                    </view>
2320+                    <connections>
2321+                        <outlet property="webView" destination="eOr-cG-IQY" id="GFe-mU-dBY"/>
2322+                    </connections>
2323+                </viewController>
2324+                <customObject id="rPt-NT-nkU" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
2325+            </objects>
2326+            <point key="canvasLocation" x="75" y="655"/>
2327+        </scene>
2328+    </scenes>
2329+</document>
2330diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/Info.plist b/plugins/baa-safari/baa-safari-host/BAASafariHost/Info.plist
2331new file mode 100644
2332index 0000000000000000000000000000000000000000..716d66f1ba524566386509a943210e39fc8e4dd0
2333--- /dev/null
2334+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/Info.plist
2335@@ -0,0 +1,8 @@
2336+<?xml version="1.0" encoding="UTF-8"?>
2337+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2338+<plist version="1.0">
2339+<dict>
2340+	<key>SFSafariWebExtensionConverterVersion</key>
2341+	<string>16.2</string>
2342+</dict>
2343+</plist>
2344diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Base.lproj/Main.html b/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Base.lproj/Main.html
2345new file mode 100644
2346index 0000000000000000000000000000000000000000..e591d7054e219cc6e52a6744eac938dd88e4bfa6
2347--- /dev/null
2348+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Base.lproj/Main.html
2349@@ -0,0 +1,31 @@
2350+<!DOCTYPE html>
2351+<html>
2352+<head>
2353+    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
2354+    <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
2355+
2356+    <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
2357+
2358+    <link rel="stylesheet" href="../Style.css">
2359+    <script src="../Script.js" defer></script>
2360+</head>
2361+<body>
2362+    <img src="../Icon.png" width="128" height="128" alt="BAASafariHost Icon">
2363+    <p class="eyebrow">BAA Safari Host</p>
2364+    <h1>请在 Safari 设置中启用 BAA 扩展</h1>
2365+    <p class="lead">
2366+        这个宿主 App 只负责承载 Safari Web Extension。首版长期 runtime owner 仍然预留给
2367+        <code>controller.html</code>,不是这个宿主窗口。
2368+    </p>
2369+    <p class="state-unknown">你可以在 Safari 设置的“扩展”里启用 BAA Safari Extension。</p>
2370+    <p class="state-on">BAA Safari Extension 当前已启用。接下来请点击 Safari 工具栏里的扩展按钮打开控制页。</p>
2371+    <p class="state-off">BAA Safari Extension 当前未启用。请先在 Safari 设置的“扩展”里打开它。</p>
2372+    <ol class="steps">
2373+        <li>打开 Safari 设置。</li>
2374+        <li>进入“扩展”。</li>
2375+        <li>启用 BAA Safari Extension。</li>
2376+        <li>给 Claude / ChatGPT / Gemini 网站授权。</li>
2377+    </ol>
2378+    <button class="open-preferences">退出并打开 Safari 设置…</button>
2379+</body>
2380+</html>
2381diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Icon.png b/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Icon.png
2382new file mode 100644
2383index 0000000000000000000000000000000000000000..423b491de9f0c035d4c9d7736c535bebfe078c8e
2384Binary files /dev/null and b/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Icon.png differ
2385diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Script.js b/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Script.js
2386new file mode 100644
2387index 0000000000000000000000000000000000000000..48a5ffa86d180d371570d5f63658d71a88ee25c3
2388--- /dev/null
2389+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Script.js
2390@@ -0,0 +1,22 @@
2391+function show(enabled, useSettingsInsteadOfPreferences) {
2392+    if (useSettingsInsteadOfPreferences) {
2393+        document.getElementsByClassName('state-on')[0].innerText = "BAA Safari Extension 当前已启用。接下来请点击 Safari 工具栏里的扩展按钮打开控制页。";
2394+        document.getElementsByClassName('state-off')[0].innerText = "BAA Safari Extension 当前未启用。请先在 Safari 设置的“扩展”里打开它。";
2395+        document.getElementsByClassName('state-unknown')[0].innerText = "你可以在 Safari 设置的“扩展”里启用 BAA Safari Extension。";
2396+        document.getElementsByClassName('open-preferences')[0].innerText = "退出并打开 Safari 设置…";
2397+    }
2398+
2399+    if (typeof enabled === "boolean") {
2400+        document.body.classList.toggle(`state-on`, enabled);
2401+        document.body.classList.toggle(`state-off`, !enabled);
2402+    } else {
2403+        document.body.classList.remove(`state-on`);
2404+        document.body.classList.remove(`state-off`);
2405+    }
2406+}
2407+
2408+function openPreferences() {
2409+    webkit.messageHandlers.controller.postMessage("open-preferences");
2410+}
2411+
2412+document.querySelector("button.open-preferences").addEventListener("click", openPreferences);
2413diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Style.css b/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Style.css
2414new file mode 100644
2415index 0000000000000000000000000000000000000000..f7aadd600b769dd3a27f5314f87c890ac57b7ea9
2416--- /dev/null
2417+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/Resources/Style.css
2418@@ -0,0 +1,82 @@
2419+* {
2420+    -webkit-user-select: none;
2421+    -webkit-user-drag: none;
2422+    cursor: default;
2423+}
2424+
2425+:root {
2426+    color-scheme: light dark;
2427+
2428+    --spacing: 20px;
2429+}
2430+
2431+html {
2432+    height: 100%;
2433+}
2434+
2435+body {
2436+    display: flex;
2437+    align-items: center;
2438+    justify-content: center;
2439+    flex-direction: column;
2440+
2441+    gap: var(--spacing);
2442+    margin: 0 calc(var(--spacing) * 2);
2443+    height: 100%;
2444+
2445+    font: -apple-system-short-body;
2446+    text-align: center;
2447+}
2448+
2449+img {
2450+    border-radius: 24px;
2451+}
2452+
2453+.eyebrow {
2454+    margin: 0;
2455+    font-size: 12px;
2456+    font-weight: 700;
2457+    letter-spacing: 0.08em;
2458+    text-transform: uppercase;
2459+    color: #2f688f;
2460+}
2461+
2462+h1 {
2463+    margin: 0;
2464+    max-width: 16em;
2465+    font-size: 28px;
2466+    line-height: 1.25;
2467+}
2468+
2469+.lead,
2470+.steps,
2471+p {
2472+    margin: 0;
2473+    max-width: 34rem;
2474+    line-height: 1.6;
2475+}
2476+
2477+.steps {
2478+    text-align: left;
2479+}
2480+
2481+body:not(.state-on, .state-off) :is(.state-on, .state-off) {
2482+    display: none;
2483+}
2484+
2485+body.state-on :is(.state-off, .state-unknown) {
2486+    display: none;
2487+}
2488+
2489+body.state-off :is(.state-on, .state-unknown) {
2490+    display: none;
2491+}
2492+
2493+button {
2494+    font-size: 1em;
2495+    border-radius: 999px;
2496+    border: 0;
2497+    padding: 11px 16px;
2498+    background: #1f6f5f;
2499+    color: #fff;
2500+}
2501diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHost/ViewController.swift b/plugins/baa-safari/baa-safari-host/BAASafariHost/ViewController.swift
2502new file mode 100644
2503index 0000000000000000000000000000000000000000..42a6de11a74f77d85e090d5320a7f5dc05bfcb70
2504--- /dev/null
2505+++ b/plugins/baa-safari/baa-safari-host/BAASafariHost/ViewController.swift
2506@@ -0,0 +1,57 @@
2507+//
2508+//  ViewController.swift
2509+//  BAASafariHost
2510+//
2511+//  Created by george on 2026/4/2.
2512+//
2513+
2514+import Cocoa
2515+import SafariServices
2516+import WebKit
2517+
2518+let extensionBundleIdentifier = "com.baa.safari.BAASafariHost.Extension"
2519+
2520+class ViewController: NSViewController, WKNavigationDelegate, WKScriptMessageHandler {
2521+
2522+    @IBOutlet var webView: WKWebView!
2523+
2524+    override func viewDidLoad() {
2525+        super.viewDidLoad()
2526+
2527+        self.webView.navigationDelegate = self
2528+
2529+        self.webView.configuration.userContentController.add(self, name: "controller")
2530+
2531+        self.webView.loadFileURL(Bundle.main.url(forResource: "Main", withExtension: "html")!, allowingReadAccessTo: Bundle.main.resourceURL!)
2532+    }
2533+
2534+    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
2535+        SFSafariExtensionManager.getStateOfSafariExtension(withIdentifier: extensionBundleIdentifier) { (state, error) in
2536+            guard let state = state, error == nil else {
2537+                // Insert code to inform the user that something went wrong.
2538+                return
2539+            }
2540+
2541+            DispatchQueue.main.async {
2542+                if #available(macOS 13, *) {
2543+                    webView.evaluateJavaScript("show(\(state.isEnabled), true)")
2544+                } else {
2545+                    webView.evaluateJavaScript("show(\(state.isEnabled), false)")
2546+                }
2547+            }
2548+        }
2549+    }
2550+
2551+    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
2552+        if (message.body as! String != "open-preferences") {
2553+            return;
2554+        }
2555+
2556+        SFSafariApplication.showPreferencesForExtension(withIdentifier: extensionBundleIdentifier) { error in
2557+            DispatchQueue.main.async {
2558+                NSApplication.shared.terminate(nil)
2559+            }
2560+        }
2561+    }
2562+
2563+}
2564diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHostTests/BAASafariHostTests.swift b/plugins/baa-safari/baa-safari-host/BAASafariHostTests/BAASafariHostTests.swift
2565new file mode 100644
2566index 0000000000000000000000000000000000000000..6649c2b53dbb3bf4edb2cda2ffd30d4a259909ce
2567--- /dev/null
2568+++ b/plugins/baa-safari/baa-safari-host/BAASafariHostTests/BAASafariHostTests.swift
2569@@ -0,0 +1,17 @@
2570+//
2571+//  BAASafariHostTests.swift
2572+//  BAASafariHostTests
2573+//
2574+//  Created by george on 2026/4/2.
2575+//
2576+
2577+import Testing
2578+@testable import BAASafariHost
2579+
2580+struct BAASafariHostTests {
2581+
2582+    @Test func example() async throws {
2583+        // Write your test here and use APIs like `#expect(...)` to check expected conditions.
2584+    }
2585+
2586+}
2587diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHostUITests/BAASafariHostUITests.swift b/plugins/baa-safari/baa-safari-host/BAASafariHostUITests/BAASafariHostUITests.swift
2588new file mode 100644
2589index 0000000000000000000000000000000000000000..8b2a1710cbe0c292e8b5293c2facfa13288ee051
2590--- /dev/null
2591+++ b/plugins/baa-safari/baa-safari-host/BAASafariHostUITests/BAASafariHostUITests.swift
2592@@ -0,0 +1,43 @@
2593+//
2594+//  BAASafariHostUITests.swift
2595+//  BAASafariHostUITests
2596+//
2597+//  Created by george on 2026/4/2.
2598+//
2599+
2600+import XCTest
2601+
2602+final class BAASafariHostUITests: XCTestCase {
2603+
2604+    override func setUpWithError() throws {
2605+        // Put setup code here. This method is called before the invocation of each test method in the class.
2606+
2607+        // In UI tests it is usually best to stop immediately when a failure occurs.
2608+        continueAfterFailure = false
2609+
2610+        // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
2611+    }
2612+
2613+    override func tearDownWithError() throws {
2614+        // Put teardown code here. This method is called after the invocation of each test method in the class.
2615+    }
2616+
2617+    @MainActor
2618+    func testExample() throws {
2619+        // UI tests must launch the application that they test.
2620+        let app = XCUIApplication()
2621+        app.launch()
2622+
2623+        // Use XCTAssert and related functions to verify your tests produce the correct results.
2624+    }
2625+
2626+    @MainActor
2627+    func testLaunchPerformance() throws {
2628+        if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
2629+            // This measures how long it takes to launch your application.
2630+            measure(metrics: [XCTApplicationLaunchMetric()]) {
2631+                XCUIApplication().launch()
2632+            }
2633+        }
2634+    }
2635+}
2636diff --git a/plugins/baa-safari/baa-safari-host/BAASafariHostUITests/BAASafariHostUITestsLaunchTests.swift b/plugins/baa-safari/baa-safari-host/BAASafariHostUITests/BAASafariHostUITestsLaunchTests.swift
2637new file mode 100644
2638index 0000000000000000000000000000000000000000..a7a8c9bd5e63f7e4c7fd458161e8f764fe046a80
2639--- /dev/null
2640+++ b/plugins/baa-safari/baa-safari-host/BAASafariHostUITests/BAASafariHostUITestsLaunchTests.swift
2641@@ -0,0 +1,33 @@
2642+//
2643+//  BAASafariHostUITestsLaunchTests.swift
2644+//  BAASafariHostUITests
2645+//
2646+//  Created by george on 2026/4/2.
2647+//
2648+
2649+import XCTest
2650+
2651+final class BAASafariHostUITestsLaunchTests: XCTestCase {
2652+
2653+    override class var runsForEachTargetApplicationUIConfiguration: Bool {
2654+        true
2655+    }
2656+
2657+    override func setUpWithError() throws {
2658+        continueAfterFailure = false
2659+    }
2660+
2661+    @MainActor
2662+    func testLaunch() throws {
2663+        let app = XCUIApplication()
2664+        app.launch()
2665+
2666+        // Insert steps here to perform after app launch but before taking a screenshot,
2667+        // such as logging into a test account or navigating somewhere in the app
2668+
2669+        let attachment = XCTAttachment(screenshot: app.screenshot())
2670+        attachment.name = "Launch Screen"
2671+        attachment.lifetime = .keepAlways
2672+        add(attachment)
2673+    }
2674+}
2675diff --git a/plugins/baa-safari/background.js b/plugins/baa-safari/background.js
2676new file mode 100644
2677index 0000000000000000000000000000000000000000..9161e77600226278677d93ee893e919b325526a8
2678--- /dev/null
2679+++ b/plugins/baa-safari/background.js
2680@@ -0,0 +1,1568 @@
2681+const CONTROLLER_URL = browser.runtime.getURL("controller.html");
2682+const CONTROLLER_TAB_STORAGE_KEY = "baaSafari.controllerTabId";
2683+const FINAL_MESSAGE_RELAY_CACHE_STORAGE_KEY = "baaSafari.finalMessageRelayCache";
2684+const SAFARI_BROWSER_WS_CLIENT_ID_STORAGE_KEY = "baaSafari.browserWsClientId";
2685+const SAFARI_BROWSER_WS_URL_STORAGE_KEY = "baaSafari.browserWsUrl";
2686+const LOG_LIMIT = 80;
2687+const ENDPOINT_LIMIT = 80;
2688+const CONTENT_SCRIPT_STALE_MS = 30_000;
2689+const PAGE_BRIDGE_STALE_MS = 12_000;
2690+const CREDENTIAL_TTL_MS = 15 * 60_000;
2691+const DEFAULT_BROWSER_WS_URL = "ws://127.0.0.1:4317/ws/browser";
2692+const BROWSER_WS_PROTOCOLS = ["baa.browser.local", "baa.firefox.local"];
2693+const WS_RECONNECT_DELAY_MS = 2_000;
2694+const FINAL_MESSAGE_RELAY_CACHE_LIMIT = 20;
2695+const ACCOUNT_EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i;
2696+const FINAL_MESSAGE_HELPERS = globalThis.BAAFinalMessage || null;
2697+
2698+const PLATFORMS = {
2699+  claude: {
2700+    implemented: false,
2701+    label: "Claude",
2702+    pageHosts: ["claude.ai"],
2703+    requestHosts: ["claude.ai"],
2704+    requestUrlPatterns: ["*://claude.ai/*"]
2705+  },
2706+  chatgpt: {
2707+    implemented: true,
2708+    label: "ChatGPT",
2709+    pageHosts: ["chatgpt.com", "chat.openai.com"],
2710+    requestHosts: ["chatgpt.com", "chat.openai.com", "openai.com", "oaiusercontent.com"],
2711+    requestUrlPatterns: [
2712+      "*://chatgpt.com/*",
2713+      "*://*.chatgpt.com/*",
2714+      "*://chat.openai.com/*",
2715+      "*://*.chat.openai.com/*",
2716+      "*://openai.com/*",
2717+      "*://*.openai.com/*",
2718+      "*://oaiusercontent.com/*",
2719+      "*://*.oaiusercontent.com/*"
2720+    ]
2721+  },
2722+  gemini: {
2723+    implemented: false,
2724+    label: "Gemini",
2725+    pageHosts: ["gemini.google.com"],
2726+    requestHosts: ["gemini.google.com"],
2727+    requestUrlPatterns: ["*://gemini.google.com/*"]
2728+  }
2729+};
2730+
2731+const PLATFORM_ORDER = Object.keys(PLATFORMS);
2732+const REQUEST_URL_PATTERNS = PLATFORM_ORDER.flatMap((platform) => PLATFORMS[platform].requestUrlPatterns);
2733+const SENSITIVE_HEADERS = {
2734+  claude: ["cookie", "x-org-id"],
2735+  chatgpt: [
2736+    "authorization",
2737+    "cookie",
2738+    "oai-device-id",
2739+    "openai-sentinel-chat-requirements-token",
2740+    "openai-sentinel-proof-token",
2741+    "openai-sentinel-turnstile-token"
2742+  ],
2743+  gemini: ["authorization", "cookie", "x-goog-authuser"]
2744+};
2745+
2746+function createPlatformMap(factory) {
2747+  return Object.fromEntries(PLATFORM_ORDER.map((platform) => [platform, factory(platform)]));
2748+}
2749+
2750+function createFinalMessageRelayObserver(platform) {
2751+  if (platform !== "chatgpt" || !FINAL_MESSAGE_HELPERS?.createRelayState) {
2752+    return null;
2753+  }
2754+
2755+  return FINAL_MESSAGE_HELPERS.createRelayState(platform);
2756+}
2757+
2758+function createDefaultCredentialState() {
2759+  return {
2760+    account_captured_at: null,
2761+    account_hint: null,
2762+    account_kind: null,
2763+    account_last_seen_at: null,
2764+    account_source: null,
2765+    captured_at: null,
2766+    credential_fingerprint: null,
2767+    endpoint_count: 0,
2768+    freshness: "missing",
2769+    header_count: 0,
2770+    header_names: [],
2771+    last_seen_at: null,
2772+    tab_id: null,
2773+    url: null
2774+  };
2775+}
2776+
2777+function createDefaultPlatformState(platform) {
2778+  return {
2779+    endpoint_metadata: [],
2780+    implemented: PLATFORMS[platform]?.implemented === true,
2781+    label: PLATFORMS[platform]?.label || platform,
2782+    lastContentScript: null,
2783+    lastDiagnostic: null,
2784+    lastFinalMessage: null,
2785+    lastNetwork: null,
2786+    lastPageBridgeReady: null,
2787+    lastProxyResponse: null,
2788+    lastSse: null,
2789+    platform,
2790+    scope: PLATFORMS[platform]?.implemented === true ? "foundation" : "placeholder",
2791+    credential: createDefaultCredentialState()
2792+  };
2793+}
2794+
2795+function createDefaultWsState() {
2796+  return {
2797+    clientId: null,
2798+    connected: false,
2799+    lastConnectedAt: null,
2800+    lastError: null,
2801+    lastHelloAckAt: null,
2802+    pendingRelayCount: 0,
2803+    status: "idle",
2804+    wsUrl: DEFAULT_BROWSER_WS_URL
2805+  };
2806+}
2807+
2808+const runtimeState = {
2809+  background: {
2810+    startedAt: Date.now()
2811+  },
2812+  controller: {
2813+    lastReadyAt: null,
2814+    tabId: null
2815+  },
2816+  lastUpdatedAt: Date.now(),
2817+  permissionHints: [],
2818+  platforms: createPlatformMap((platform) => createDefaultPlatformState(platform)),
2819+  recentLogs: [],
2820+  runtimeOwner: "controller.html",
2821+  ws: createDefaultWsState()
2822+};
2823+
2824+const finalMessageRelayObservers = createPlatformMap((platform) => createFinalMessageRelayObserver(platform));
2825+const wsRuntime = {
2826+  reconnectTimer: null,
2827+  socket: null
2828+};
2829+const pendingRelayQueue = [];
2830+
2831+function trimToNull(value) {
2832+  if (typeof value !== "string") {
2833+    return null;
2834+  }
2835+
2836+  const normalized = value.trim();
2837+  return normalized === "" ? null : normalized;
2838+}
2839+
2840+function isRecord(value) {
2841+  return value !== null && typeof value === "object" && !Array.isArray(value);
2842+}
2843+
2844+const TEST_FLAGS = isRecord(globalThis.__BAA_SAFARI_TEST_FLAGS__)
2845+  ? globalThis.__BAA_SAFARI_TEST_FLAGS__
2846+  : {};
2847+
2848+function simpleHash(input) {
2849+  const text = String(input || "");
2850+  let hash = 2166136261;
2851+
2852+  for (let index = 0; index < text.length; index += 1) {
2853+    hash ^= text.charCodeAt(index);
2854+    hash = Math.imul(hash, 16777619);
2855+  }
2856+
2857+  return (hash >>> 0).toString(16).padStart(8, "0");
2858+}
2859+
2860+function normalizeRecentRelayKeys(value) {
2861+  const source = Array.isArray(value) ? value : [];
2862+  const seen = new Set();
2863+  const normalized = [];
2864+
2865+  for (const entry of source) {
2866+    const key = trimToNull(entry);
2867+    if (!key || seen.has(key)) {
2868+      continue;
2869+    }
2870+
2871+    seen.add(key);
2872+    normalized.push(key);
2873+  }
2874+
2875+  return normalized.slice(-FINAL_MESSAGE_RELAY_CACHE_LIMIT);
2876+}
2877+
2878+function serializeFinalMessageRelayCache() {
2879+  return createPlatformMap((platform) => {
2880+    const observer = finalMessageRelayObservers[platform];
2881+    return observer ? normalizeRecentRelayKeys(observer.recentRelayKeys) : [];
2882+  });
2883+}
2884+
2885+function restoreFinalMessageRelayCache(raw) {
2886+  const source = isRecord(raw) ? raw : {};
2887+
2888+  for (const platform of PLATFORM_ORDER) {
2889+    const observer = finalMessageRelayObservers[platform];
2890+    if (!observer) {
2891+      continue;
2892+    }
2893+
2894+    observer.activeStream = null;
2895+    observer.recentRelayKeys = normalizeRecentRelayKeys(source[platform]);
2896+  }
2897+}
2898+
2899+async function persistFinalMessageRelayCache() {
2900+  await browser.storage.local.set({
2901+    [FINAL_MESSAGE_RELAY_CACHE_STORAGE_KEY]: serializeFinalMessageRelayCache()
2902+  });
2903+}
2904+
2905+function normalizeBrowserWsUrl(value) {
2906+  const raw = trimToNull(value);
2907+  if (!raw) {
2908+    return DEFAULT_BROWSER_WS_URL;
2909+  }
2910+
2911+  try {
2912+    const parsed = new URL(raw);
2913+    if (!/^wss?:$/u.test(parsed.protocol)) {
2914+      return DEFAULT_BROWSER_WS_URL;
2915+    }
2916+
2917+    if (!parsed.pathname || parsed.pathname === "/") {
2918+      parsed.pathname = "/ws/browser";
2919+    }
2920+
2921+    parsed.search = "";
2922+    parsed.hash = "";
2923+    return parsed.toString();
2924+  } catch (_) {
2925+    return DEFAULT_BROWSER_WS_URL;
2926+  }
2927+}
2928+
2929+function buildBrowserWsClientId() {
2930+  const entropy = typeof crypto?.randomUUID === "function"
2931+    ? crypto.randomUUID()
2932+    : `${Date.now()}|${Math.random()}`;
2933+  return `safari-${simpleHash(entropy)}`;
2934+}
2935+
2936+function touchState() {
2937+  runtimeState.lastUpdatedAt = Date.now();
2938+}
2939+
2940+function cloneState() {
2941+  return JSON.parse(JSON.stringify(runtimeState));
2942+}
2943+
2944+function appendLog(level, event, detail = {}) {
2945+  runtimeState.recentLogs.unshift({
2946+    detail,
2947+    event,
2948+    level,
2949+    timestamp: Date.now()
2950+  });
2951+  runtimeState.recentLogs = runtimeState.recentLogs.slice(0, LOG_LIMIT);
2952+  touchState();
2953+}
2954+
2955+function hostnameMatches(hostname, hosts) {
2956+  return hosts.some((host) => hostname === host || hostname.endsWith(`.${host}`));
2957+}
2958+
2959+function detectPlatformFromUrl(url, type = "page") {
2960+  const raw = trimToNull(url);
2961+  if (!raw) {
2962+    return null;
2963+  }
2964+
2965+  try {
2966+    const parsed = new URL(raw);
2967+    for (const platform of PLATFORM_ORDER) {
2968+      const hosts = type === "request" ? PLATFORMS[platform].requestHosts : PLATFORMS[platform].pageHosts;
2969+
2970+      if (hostnameMatches(parsed.hostname, hosts)) {
2971+        return platform;
2972+      }
2973+    }
2974+  } catch (_) {}
2975+
2976+  return null;
2977+}
2978+
2979+function getRequestPath(url) {
2980+  const raw = trimToNull(url);
2981+
2982+  if (!raw) {
2983+    return "";
2984+  }
2985+
2986+  try {
2987+    return new URL(raw).pathname || "/";
2988+  } catch (_) {
2989+    return raw.split(/[?#]/u)[0] || "";
2990+  }
2991+}
2992+
2993+function shouldTrackRequest(platform, path) {
2994+  const lower = String(path || "").toLowerCase();
2995+
2996+  switch (platform) {
2997+    case "claude":
2998+      return lower.includes("/api/");
2999+    case "chatgpt":
3000+      return lower.includes("/backend-api/")
3001+        || lower.includes("/backend-anon/")
3002+        || lower.includes("/public-api/")
3003+        || lower.includes("/conversation")
3004+        || lower.includes("/models")
3005+        || lower.includes("/accounts")
3006+        || lower.includes("/auth")
3007+        || lower.includes("/session")
3008+        || lower.includes("/user")
3009+        || lower.includes("/settings");
3010+    case "gemini":
3011+      return lower.startsWith("/_/")
3012+        || lower.includes("/api/")
3013+        || lower.includes("bardchatui")
3014+        || lower.includes("streamgenerate")
3015+        || lower.includes("generatecontent");
3016+    default:
3017+      return false;
3018+  }
3019+}
3020+
3021+function normalizeEndpointPath(url) {
3022+  const raw = trimToNull(url);
3023+  if (!raw) {
3024+    return null;
3025+  }
3026+
3027+  try {
3028+    const parsed = new URL(raw);
3029+    const pathname = parsed.pathname || "/";
3030+    return `${pathname}${parsed.search || ""}`;
3031+  } catch (_) {
3032+    return raw;
3033+  }
3034+}
3035+
3036+function normalizeHeaderMap(source) {
3037+  const out = {};
3038+  const entries = Array.isArray(source)
3039+    ? source
3040+    : (isRecord(source) ? Object.entries(source).map(([name, value]) => ({ name, value })) : []);
3041+
3042+  for (const entry of entries) {
3043+    const name = trimToNull(entry?.name);
3044+    if (!name) {
3045+      continue;
3046+    }
3047+
3048+    const value = entry?.value;
3049+    if (typeof value !== "string" || value === "") {
3050+      continue;
3051+    }
3052+
3053+    out[name.toLowerCase()] = value;
3054+  }
3055+
3056+  return out;
3057+}
3058+
3059+function buildCredentialFingerprint(platform, headerMap) {
3060+  const names = SENSITIVE_HEADERS[platform] || [];
3061+  const parts = [];
3062+
3063+  for (const name of names) {
3064+    const value = trimToNull(headerMap[name]);
3065+    if (value) {
3066+      parts.push(`${name}:${value}`);
3067+    }
3068+  }
3069+
3070+  return parts.length > 0 ? simpleHash(parts.join("\n")) : null;
3071+}
3072+
3073+function computeCredentialFreshness(lastSeenAt) {
3074+  if (!Number.isFinite(lastSeenAt) || lastSeenAt <= 0) {
3075+    return "missing";
3076+  }
3077+
3078+  return Date.now() - lastSeenAt > CREDENTIAL_TTL_MS ? "stale" : "fresh";
3079+}
3080+
3081+function updateCredentialSnapshot(platform, input) {
3082+  const current = runtimeState.platforms[platform].credential;
3083+  const headerMap = normalizeHeaderMap(input?.requestHeaders);
3084+  const headerNames = Object.keys(headerMap).sort();
3085+  const fingerprint = buildCredentialFingerprint(platform, headerMap);
3086+
3087+  if (headerNames.length === 0 && !fingerprint) {
3088+    return false;
3089+  }
3090+
3091+  const now = Date.now();
3092+  const nextCapturedAt =
3093+    current.credential_fingerprint === fingerprint && current.captured_at != null
3094+      ? current.captured_at
3095+      : now;
3096+
3097+  const next = {
3098+    ...current,
3099+    captured_at: nextCapturedAt,
3100+    credential_fingerprint: fingerprint,
3101+    endpoint_count: runtimeState.platforms[platform].endpoint_metadata.length,
3102+    freshness: computeCredentialFreshness(now),
3103+    header_count: headerNames.length,
3104+    header_names: headerNames,
3105+    last_seen_at: now,
3106+    tab_id: Number.isInteger(input?.tabId) ? input.tabId : current.tab_id,
3107+    url: trimToNull(input?.url) || current.url
3108+  };
3109+  const changed = JSON.stringify(current) !== JSON.stringify(next);
3110+
3111+  runtimeState.platforms[platform].credential = next;
3112+  touchState();
3113+  return changed;
3114+}
3115+
3116+function createAccountCandidate(value, meta = {}) {
3117+  const normalized = trimToNull(value);
3118+  if (!normalized) {
3119+    return null;
3120+  }
3121+
3122+  return {
3123+    kind: trimToNull(meta.kind),
3124+    observedAt: Number(meta.observedAt) || Date.now(),
3125+    priority: Number(meta.priority) || 0,
3126+    source: trimToNull(meta.source),
3127+    value: ACCOUNT_EMAIL_RE.test(normalized) ? normalized.toLowerCase() : normalized
3128+  };
3129+}
3130+
3131+function selectAccountCandidate(current, next) {
3132+  if (!next) {
3133+    return current;
3134+  }
3135+
3136+  if (!current || !current.value) {
3137+    return next;
3138+  }
3139+
3140+  if (current.value === next.value) {
3141+    return {
3142+      ...current,
3143+      kind: next.kind || current.kind,
3144+      observedAt: Math.max(current.observedAt || 0, next.observedAt || 0),
3145+      priority: Math.max(current.priority || 0, next.priority || 0),
3146+      source: next.source || current.source
3147+    };
3148+  }
3149+
3150+  return (next.priority || 0) > (current.priority || 0) ? next : current;
3151+}
3152+
3153+function pathLooksLikeAccountMetadata(url) {
3154+  const lower = getRequestPath(url).toLowerCase();
3155+  if (!lower) {
3156+    return false;
3157+  }
3158+
3159+  if (lower.includes("/conversation") || lower.includes("/backend-api/conversation")) {
3160+    return false;
3161+  }
3162+
3163+  return lower.includes("/account")
3164+    || lower.includes("/accounts")
3165+    || lower.includes("/profile")
3166+    || lower.includes("/session")
3167+    || lower.includes("/settings")
3168+    || lower.includes("/user")
3169+    || lower.includes("/users")
3170+    || lower.includes("/me")
3171+    || lower.includes("/auth")
3172+    || lower.includes("/organizations");
3173+}
3174+
3175+function collectAccountCandidates(source, candidates, meta = {}, path = "", depth = 0, scanned = { count: 0 }) {
3176+  if (depth > 4 || scanned.count > 120) {
3177+    return;
3178+  }
3179+
3180+  scanned.count += 1;
3181+
3182+  if (typeof source === "string") {
3183+    const normalized = source.trim();
3184+    if (!normalized) {
3185+      return;
3186+    }
3187+
3188+    const lowerPath = path.toLowerCase();
3189+    const emailMatch = normalized.match(ACCOUNT_EMAIL_RE);
3190+
3191+    if (emailMatch?.[0]) {
3192+      candidates.push(createAccountCandidate(emailMatch[0], {
3193+        kind: "email",
3194+        observedAt: meta.observedAt,
3195+        priority: 100,
3196+        source: meta.source
3197+      }));
3198+      return;
3199+    }
3200+
3201+    if (lowerPath.includes("email")) {
3202+      candidates.push(createAccountCandidate(normalized, {
3203+        kind: "email_like",
3204+        observedAt: meta.observedAt,
3205+        priority: 90,
3206+        source: meta.source
3207+      }));
3208+      return;
3209+    }
3210+
3211+    if (/(account|user|profile|login|username|handle|identifier|name)$/u.test(lowerPath)) {
3212+      candidates.push(createAccountCandidate(normalized, {
3213+        kind: "account_hint",
3214+        observedAt: meta.observedAt,
3215+        priority: 60,
3216+        source: meta.source
3217+      }));
3218+    }
3219+
3220+    return;
3221+  }
3222+
3223+  if (Array.isArray(source)) {
3224+    for (let index = 0; index < source.length && index < 12; index += 1) {
3225+      collectAccountCandidates(source[index], candidates, meta, `${path}[${index}]`, depth + 1, scanned);
3226+    }
3227+    return;
3228+  }
3229+
3230+  if (!isRecord(source)) {
3231+    return;
3232+  }
3233+
3234+  for (const [key, value] of Object.entries(source).slice(0, 24)) {
3235+    collectAccountCandidates(value, candidates, meta, path ? `${path}.${key}` : key, depth + 1, scanned);
3236+  }
3237+}
3238+
3239+function extractAccountCandidateFromBody(url, body) {
3240+  if (!pathLooksLikeAccountMetadata(url) || typeof body !== "string" || !body.trim()) {
3241+    return null;
3242+  }
3243+
3244+  const observedAt = Date.now();
3245+  const candidates = [];
3246+  let parsed = null;
3247+
3248+  try {
3249+    parsed = JSON.parse(body);
3250+  } catch (_) {
3251+    parsed = null;
3252+  }
3253+
3254+  if (parsed != null) {
3255+    collectAccountCandidates(parsed, candidates, {
3256+      observedAt,
3257+      source: "response_body"
3258+    });
3259+  } else {
3260+    const match = body.match(ACCOUNT_EMAIL_RE);
3261+    if (match?.[0]) {
3262+      candidates.push(createAccountCandidate(match[0], {
3263+        kind: "email",
3264+        observedAt,
3265+        priority: 100,
3266+        source: "response_body"
3267+      }));
3268+    }
3269+  }
3270+
3271+  let best = null;
3272+  for (const candidate of candidates) {
3273+    best = selectAccountCandidate(best, candidate);
3274+  }
3275+  return best;
3276+}
3277+
3278+function observeAccountHint(platform, url, body) {
3279+  const candidate = extractAccountCandidateFromBody(url, body);
3280+  if (!candidate) {
3281+    return false;
3282+  }
3283+
3284+  const credential = runtimeState.platforms[platform].credential;
3285+  const currentCandidate = credential.account_hint
3286+    ? {
3287+        kind: credential.account_kind,
3288+        observedAt: credential.account_last_seen_at || credential.account_captured_at || 0,
3289+        priority: credential.account_hint === candidate.value ? candidate.priority : 0,
3290+        source: credential.account_source,
3291+        value: credential.account_hint
3292+      }
3293+    : null;
3294+  const selected = selectAccountCandidate(currentCandidate, candidate);
3295+
3296+  if (!selected?.value) {
3297+    return false;
3298+  }
3299+
3300+  const sameValue = credential.account_hint === selected.value;
3301+  runtimeState.platforms[platform].credential = {
3302+    ...credential,
3303+    account_captured_at: sameValue && credential.account_captured_at != null
3304+      ? credential.account_captured_at
3305+      : selected.observedAt,
3306+    account_hint: selected.value,
3307+    account_kind: selected.kind,
3308+    account_last_seen_at: selected.observedAt,
3309+    account_source: selected.source
3310+  };
3311+  touchState();
3312+  return true;
3313+}
3314+
3315+function summarizeEvent(detail) {
3316+  return {
3317+    done: detail?.done === true,
3318+    error: trimToNull(detail?.error),
3319+    method: trimToNull(detail?.method),
3320+    open: detail?.open === true,
3321+    source: trimToNull(detail?.source),
3322+    status: Number.isFinite(Number(detail?.status)) ? Number(detail.status) : null,
3323+    tabId: Number.isInteger(detail?.tabId) ? detail.tabId : null,
3324+    ts: Number.isFinite(Number(detail?.ts)) ? Number(detail.ts) : Date.now(),
3325+    url: trimToNull(detail?.url)
3326+  };
3327+}
3328+
3329+function updateEndpointMetadata(platform, input) {
3330+  const method = trimToNull(input?.method)?.toUpperCase() || "GET";
3331+  const path = normalizeEndpointPath(input?.url);
3332+
3333+  if (!path || !shouldTrackRequest(platform, getRequestPath(path))) {
3334+    return false;
3335+  }
3336+
3337+  const observedAt = Date.now();
3338+  const current = runtimeState.platforms[platform].endpoint_metadata;
3339+  const key = `${method} ${path}`;
3340+  const existing = current.find((entry) => entry.key === key) || null;
3341+  const next = {
3342+    first_seen_at: existing?.first_seen_at || observedAt,
3343+    key,
3344+    last_seen_at: observedAt,
3345+    last_status: Number.isFinite(Number(input?.status)) ? Number(input.status) : (existing?.last_status ?? null),
3346+    method,
3347+    path,
3348+    source: trimToNull(input?.source) || existing?.source || "network"
3349+  };
3350+  const remaining = current.filter((entry) => entry.key !== key);
3351+
3352+  runtimeState.platforms[platform].endpoint_metadata = [next, ...remaining]
3353+    .sort((left, right) => (right.last_seen_at || 0) - (left.last_seen_at || 0))
3354+    .slice(0, ENDPOINT_LIMIT);
3355+  runtimeState.platforms[platform].credential.endpoint_count = runtimeState.platforms[platform].endpoint_metadata.length;
3356+  touchState();
3357+
3358+  return existing == null;
3359+}
3360+
3361+async function getStoredControllerTabId() {
3362+  const data = await browser.storage.local.get(CONTROLLER_TAB_STORAGE_KEY);
3363+  return Number.isInteger(data[CONTROLLER_TAB_STORAGE_KEY]) ? data[CONTROLLER_TAB_STORAGE_KEY] : null;
3364+}
3365+
3366+async function setStoredControllerTabId(tabId) {
3367+  if (Number.isInteger(tabId)) {
3368+    await browser.storage.local.set({ [CONTROLLER_TAB_STORAGE_KEY]: tabId });
3369+    return;
3370+  }
3371+
3372+  await browser.storage.local.remove(CONTROLLER_TAB_STORAGE_KEY);
3373+}
3374+
3375+function setWsStatus(status, detail = {}) {
3376+  runtimeState.ws = {
3377+    ...runtimeState.ws,
3378+    connected: status === "connected",
3379+    lastError: Object.prototype.hasOwnProperty.call(detail, "lastError")
3380+      ? trimToNull(detail.lastError)
3381+      : runtimeState.ws.lastError,
3382+    lastHelloAckAt: Object.prototype.hasOwnProperty.call(detail, "lastHelloAckAt")
3383+      ? detail.lastHelloAckAt
3384+      : runtimeState.ws.lastHelloAckAt,
3385+    lastConnectedAt: Object.prototype.hasOwnProperty.call(detail, "lastConnectedAt")
3386+      ? detail.lastConnectedAt
3387+      : runtimeState.ws.lastConnectedAt,
3388+    status,
3389+    wsUrl: Object.prototype.hasOwnProperty.call(detail, "wsUrl")
3390+      ? normalizeBrowserWsUrl(detail.wsUrl)
3391+      : runtimeState.ws.wsUrl
3392+  };
3393+  touchState();
3394+}
3395+
3396+function syncPendingRelayCount() {
3397+  runtimeState.ws.pendingRelayCount = pendingRelayQueue.length;
3398+  touchState();
3399+}
3400+
3401+function queuePendingRelay(platform, relay, source, context) {
3402+  const dedupeKey = trimToNull(relay?.dedupeKey);
3403+  if (!dedupeKey) {
3404+    return false;
3405+  }
3406+
3407+  if (pendingRelayQueue.some((entry) => entry.dedupeKey === dedupeKey)) {
3408+    return false;
3409+  }
3410+
3411+  pendingRelayQueue.push({
3412+    context,
3413+    dedupeKey,
3414+    platform,
3415+    relay,
3416+    source
3417+  });
3418+  syncPendingRelayCount();
3419+  return true;
3420+}
3421+
3422+function clearReconnectTimer() {
3423+  if (wsRuntime.reconnectTimer != null) {
3424+    clearTimeout(wsRuntime.reconnectTimer);
3425+    wsRuntime.reconnectTimer = null;
3426+  }
3427+}
3428+
3429+function scheduleWsReconnect(delayMs = WS_RECONNECT_DELAY_MS) {
3430+  if (TEST_FLAGS.disableReconnect === true) {
3431+    return;
3432+  }
3433+
3434+  clearReconnectTimer();
3435+  wsRuntime.reconnectTimer = globalThis.setTimeout(() => {
3436+    wsRuntime.reconnectTimer = null;
3437+    void connectBrowserWs();
3438+  }, Math.max(250, Number(delayMs) || WS_RECONNECT_DELAY_MS));
3439+}
3440+
3441+function wsSend(payload) {
3442+  if (!wsRuntime.socket || wsRuntime.socket.readyState !== WebSocket.OPEN) {
3443+    return false;
3444+  }
3445+
3446+  wsRuntime.socket.send(JSON.stringify(payload));
3447+  return true;
3448+}
3449+
3450+function buildBrowserHelloPayload() {
3451+  return {
3452+    type: "hello",
3453+    clientId: runtimeState.ws.clientId,
3454+    nodeType: "browser",
3455+    nodeCategory: "proxy",
3456+    nodePlatform: "safari",
3457+    capabilities: {
3458+      credential_metadata: true,
3459+      final_message_relay: true,
3460+      page_bridge: true,
3461+      implemented_platforms: ["chatgpt"]
3462+    }
3463+  };
3464+}
3465+
3466+function sendBrowserHello() {
3467+  return wsSend(buildBrowserHelloPayload());
3468+}
3469+
3470+function buildFinalMessageContext(sender, fallbackUrl = null) {
3471+  return {
3472+    isShellPage: false,
3473+    pageTitle: trimToNull(sender?.tab?.title),
3474+    senderUrl: trimToNull(sender?.tab?.url) || trimToNull(fallbackUrl),
3475+    tabId: Number.isInteger(sender?.tab?.id) ? sender.tab.id : null
3476+  };
3477+}
3478+
3479+function recordFinalMessageRelay(platform, relay, source, context = null) {
3480+  runtimeState.platforms[platform].lastFinalMessage = {
3481+    assistant_message_id: trimToNull(relay?.payload?.assistant_message_id),
3482+    conversation_id: trimToNull(relay?.payload?.conversation_id),
3483+    observed_at: Number.isFinite(Number(relay?.payload?.observed_at)) ? Number(relay.payload.observed_at) : null,
3484+    page_url: trimToNull(context?.senderUrl),
3485+    raw_text: trimToNull(relay?.payload?.raw_text),
3486+    seenAt: Date.now(),
3487+    source: trimToNull(source) || "page_observed",
3488+    tab_id: Number.isInteger(context?.tabId) ? context.tabId : null
3489+  };
3490+  touchState();
3491+}
3492+
3493+function sendObservedFinalMessage(platform, relay, source = "page_observed", context = null) {
3494+  const observer = finalMessageRelayObservers[platform];
3495+  if (!observer || !relay?.payload) {
3496+    return false;
3497+  }
3498+
3499+  const payload = {
3500+    ...relay.payload,
3501+    page_title: trimToNull(context?.pageTitle),
3502+    page_url: trimToNull(context?.senderUrl),
3503+    shell_page: context?.isShellPage === true,
3504+    tab_id: Number.isInteger(context?.tabId) ? context.tabId : null
3505+  };
3506+
3507+  if (!wsSend(payload)) {
3508+    return false;
3509+  }
3510+
3511+  FINAL_MESSAGE_HELPERS?.rememberRelay(observer, relay);
3512+  void persistFinalMessageRelayCache().catch(() => {});
3513+  recordFinalMessageRelay(platform, relay, source, context);
3514+  appendLog("info", "browser_final_message_relayed", {
3515+    assistant_message_id: relay.payload.assistant_message_id,
3516+    platform,
3517+    raw_text_length: relay.payload.raw_text?.length ?? 0,
3518+    source,
3519+    url: trimToNull(context?.senderUrl)
3520+  });
3521+  return true;
3522+}
3523+
3524+function flushPendingRelayQueue() {
3525+  if (pendingRelayQueue.length === 0) {
3526+    return;
3527+  }
3528+
3529+  const queued = pendingRelayQueue.splice(0, pendingRelayQueue.length);
3530+  syncPendingRelayCount();
3531+
3532+  for (const entry of queued) {
3533+    if (!sendObservedFinalMessage(entry.platform, entry.relay, entry.source, entry.context)) {
3534+      pendingRelayQueue.unshift(entry);
3535+      syncPendingRelayCount();
3536+      break;
3537+    }
3538+  }
3539+}
3540+
3541+function relayObservedFinalMessage(platform, relay, source = "page_observed", context = null) {
3542+  const observer = finalMessageRelayObservers[platform];
3543+  if (!observer || !relay?.payload) {
3544+    return false;
3545+  }
3546+
3547+  if (sendObservedFinalMessage(platform, relay, source, context)) {
3548+    return true;
3549+  }
3550+
3551+  const queued = queuePendingRelay(platform, relay, source, context);
3552+  appendLog("warn", "browser_final_message_buffered", {
3553+    assistant_message_id: relay.payload.assistant_message_id,
3554+    platform,
3555+    queued,
3556+    source,
3557+    ws_status: runtimeState.ws.status
3558+  });
3559+  void connectBrowserWs();
3560+  return false;
3561+}
3562+
3563+function observeFinalMessageFromPageNetwork(data, sender) {
3564+  if (!FINAL_MESSAGE_HELPERS || !data || data.source === "proxy" || typeof data.url !== "string") {
3565+    return;
3566+  }
3567+
3568+  const platform = trimToNull(data?.platform) || detectPlatformFromUrl(data?.url || sender?.tab?.url || "", "request");
3569+  const observer = platform ? finalMessageRelayObservers[platform] : null;
3570+  if (!observer) {
3571+    return;
3572+  }
3573+
3574+  const relay = FINAL_MESSAGE_HELPERS.observeNetwork(observer, data, {
3575+    observedAt: Date.now(),
3576+    pageUrl: sender?.tab?.url || ""
3577+  });
3578+
3579+  if (relay) {
3580+    relayObservedFinalMessage(platform, relay, "page_network", buildFinalMessageContext(sender, data?.url));
3581+  }
3582+}
3583+
3584+function observeFinalMessageFromPageSse(data, sender) {
3585+  if (!FINAL_MESSAGE_HELPERS || !data || data.source === "proxy" || typeof data.url !== "string") {
3586+    return;
3587+  }
3588+
3589+  const platform = trimToNull(data?.platform) || detectPlatformFromUrl(data?.url || sender?.tab?.url || "", "request");
3590+  const observer = platform ? finalMessageRelayObservers[platform] : null;
3591+  if (!observer) {
3592+    return;
3593+  }
3594+
3595+  const relay = FINAL_MESSAGE_HELPERS.observeSse(observer, data, {
3596+    observedAt: Date.now(),
3597+    pageUrl: sender?.tab?.url || ""
3598+  });
3599+
3600+  if (relay) {
3601+    relayObservedFinalMessage(platform, relay, "page_sse", buildFinalMessageContext(sender, data?.url));
3602+  }
3603+}
3604+
3605+function handleBrowserWsMessage(rawData) {
3606+  let message = null;
3607+
3608+  try {
3609+    message = JSON.parse(String(rawData || ""));
3610+  } catch (error) {
3611+    appendLog("warn", "browser_ws_invalid_message", {
3612+      error: error instanceof Error ? error.message : String(error || "invalid_json")
3613+    });
3614+    return;
3615+  }
3616+
3617+  if (!isRecord(message)) {
3618+    return;
3619+  }
3620+
3621+  if (message.type === "hello_ack") {
3622+    const wsUrl = normalizeBrowserWsUrl(message.wsUrl || message.ws_url || runtimeState.ws.wsUrl);
3623+    setWsStatus("connected", {
3624+      lastError: null,
3625+      lastHelloAckAt: Date.now(),
3626+      wsUrl
3627+    });
3628+    appendLog("info", "browser_ws_hello_ack", {
3629+      clientId: trimToNull(message.clientId) || runtimeState.ws.clientId,
3630+      protocol: trimToNull(message.protocol),
3631+      wsUrl
3632+    });
3633+    void browser.storage.local.set({
3634+      [SAFARI_BROWSER_WS_URL_STORAGE_KEY]: wsUrl
3635+    }).catch(() => {});
3636+    flushPendingRelayQueue();
3637+    return;
3638+  }
3639+
3640+  if (message.type === "state_snapshot") {
3641+    appendLog("debug", "browser_ws_state_snapshot", {
3642+      reason: trimToNull(message.reason)
3643+    });
3644+  }
3645+}
3646+
3647+async function loadStoredRuntimeState() {
3648+  const saved = await browser.storage.local.get([
3649+    CONTROLLER_TAB_STORAGE_KEY,
3650+    FINAL_MESSAGE_RELAY_CACHE_STORAGE_KEY,
3651+    SAFARI_BROWSER_WS_CLIENT_ID_STORAGE_KEY,
3652+    SAFARI_BROWSER_WS_URL_STORAGE_KEY
3653+  ]);
3654+  const updates = {};
3655+  const storedClientId = trimToNull(saved[SAFARI_BROWSER_WS_CLIENT_ID_STORAGE_KEY]);
3656+  const clientId = storedClientId || buildBrowserWsClientId();
3657+  const wsUrl = normalizeBrowserWsUrl(saved[SAFARI_BROWSER_WS_URL_STORAGE_KEY]);
3658+
3659+  runtimeState.controller.tabId = Number.isInteger(saved[CONTROLLER_TAB_STORAGE_KEY])
3660+    ? saved[CONTROLLER_TAB_STORAGE_KEY]
3661+    : null;
3662+  runtimeState.ws.clientId = clientId;
3663+  runtimeState.ws.wsUrl = wsUrl;
3664+  restoreFinalMessageRelayCache(saved[FINAL_MESSAGE_RELAY_CACHE_STORAGE_KEY]);
3665+  touchState();
3666+
3667+  if (!storedClientId) {
3668+    updates[SAFARI_BROWSER_WS_CLIENT_ID_STORAGE_KEY] = clientId;
3669+  }
3670+
3671+  if (saved[SAFARI_BROWSER_WS_URL_STORAGE_KEY] !== wsUrl) {
3672+    updates[SAFARI_BROWSER_WS_URL_STORAGE_KEY] = wsUrl;
3673+  }
3674+
3675+  if (Object.keys(updates).length > 0) {
3676+    await browser.storage.local.set(updates);
3677+  }
3678+}
3679+
3680+async function connectBrowserWs() {
3681+  if (!runtimeState.ws.clientId) {
3682+    await loadStoredRuntimeState();
3683+  }
3684+
3685+  if (wsRuntime.socket && (
3686+    wsRuntime.socket.readyState === WebSocket.CONNECTING
3687+    || wsRuntime.socket.readyState === WebSocket.OPEN
3688+  )) {
3689+    return;
3690+  }
3691+
3692+  clearReconnectTimer();
3693+  const wsUrl = normalizeBrowserWsUrl(runtimeState.ws.wsUrl);
3694+  setWsStatus("connecting", {
3695+    lastError: null,
3696+    wsUrl
3697+  });
3698+  appendLog("info", "browser_ws_connecting", {
3699+    clientId: runtimeState.ws.clientId,
3700+    wsUrl
3701+  });
3702+
3703+  let socket = null;
3704+
3705+  try {
3706+    socket = new WebSocket(wsUrl, BROWSER_WS_PROTOCOLS);
3707+  } catch (error) {
3708+    setWsStatus("error", {
3709+      lastError: error instanceof Error ? error.message : String(error || "websocket_init_failed"),
3710+      wsUrl
3711+    });
3712+    appendLog("warn", "browser_ws_init_failed", {
3713+      error: error instanceof Error ? error.message : String(error || "websocket_init_failed"),
3714+      wsUrl
3715+    });
3716+    scheduleWsReconnect();
3717+    return;
3718+  }
3719+
3720+  wsRuntime.socket = socket;
3721+
3722+  socket.addEventListener("open", () => {
3723+    if (wsRuntime.socket !== socket) {
3724+      return;
3725+    }
3726+
3727+    setWsStatus("open", {
3728+      lastConnectedAt: Date.now(),
3729+      lastError: null,
3730+      wsUrl
3731+    });
3732+    sendBrowserHello();
3733+  });
3734+
3735+  socket.addEventListener("message", (event) => {
3736+    handleBrowserWsMessage(event?.data);
3737+  });
3738+
3739+  socket.addEventListener("error", () => {
3740+    if (wsRuntime.socket !== socket) {
3741+      return;
3742+    }
3743+
3744+    setWsStatus("error", {
3745+      lastError: "websocket_error",
3746+      wsUrl
3747+    });
3748+    appendLog("warn", "browser_ws_error", {
3749+      wsUrl
3750+    });
3751+  });
3752+
3753+  socket.addEventListener("close", (event) => {
3754+    if (wsRuntime.socket === socket) {
3755+      wsRuntime.socket = null;
3756+    }
3757+
3758+    setWsStatus("disconnected", {
3759+      lastError: trimToNull(event?.reason) || runtimeState.ws.lastError,
3760+      wsUrl
3761+    });
3762+    appendLog("warn", "browser_ws_closed", {
3763+      code: Number.isFinite(Number(event?.code)) ? Number(event.code) : null,
3764+      reason: trimToNull(event?.reason),
3765+      wsUrl
3766+    });
3767+    scheduleWsReconnect();
3768+  });
3769+}
3770+
3771+async function initializeBackgroundRuntime() {
3772+  await loadStoredRuntimeState();
3773+
3774+  if (!FINAL_MESSAGE_HELPERS) {
3775+    appendLog("warn", "final_message_helper_missing");
3776+    return;
3777+  }
3778+
3779+  if (TEST_FLAGS.disableWsConnect === true) {
3780+    return;
3781+  }
3782+
3783+  await connectBrowserWs();
3784+}
3785+
3786+async function queryControllerTabs() {
3787+  return browser.tabs.query({ url: CONTROLLER_URL });
3788+}
3789+
3790+async function resolveControllerTab() {
3791+  const tabs = await queryControllerTabs();
3792+
3793+  if (tabs.length === 0) {
3794+    runtimeState.controller.tabId = null;
3795+    touchState();
3796+    await setStoredControllerTabId(null);
3797+    return null;
3798+  }
3799+
3800+  const storedTabId = await getStoredControllerTabId();
3801+  const canonical =
3802+    tabs.find((tab) => tab.id === storedTabId)
3803+    || tabs.find((tab) => Number.isInteger(tab.id))
3804+    || null;
3805+
3806+  if (!canonical || !Number.isInteger(canonical.id)) {
3807+    return null;
3808+  }
3809+
3810+  const duplicateIds = tabs
3811+    .map((tab) => tab.id)
3812+    .filter((tabId) => Number.isInteger(tabId) && tabId !== canonical.id);
3813+
3814+  if (duplicateIds.length > 0) {
3815+    await browser.tabs.remove(duplicateIds);
3816+  }
3817+
3818+  runtimeState.controller.tabId = canonical.id;
3819+  touchState();
3820+  await setStoredControllerTabId(canonical.id);
3821+  return canonical;
3822+}
3823+
3824+async function ensureControllerTab(options = {}) {
3825+  const activate = options.activate === true;
3826+  let tab = await resolveControllerTab();
3827+
3828+  if (tab == null) {
3829+    tab = await browser.tabs.create({
3830+      active: activate,
3831+      url: CONTROLLER_URL
3832+    });
3833+    runtimeState.controller.tabId = Number.isInteger(tab?.id) ? tab.id : null;
3834+    touchState();
3835+    await setStoredControllerTabId(runtimeState.controller.tabId);
3836+    return tab;
3837+  }
3838+
3839+  if (activate) {
3840+    await browser.tabs.update(tab.id, { active: true });
3841+
3842+    if (Number.isInteger(tab.windowId)) {
3843+      await browser.windows.update(tab.windowId, { focused: true });
3844+    }
3845+  }
3846+
3847+  return tab;
3848+}
3849+
3850+async function refreshPermissionHints() {
3851+  const tabs = await browser.tabs.query({});
3852+  const hints = [];
3853+  const now = Date.now();
3854+
3855+  for (const tab of tabs) {
3856+    if (!Number.isInteger(tab?.id) || typeof tab?.url !== "string") {
3857+      continue;
3858+    }
3859+
3860+    const platform = detectPlatformFromUrl(tab.url, "page");
3861+    if (!platform) {
3862+      continue;
3863+    }
3864+
3865+    const platformState = runtimeState.platforms[platform];
3866+    const lastContentScript = platformState.lastContentScript;
3867+    const lastBridgeReady = platformState.lastPageBridgeReady;
3868+
3869+    if (!lastContentScript || lastContentScript.tabId !== tab.id || now - lastContentScript.seenAt > CONTENT_SCRIPT_STALE_MS) {
3870+      hints.push({
3871+        level: "warn",
3872+        message: `${platformState.label} 标签页已打开,但没有收到 content script 回报。优先检查 Safari -> Settings -> Extensions -> Website Access。`,
3873+        platform,
3874+        reason: "website_access_or_content_script_missing",
3875+        tabId: tab.id,
3876+        url: tab.url
3877+      });
3878+      continue;
3879+    }
3880+
3881+    if (
3882+      !lastBridgeReady
3883+      || lastBridgeReady.tabId !== tab.id
3884+      || now - lastBridgeReady.seenAt > PAGE_BRIDGE_STALE_MS
3885+    ) {
3886+      hints.push({
3887+        level: "warn",
3888+        message: `${platformState.label} content script 已触达,但页面主世界没有返回 ready 事件。优先检查 page-interceptor 注入或站点 CSP。`,
3889+        platform,
3890+        reason: "page_bridge_not_ready",
3891+        tabId: tab.id,
3892+        url: tab.url
3893+      });
3894+    }
3895+  }
3896+
3897+  runtimeState.permissionHints = hints;
3898+  touchState();
3899+}
3900+
3901+function handleContentScriptSeen(message, sender) {
3902+  const platform = trimToNull(message?.platform) || detectPlatformFromUrl(message?.url || sender?.tab?.url || "", "page");
3903+
3904+  if (!platform || !runtimeState.platforms[platform]) {
3905+    return;
3906+  }
3907+
3908+  runtimeState.platforms[platform].lastContentScript = {
3909+    seenAt: Date.now(),
3910+    tabId: Number.isInteger(sender?.tab?.id) ? sender.tab.id : null,
3911+    url: trimToNull(message?.url) || trimToNull(sender?.tab?.url)
3912+  };
3913+  touchState();
3914+}
3915+
3916+function handlePageBridgeReady(data, sender) {
3917+  const platform = trimToNull(data?.platform) || detectPlatformFromUrl(data?.url || sender?.tab?.url || "", "page");
3918+
3919+  if (!platform || !runtimeState.platforms[platform]) {
3920+    return;
3921+  }
3922+
3923+  const source = trimToNull(data?.source) || "content-script";
3924+  if (source !== "content-script") {
3925+    runtimeState.platforms[platform].lastPageBridgeReady = {
3926+      seenAt: Date.now(),
3927+      source,
3928+      tabId: Number.isInteger(sender?.tab?.id) ? sender.tab.id : null,
3929+      url: trimToNull(data?.url) || trimToNull(sender?.tab?.url)
3930+    };
3931+    touchState();
3932+  }
3933+
3934+  appendLog("info", source === "content-script" ? "content_script_ready" : "page_bridge_ready", {
3935+    platform,
3936+    source,
3937+    tabId: Number.isInteger(sender?.tab?.id) ? sender.tab.id : null,
3938+    url: trimToNull(data?.url) || trimToNull(sender?.tab?.url)
3939+  });
3940+}
3941+
3942+function handlePageNetwork(data, sender) {
3943+  const platform = trimToNull(data?.platform) || detectPlatformFromUrl(data?.url || sender?.tab?.url || "", "request");
3944+
3945+  if (!platform || !runtimeState.platforms[platform]) {
3946+    return;
3947+  }
3948+
3949+  runtimeState.platforms[platform].lastNetwork = {
3950+    ...summarizeEvent(data),
3951+    error: trimToNull(data?.error)
3952+  };
3953+  touchState();
3954+
3955+  const discovered = updateEndpointMetadata(platform, {
3956+    method: data?.method,
3957+    source: trimToNull(data?.source) || "page",
3958+    status: data?.status,
3959+    url: data?.url
3960+  });
3961+  if (discovered) {
3962+    appendLog("info", "endpoint_metadata_seen", {
3963+      endpoint_metadata: runtimeState.platforms[platform].endpoint_metadata[0] || null,
3964+      platform
3965+    });
3966+  }
3967+
3968+  if (platform === "chatgpt" && observeAccountHint(platform, data?.url, data?.resBody)) {
3969+    appendLog("info", "account_hint_updated", {
3970+      account_hint: runtimeState.platforms[platform].credential.account_hint,
3971+      platform,
3972+      source: runtimeState.platforms[platform].credential.account_source
3973+    });
3974+  }
3975+
3976+  observeFinalMessageFromPageNetwork(data, sender);
3977+}
3978+
3979+function handlePageSse(data, sender) {
3980+  const platform = trimToNull(data?.platform) || detectPlatformFromUrl(data?.url || sender?.tab?.url || "", "request");
3981+
3982+  if (!platform || !runtimeState.platforms[platform]) {
3983+    return;
3984+  }
3985+
3986+  runtimeState.platforms[platform].lastSse = summarizeEvent(data);
3987+  touchState();
3988+
3989+  if (data?.open === true || data?.done === true || trimToNull(data?.error)) {
3990+    appendLog(trimToNull(data?.error) ? "warn" : "debug", "page_sse", {
3991+      done: data?.done === true,
3992+      error: trimToNull(data?.error),
3993+      platform,
3994+      source: trimToNull(data?.source),
3995+      status: Number.isFinite(Number(data?.status)) ? Number(data.status) : null,
3996+      url: trimToNull(data?.url)
3997+    });
3998+  }
3999+
4000+  observeFinalMessageFromPageSse(data, sender);
4001+}
4002+
4003+function handlePageProxyResponse(data, sender) {
4004+  const platform = trimToNull(data?.platform) || detectPlatformFromUrl(data?.url || sender?.tab?.url || "", "request");
4005+
4006+  if (!platform || !runtimeState.platforms[platform]) {
4007+    return;
4008+  }
4009+
4010+  runtimeState.platforms[platform].lastProxyResponse = {
4011+    error: trimToNull(data?.error),
4012+    id: trimToNull(data?.id),
4013+    ok: data?.ok === true,
4014+    seenAt: Date.now(),
4015+    status: Number.isFinite(Number(data?.status)) ? Number(data.status) : null,
4016+    tabId: Number.isInteger(sender?.tab?.id) ? sender.tab.id : null,
4017+    url: trimToNull(data?.url)
4018+  };
4019+  touchState();
4020+}
4021+
4022+function handleDiagnosticLog(data, sender) {
4023+  const platform = trimToNull(data?.platform) || detectPlatformFromUrl(data?.url || sender?.tab?.url || "", "page");
4024+  const event = trimToNull(data?.event);
4025+
4026+  if (!platform || !runtimeState.platforms[platform] || !event) {
4027+    return;
4028+  }
4029+
4030+  runtimeState.platforms[platform].lastDiagnostic = {
4031+    event,
4032+    method: trimToNull(data?.method),
4033+    seenAt: Date.now(),
4034+    source: trimToNull(data?.source) || "content-script",
4035+    tabId: Number.isInteger(sender?.tab?.id) ? sender.tab.id : null,
4036+    url: trimToNull(data?.url) || trimToNull(sender?.tab?.url)
4037+  };
4038+  touchState();
4039+
4040+  appendLog(
4041+    /timeout|error|missing|failed/u.test(event) ? "warn" : "debug",
4042+    event,
4043+    {
4044+      method: trimToNull(data?.method),
4045+      platform,
4046+      source: trimToNull(data?.source) || "content-script",
4047+      tabId: Number.isInteger(sender?.tab?.id) ? sender.tab.id : null,
4048+      url: trimToNull(data?.url) || trimToNull(sender?.tab?.url)
4049+    }
4050+  );
4051+}
4052+
4053+function handleBeforeSendHeaders(details) {
4054+  const platform = detectPlatformFromUrl(details?.url, "request");
4055+
4056+  if (!platform || !runtimeState.platforms[platform]) {
4057+    return;
4058+  }
4059+
4060+  const changed = updateCredentialSnapshot(platform, {
4061+    requestHeaders: details.requestHeaders,
4062+    tabId: details.tabId,
4063+    url: details.url
4064+  });
4065+
4066+  if (changed) {
4067+    appendLog("debug", "credential_snapshot_seen", {
4068+      credential_fingerprint: runtimeState.platforms[platform].credential.credential_fingerprint,
4069+      header_count: runtimeState.platforms[platform].credential.header_count,
4070+      platform,
4071+      tabId: Number.isInteger(details?.tabId) ? details.tabId : null,
4072+      url: trimToNull(details?.url)
4073+    });
4074+  }
4075+}
4076+
4077+function handleCompleted(details) {
4078+  const platform = detectPlatformFromUrl(details?.url, "request");
4079+
4080+  if (!platform || !runtimeState.platforms[platform]) {
4081+    return;
4082+  }
4083+
4084+  updateEndpointMetadata(platform, {
4085+    method: details.method,
4086+    source: "webRequest",
4087+    status: details.statusCode,
4088+    url: details.url
4089+  });
4090+}
4091+
4092+function handleErrorOccurred(details) {
4093+  const platform = detectPlatformFromUrl(details?.url, "request");
4094+
4095+  if (!platform || !runtimeState.platforms[platform]) {
4096+    return;
4097+  }
4098+
4099+  appendLog("warn", "request_error", {
4100+    error: trimToNull(details?.error),
4101+    method: trimToNull(details?.method),
4102+    platform,
4103+    tabId: Number.isInteger(details?.tabId) ? details.tabId : null,
4104+    url: trimToNull(details?.url)
4105+  });
4106+}
4107+
4108+browser.runtime.onInstalled.addListener(() => {
4109+  void ensureControllerTab({ activate: true });
4110+});
4111+
4112+browser.action.onClicked.addListener(() => {
4113+  void ensureControllerTab({ activate: true });
4114+});
4115+
4116+browser.tabs.onRemoved.addListener((tabId) => {
4117+  if (runtimeState.controller.tabId !== tabId) {
4118+    return;
4119+  }
4120+
4121+  runtimeState.controller.lastReadyAt = null;
4122+  runtimeState.controller.tabId = null;
4123+  touchState();
4124+  void setStoredControllerTabId(null);
4125+});
4126+
4127+browser.webRequest.onBeforeSendHeaders.addListener(
4128+  handleBeforeSendHeaders,
4129+  { urls: REQUEST_URL_PATTERNS },
4130+  ["requestHeaders"]
4131+);
4132+
4133+browser.webRequest.onCompleted.addListener(
4134+  handleCompleted,
4135+  { urls: REQUEST_URL_PATTERNS }
4136+);
4137+
4138+browser.webRequest.onErrorOccurred.addListener(
4139+  handleErrorOccurred,
4140+  { urls: REQUEST_URL_PATTERNS }
4141+);
4142+
4143+browser.runtime.onMessage.addListener((message, sender) => {
4144+  if (!isRecord(message)) {
4145+    return undefined;
4146+  }
4147+
4148+  switch (message.type) {
4149+    case "baa_safari.controller_ready":
4150+      runtimeState.controller.lastReadyAt = Date.now();
4151+      runtimeState.controller.tabId = Number.isInteger(message.tabId) ? message.tabId : runtimeState.controller.tabId;
4152+      touchState();
4153+      if (Number.isInteger(message.tabId)) {
4154+        void setStoredControllerTabId(message.tabId);
4155+      }
4156+      return Promise.resolve({
4157+        ok: true,
4158+        runtimeOwner: runtimeState.runtimeOwner
4159+      });
4160+    case "baa_safari.get_state":
4161+      return refreshPermissionHints().then(() => cloneState());
4162+    case "baa_safari.focus_controller":
4163+      return ensureControllerTab({ activate: true }).then((tab) => ({
4164+        ok: true,
4165+        tabId: tab?.id ?? null
4166+      }));
4167+    case "baa_safari.content_script_seen":
4168+      handleContentScriptSeen(message, sender);
4169+      return Promise.resolve({ ok: true });
4170+    case "baa_page_bridge_ready":
4171+      handlePageBridgeReady(message.data || {}, sender);
4172+      return Promise.resolve({ ok: true });
4173+    case "baa_page_network":
4174+      handlePageNetwork(message.data || {}, sender);
4175+      return Promise.resolve({ ok: true });
4176+    case "baa_page_sse":
4177+      handlePageSse(message.data || {}, sender);
4178+      return Promise.resolve({ ok: true });
4179+    case "baa_diagnostic_log":
4180+      handleDiagnosticLog(message.data || {}, sender);
4181+      return Promise.resolve({ ok: true });
4182+    case "baa_page_proxy_response":
4183+      handlePageProxyResponse(message.data || {}, sender);
4184+      return Promise.resolve({ ok: true });
4185+    default:
4186+      return undefined;
4187+  }
4188+});
4189+
4190+if (TEST_FLAGS.disableBoot !== true) {
4191+  void initializeBackgroundRuntime().catch((error) => {
4192+    appendLog("warn", "background_boot_failed", {
4193+      error: error instanceof Error ? error.message : String(error || "unknown_error")
4194+    });
4195+  });
4196+}
4197+
4198+if (typeof module !== "undefined" && module.exports) {
4199+  module.exports = {
4200+    __test__: {
4201+      buildBrowserHelloPayload,
4202+      connectBrowserWs,
4203+      handlePageNetwork,
4204+      handlePageSse,
4205+      initializeBackgroundRuntime,
4206+      normalizeBrowserWsUrl,
4207+      relayObservedFinalMessage,
4208+      restoreFinalMessageRelayCache,
4209+      runtimeState,
4210+      sendObservedFinalMessage,
4211+      serializeFinalMessageRelayCache,
4212+      setWsStatus,
4213+      syncPendingRelayCount,
4214+      pendingRelayQueue,
4215+      finalMessageRelayObservers,
4216+      wsRuntime,
4217+      reset() {
4218+        clearReconnectTimer();
4219+        if (wsRuntime.socket && typeof wsRuntime.socket.close === "function") {
4220+          try {
4221+            wsRuntime.socket.close();
4222+          } catch (_) {}
4223+        }
4224+        wsRuntime.socket = null;
4225+        pendingRelayQueue.splice(0, pendingRelayQueue.length);
4226+        runtimeState.background.startedAt = Date.now();
4227+        runtimeState.controller.lastReadyAt = null;
4228+        runtimeState.controller.tabId = null;
4229+        runtimeState.lastUpdatedAt = Date.now();
4230+        runtimeState.permissionHints = [];
4231+        runtimeState.platforms = createPlatformMap((platform) => createDefaultPlatformState(platform));
4232+        runtimeState.recentLogs = [];
4233+        runtimeState.runtimeOwner = "controller.html";
4234+        runtimeState.ws = createDefaultWsState();
4235+
4236+        for (const platform of PLATFORM_ORDER) {
4237+          const observer = finalMessageRelayObservers[platform];
4238+          if (!observer) {
4239+            continue;
4240+          }
4241+
4242+          observer.activeStream = null;
4243+          observer.recentRelayKeys = [];
4244+        }
4245+      }
4246+    }
4247+  };
4248+}
4249diff --git a/plugins/baa-safari/background.test.cjs b/plugins/baa-safari/background.test.cjs
4250new file mode 100644
4251index 0000000000000000000000000000000000000000..7d113da00a52a5ad134b8d6fee09396ab7f69531
4252--- /dev/null
4253+++ b/plugins/baa-safari/background.test.cjs
4254@@ -0,0 +1,226 @@
4255+const assert = require("node:assert/strict");
4256+const test = require("node:test");
4257+
4258+function createStorageArea() {
4259+  const data = {};
4260+
4261+  return {
4262+    _data: data,
4263+    async get(keys) {
4264+      if (Array.isArray(keys)) {
4265+        return Object.fromEntries(keys.map((key) => [key, data[key]]));
4266+      }
4267+
4268+      if (typeof keys === "string") {
4269+        return { [keys]: data[keys] };
4270+      }
4271+
4272+      if (keys && typeof keys === "object") {
4273+        return Object.fromEntries(Object.keys(keys).map((key) => [key, data[key] ?? keys[key]]));
4274+      }
4275+
4276+      return { ...data };
4277+    },
4278+    async remove(keys) {
4279+      const items = Array.isArray(keys) ? keys : [keys];
4280+      for (const key of items) {
4281+        delete data[key];
4282+      }
4283+    },
4284+    async set(input) {
4285+      Object.assign(data, input || {});
4286+    }
4287+  };
4288+}
4289+
4290+function createBrowserStub() {
4291+  return {
4292+    action: {
4293+      onClicked: {
4294+        addListener() {}
4295+      }
4296+    },
4297+    runtime: {
4298+      getURL(path) {
4299+        return `safari-web-extension://${path}`;
4300+      },
4301+      onInstalled: {
4302+        addListener() {}
4303+      },
4304+      onMessage: {
4305+        addListener() {}
4306+      }
4307+    },
4308+    storage: {
4309+      local: createStorageArea()
4310+    },
4311+    tabs: {
4312+      onRemoved: {
4313+        addListener() {}
4314+      },
4315+      async create() {
4316+        return { id: 100, url: "safari-web-extension://controller.html", windowId: 1 };
4317+      },
4318+      async query() {
4319+        return [];
4320+      },
4321+      async remove() {},
4322+      async update(id, patch = {}) {
4323+        return { id, windowId: 1, ...patch };
4324+      }
4325+    },
4326+    webRequest: {
4327+      onBeforeSendHeaders: {
4328+        addListener() {}
4329+      },
4330+      onCompleted: {
4331+        addListener() {}
4332+      },
4333+      onErrorOccurred: {
4334+        addListener() {}
4335+      }
4336+    },
4337+    windows: {
4338+      async update() {}
4339+    }
4340+  };
4341+}
4342+
4343+class FakeWebSocket {}
4344+FakeWebSocket.CONNECTING = 0;
4345+FakeWebSocket.OPEN = 1;
4346+FakeWebSocket.CLOSING = 2;
4347+FakeWebSocket.CLOSED = 3;
4348+
4349+function loadBackgroundHarness() {
4350+  delete require.cache[require.resolve("./background.js")];
4351+  delete require.cache[require.resolve("./final-message.js")];
4352+
4353+  globalThis.__BAA_SAFARI_TEST_FLAGS__ = {
4354+    disableBoot: true,
4355+    disableReconnect: true,
4356+    disableWsConnect: true
4357+  };
4358+  globalThis.browser = createBrowserStub();
4359+  globalThis.WebSocket = FakeWebSocket;
4360+  globalThis.BAAFinalMessage = require("./final-message.js");
4361+
4362+  return require("./background.js").__test__;
4363+}
4364+
4365+function createOpenSocket() {
4366+  return {
4367+    readyState: FakeWebSocket.OPEN,
4368+    sent: [],
4369+    close() {},
4370+    send(payload) {
4371+      this.sent.push(JSON.parse(payload));
4372+    }
4373+  };
4374+}
4375+
4376+function buildChatgptChunk({
4377+  assistantMessageId = "msg_abort",
4378+  conversationId = "conv_abort",
4379+  text = "@conductor::status"
4380+} = {}) {
4381+  return `data: ${JSON.stringify({
4382+    conversation_id: conversationId,
4383+    message: {
4384+      author: {
4385+        role: "assistant"
4386+      },
4387+      content: {
4388+        parts: [text]
4389+      },
4390+      end_turn: true,
4391+      id: assistantMessageId
4392+    }
4393+  })}`;
4394+}
4395+
4396+test("Safari background relays ChatGPT final_message through browser WS", () => {
4397+  const harness = loadBackgroundHarness();
4398+  harness.reset();
4399+  const socket = createOpenSocket();
4400+
4401+  harness.runtimeState.ws.clientId = "safari-test";
4402+  harness.setWsStatus("connected", {
4403+    lastHelloAckAt: Date.now(),
4404+    wsUrl: "ws://127.0.0.1:4317/ws/browser"
4405+  });
4406+  harness.wsRuntime.socket = socket;
4407+
4408+  harness.handlePageSse({
4409+    chunk: buildChatgptChunk(),
4410+    reqBody: JSON.stringify({ conversation_id: "conv_abort" }),
4411+    url: "https://chatgpt.com/backend-api/f/conversation"
4412+  }, {
4413+    tab: {
4414+      id: 7,
4415+      title: "ChatGPT",
4416+      url: "https://chatgpt.com/c/conv_abort"
4417+    }
4418+  });
4419+
4420+  harness.handlePageSse({
4421+    error: "The operation was aborted.",
4422+    reqBody: JSON.stringify({ conversation_id: "conv_abort" }),
4423+    url: "https://chatgpt.com/backend-api/f/conversation"
4424+  }, {
4425+    tab: {
4426+      id: 7,
4427+      title: "ChatGPT",
4428+      url: "https://chatgpt.com/c/conv_abort"
4429+    }
4430+  });
4431+
4432+  assert.equal(socket.sent.length, 1);
4433+  assert.equal(socket.sent[0].type, "browser.final_message");
4434+  assert.equal(socket.sent[0].platform, "chatgpt");
4435+  assert.equal(socket.sent[0].assistant_message_id, "msg_abort");
4436+  assert.equal(socket.sent[0].raw_text, "@conductor::status");
4437+  assert.equal(harness.runtimeState.platforms.chatgpt.lastFinalMessage.assistant_message_id, "msg_abort");
4438+});
4439+
4440+test("Safari background suppresses stale replay when relay cache already contains the ChatGPT message", () => {
4441+  const harness = loadBackgroundHarness();
4442+  harness.reset();
4443+  const socket = createOpenSocket();
4444+
4445+  harness.runtimeState.ws.clientId = "safari-test";
4446+  harness.setWsStatus("connected", {
4447+    lastHelloAckAt: Date.now(),
4448+    wsUrl: "ws://127.0.0.1:4317/ws/browser"
4449+  });
4450+  harness.wsRuntime.socket = socket;
4451+  harness.restoreFinalMessageRelayCache({
4452+    chatgpt: ["chatgpt|conv_abort|msg_abort|@conductor::status"]
4453+  });
4454+
4455+  harness.handlePageSse({
4456+    chunk: buildChatgptChunk(),
4457+    reqBody: JSON.stringify({ conversation_id: "conv_abort" }),
4458+    url: "https://chatgpt.com/backend-api/f/conversation"
4459+  }, {
4460+    tab: {
4461+      id: 7,
4462+      title: "ChatGPT",
4463+      url: "https://chatgpt.com/c/conv_abort"
4464+    }
4465+  });
4466+
4467+  harness.handlePageSse({
4468+    error: "The operation was aborted.",
4469+    reqBody: JSON.stringify({ conversation_id: "conv_abort" }),
4470+    url: "https://chatgpt.com/backend-api/f/conversation"
4471+  }, {
4472+    tab: {
4473+      id: 7,
4474+      title: "ChatGPT",
4475+      url: "https://chatgpt.com/c/conv_abort"
4476+    }
4477+  });
4478+
4479+  assert.equal(socket.sent.length, 0);
4480+});
4481diff --git a/plugins/baa-safari/content-script.js b/plugins/baa-safari/content-script.js
4482new file mode 100644
4483index 0000000000000000000000000000000000000000..44c3b2a700e1ebb11ce2ebd2c5e2ca9ad50a9f02
4484--- /dev/null
4485+++ b/plugins/baa-safari/content-script.js
4486@@ -0,0 +1,197 @@
4487+const CONTENT_SCRIPT_RUNTIME_KEY = "__baaSafariContentScriptRuntime__";
4488+const PAGE_READY_TIMEOUT_MS = 4_000;
4489+
4490+const previousRuntime = window[CONTENT_SCRIPT_RUNTIME_KEY];
4491+if (previousRuntime && typeof previousRuntime.dispose === "function") {
4492+  previousRuntime.dispose();
4493+}
4494+
4495+function trimToNull(value) {
4496+  if (typeof value !== "string") {
4497+    return null;
4498+  }
4499+
4500+  const normalized = value.trim();
4501+  return normalized === "" ? null : normalized;
4502+}
4503+
4504+function detectPlatformFromLocation() {
4505+  const host = globalThis.location?.hostname || "";
4506+
4507+  if (host === "claude.ai") {
4508+    return "claude";
4509+  }
4510+
4511+  if (host === "chatgpt.com" || host === "chat.openai.com" || host.endsWith(".chatgpt.com") || host.endsWith(".chat.openai.com")) {
4512+    return "chatgpt";
4513+  }
4514+
4515+  if (host === "gemini.google.com") {
4516+    return "gemini";
4517+  }
4518+
4519+  return "unknown";
4520+}
4521+
4522+function sendBridgeMessage(type, data) {
4523+  browser.runtime.sendMessage({
4524+    type,
4525+    data
4526+  }).catch(() => {});
4527+}
4528+
4529+function sendDiagnosticLog(eventName, detail = {}) {
4530+  const event = trimToNull(eventName);
4531+
4532+  if (!event) {
4533+    return;
4534+  }
4535+
4536+  sendBridgeMessage("baa_diagnostic_log", {
4537+    ...(detail || {}),
4538+    event,
4539+    platform: trimToNull(detail?.platform) || detectPlatformFromLocation(),
4540+    source: trimToNull(detail?.source) || "content-script",
4541+    url: trimToNull(detail?.url) || globalThis.location?.href || null
4542+  });
4543+}
4544+
4545+function handlePageReady(event) {
4546+  const detail = event?.detail && typeof event.detail === "object" ? event.detail : {};
4547+
4548+  if (contentScriptRuntime.readyTimeout != null) {
4549+    clearTimeout(contentScriptRuntime.readyTimeout);
4550+    contentScriptRuntime.readyTimeout = null;
4551+  }
4552+
4553+  sendBridgeMessage("baa_page_bridge_ready", {
4554+    ...detail,
4555+    platform: trimToNull(detail.platform) || detectPlatformFromLocation(),
4556+    source: trimToNull(detail.source) || "page-interceptor",
4557+    url: trimToNull(detail.url) || globalThis.location?.href || null
4558+  });
4559+}
4560+
4561+function handlePageNetwork(event) {
4562+  sendBridgeMessage("baa_page_network", event?.detail || {});
4563+}
4564+
4565+function handlePageSse(event) {
4566+  sendBridgeMessage("baa_page_sse", event?.detail || {});
4567+}
4568+
4569+function handlePageProxyResponse(event) {
4570+  sendBridgeMessage("baa_page_proxy_response", event?.detail || {});
4571+}
4572+
4573+function handleDiagnosticEvent(event) {
4574+  const detail = event?.detail && typeof event.detail === "object" ? event.detail : {};
4575+  sendDiagnosticLog(detail.event, detail);
4576+}
4577+
4578+function handleRuntimeMessage(message) {
4579+  if (!message || typeof message !== "object") {
4580+    return undefined;
4581+  }
4582+
4583+  if (message.type === "baa_page_proxy_request") {
4584+    window.dispatchEvent(new CustomEvent("__baa_proxy_request__", {
4585+      detail: JSON.stringify(message.data || {})
4586+    }));
4587+    return undefined;
4588+  }
4589+
4590+  if (message.type === "baa_page_proxy_cancel") {
4591+    window.dispatchEvent(new CustomEvent("__baa_proxy_cancel__", {
4592+      detail: JSON.stringify(message.data || {})
4593+    }));
4594+    return undefined;
4595+  }
4596+
4597+  return undefined;
4598+}
4599+
4600+function injectPageInterceptor() {
4601+  const root = document.documentElement;
4602+
4603+  if (!root) {
4604+    sendDiagnosticLog("page_interceptor_injection_failed", {
4605+      reason: "missing_document_root"
4606+    });
4607+    return;
4608+  }
4609+
4610+  const script = document.createElement("script");
4611+  script.src = browser.runtime.getURL("page-interceptor.js");
4612+  script.async = false;
4613+  script.dataset.baaSafari = "page-interceptor";
4614+  script.onload = () => {
4615+    script.remove();
4616+    sendDiagnosticLog("page_interceptor_script_loaded", {
4617+      source: "content-script"
4618+    });
4619+  };
4620+  script.onerror = () => {
4621+    sendDiagnosticLog("page_interceptor_script_error", {
4622+      source: "content-script"
4623+    });
4624+    script.remove();
4625+  };
4626+
4627+  (document.head || root).appendChild(script);
4628+}
4629+
4630+window.addEventListener("__baa_ready__", handlePageReady);
4631+window.addEventListener("__baa_net__", handlePageNetwork);
4632+window.addEventListener("__baa_sse__", handlePageSse);
4633+window.addEventListener("__baa_diagnostic__", handleDiagnosticEvent);
4634+window.addEventListener("__baa_proxy_response__", handlePageProxyResponse);
4635+browser.runtime.onMessage.addListener(handleRuntimeMessage);
4636+
4637+browser.runtime.sendMessage({
4638+  platform: detectPlatformFromLocation(),
4639+  type: "baa_safari.content_script_seen",
4640+  url: globalThis.location?.href || null
4641+}).catch(() => {});
4642+
4643+sendBridgeMessage("baa_page_bridge_ready", {
4644+  platform: detectPlatformFromLocation(),
4645+  source: "content-script",
4646+  url: globalThis.location?.href || null
4647+});
4648+sendDiagnosticLog("page_bridge_ready", {
4649+  source: "content-script"
4650+});
4651+
4652+const contentScriptRuntime = {
4653+  readyTimeout: setTimeout(() => {
4654+    sendDiagnosticLog("page_interceptor_timeout", {
4655+      reason: "no __baa_ready__ event received",
4656+      source: "content-script"
4657+    });
4658+  }, PAGE_READY_TIMEOUT_MS),
4659+  dispose() {
4660+    if (contentScriptRuntime.readyTimeout != null) {
4661+      clearTimeout(contentScriptRuntime.readyTimeout);
4662+      contentScriptRuntime.readyTimeout = null;
4663+    }
4664+
4665+    window.removeEventListener("__baa_ready__", handlePageReady);
4666+    window.removeEventListener("__baa_net__", handlePageNetwork);
4667+    window.removeEventListener("__baa_sse__", handlePageSse);
4668+    window.removeEventListener("__baa_diagnostic__", handleDiagnosticEvent);
4669+    window.removeEventListener("__baa_proxy_response__", handlePageProxyResponse);
4670+    browser.runtime.onMessage.removeListener(handleRuntimeMessage);
4671+
4672+    if (window[CONTENT_SCRIPT_RUNTIME_KEY] === contentScriptRuntime) {
4673+      try {
4674+        delete window[CONTENT_SCRIPT_RUNTIME_KEY];
4675+      } catch (_) {
4676+        window[CONTENT_SCRIPT_RUNTIME_KEY] = null;
4677+      }
4678+    }
4679+  }
4680+};
4681+
4682+window[CONTENT_SCRIPT_RUNTIME_KEY] = contentScriptRuntime;
4683+injectPageInterceptor();
4684diff --git a/plugins/baa-safari/controller.css b/plugins/baa-safari/controller.css
4685new file mode 100644
4686index 0000000000000000000000000000000000000000..60a18009fe48faec0fccc04b40d484cd770e6b66
4687--- /dev/null
4688+++ b/plugins/baa-safari/controller.css
4689@@ -0,0 +1,129 @@
4690+* {
4691+  box-sizing: border-box;
4692+}
4693+
4694+body {
4695+  margin: 0;
4696+  font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
4697+  background:
4698+    radial-gradient(circle at top left, rgba(21, 92, 159, 0.18), transparent 34%),
4699+    linear-gradient(180deg, #f4f8fc 0%, #eef4f8 100%);
4700+  color: #173247;
4701+}
4702+
4703+.shell {
4704+  max-width: 980px;
4705+  margin: 0 auto;
4706+  padding: 32px 20px 48px;
4707+}
4708+
4709+.hero,
4710+.actions,
4711+.grid,
4712+.panel {
4713+  margin-bottom: 20px;
4714+}
4715+
4716+.eyebrow {
4717+  margin: 0 0 8px;
4718+  font-size: 12px;
4719+  font-weight: 700;
4720+  letter-spacing: 0.08em;
4721+  text-transform: uppercase;
4722+  color: #245e8a;
4723+}
4724+
4725+h1,
4726+h2,
4727+p {
4728+  margin: 0;
4729+}
4730+
4731+.lead {
4732+  margin-top: 10px;
4733+  max-width: 720px;
4734+  line-height: 1.6;
4735+  color: #36566f;
4736+}
4737+
4738+.actions {
4739+  display: flex;
4740+  gap: 12px;
4741+}
4742+
4743+button {
4744+  border: 0;
4745+  border-radius: 999px;
4746+  padding: 11px 16px;
4747+  font: inherit;
4748+  font-weight: 600;
4749+  color: #fff;
4750+  background: #1f6f5f;
4751+  cursor: pointer;
4752+}
4753+
4754+button:last-child {
4755+  background: #245e8a;
4756+}
4757+
4758+.grid {
4759+  display: grid;
4760+  grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
4761+  gap: 14px;
4762+}
4763+
4764+.card,
4765+.panel {
4766+  border: 1px solid rgba(23, 50, 71, 0.08);
4767+  border-radius: 18px;
4768+  padding: 16px;
4769+  background: rgba(255, 255, 255, 0.84);
4770+  backdrop-filter: blur(12px);
4771+}
4772+
4773+.label {
4774+  font-size: 12px;
4775+  font-weight: 700;
4776+  letter-spacing: 0.06em;
4777+  text-transform: uppercase;
4778+  color: #6a879d;
4779+}
4780+
4781+.value {
4782+  margin-top: 10px;
4783+  font-size: 24px;
4784+  font-weight: 700;
4785+}
4786+
4787+.value.off {
4788+  color: #7d8d99;
4789+}
4790+
4791+.meta {
4792+  margin-top: 10px;
4793+  line-height: 1.5;
4794+  color: #4b6578;
4795+}
4796+
4797+.panel-head {
4798+  margin-bottom: 12px;
4799+}
4800+
4801+.code {
4802+  margin: 0;
4803+  overflow: auto;
4804+  white-space: pre-wrap;
4805+  font: 13px/1.6 "SFMono-Regular", ui-monospace, monospace;
4806+  color: #204157;
4807+}
4808+
4809+.steps {
4810+  margin: 0;
4811+  padding-left: 20px;
4812+  color: #36566f;
4813+  line-height: 1.7;
4814+}
4815+
4816+code {
4817+  font-family: "SFMono-Regular", ui-monospace, monospace;
4818+}
4819diff --git a/plugins/baa-safari/controller.html b/plugins/baa-safari/controller.html
4820new file mode 100644
4821index 0000000000000000000000000000000000000000..9f05f1fbcf1a5d129494c6373a97f253bed10d94
4822--- /dev/null
4823+++ b/plugins/baa-safari/controller.html
4824@@ -0,0 +1,133 @@
4825+<!doctype html>
4826+<html lang="zh-CN">
4827+<head>
4828+  <meta charset="utf-8">
4829+  <meta name="viewport" content="width=device-width,initial-scale=1">
4830+  <title>BAA Safari 控制页</title>
4831+  <link rel="stylesheet" href="controller.css">
4832+</head>
4833+<body>
4834+  <main class="shell">
4835+    <section class="hero">
4836+      <p class="eyebrow">BAA Safari 控制页</p>
4837+      <h1>Safari 页面桥接与 Final Message</h1>
4838+      <p class="lead">
4839+        这张卡收口页面注入、桥接事件、ChatGPT final-message relay、本地 WS 和权限诊断。
4840+        <code>controller.html</code> 负责显示 bridge 健康度,<code>background.js</code> 聚合页面、<code>webRequest</code> 元数据并上送 <code>browser.final_message</code>。
4841+      </p>
4842+    </section>
4843+
4844+    <section class="actions">
4845+      <button id="refresh-btn" type="button">刷新状态</button>
4846+      <button id="focus-btn" type="button">聚焦控制页</button>
4847+    </section>
4848+
4849+    <section class="grid">
4850+      <article class="card">
4851+        <p class="label">Runtime Owner</p>
4852+        <p id="runtime-owner" class="value">controller.html</p>
4853+        <p class="meta">长期运行时 owner 仍按 Safari Phase 1 合同保留在控制页。</p>
4854+      </article>
4855+      <article class="card">
4856+        <p class="label">Background</p>
4857+        <p id="background-status" class="value off">等待同步</p>
4858+        <p id="background-meta" class="meta">聚合页面桥接、权限提示和 metadata。</p>
4859+      </article>
4860+      <article class="card">
4861+        <p class="label">Content Script</p>
4862+        <p id="content-status" class="value off">暂无页面回报</p>
4863+        <p id="content-meta" class="meta">等待 AI 页面触发 content script 入口。</p>
4864+      </article>
4865+      <article class="card">
4866+        <p class="label">Page Bridge</p>
4867+        <p id="page-bridge-status" class="value off">未 ready</p>
4868+        <p id="page-bridge-meta" class="meta">等待页面主世界返回 <code>__baa_ready__</code>。</p>
4869+      </article>
4870+      <article class="card">
4871+        <p class="label">ChatGPT Metadata</p>
4872+        <p id="metadata-status" class="value off">未采集</p>
4873+        <p id="metadata-meta" class="meta">关注 <code>credential_fingerprint</code> 和 <code>endpoint_metadata</code>。</p>
4874+      </article>
4875+      <article class="card">
4876+        <p class="label">Local WS</p>
4877+        <p id="ws-status" class="value off">未连接</p>
4878+        <p id="ws-meta" class="meta">等待 Safari background 连接 <code>/ws/browser</code>。</p>
4879+      </article>
4880+      <article class="card">
4881+        <p class="label">Final Message</p>
4882+        <p id="final-message-status" class="value off">未转发</p>
4883+        <p id="final-message-meta" class="meta">关注 ChatGPT 回复完成后是否产出 <code>browser.final_message</code>。</p>
4884+      </article>
4885+      <article class="card">
4886+        <p class="label">Website Access</p>
4887+        <p id="hint-status" class="value off">待检查</p>
4888+        <p class="meta">如果这里持续告警,先去 Safari 扩展设置检查网站授权。</p>
4889+      </article>
4890+    </section>
4891+
4892+    <section class="panel">
4893+      <div class="panel-head">
4894+        <h2>职责边界</h2>
4895+      </div>
4896+      <pre id="contract-view" class="code"></pre>
4897+    </section>
4898+
4899+    <section class="panel">
4900+      <div class="panel-head">
4901+        <h2>当前支持范围</h2>
4902+      </div>
4903+      <pre id="support-view" class="code"></pre>
4904+    </section>
4905+
4906+    <section class="panel">
4907+      <div class="panel-head">
4908+        <h2>权限与注入提示</h2>
4909+      </div>
4910+      <pre id="hint-view" class="code"></pre>
4911+    </section>
4912+
4913+    <section class="panel">
4914+      <div class="panel-head">
4915+        <h2>最近诊断日志</h2>
4916+      </div>
4917+      <pre id="log-view" class="code"></pre>
4918+    </section>
4919+
4920+    <section class="panel">
4921+      <div class="panel-head">
4922+        <h2>ChatGPT Credential Snapshot</h2>
4923+      </div>
4924+      <pre id="credential-view" class="code"></pre>
4925+    </section>
4926+
4927+    <section class="panel">
4928+      <div class="panel-head">
4929+        <h2>ChatGPT Endpoint Metadata</h2>
4930+      </div>
4931+      <pre id="endpoint-view" class="code"></pre>
4932+    </section>
4933+
4934+    <section class="panel">
4935+      <div class="panel-head">
4936+        <h2>完整状态</h2>
4937+      </div>
4938+      <pre id="state-view" class="code"></pre>
4939+    </section>
4940+
4941+    <section class="panel">
4942+      <div class="panel-head">
4943+        <h2>本地启用步骤</h2>
4944+      </div>
4945+      <ol class="steps">
4946+        <li>先构建并启动 <code>BAASafariHost</code> 宿主 App。</li>
4947+        <li>在 Safari 设置里启用 BAA 扩展。</li>
4948+        <li>在 <code>Website Access</code> 里给 ChatGPT 授权;Claude / Gemini 先只做桥接占位。</li>
4949+        <li>打开 ChatGPT 页面,确认本页出现 Content Script 和 Page Bridge 状态。</li>
4950+        <li>如果一直没有页面回报,优先检查网站授权,再看诊断日志里的注入错误。</li>
4951+      </ol>
4952+    </section>
4953+  </main>
4954+  <script src="final-message.js"></script>
4955+  <script src="controller.js"></script>
4956+</body>
4957+</html>
4958diff --git a/plugins/baa-safari/controller.js b/plugins/baa-safari/controller.js
4959new file mode 100644
4960index 0000000000000000000000000000000000000000..9cba9b990c713be0ce2a276d0e3e9faac7666d1b
4961--- /dev/null
4962+++ b/plugins/baa-safari/controller.js
4963@@ -0,0 +1,275 @@
4964+const REFRESH_INTERVAL_MS = 2_000;
4965+
4966+const elements = {
4967+  backgroundMeta: document.getElementById("background-meta"),
4968+  backgroundStatus: document.getElementById("background-status"),
4969+  contentMeta: document.getElementById("content-meta"),
4970+  contentStatus: document.getElementById("content-status"),
4971+  contractView: document.getElementById("contract-view"),
4972+  credentialView: document.getElementById("credential-view"),
4973+  endpointView: document.getElementById("endpoint-view"),
4974+  finalMessageMeta: document.getElementById("final-message-meta"),
4975+  finalMessageStatus: document.getElementById("final-message-status"),
4976+  focusButton: document.getElementById("focus-btn"),
4977+  hintStatus: document.getElementById("hint-status"),
4978+  hintView: document.getElementById("hint-view"),
4979+  metadataMeta: document.getElementById("metadata-meta"),
4980+  metadataStatus: document.getElementById("metadata-status"),
4981+  pageBridgeMeta: document.getElementById("page-bridge-meta"),
4982+  pageBridgeStatus: document.getElementById("page-bridge-status"),
4983+  refreshButton: document.getElementById("refresh-btn"),
4984+  runtimeOwner: document.getElementById("runtime-owner"),
4985+  stateView: document.getElementById("state-view"),
4986+  supportView: document.getElementById("support-view"),
4987+  logView: document.getElementById("log-view"),
4988+  wsMeta: document.getElementById("ws-meta"),
4989+  wsStatus: document.getElementById("ws-status")
4990+};
4991+
4992+function formatTimestamp(value) {
4993+  return Number.isFinite(value) && value > 0
4994+    ? new Date(value).toLocaleString("zh-CN", { hour12: false })
4995+    : "未记录";
4996+}
4997+
4998+function stringify(value) {
4999+  return JSON.stringify(value, null, 2);
5000+}
5001+
5002+function setValue(node, text, active = true) {
5003+  if (!node) {
5004+    return;
5005+  }
5006+
5007+  node.textContent = text;
5008+  node.classList.toggle("off", !active);
5009+}
5010+
5011+function buildContractSnapshot() {
5012+  return {
5013+    background_js: "collects page bridge events, webRequest metadata, ChatGPT final-message relay, and browser WS state",
5014+    content_script_js: "bridges __baa_ready__/__baa_net__/__baa_sse__/__baa_proxy_response__ to runtime messages",
5015+    controller_html: "operator view for bridge health, metadata, and Safari website-access diagnostics",
5016+    final_message_js: "shared pure-JS relay helper copied from Firefox; background owns observer + dedupe cache",
5017+    page_interceptor_js: "shared Firefox-style fetch/XHR interceptor with __baa_proxy_request__ support",
5018+    runtime_owner: "controller.html",
5019+    supported_now: {
5020+      chatgpt: [
5021+        "page bridge ready",
5022+        "network + SSE event forwarding",
5023+        "credential_fingerprint",
5024+        "endpoint_metadata",
5025+        "account_hint",
5026+        "browser.final_message relay"
5027+      ],
5028+      claude: ["page bridge foundation only"],
5029+      gemini: ["page bridge foundation only"]
5030+    }
5031+  };
5032+}
5033+
5034+function readMostRecentPlatformReport(state, key) {
5035+  const platforms = Object.values(state?.platforms || {});
5036+  const reports = platforms
5037+    .map((platformState) => ({
5038+      platform: platformState.platform,
5039+      report: platformState[key]
5040+    }))
5041+    .filter((entry) => entry.report && Number.isFinite(entry.report.seenAt || entry.report.ts));
5042+
5043+  reports.sort((left, right) => {
5044+    const leftTime = left.report.seenAt || left.report.ts || 0;
5045+    const rightTime = right.report.seenAt || right.report.ts || 0;
5046+    return rightTime - leftTime;
5047+  });
5048+
5049+  return reports[0] || null;
5050+}
5051+
5052+function formatHintText(hints = []) {
5053+  if (!Array.isArray(hints) || hints.length === 0) {
5054+    return "未发现明显的网站权限或注入阻塞。";
5055+  }
5056+
5057+  return hints
5058+    .map((hint) => `- [${hint.platform}] ${hint.message}${hint.tabId != null ? ` (tab=${hint.tabId})` : ""}`)
5059+    .join("\n");
5060+}
5061+
5062+function formatLogText(logs = []) {
5063+  if (!Array.isArray(logs) || logs.length === 0) {
5064+    return "暂无桥接日志。";
5065+  }
5066+
5067+  return logs
5068+    .slice(0, 24)
5069+    .map((entry) => {
5070+      const timestamp = formatTimestamp(entry.timestamp);
5071+      const platform = entry?.detail?.platform ? ` [${entry.detail.platform}]` : "";
5072+      const source = entry?.detail?.source ? ` source=${entry.detail.source}` : "";
5073+      const url = entry?.detail?.url ? ` url=${entry.detail.url}` : "";
5074+      return `${timestamp} ${String(entry.level || "info").toUpperCase()} ${entry.event}${platform}${source}${url}`;
5075+    })
5076+    .join("\n");
5077+}
5078+
5079+function formatEndpointText(entries = []) {
5080+  if (!Array.isArray(entries) || entries.length === 0) {
5081+    return "还没有 ChatGPT endpoint metadata。";
5082+  }
5083+
5084+  return entries
5085+    .map((entry) =>
5086+      `${entry.method} ${entry.path}\n  first=${formatTimestamp(entry.first_seen_at)}\n  last=${formatTimestamp(entry.last_seen_at)}\n  source=${entry.source || "-"}\n  status=${entry.last_status ?? "-"}`
5087+    )
5088+    .join("\n\n");
5089+}
5090+
5091+function formatFinalMessageText(entry) {
5092+  if (!entry) {
5093+    return "还没有 ChatGPT final-message relay。";
5094+  }
5095+
5096+  return [
5097+    `assistant=${entry.assistant_message_id || "无"}`,
5098+    `conversation=${entry.conversation_id || "无"}`,
5099+    `source=${entry.source || "-"}`,
5100+    `page=${entry.page_url || "-"}`,
5101+    `seen=${formatTimestamp(entry.seenAt)}`,
5102+    "",
5103+    entry.raw_text || ""
5104+  ].join("\n");
5105+}
5106+
5107+async function readCurrentTabId() {
5108+  if (!browser?.tabs?.getCurrent) {
5109+    return null;
5110+  }
5111+
5112+  try {
5113+    const tab = await browser.tabs.getCurrent();
5114+    return Number.isInteger(tab?.id) ? tab.id : null;
5115+  } catch {
5116+    return null;
5117+  }
5118+}
5119+
5120+async function fetchState() {
5121+  return browser.runtime.sendMessage({
5122+    type: "baa_safari.get_state"
5123+  });
5124+}
5125+
5126+function renderState(state) {
5127+  const latestContent = readMostRecentPlatformReport(state, "lastContentScript");
5128+  const latestBridge = readMostRecentPlatformReport(state, "lastPageBridgeReady");
5129+  const chatgpt = state?.platforms?.chatgpt || null;
5130+  const credential = chatgpt?.credential || null;
5131+  const lastFinalMessage = chatgpt?.lastFinalMessage || null;
5132+  const metadataReady = Boolean(credential?.credential_fingerprint || (chatgpt?.endpoint_metadata || []).length > 0);
5133+  const ws = state?.ws || null;
5134+  const wsReady = ws?.connected === true || ws?.status === "connected";
5135+
5136+  elements.runtimeOwner.textContent = state.runtimeOwner;
5137+
5138+  setValue(
5139+    elements.backgroundStatus,
5140+    state.background?.startedAt == null ? "未启动" : "已启动",
5141+    state.background?.startedAt != null
5142+  );
5143+  elements.backgroundMeta.textContent =
5144+    `最近同步: ${formatTimestamp(state.lastUpdatedAt)}\ncontrollerTabId: ${state.controller?.tabId ?? "无"}`;
5145+
5146+  setValue(
5147+    elements.contentStatus,
5148+    latestContent == null ? "暂无页面回报" : latestContent.platform,
5149+    latestContent != null
5150+  );
5151+  elements.contentMeta.textContent =
5152+    latestContent == null
5153+      ? "等待 Claude / ChatGPT / Gemini 页面触发 content script。"
5154+      : `${latestContent.report.url || "unknown"}\n最近触达: ${formatTimestamp(latestContent.report.seenAt)}`;
5155+
5156+  setValue(
5157+    elements.pageBridgeStatus,
5158+    latestBridge == null ? "未 ready" : `${latestBridge.platform} / ${latestBridge.report.source || "page-interceptor"}`,
5159+    latestBridge != null
5160+  );
5161+  elements.pageBridgeMeta.textContent =
5162+    latestBridge == null
5163+      ? "还没有页面主世界返回 __baa_ready__ 或 content-script ready。"
5164+      : `${latestBridge.report.url || "unknown"}\n最近 ready: ${formatTimestamp(latestBridge.report.seenAt)}`;
5165+
5166+  setValue(
5167+    elements.metadataStatus,
5168+    metadataReady ? "ChatGPT 已采集" : "ChatGPT 未采集",
5169+    metadataReady
5170+  );
5171+  elements.metadataMeta.textContent =
5172+    credential == null
5173+      ? "当前没有 ChatGPT metadata state。"
5174+      : `credential_fingerprint: ${credential.credential_fingerprint || "无"}\nendpoint_count: ${credential.endpoint_count ?? 0}\naccount_hint: ${credential.account_hint || "无"}`;
5175+
5176+  setValue(
5177+    elements.wsStatus,
5178+    wsReady ? "已连接" : (ws?.status || "未连接"),
5179+    wsReady
5180+  );
5181+  elements.wsMeta.textContent =
5182+    ws == null
5183+      ? "当前没有本地 WS 状态。"
5184+      : `clientId: ${ws.clientId || "无"}\nwsUrl: ${ws.wsUrl || "无"}\nlastHelloAck: ${formatTimestamp(ws.lastHelloAckAt)}\npendingRelays: ${ws.pendingRelayCount ?? 0}`;
5185+
5186+  setValue(
5187+    elements.finalMessageStatus,
5188+    lastFinalMessage == null ? "未转发" : "已转发",
5189+    lastFinalMessage != null
5190+  );
5191+  elements.finalMessageMeta.textContent = formatFinalMessageText(lastFinalMessage);
5192+
5193+  setValue(
5194+    elements.hintStatus,
5195+    Array.isArray(state.permissionHints) && state.permissionHints.length > 0 ? "需要排查" : "无阻塞提示",
5196+    !(Array.isArray(state.permissionHints) && state.permissionHints.length > 0)
5197+  );
5198+
5199+  elements.contractView.textContent = stringify(buildContractSnapshot());
5200+  elements.supportView.textContent =
5201+    "当前验收下限:ChatGPT 页面桥接、browser.final_message relay、本地 WS 和脱敏登录态元数据。\nClaude / Gemini 只保留页面桥接基础,不宣称已支持 final-message 或 proxy_delivery。";
5202+  elements.hintView.textContent = formatHintText(state.permissionHints);
5203+  elements.logView.textContent = formatLogText(state.recentLogs);
5204+  elements.credentialView.textContent = stringify(credential || {});
5205+  elements.endpointView.textContent = formatEndpointText(chatgpt?.endpoint_metadata || []);
5206+  elements.stateView.textContent = stringify(state);
5207+}
5208+
5209+async function refreshState() {
5210+  const state = await fetchState();
5211+  renderState(state);
5212+}
5213+
5214+async function boot() {
5215+  const tabId = await readCurrentTabId();
5216+
5217+  await browser.runtime.sendMessage({
5218+    tabId,
5219+    type: "baa_safari.controller_ready"
5220+  });
5221+
5222+  await refreshState();
5223+
5224+  elements.refreshButton?.addEventListener("click", () => {
5225+    void refreshState();
5226+  });
5227+  elements.focusButton?.addEventListener("click", () => {
5228+    void browser.runtime.sendMessage({
5229+      type: "baa_safari.focus_controller"
5230+    });
5231+  });
5232+
5233+  globalThis.setInterval(() => {
5234+    void refreshState();
5235+  }, REFRESH_INTERVAL_MS);
5236+}
5237+
5238+void boot();
5239diff --git a/plugins/baa-safari/final-message.js b/plugins/baa-safari/final-message.js
5240new file mode 100644
5241index 0000000000000000000000000000000000000000..21676aba8cd73759817bfbe0e404cb499d6f1ea4
5242--- /dev/null
5243+++ b/plugins/baa-safari/final-message.js
5244@@ -0,0 +1,1119 @@
5245+(function initBaaFinalMessage(globalScope) {
5246+  const CHATGPT_TERMINAL_STATUSES = new Set([
5247+    "completed",
5248+    "finished",
5249+    "finished_successfully",
5250+    "incomplete",
5251+    "max_tokens",
5252+    "stopped"
5253+  ]);
5254+  const CLAUDE_SSE_PAYLOAD_TYPES = new Set([
5255+    "completion",
5256+    "content_block_delta",
5257+    "content_block_start",
5258+    "content_block_stop",
5259+    "message_delta",
5260+    "message_start",
5261+    "message_stop",
5262+    "thinking"
5263+  ]);
5264+  const RECENT_RELAY_LIMIT = 20;
5265+  const MAX_WALK_DEPTH = 8;
5266+  const MAX_WALK_NODES = 400;
5267+
5268+  function isRecord(value) {
5269+    return value !== null && typeof value === "object" && !Array.isArray(value);
5270+  }
5271+
5272+  function trimToNull(value) {
5273+    return typeof value === "string" && value.trim() ? value.trim() : null;
5274+  }
5275+
5276+  function parseJson(text) {
5277+    if (typeof text !== "string" || !text.trim()) return null;
5278+
5279+    try {
5280+      return JSON.parse(text);
5281+    } catch (_) {
5282+      return null;
5283+    }
5284+  }
5285+
5286+  function splitSseBlocks(text) {
5287+    return String(text || "")
5288+      .split(/\r?\n\r?\n+/u)
5289+      .filter((block) => block.trim());
5290+  }
5291+
5292+  function simpleHash(input) {
5293+    const text = String(input || "");
5294+    let hash = 2166136261;
5295+
5296+    for (let index = 0; index < text.length; index += 1) {
5297+      hash ^= text.charCodeAt(index);
5298+      hash = Math.imul(hash, 16777619);
5299+    }
5300+
5301+    return (hash >>> 0).toString(16).padStart(8, "0");
5302+  }
5303+
5304+  function normalizeUrlForSignature(url) {
5305+    const raw = trimToNull(url);
5306+    if (!raw) return "-";
5307+
5308+    try {
5309+      const parsed = new URL(raw, "https://platform.invalid/");
5310+      return `${parsed.origin}${parsed.pathname || "/"}${parsed.search || ""}`;
5311+    } catch (_) {
5312+      return raw;
5313+    }
5314+  }
5315+
5316+  function extractUrlPathname(url) {
5317+    const raw = trimToNull(url);
5318+    if (!raw) return "";
5319+
5320+    try {
5321+      return (new URL(raw, "https://platform.invalid/").pathname || "/").toLowerCase();
5322+    } catch (_) {
5323+      return raw.toLowerCase().split(/[?#]/u)[0] || "";
5324+    }
5325+  }
5326+
5327+  function normalizeMessageText(value) {
5328+    if (typeof value !== "string") return null;
5329+
5330+    const normalized = value
5331+      .replace(/\r\n?/gu, "\n")
5332+      .replace(/[ \t]+\n/gu, "\n")
5333+      .replace(/\u200b/gu, "")
5334+      .trim();
5335+
5336+    return normalized ? normalized : null;
5337+  }
5338+
5339+  function flattenTextFragments(value, depth = 0) {
5340+    if (depth > 5 || value == null) return [];
5341+
5342+    if (typeof value === "string") {
5343+      const text = normalizeMessageText(value);
5344+      return text ? [text] : [];
5345+    }
5346+
5347+    if (Array.isArray(value)) {
5348+      return value.flatMap((entry) => flattenTextFragments(entry, depth + 1));
5349+    }
5350+
5351+    if (!isRecord(value)) {
5352+      return [];
5353+    }
5354+
5355+    const out = [];
5356+    for (const key of ["text", "value", "content", "parts", "segments"]) {
5357+      if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
5358+      out.push(...flattenTextFragments(value[key], depth + 1));
5359+    }
5360+    return out;
5361+  }
5362+
5363+  function extractChatgptConversationIdFromUrl(url) {
5364+    const raw = trimToNull(url);
5365+    if (!raw) return null;
5366+
5367+    try {
5368+      const parsed = new URL(raw, "https://chatgpt.com/");
5369+      const pathname = parsed.pathname || "/";
5370+      const match = pathname.match(/\/c\/([^/?#]+)/u);
5371+      if (match?.[1]) return match[1];
5372+
5373+      return trimToNull(parsed.searchParams.get("conversation_id"));
5374+    } catch (_) {
5375+      return null;
5376+    }
5377+  }
5378+
5379+  function extractGeminiConversationIdFromUrl(url) {
5380+    const raw = trimToNull(url);
5381+    if (!raw) return null;
5382+
5383+    try {
5384+      const parsed = new URL(raw, "https://gemini.google.com/");
5385+      const pathname = parsed.pathname || "/";
5386+      const match = pathname.match(/\/app\/([^/?#]+)/u);
5387+      if (match?.[1]) return match[1];
5388+
5389+      return trimToNull(parsed.searchParams.get("conversation_id"));
5390+    } catch (_) {
5391+      return null;
5392+    }
5393+  }
5394+
5395+  function extractClaudeConversationIdFromUrl(url) {
5396+    const raw = trimToNull(url);
5397+    if (!raw) return null;
5398+
5399+    try {
5400+      const parsed = new URL(raw, "https://claude.ai/");
5401+      const pathname = parsed.pathname || "/";
5402+      const completionMatch = pathname.match(/\/chat_conversations\/([^/?#]+)\/completion(?:\/)?$/iu);
5403+      if (completionMatch?.[1]) return completionMatch[1];
5404+
5405+      const pageMatch = pathname.match(/\/chats?\/([^/?#]+)/iu);
5406+      if (pageMatch?.[1]) return pageMatch[1];
5407+
5408+      return trimToNull(parsed.searchParams.get("conversation_id"))
5409+        || trimToNull(parsed.searchParams.get("conversationId"));
5410+    } catch (_) {
5411+      return null;
5412+    }
5413+  }
5414+
5415+  function extractChatgptConversationIdFromReqBody(reqBody) {
5416+    const parsed = parseJson(reqBody);
5417+    if (!isRecord(parsed)) return null;
5418+    return trimToNull(parsed.conversation_id) || trimToNull(parsed.conversationId) || null;
5419+  }
5420+
5421+  function extractGeminiPromptFromReqBody(reqBody) {
5422+    if (typeof reqBody !== "string" || !reqBody) return null;
5423+
5424+    try {
5425+      const params = new URLSearchParams(reqBody);
5426+      const outerPayload = params.get("f.req");
5427+      if (!outerPayload) return null;
5428+
5429+      const outer = JSON.parse(outerPayload);
5430+      if (!Array.isArray(outer) || typeof outer[1] !== "string") return null;
5431+
5432+      const inner = JSON.parse(outer[1]);
5433+      if (!Array.isArray(inner) || !Array.isArray(inner[0])) return null;
5434+
5435+      return trimToNull(inner[0][0]);
5436+    } catch (_) {
5437+      return null;
5438+    }
5439+  }
5440+
5441+  function parseSseChunkPayload(chunk) {
5442+    const source = String(chunk || "");
5443+    const dataLines = source
5444+      .split(/\r?\n/u)
5445+      .filter((line) => line.startsWith("data:"))
5446+      .map((line) => line.slice(5).trimStart());
5447+    const payloadText = dataLines.join("\n").trim();
5448+
5449+    if (!payloadText || payloadText === "[DONE]") {
5450+      return null;
5451+    }
5452+
5453+    return parseJson(payloadText) || parseJson(source.trim()) || null;
5454+  }
5455+
5456+  function parseSseChunkEvent(chunk) {
5457+    for (const rawLine of String(chunk || "").split(/\r?\n/u)) {
5458+      const line = rawLine.trim();
5459+      if (!line.startsWith("event:")) continue;
5460+      return trimToNull(line.slice(6));
5461+    }
5462+
5463+    return null;
5464+  }
5465+
5466+  function extractChatgptMessageText(message) {
5467+    if (!isRecord(message)) return null;
5468+
5469+    if (Array.isArray(message.content?.parts)) {
5470+      const parts = message.content.parts
5471+        .flatMap((entry) => flattenTextFragments(entry))
5472+        .filter(Boolean);
5473+      return normalizeMessageText(parts.join("\n"));
5474+    }
5475+
5476+    if (typeof message.content?.text === "string") {
5477+      return normalizeMessageText(message.content.text);
5478+    }
5479+
5480+    if (typeof message.text === "string") {
5481+      return normalizeMessageText(message.text);
5482+    }
5483+
5484+    const fragments = flattenTextFragments(message.content);
5485+    return normalizeMessageText(fragments.join("\n"));
5486+  }
5487+
5488+  function normalizeCandidatePath(path) {
5489+    return String(path || "").replace(/^\./u, "");
5490+  }
5491+
5492+  function isHistoricalChatgptPath(path) {
5493+    return /(?:^|\.)(?:history|items|linear_conversation|mapping|message_map|message_nodes|messages|nodes|entries|turns)(?:$|[.[\]])/iu
5494+      .test(normalizeCandidatePath(path));
5495+  }
5496+
5497+  function scoreChatgptCandidatePath(path) {
5498+    const normalizedPath = normalizeCandidatePath(path);
5499+    if (!normalizedPath) {
5500+      return 0;
5501+    }
5502+
5503+    let score = 0;
5504+
5505+    if (normalizedPath === "message") {
5506+      score += 280;
5507+    }
5508+
5509+    if (normalizedPath.endsWith(".message")) {
5510+      score += 140;
5511+    }
5512+
5513+    if (/(?:^|\.)(?:assistant|completion|current|message|output|response)(?:$|[.[\]])/iu.test(normalizedPath)) {
5514+      score += isHistoricalChatgptPath(normalizedPath) ? 0 : 120;
5515+    }
5516+
5517+    if (isHistoricalChatgptPath(normalizedPath)) {
5518+      score -= 260;
5519+    }
5520+
5521+    return score;
5522+  }
5523+
5524+  function buildChatgptCandidate(message, envelope, path, context) {
5525+    if (!isRecord(message)) return null;
5526+
5527+    const role = trimToNull(message.author?.role) || trimToNull(message.role);
5528+    if (role !== "assistant") {
5529+      return null;
5530+    }
5531+
5532+    const rawText = extractChatgptMessageText(message);
5533+    if (!rawText) {
5534+      return null;
5535+    }
5536+
5537+    const status = trimToNull(message.status)?.toLowerCase() || null;
5538+    const metadata = isRecord(message.metadata) ? message.metadata : {};
5539+    const terminal = Boolean(
5540+      (status && CHATGPT_TERMINAL_STATUSES.has(status))
5541+      || message.end_turn === true
5542+      || metadata.is_complete === true
5543+      || trimToNull(metadata.finish_details?.type)
5544+      || trimToNull(metadata.finish_details?.stop)
5545+    );
5546+    const conversationId =
5547+      trimToNull(envelope?.conversation_id)
5548+      || trimToNull(envelope?.conversationId)
5549+      || trimToNull(message.conversation_id)
5550+      || trimToNull(message.conversationId)
5551+      || extractChatgptConversationIdFromReqBody(context.reqBody)
5552+      || extractChatgptConversationIdFromUrl(context.pageUrl || context.url)
5553+      || null;
5554+    const assistantMessageId =
5555+      trimToNull(message.id)
5556+      || trimToNull(message.message_id)
5557+      || trimToNull(message.messageId)
5558+      || trimToNull(envelope?.message_id)
5559+      || trimToNull(envelope?.messageId)
5560+      || null;
5561+
5562+    let score = rawText.length + scoreChatgptCandidatePath(path);
5563+    if (assistantMessageId) score += 120;
5564+    if (conversationId) score += 80;
5565+    if (terminal) score += 160;
5566+    if ((path || "").includes(".message")) score += 40;
5567+
5568+    return {
5569+      assistantMessageId,
5570+      conversationId,
5571+      path: normalizeCandidatePath(path),
5572+      rawText,
5573+      score
5574+    };
5575+  }
5576+
5577+  function collectChatgptCandidates(root, context) {
5578+    const candidates = [];
5579+    const queue = [{ value: root, path: "", depth: 0 }];
5580+    let walked = 0;
5581+
5582+    while (queue.length > 0 && walked < MAX_WALK_NODES) {
5583+      const current = queue.shift();
5584+      walked += 1;
5585+      if (!current) continue;
5586+
5587+      const { depth, path, value } = current;
5588+      if (depth > MAX_WALK_DEPTH || value == null) {
5589+        continue;
5590+      }
5591+
5592+      if (Array.isArray(value)) {
5593+        for (let index = 0; index < value.length && index < 32; index += 1) {
5594+          queue.push({
5595+            value: value[index],
5596+            path: `${path}[${index}]`,
5597+            depth: depth + 1
5598+          });
5599+        }
5600+        continue;
5601+      }
5602+
5603+      if (!isRecord(value)) {
5604+        continue;
5605+      }
5606+
5607+      const directCandidate = buildChatgptCandidate(value, value, path, context);
5608+      if (directCandidate) {
5609+        candidates.push(directCandidate);
5610+      }
5611+
5612+      if (isRecord(value.message)) {
5613+        const wrappedCandidate = buildChatgptCandidate(value.message, value, `${path}.message`, context);
5614+        if (wrappedCandidate) {
5615+          candidates.push(wrappedCandidate);
5616+        }
5617+      }
5618+
5619+      for (const [key, child] of Object.entries(value).slice(0, 40)) {
5620+        queue.push({
5621+          value: child,
5622+          path: path ? `${path}.${key}` : key,
5623+          depth: depth + 1
5624+        });
5625+      }
5626+    }
5627+
5628+    return candidates.sort((left, right) => compareCandidates(left, right))[0] || null;
5629+  }
5630+
5631+  function compareCandidates(current, next) {
5632+    const currentScore = Number(current?.score) || 0;
5633+    const nextScore = Number(next?.score) || 0;
5634+    if (nextScore !== currentScore) {
5635+      return nextScore - currentScore;
5636+    }
5637+
5638+    const currentTextLength = (current?.rawText || "").length;
5639+    const nextTextLength = (next?.rawText || "").length;
5640+    if (nextTextLength !== currentTextLength) {
5641+      return nextTextLength - currentTextLength;
5642+    }
5643+
5644+    const currentAssistant = trimToNull(current?.assistantMessageId) ? 1 : 0;
5645+    const nextAssistant = trimToNull(next?.assistantMessageId) ? 1 : 0;
5646+    if (nextAssistant !== currentAssistant) {
5647+      return nextAssistant - currentAssistant;
5648+    }
5649+
5650+    const currentConversation = trimToNull(current?.conversationId) ? 1 : 0;
5651+    const nextConversation = trimToNull(next?.conversationId) ? 1 : 0;
5652+    return nextConversation - currentConversation;
5653+  }
5654+
5655+  function pickPreferredCandidate(current, next) {
5656+    if (!current) return next ? { ...next } : null;
5657+    if (!next) return { ...current };
5658+    return compareCandidates(current, next) > 0 ? { ...next } : { ...current };
5659+  }
5660+
5661+  function candidateTextsOverlap(current, next) {
5662+    const currentText = normalizeMessageText(current?.rawText);
5663+    const nextText = normalizeMessageText(next?.rawText);
5664+    if (!currentText || !nextText) {
5665+      return false;
5666+    }
5667+
5668+    return currentText === nextText || currentText.includes(nextText) || nextText.includes(currentText);
5669+  }
5670+
5671+  function shouldMergeCandidatePair(current, next) {
5672+    if (!current || !next) {
5673+      return true;
5674+    }
5675+
5676+    const currentAssistant = trimToNull(current?.assistantMessageId);
5677+    const nextAssistant = trimToNull(next?.assistantMessageId);
5678+    if (currentAssistant && nextAssistant) {
5679+      return currentAssistant === nextAssistant;
5680+    }
5681+
5682+    const currentConversation = trimToNull(current?.conversationId);
5683+    const nextConversation = trimToNull(next?.conversationId);
5684+    if (currentConversation && nextConversation && currentConversation !== nextConversation) {
5685+      return false;
5686+    }
5687+
5688+    if (candidateTextsOverlap(current, next)) {
5689+      return true;
5690+    }
5691+
5692+    if (!current?.rawText || !next?.rawText) {
5693+      return Boolean(currentAssistant || nextAssistant || (currentConversation && nextConversation));
5694+    }
5695+
5696+    return false;
5697+  }
5698+
5699+  function mergeCandidates(current, next) {
5700+    if (!next) return current;
5701+    if (!current) return { ...next };
5702+
5703+    if (!shouldMergeCandidatePair(current, next)) {
5704+      return pickPreferredCandidate(current, next);
5705+    }
5706+
5707+    return {
5708+      assistantMessageId: next.assistantMessageId || current.assistantMessageId || null,
5709+      conversationId: next.conversationId || current.conversationId || null,
5710+      path: next.path || current.path || "",
5711+      rawText:
5712+        (next.rawText && next.rawText.length >= (current.rawText || "").length)
5713+          ? next.rawText
5714+          : current.rawText,
5715+      score: Math.max(Number(current.score) || 0, Number(next.score) || 0)
5716+    };
5717+  }
5718+
5719+  function extractChatgptCandidateFromChunk(chunk, context) {
5720+    const payload = parseSseChunkPayload(chunk);
5721+    if (!payload) return null;
5722+    return collectChatgptCandidates(payload, context);
5723+  }
5724+
5725+  function extractChatgptCandidateFromText(text, context) {
5726+    const parsed = parseJson(text);
5727+    if (parsed != null) {
5728+      return collectChatgptCandidates(parsed, context);
5729+    }
5730+
5731+    let merged = null;
5732+    for (const block of splitSseBlocks(text)) {
5733+      merged = mergeCandidates(merged, extractChatgptCandidateFromChunk(block, context));
5734+    }
5735+    return merged;
5736+  }
5737+
5738+  function looksLikeUrl(text) {
5739+    return /^https?:\/\//iu.test(text);
5740+  }
5741+
5742+  function looksIdLike(text) {
5743+    if (!text || /\s/u.test(text) || looksLikeUrl(text)) return false;
5744+    return /^[A-Za-z0-9:_./-]{6,120}$/u.test(text);
5745+  }
5746+
5747+  function looksOpaqueGeminiTextToken(text) {
5748+    if (!text || /\s/u.test(text) || looksLikeUrl(text)) return false;
5749+
5750+    const normalized = String(text || "").trim();
5751+    if (!normalized) return false;
5752+
5753+    if (/^[0-9a-f]{8,}$/iu.test(normalized)) return true;
5754+    if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(normalized)) {
5755+      return true;
5756+    }
5757+    if (/^(?:msg|req|resp|conv|conversation|assistant|candidate|turn|chatcmpl|chatcompl|cmpl|id)[-_:][A-Za-z0-9:_-]{4,}$/iu.test(normalized)) {
5758+      return true;
5759+    }
5760+
5761+    const letters = (normalized.match(/[A-Za-z]/gu) || []).length;
5762+    const digits = (normalized.match(/[0-9]/gu) || []).length;
5763+    const separators = (normalized.match(/[-_:./]/gu) || []).length;
5764+
5765+    if (letters === 0 && digits >= 6) return true;
5766+    if (/^[A-Za-z0-9:_./-]{16,}$/u.test(normalized) && !/[aeiou]/iu.test(normalized) && digits > 0) {
5767+      return true;
5768+    }
5769+    if (digits >= 6 && separators >= 2 && letters <= 6) return true;
5770+
5771+    return false;
5772+  }
5773+
5774+  function looksLikePromptEcho(text, prompt) {
5775+    const normalizedText = normalizeMessageText(text);
5776+    const normalizedPrompt = normalizeMessageText(prompt);
5777+    if (!normalizedText || !normalizedPrompt) return false;
5778+    if (normalizedText === normalizedPrompt) return true;
5779+
5780+    const wordCount = normalizedText.split(/\s+/u).filter(Boolean).length;
5781+    return wordCount >= 4 && normalizedText.length >= 32 && normalizedPrompt.includes(normalizedText);
5782+  }
5783+
5784+  function looksLikeGeminiProtocolFragment(text) {
5785+    const normalized = normalizeMessageText(text);
5786+    if (!normalized || !/^(?:\[|\{)/u.test(normalized)) return false;
5787+
5788+    const bracketCount = (normalized.match(/[\[\]\{\}]/gu) || []).length;
5789+    const digitCount = (normalized.match(/[0-9]/gu) || []).length;
5790+    const alphaCount = (normalized.match(/[A-Za-z]/gu) || []).length;
5791+
5792+    if (/\b(?:wrb\.fr|generic|af\.httprm|di)\b/iu.test(normalized)) return true;
5793+    if (bracketCount >= 6 && digitCount >= 4 && alphaCount <= Math.max(12, Math.floor(normalized.length / 6))) {
5794+      return true;
5795+    }
5796+
5797+    return false;
5798+  }
5799+
5800+  function readGeminiLineRoots(text) {
5801+    const roots = [];
5802+    const source = String(text || "")
5803+      .replace(/^\)\]\}'\s*/u, "")
5804+      .trim();
5805+
5806+    if (!source) {
5807+      return roots;
5808+    }
5809+
5810+    const wholePayload = parseJson(source);
5811+    if (wholePayload != null) {
5812+      roots.push(wholePayload);
5813+    }
5814+
5815+    for (const rawLine of source.split(/\r?\n/u)) {
5816+      const line = rawLine.trim();
5817+      if (!line || /^\d+$/u.test(line)) continue;
5818+
5819+      const parsedLine = parseJson(line);
5820+      if (parsedLine != null) {
5821+        roots.push(parsedLine);
5822+      }
5823+    }
5824+
5825+    return roots;
5826+  }
5827+
5828+  function collectGeminiIdCandidate(target, kind, value) {
5829+    const normalized = trimToNull(value);
5830+    if (!normalized || !looksIdLike(normalized)) return;
5831+    target.push({
5832+      kind,
5833+      score: kind === "message" ? 120 : 100,
5834+      value: normalized
5835+    });
5836+  }
5837+
5838+  function scoreGeminiTextCandidate(text, path, prompt) {
5839+    if (!text) return -1;
5840+
5841+    const normalizedText = normalizeMessageText(text);
5842+    if (!normalizedText) return -1;
5843+
5844+    if (looksLikePromptEcho(normalizedText, prompt)) return -1;
5845+    if (looksLikeUrl(normalizedText)) return -1;
5846+    if (looksOpaqueGeminiTextToken(normalizedText)) return -1;
5847+    if (looksLikeGeminiProtocolFragment(normalizedText)) return -1;
5848+    if (/^(wrb\.fr|generic|di|af\.httprm)$/iu.test(normalizedText)) return -1;
5849+    if (/^[\[\]{}",:0-9.\s_-]+$/u.test(normalizedText)) return -1;
5850+
5851+    const lowerPath = String(path || "").toLowerCase();
5852+    let score = Math.min(normalizedText.length, 220);
5853+
5854+    if (/\s/u.test(normalizedText)) score += 60;
5855+    if (/[A-Za-z\u4e00-\u9fff]/u.test(normalizedText)) score += 50;
5856+    if (/\n/u.test(normalizedText)) score += 30;
5857+    if (/(text|content|message|response|answer|markdown|candidate)/u.test(lowerPath)) score += 80;
5858+    if (/(prompt|query|request|input|user)/u.test(lowerPath)) score -= 90;
5859+    if (/(url|image|token|safety|metadata|source)/u.test(lowerPath)) score -= 40;
5860+
5861+    return score;
5862+  }
5863+
5864+  function maybeParseNestedJson(text) {
5865+    const normalized = trimToNull(text);
5866+    if (!normalized || normalized.length > 200_000) return null;
5867+    if (!/^(?:\[|\{)/u.test(normalized)) return null;
5868+    return parseJson(normalized);
5869+  }
5870+
5871+  function walkGeminiValue(value, context, bucket, path = "", depth = 0, state = { walked: 0 }) {
5872+    if (depth > MAX_WALK_DEPTH || state.walked >= MAX_WALK_NODES || value == null) {
5873+      return;
5874+    }
5875+
5876+    state.walked += 1;
5877+
5878+    if (typeof value === "string") {
5879+      const nested = maybeParseNestedJson(value);
5880+      if (nested != null) {
5881+        walkGeminiValue(nested, context, bucket, `${path}.$json`, depth + 1, state);
5882+      }
5883+
5884+      const normalized = normalizeMessageText(value);
5885+      if (!normalized) return;
5886+      if (nested != null && /^(?:\[|\{)/u.test(normalized)) return;
5887+
5888+      const score = scoreGeminiTextCandidate(normalized, path, context.prompt);
5889+      if (score > 0) {
5890+        bucket.texts.push({
5891+          path,
5892+          score,
5893+          text: normalized
5894+        });
5895+      }
5896+      return;
5897+    }
5898+
5899+    if (Array.isArray(value)) {
5900+      for (let index = 0; index < value.length && index < 40; index += 1) {
5901+        walkGeminiValue(value[index], context, bucket, `${path}[${index}]`, depth + 1, state);
5902+      }
5903+      return;
5904+    }
5905+
5906+    if (!isRecord(value)) {
5907+      return;
5908+    }
5909+
5910+    for (const [key, child] of Object.entries(value).slice(0, 40)) {
5911+      const nextPath = path ? `${path}.${key}` : key;
5912+      const lowerKey = key.toLowerCase();
5913+
5914+      if (typeof child === "string") {
5915+        if (/(conversation|conv|chat).*id/u.test(lowerKey)) {
5916+          collectGeminiIdCandidate(bucket.conversationIds, "conversation", child);
5917+        } else if (/(message|response|candidate|turn).*id/u.test(lowerKey)) {
5918+          collectGeminiIdCandidate(bucket.messageIds, "message", child);
5919+        } else if (lowerKey === "id") {
5920+          collectGeminiIdCandidate(bucket.genericIds, "generic", child);
5921+        }
5922+      }
5923+
5924+      walkGeminiValue(child, context, bucket, nextPath, depth + 1, state);
5925+    }
5926+  }
5927+
5928+  function pickBestId(primary, secondary = []) {
5929+    const source = [...primary, ...secondary]
5930+      .sort((left, right) => right.score - left.score)
5931+      .map((entry) => entry.value);
5932+
5933+    return trimToNull(source[0]) || null;
5934+  }
5935+
5936+  function extractGeminiCandidateFromText(text, context) {
5937+    const roots = readGeminiLineRoots(text);
5938+    const bucket = {
5939+      conversationIds: [],
5940+      genericIds: [],
5941+      messageIds: [],
5942+      texts: []
5943+    };
5944+    const geminiContext = {
5945+      pageUrl: context.pageUrl || context.url || "",
5946+      prompt: extractGeminiPromptFromReqBody(context.reqBody)
5947+    };
5948+
5949+    if (roots.length > 0) {
5950+      for (const root of roots) {
5951+        walkGeminiValue(root, geminiContext, bucket);
5952+      }
5953+    } else {
5954+      walkGeminiValue(String(text || ""), geminiContext, bucket);
5955+    }
5956+
5957+    const bestText = bucket.texts.sort((left, right) =>
5958+      (right.score - left.score) || (right.text.length - left.text.length)
5959+    )[0] || null;
5960+
5961+    if (!bestText?.text) {
5962+      return null;
5963+    }
5964+
5965+    return {
5966+      assistantMessageId: pickBestId(bucket.messageIds, bucket.genericIds),
5967+      conversationId: extractGeminiConversationIdFromUrl(geminiContext.pageUrl) || pickBestId(bucket.conversationIds),
5968+      rawText: bestText.text,
5969+      score: bestText.score
5970+    };
5971+  }
5972+
5973+  function parseClaudeSsePayload(chunk) {
5974+    const payload = parseSseChunkPayload(chunk);
5975+    if (!isRecord(payload)) return null;
5976+
5977+    const payloadType = trimToNull(payload.type)?.toLowerCase() || null;
5978+    const eventType = trimToNull(parseSseChunkEvent(chunk))?.toLowerCase() || null;
5979+    if (!payloadType && eventType !== "completion") {
5980+      return null;
5981+    }
5982+
5983+    if (payloadType && !CLAUDE_SSE_PAYLOAD_TYPES.has(payloadType) && eventType !== "completion") {
5984+      return null;
5985+    }
5986+
5987+    return {
5988+      eventType,
5989+      payload,
5990+      payloadType
5991+    };
5992+  }
5993+
5994+  function extractClaudeAssistantMessageId(payload) {
5995+    return trimToNull(payload?.id)
5996+      || trimToNull(payload?.uuid)
5997+      || trimToNull(payload?.message_id)
5998+      || trimToNull(payload?.messageId)
5999+      || trimToNull(payload?.message?.uuid)
6000+      || trimToNull(payload?.message?.id)
6001+      || trimToNull(payload?.message?.message_id)
6002+      || trimToNull(payload?.message?.messageId)
6003+      || null;
6004+  }
6005+
6006+  function extractClaudeConversationId(payload, context) {
6007+    return trimToNull(payload?.conversation_id)
6008+      || trimToNull(payload?.conversationId)
6009+      || trimToNull(payload?.conversation_uuid)
6010+      || trimToNull(payload?.conversationUuid)
6011+      || trimToNull(payload?.conversation?.id)
6012+      || trimToNull(payload?.conversation?.uuid)
6013+      || trimToNull(payload?.message?.conversation_id)
6014+      || trimToNull(payload?.message?.conversationId)
6015+      || trimToNull(payload?.message?.conversation_uuid)
6016+      || trimToNull(payload?.message?.conversationUuid)
6017+      || trimToNull(payload?.message?.conversation?.id)
6018+      || trimToNull(payload?.message?.conversation?.uuid)
6019+      || extractClaudeConversationIdFromUrl(context.url)
6020+      || extractClaudeConversationIdFromUrl(context.pageUrl)
6021+      || null;
6022+  }
6023+
6024+  function extractClaudeTextFragment(parsedChunk) {
6025+    if (!parsedChunk?.payload || !isRecord(parsedChunk.payload)) {
6026+      return null;
6027+    }
6028+
6029+    if (parsedChunk.payloadType === "completion" || (!parsedChunk.payloadType && parsedChunk.eventType === "completion")) {
6030+      if (typeof parsedChunk.payload.completion !== "string") {
6031+        return null;
6032+      }
6033+
6034+      return {
6035+        kind: "completion",
6036+        text: parsedChunk.payload.completion
6037+      };
6038+    }
6039+
6040+    if (parsedChunk.payloadType === "content_block_delta") {
6041+      const deltaType = trimToNull(parsedChunk.payload.delta?.type)?.toLowerCase() || null;
6042+      if (deltaType !== "text_delta" || typeof parsedChunk.payload.delta.text !== "string") {
6043+        return null;
6044+      }
6045+
6046+      return {
6047+        kind: "text_delta",
6048+        text: parsedChunk.payload.delta.text
6049+      };
6050+    }
6051+
6052+    return null;
6053+  }
6054+
6055+  function buildClaudeCandidate(rawText, payload, context) {
6056+    const assistantMessageId = extractClaudeAssistantMessageId(payload);
6057+    const conversationId = extractClaudeConversationId(payload, context);
6058+    const normalizedRawText = typeof rawText === "string" && rawText.length > 0 ? rawText : null;
6059+
6060+    if (!normalizedRawText && !assistantMessageId && !conversationId) {
6061+      return null;
6062+    }
6063+
6064+    let score = normalizedRawText ? normalizedRawText.length : 0;
6065+    if (assistantMessageId) score += 120;
6066+    if (conversationId) score += 80;
6067+
6068+    return {
6069+      assistantMessageId,
6070+      conversationId,
6071+      rawText: normalizedRawText,
6072+      score
6073+    };
6074+  }
6075+
6076+  function extractClaudeMetadataFromText(text, context) {
6077+    let merged = null;
6078+
6079+    for (const block of splitSseBlocks(text)) {
6080+      const parsedChunk = parseClaudeSsePayload(block);
6081+      if (!parsedChunk) continue;
6082+      merged = mergeCandidates(merged, buildClaudeCandidate(null, parsedChunk.payload, context));
6083+    }
6084+
6085+    return merged;
6086+  }
6087+
6088+  function extractClaudeCandidateFromText(text, context) {
6089+    let completionText = "";
6090+    let deltaText = "";
6091+    let metadata = null;
6092+    let matched = false;
6093+
6094+    for (const block of splitSseBlocks(text)) {
6095+      const parsedChunk = parseClaudeSsePayload(block);
6096+      if (!parsedChunk) continue;
6097+
6098+      matched = true;
6099+      metadata = mergeCandidates(metadata, buildClaudeCandidate(null, parsedChunk.payload, context));
6100+
6101+      const fragment = extractClaudeTextFragment(parsedChunk);
6102+      if (!fragment) {
6103+        continue;
6104+      }
6105+
6106+      if (fragment.kind === "completion") {
6107+        completionText += fragment.text;
6108+      } else {
6109+        deltaText += fragment.text;
6110+      }
6111+    }
6112+
6113+    if (!matched) {
6114+      return null;
6115+    }
6116+
6117+    const preferredText = normalizeMessageText(completionText) || normalizeMessageText(deltaText) || null;
6118+    return buildClaudeCandidate(
6119+      preferredText,
6120+      {
6121+        conversation_id: metadata?.conversationId,
6122+        id: metadata?.assistantMessageId
6123+      },
6124+      context
6125+    ) || metadata;
6126+  }
6127+
6128+  function createRelayState(platform) {
6129+    return {
6130+      activeStream: null,
6131+      platform,
6132+      recentRelayKeys: []
6133+    };
6134+  }
6135+
6136+  function isRelevantStreamUrl(platform, url) {
6137+    const lower = String(url || "").toLowerCase();
6138+    const pathname = extractUrlPathname(url);
6139+
6140+    if (platform === "chatgpt") {
6141+      return /^\/conversation\/?$/iu.test(pathname)
6142+        || /^\/(?:backend-api|backend-anon|public-api)\/conversation\/?$/iu.test(pathname)
6143+        || /^\/(?:backend-api|backend-anon|public-api)\/f\/conversation\/?$/iu.test(pathname);
6144+    }
6145+
6146+    if (platform === "claude") {
6147+      return lower.includes("/completion");
6148+    }
6149+
6150+    if (platform === "gemini") {
6151+      return lower.includes("streamgenerate")
6152+        || lower.includes("generatecontent")
6153+        || lower.includes("modelresponse")
6154+        || lower.includes("bardchatui");
6155+    }
6156+
6157+    return false;
6158+  }
6159+
6160+  function buildStreamSignature(state, detail, meta) {
6161+    return [
6162+      state.platform,
6163+      normalizeUrlForSignature(detail.url),
6164+      simpleHash(detail.reqBody || ""),
6165+      normalizeUrlForSignature(meta.pageUrl || "")
6166+    ].join("|");
6167+  }
6168+
6169+  function ensureActiveStream(state, detail, meta) {
6170+    const signature = buildStreamSignature(state, detail, meta);
6171+
6172+    if (!state.activeStream || state.activeStream.signature !== signature) {
6173+      state.activeStream = {
6174+        chunks: [],
6175+        latestCandidate: null,
6176+        pageUrl: meta.pageUrl || "",
6177+        reqBody: detail.reqBody || "",
6178+        signature,
6179+        url: detail.url || ""
6180+      };
6181+    }
6182+
6183+    return state.activeStream;
6184+  }
6185+
6186+  function extractSseCandidateFromText(platform, text, context) {
6187+    if (platform === "chatgpt") {
6188+      return extractChatgptCandidateFromText(text, context);
6189+    }
6190+
6191+    if (platform === "claude") {
6192+      return extractClaudeCandidateFromText(text, context);
6193+    }
6194+
6195+    return extractGeminiCandidateFromText(text, context);
6196+  }
6197+
6198+  function finalizeObservedSseRelay(state, stream, meta = {}) {
6199+    if (!state || !stream) {
6200+      return null;
6201+    }
6202+
6203+    const context = {
6204+      pageUrl: stream.pageUrl,
6205+      reqBody: stream.reqBody,
6206+      url: stream.url
6207+    };
6208+    const finalCandidate = extractSseCandidateFromText(
6209+      state.platform,
6210+      stream.chunks.join("\n\n"),
6211+      context
6212+    );
6213+    const relay = buildRelayEnvelope(
6214+      state.platform,
6215+      mergeCandidates(stream.latestCandidate, finalCandidate),
6216+      meta.observedAt
6217+    );
6218+
6219+    state.activeStream = null;
6220+    if (!relay || hasSeenRelay(state, relay)) {
6221+      return null;
6222+    }
6223+
6224+    return relay;
6225+  }
6226+
6227+  function buildRelayEnvelope(platform, candidate, observedAt) {
6228+    const rawText = normalizeMessageText(candidate?.rawText);
6229+    if (!rawText) return null;
6230+
6231+    const conversationId = trimToNull(candidate?.conversationId) || null;
6232+    const assistantMessageId =
6233+      trimToNull(candidate?.assistantMessageId)
6234+      || `synthetic_${simpleHash(`${platform}|${conversationId || "-"}|${rawText}`)}`;
6235+    const dedupeKey = `${platform}|${conversationId || "-"}|${assistantMessageId}|${rawText}`;
6236+
6237+    return {
6238+      dedupeKey,
6239+      payload: {
6240+        type: "browser.final_message",
6241+        platform,
6242+        conversation_id: conversationId,
6243+        assistant_message_id: assistantMessageId,
6244+        raw_text: rawText,
6245+        observed_at: Number.isFinite(observedAt) ? Math.round(observedAt) : Date.now()
6246+      }
6247+    };
6248+  }
6249+
6250+  function hasSeenRelay(state, relay) {
6251+    if (!relay?.dedupeKey) return false;
6252+    return state.recentRelayKeys.includes(relay.dedupeKey);
6253+  }
6254+
6255+  function rememberRelay(state, relay) {
6256+    if (!relay?.dedupeKey || hasSeenRelay(state, relay)) {
6257+      return false;
6258+    }
6259+
6260+    state.recentRelayKeys.push(relay.dedupeKey);
6261+    if (state.recentRelayKeys.length > RECENT_RELAY_LIMIT) {
6262+      state.recentRelayKeys.splice(0, state.recentRelayKeys.length - RECENT_RELAY_LIMIT);
6263+    }
6264+    return true;
6265+  }
6266+
6267+  function observeSse(state, detail, meta = {}) {
6268+    if (!state || !detail || detail.source === "proxy" || !isRelevantStreamUrl(state.platform, detail.url)) {
6269+      return null;
6270+    }
6271+
6272+    const stream = ensureActiveStream(state, detail, meta);
6273+    const context = {
6274+      pageUrl: stream.pageUrl,
6275+      reqBody: stream.reqBody,
6276+      url: stream.url
6277+    };
6278+
6279+    if (typeof detail.chunk === "string" && detail.chunk) {
6280+      stream.chunks.push(detail.chunk);
6281+
6282+      if (state.platform === "chatgpt") {
6283+        stream.latestCandidate = mergeCandidates(
6284+          stream.latestCandidate,
6285+          extractChatgptCandidateFromChunk(detail.chunk, context)
6286+        );
6287+      } else if (state.platform === "claude") {
6288+        stream.latestCandidate = mergeCandidates(
6289+          stream.latestCandidate,
6290+          extractClaudeMetadataFromText(detail.chunk, context)
6291+        );
6292+      }
6293+    }
6294+
6295+    if (detail.done === true) {
6296+      return finalizeObservedSseRelay(state, stream, meta);
6297+    }
6298+
6299+    if (detail.error) {
6300+      if (!stream.latestCandidate && stream.chunks.length === 0) {
6301+        state.activeStream = null;
6302+        return null;
6303+      }
6304+
6305+      return finalizeObservedSseRelay(state, stream, meta);
6306+    }
6307+
6308+    if (detail.done !== true) {
6309+      return null;
6310+    }
6311+
6312+    return null;
6313+  }
6314+
6315+  function observeNetwork(state, detail, meta = {}) {
6316+    if (!state || !detail || detail.source === "proxy" || !isRelevantStreamUrl(state.platform, detail.url)) {
6317+      return null;
6318+    }
6319+
6320+    if (typeof detail.resBody !== "string" || !detail.resBody) {
6321+      return null;
6322+    }
6323+
6324+    const context = {
6325+      pageUrl: meta.pageUrl || "",
6326+      reqBody: detail.reqBody || "",
6327+      url: detail.url || ""
6328+    };
6329+    let candidate = null;
6330+    if (state.platform === "chatgpt") {
6331+      candidate = extractChatgptCandidateFromText(detail.resBody, context);
6332+    } else if (state.platform === "claude") {
6333+      candidate = extractClaudeCandidateFromText(detail.resBody, context);
6334+    } else {
6335+      candidate = extractGeminiCandidateFromText(detail.resBody, context);
6336+    }
6337+    const relay = buildRelayEnvelope(state.platform, candidate, meta.observedAt);
6338+
6339+    if (!relay || hasSeenRelay(state, relay)) {
6340+      return null;
6341+    }
6342+
6343+    return relay;
6344+  }
6345+
6346+  const api = {
6347+    createRelayState,
6348+    extractClaudeCandidateFromText,
6349+    extractChatgptCandidateFromChunk,
6350+    extractChatgptCandidateFromText,
6351+    extractGeminiCandidateFromText,
6352+    isRelevantStreamUrl,
6353+    observeNetwork,
6354+    observeSse,
6355+    rememberRelay
6356+  };
6357+
6358+  if (typeof module !== "undefined" && module.exports) {
6359+    module.exports = api;
6360+  }
6361+
6362+  globalScope.BAAFinalMessage = api;
6363+})(typeof globalThis !== "undefined" ? globalThis : this);
6364diff --git a/plugins/baa-safari/final-message.test.cjs b/plugins/baa-safari/final-message.test.cjs
6365new file mode 100644
6366index 0000000000000000000000000000000000000000..034462345c8bbe2d02e74abeaaea59baf8054e48
6367--- /dev/null
6368+++ b/plugins/baa-safari/final-message.test.cjs
6369@@ -0,0 +1,77 @@
6370+const assert = require("node:assert/strict");
6371+const test = require("node:test");
6372+
6373+const {
6374+  createRelayState,
6375+  isRelevantStreamUrl,
6376+  observeSse
6377+} = require("./final-message.js");
6378+
6379+function buildChatgptChunk({
6380+  assistantMessageId = "msg_abort",
6381+  conversationId = "conv_abort",
6382+  text = "@conductor::status"
6383+} = {}) {
6384+  return `data: ${JSON.stringify({
6385+    conversation_id: conversationId,
6386+    message: {
6387+      author: {
6388+        role: "assistant"
6389+      },
6390+      content: {
6391+        parts: [text]
6392+      },
6393+      end_turn: true,
6394+      id: assistantMessageId
6395+    }
6396+  })}`;
6397+}
6398+
6399+test("Safari final-message helper only accepts ChatGPT root conversation streams", () => {
6400+  assert.equal(
6401+    isRelevantStreamUrl("chatgpt", "https://chatgpt.com/backend-api/conversation"),
6402+    true
6403+  );
6404+  assert.equal(
6405+    isRelevantStreamUrl("chatgpt", "https://chatgpt.com/backend-api/f/conversation?oai-device-id=test"),
6406+    true
6407+  );
6408+  assert.equal(
6409+    isRelevantStreamUrl("chatgpt", "https://chatgpt.com/backend-api/conversation/implicit_message_feedback"),
6410+    false
6411+  );
6412+});
6413+
6414+test("Safari final-message helper recovers ChatGPT final message after aborted SSE", () => {
6415+  const state = createRelayState("chatgpt");
6416+  const meta = {
6417+    observedAt: 1743206400000,
6418+    pageUrl: "https://chatgpt.com/c/conv_abort"
6419+  };
6420+  const url = "https://chatgpt.com/backend-api/f/conversation";
6421+  const reqBody = JSON.stringify({
6422+    conversation_id: "conv_abort"
6423+  });
6424+
6425+  assert.equal(
6426+    observeSse(state, {
6427+      chunk: buildChatgptChunk(),
6428+      reqBody,
6429+      url
6430+    }, meta),
6431+    null
6432+  );
6433+
6434+  const relay = observeSse(state, {
6435+    error: "The operation was aborted.",
6436+    reqBody,
6437+    url
6438+  }, meta);
6439+
6440+  assert.ok(relay);
6441+  assert.equal(relay.payload.type, "browser.final_message");
6442+  assert.equal(relay.payload.platform, "chatgpt");
6443+  assert.equal(relay.payload.conversation_id, "conv_abort");
6444+  assert.equal(relay.payload.assistant_message_id, "msg_abort");
6445+  assert.equal(relay.payload.raw_text, "@conductor::status");
6446+});
6447diff --git a/plugins/baa-safari/icons/128.png b/plugins/baa-safari/icons/128.png
6448new file mode 100644
6449index 0000000000000000000000000000000000000000..423b491de9f0c035d4c9d7736c535bebfe078c8e
6450Binary files /dev/null and b/plugins/baa-safari/icons/128.png differ
6451diff --git a/plugins/baa-safari/icons/48.png b/plugins/baa-safari/icons/48.png
6452new file mode 100644
6453index 0000000000000000000000000000000000000000..e5bb1f5ad3a8b1842c8a6020d85f3aab85586956
6454Binary files /dev/null and b/plugins/baa-safari/icons/48.png differ
6455diff --git a/plugins/baa-safari/icons/96.png b/plugins/baa-safari/icons/96.png
6456new file mode 100644
6457index 0000000000000000000000000000000000000000..604f9c2730425810c11ca374123c4819bc82b9a4
6458Binary files /dev/null and b/plugins/baa-safari/icons/96.png differ
6459diff --git a/plugins/baa-safari/manifest.json b/plugins/baa-safari/manifest.json
6460new file mode 100644
6461index 0000000000000000000000000000000000000000..2549cf5cfb32d75dd8d88af6b068a39a7169d9df
6462--- /dev/null
6463+++ b/plugins/baa-safari/manifest.json
6464@@ -0,0 +1,56 @@
6465+{
6466+  "manifest_version": 2,
6467+  "name": "BAA Safari",
6468+  "version": "0.1.0",
6469+  "description": "Safari page bridge foundation for BAA conductor integration.",
6470+  "icons": {
6471+    "48": "icons/48.png",
6472+    "96": "icons/96.png",
6473+    "128": "icons/128.png"
6474+  },
6475+  "permissions": [
6476+    "storage",
6477+    "tabs",
6478+    "webNavigation",
6479+    "webRequest",
6480+    "https://claude.ai/*",
6481+    "https://chatgpt.com/*",
6482+    "https://*.chatgpt.com/*",
6483+    "https://openai.com/*",
6484+    "https://*.openai.com/*",
6485+    "https://chat.openai.com/*",
6486+    "https://*.chat.openai.com/*",
6487+    "https://oaiusercontent.com/*",
6488+    "https://*.oaiusercontent.com/*",
6489+    "https://gemini.google.com/*"
6490+  ],
6491+  "background": {
6492+    "scripts": [
6493+      "final-message.js",
6494+      "background.js"
6495+    ],
6496+    "persistent": true
6497+  },
6498+  "browser_action": {
6499+    "default_title": "BAA Safari"
6500+  },
6501+  "content_scripts": [
6502+    {
6503+      "matches": [
6504+        "https://claude.ai/*",
6505+        "https://chatgpt.com/*",
6506+        "https://*.chatgpt.com/*",
6507+        "https://chat.openai.com/*",
6508+        "https://*.chat.openai.com/*",
6509+        "https://gemini.google.com/*"
6510+      ],
6511+      "js": [
6512+        "content-script.js"
6513+      ],
6514+      "run_at": "document_start"
6515+    }
6516+  ],
6517+  "web_accessible_resources": [
6518+    "page-interceptor.js"
6519+  ]
6520+}
6521diff --git a/plugins/baa-safari/page-interceptor.js b/plugins/baa-safari/page-interceptor.js
6522new file mode 100644
6523index 0000000000000000000000000000000000000000..a152576dd18ff6490244232600c8506613ba57c2
6524--- /dev/null
6525+++ b/plugins/baa-safari/page-interceptor.js
6526@@ -0,0 +1,895 @@
6527+(function () {
6528+  const previousRuntime = window.__baaSafariIntercepted__;
6529+  const hasManagedRuntime = !!previousRuntime
6530+    && typeof previousRuntime === "object"
6531+    && typeof previousRuntime.teardown === "function";
6532+  const legacyIntercepted = previousRuntime === true
6533+    || (hasManagedRuntime && previousRuntime.legacyIntercepted === true);
6534+
6535+  if (hasManagedRuntime) {
6536+    try {
6537+      previousRuntime.teardown();
6538+    } catch (_) {}
6539+  }
6540+
6541+  const BODY_LIMIT = 5000;
6542+  const originalFetch = hasManagedRuntime ? previousRuntime.originalFetch : window.fetch;
6543+  const originalXhrOpen = hasManagedRuntime ? previousRuntime.originalXhrOpen : XMLHttpRequest.prototype.open;
6544+  const originalXhrSend = hasManagedRuntime ? previousRuntime.originalXhrSend : XMLHttpRequest.prototype.send;
6545+  const originalXhrSetRequestHeader = hasManagedRuntime ? previousRuntime.originalXhrSetRequestHeader : XMLHttpRequest.prototype.setRequestHeader;
6546+  const activeProxyControllers = new Map();
6547+  const cleanupHandlers = [];
6548+
6549+  function addWindowListener(type, listener) {
6550+    window.addEventListener(type, listener);
6551+    cleanupHandlers.push(() => {
6552+      window.removeEventListener(type, listener);
6553+    });
6554+  }
6555+
6556+  function hostnameMatches(hostname, hosts) {
6557+    return hosts.some((host) => hostname === host || hostname.endsWith(`.${host}`));
6558+  }
6559+
6560+  function isLikelyStaticPath(pathname = "") {
6561+    const lower = pathname.toLowerCase();
6562+    return lower.startsWith("/_next/")
6563+      || lower.startsWith("/assets/")
6564+      || lower.startsWith("/static/")
6565+      || lower.startsWith("/images/")
6566+      || lower.startsWith("/fonts/")
6567+      || lower.startsWith("/favicon")
6568+      || /\.(?:js|mjs|css|map|png|jpe?g|gif|svg|webp|ico|woff2?|ttf|otf|mp4|webm|txt)$/i.test(lower);
6569+  }
6570+
6571+  const PLATFORM_RULES = [
6572+    {
6573+      platform: "claude",
6574+      pageHosts: ["claude.ai"],
6575+      requestHosts: ["claude.ai"],
6576+      matchesPageHost(hostname) {
6577+        return hostnameMatches(hostname, this.pageHosts);
6578+      },
6579+      matchesRequestHost(hostname) {
6580+        return hostnameMatches(hostname, this.requestHosts);
6581+      },
6582+      shouldTrack(pathname) {
6583+        return pathname.includes("/api/");
6584+      },
6585+      isSse(pathname, contentType) {
6586+        return contentType.includes("text/event-stream") || pathname.endsWith("/completion");
6587+      }
6588+    },
6589+    {
6590+      platform: "chatgpt",
6591+      pageHosts: ["chatgpt.com", "chat.openai.com"],
6592+      requestHosts: ["chatgpt.com", "chat.openai.com", "openai.com", "oaiusercontent.com"],
6593+      matchesPageHost(hostname) {
6594+        return hostnameMatches(hostname, this.pageHosts);
6595+      },
6596+      matchesRequestHost(hostname) {
6597+        return hostnameMatches(hostname, this.requestHosts);
6598+      },
6599+      shouldTrack(pathname) {
6600+        const lower = pathname.toLowerCase();
6601+        return pathname.includes("/backend-api/")
6602+          || pathname.includes("/backend-anon/")
6603+          || pathname.includes("/public-api/")
6604+          || lower.includes("/conversation")
6605+          || lower.includes("/models")
6606+          || lower.includes("/files")
6607+          || (!isLikelyStaticPath(pathname) && (lower.includes("/api/") || lower.includes("/backend")));
6608+      },
6609+      isSse(pathname, contentType) {
6610+        const lower = pathname.toLowerCase();
6611+        return contentType.includes("text/event-stream")
6612+          || lower.includes("/conversation")
6613+          || lower.includes("/backend-api/conversation");
6614+      }
6615+    },
6616+    {
6617+      platform: "gemini",
6618+      pageHosts: ["gemini.google.com"],
6619+      requestHosts: ["gemini.google.com"],
6620+      matchesPageHost(hostname) {
6621+        return hostnameMatches(hostname, this.pageHosts);
6622+      },
6623+      matchesRequestHost(hostname) {
6624+        return hostnameMatches(hostname, this.requestHosts);
6625+      },
6626+      shouldTrack(pathname) {
6627+        const lower = pathname.toLowerCase();
6628+        return pathname.startsWith("/_/")
6629+          || pathname.includes("/api/")
6630+          || lower.includes("bardchatui")
6631+          || lower.includes("streamgenerate")
6632+          || lower.includes("generatecontent")
6633+          || lower.includes("modelresponse")
6634+          || (!isLikelyStaticPath(pathname) && lower.includes("assistant"));
6635+      },
6636+      isSse(pathname, contentType) {
6637+        return contentType.includes("text/event-stream") || pathname.toLowerCase().includes("streamgenerate");
6638+      }
6639+    }
6640+  ];
6641+
6642+  function findPageRule(hostname) {
6643+    return PLATFORM_RULES.find((rule) => rule.matchesPageHost(hostname)) || null;
6644+  }
6645+
6646+  function findRequestRule(hostname) {
6647+    return PLATFORM_RULES.find((rule) => rule.matchesRequestHost(hostname)) || null;
6648+  }
6649+
6650+  const pageRule = findPageRule(location.hostname);
6651+  if (!pageRule) return;
6652+
6653+  function getRequestContext(url) {
6654+    try {
6655+      const parsed = new URL(url, location.href);
6656+      const rule = findRequestRule(parsed.hostname);
6657+      if (!rule || rule.platform !== pageRule.platform) return null;
6658+      return { parsed, rule };
6659+    } catch (_) {
6660+      return null;
6661+    }
6662+  }
6663+
6664+  function shouldTrack(url) {
6665+    const context = getRequestContext(url);
6666+    return context ? context.rule.shouldTrack(context.parsed.pathname) : false;
6667+  }
6668+
6669+  function readHeaders(headersLike) {
6670+    const out = {};
6671+    try {
6672+      const headers = new Headers(headersLike || {});
6673+      headers.forEach((value, key) => {
6674+        out[key] = value;
6675+      });
6676+    } catch (_) {}
6677+    return out;
6678+  }
6679+
6680+  function readRawHeaders(rawHeaders) {
6681+    const out = {};
6682+    for (const line of String(rawHeaders || "").split(/\r?\n/)) {
6683+      const index = line.indexOf(":");
6684+      if (index <= 0) continue;
6685+      const name = line.slice(0, index).trim().toLowerCase();
6686+      const value = line.slice(index + 1).trim();
6687+      if (name) out[name] = value;
6688+    }
6689+    return out;
6690+  }
6691+
6692+  function trim(text) {
6693+    if (text == null) return null;
6694+    return text.length > BODY_LIMIT ? text.slice(0, BODY_LIMIT) : text;
6695+  }
6696+
6697+  function trimToNull(value) {
6698+    if (typeof value !== "string") return null;
6699+    const normalized = value.trim();
6700+    return normalized ? normalized : null;
6701+  }
6702+
6703+  function describeError(error) {
6704+    if (typeof error === "string") {
6705+      return trimToNull(error) || "unknown_error";
6706+    }
6707+
6708+    return trimToNull(error?.message) || String(error || "unknown_error");
6709+  }
6710+
6711+  function emit(type, detail, rule = pageRule) {
6712+    window.dispatchEvent(new CustomEvent(type, {
6713+      detail: {
6714+        platform: rule.platform,
6715+        ...detail
6716+      }
6717+    }));
6718+  }
6719+
6720+  function emitNet(detail, rule = pageRule) {
6721+    emit("__baa_net__", detail, rule);
6722+  }
6723+
6724+  function emitSse(detail, rule = pageRule) {
6725+    emit("__baa_sse__", detail, rule);
6726+  }
6727+
6728+  function emitDiagnostic(event, detail = {}, rule = pageRule) {
6729+    emit("__baa_diagnostic__", {
6730+      event,
6731+      ...detail
6732+    }, rule);
6733+  }
6734+
6735+  function isForbiddenProxyHeader(name) {
6736+    const lower = String(name || "").toLowerCase();
6737+    return lower === "accept-encoding"
6738+      || lower === "connection"
6739+      || lower === "content-length"
6740+      || lower === "cookie"
6741+      || lower === "host"
6742+      || lower === "origin"
6743+      || lower === "referer"
6744+      || lower === "user-agent"
6745+      || lower.startsWith("sec-");
6746+  }
6747+
6748+  emit("__baa_ready__", {
6749+    platform: pageRule.platform,
6750+    url: location.href,
6751+    source: "page-interceptor"
6752+  }, pageRule);
6753+
6754+  try { console.log("[BAA]", "interceptor_active", pageRule.platform, location.href.slice(0, 120)); } catch (_) {}
6755+  emitDiagnostic("interceptor_active", {
6756+    source: "page-interceptor",
6757+    url: location.href
6758+  }, pageRule);
6759+
6760+  function trimBodyValue(body) {
6761+    try {
6762+      if (body == null) return null;
6763+      if (typeof body === "string") return trim(body);
6764+      if (body instanceof URLSearchParams) return trim(body.toString());
6765+      if (body instanceof FormData) {
6766+        const pairs = [];
6767+        for (const [key, value] of body.entries()) {
6768+          pairs.push([key, typeof value === "string" ? value : `[${value?.constructor?.name || "binary"}]`]);
6769+        }
6770+        return trim(JSON.stringify(pairs));
6771+      }
6772+      if (body instanceof Blob) return `[blob ${body.type || "application/octet-stream"} ${body.size}]`;
6773+      if (body instanceof ArrayBuffer) return `[arraybuffer ${body.byteLength}]`;
6774+      if (ArrayBuffer.isView(body)) return `[typedarray ${body.byteLength}]`;
6775+      return trim(JSON.stringify(body));
6776+    } catch (_) {
6777+      return null;
6778+    }
6779+  }
6780+
6781+  async function readRequestBody(input, init) {
6782+    try {
6783+      if (init && Object.prototype.hasOwnProperty.call(init, "body")) {
6784+        return trimBodyValue(init.body);
6785+      }
6786+      if (input instanceof Request && !input.bodyUsed) {
6787+        return trim(await input.clone().text());
6788+      }
6789+    } catch (_) {}
6790+    return null;
6791+  }
6792+
6793+  async function readResponseText(response) {
6794+    try {
6795+      return trim(await response.clone().text());
6796+    } catch (_) {
6797+      return null;
6798+    }
6799+  }
6800+
6801+  async function streamSse(url, method, requestBody, response, startedAt, rule) {
6802+    try { console.log("[BAA]", "sse_stream_start", method, url.slice(0, 120)); } catch (_) {}
6803+    emitDiagnostic("sse_stream_start", {
6804+      method,
6805+      source: "page-interceptor",
6806+      url
6807+    }, rule);
6808+    let buffer = "";
6809+    const decoder = new TextDecoder();
6810+    const emitBufferedChunk = () => {
6811+      if (!buffer.trim()) return false;
6812+
6813+      emitSse({
6814+        url,
6815+        method,
6816+        reqBody: requestBody,
6817+        chunk: buffer,
6818+        ts: Date.now()
6819+      }, rule);
6820+      buffer = "";
6821+      return true;
6822+    };
6823+
6824+    try {
6825+      const clone = response.clone();
6826+      if (!clone.body) return;
6827+
6828+      const reader = clone.body.getReader();
6829+
6830+      while (true) {
6831+        const { done, value } = await reader.read();
6832+        if (done) break;
6833+        buffer += decoder.decode(value, { stream: true });
6834+
6835+        const chunks = buffer.split("\n\n");
6836+        buffer = chunks.pop() || "";
6837+
6838+        for (const chunk of chunks) {
6839+          if (!chunk.trim()) continue;
6840+          emitSse({
6841+            url,
6842+            method,
6843+            reqBody: requestBody,
6844+            chunk,
6845+            ts: Date.now()
6846+          }, rule);
6847+        }
6848+      }
6849+
6850+      buffer += decoder.decode();
6851+      emitBufferedChunk();
6852+
6853+      const duration = Date.now() - startedAt;
6854+      try { console.log("[BAA]", "sse_stream_done", method, url.slice(0, 120), "duration=" + duration + "ms"); } catch (_) {}
6855+      emitDiagnostic("sse_stream_done", {
6856+        duration,
6857+        method,
6858+        source: "page-interceptor",
6859+        url
6860+      }, rule);
6861+      emitSse({
6862+        url,
6863+        method,
6864+        reqBody: requestBody,
6865+        done: true,
6866+        ts: Date.now(),
6867+        duration
6868+      }, rule);
6869+    } catch (error) {
6870+      buffer += decoder.decode();
6871+      emitBufferedChunk();
6872+
6873+      emitSse({
6874+        url,
6875+        method,
6876+        reqBody: requestBody,
6877+        error: describeError(error),
6878+        ts: Date.now(),
6879+        duration: Date.now() - startedAt
6880+      }, rule);
6881+    }
6882+  }
6883+
6884+  async function streamProxyResponse(detail, response, startedAt, rule, requestBody) {
6885+    const contentType = response.headers.get("content-type") || "";
6886+    const shouldSplitChunks = contentType.includes("text/event-stream");
6887+    const streamId = detail.stream_id || detail.streamId || detail.id;
6888+    const proxySource = trimToNull(detail.source) || "proxy";
6889+    let seq = 0;
6890+
6891+    emitSse({
6892+      id: detail.id,
6893+      stream_id: streamId,
6894+      method: detail.method,
6895+      open: true,
6896+      reqBody: requestBody,
6897+      source: proxySource,
6898+      status: response.status,
6899+      ts: Date.now(),
6900+      url: detail.url
6901+    }, rule);
6902+
6903+    try {
6904+      if (!response.body) {
6905+        emitSse(
6906+          response.status >= 400
6907+            ? {
6908+                error: `upstream_status_${response.status}`,
6909+                id: detail.id,
6910+                method: detail.method,
6911+                reqBody: requestBody,
6912+                source: proxySource,
6913+                status: response.status,
6914+                stream_id: streamId,
6915+                ts: Date.now(),
6916+                url: detail.url
6917+              }
6918+            : {
6919+                id: detail.id,
6920+                stream_id: streamId,
6921+                done: true,
6922+                duration: Date.now() - startedAt,
6923+                method: detail.method,
6924+                reqBody: requestBody,
6925+                source: proxySource,
6926+                status: response.status,
6927+                ts: Date.now(),
6928+                url: detail.url
6929+              },
6930+          rule
6931+        );
6932+        return;
6933+      }
6934+
6935+      const reader = response.body.getReader();
6936+      const decoder = new TextDecoder();
6937+      let buffer = "";
6938+
6939+      while (true) {
6940+        const { done, value } = await reader.read();
6941+        if (done) break;
6942+
6943+        buffer += decoder.decode(value, { stream: true });
6944+        const chunks = shouldSplitChunks ? buffer.split("\n\n") : [buffer];
6945+        buffer = shouldSplitChunks ? (chunks.pop() || "") : "";
6946+
6947+        for (const chunk of chunks) {
6948+          if (!chunk.trim()) continue;
6949+          seq += 1;
6950+          emitSse({
6951+            chunk,
6952+            id: detail.id,
6953+            method: detail.method,
6954+            reqBody: requestBody,
6955+            seq,
6956+            source: proxySource,
6957+            status: response.status,
6958+            stream_id: streamId,
6959+            ts: Date.now(),
6960+            url: detail.url
6961+          }, rule);
6962+        }
6963+      }
6964+
6965+      buffer += decoder.decode();
6966+      if (buffer.trim()) {
6967+        seq += 1;
6968+        emitSse({
6969+          chunk: buffer,
6970+          id: detail.id,
6971+          method: detail.method,
6972+          reqBody: requestBody,
6973+          seq,
6974+          source: proxySource,
6975+          status: response.status,
6976+          stream_id: streamId,
6977+          ts: Date.now(),
6978+          url: detail.url
6979+        }, rule);
6980+      }
6981+
6982+      emitSse(
6983+        response.status >= 400
6984+          ? {
6985+              error: `upstream_status_${response.status}`,
6986+              id: detail.id,
6987+              method: detail.method,
6988+              reqBody: requestBody,
6989+              seq,
6990+              source: proxySource,
6991+              status: response.status,
6992+              stream_id: streamId,
6993+              ts: Date.now(),
6994+              url: detail.url
6995+            }
6996+          : {
6997+              done: true,
6998+              duration: Date.now() - startedAt,
6999+              id: detail.id,
7000+              method: detail.method,
7001+              reqBody: requestBody,
7002+              seq,
7003+              source: proxySource,
7004+              status: response.status,
7005+              stream_id: streamId,
7006+              ts: Date.now(),
7007+              url: detail.url
7008+            },
7009+        rule
7010+      );
7011+    } catch (error) {
7012+      emitSse({
7013+        error: error.message,
7014+        id: detail.id,
7015+        method: detail.method,
7016+        reqBody: requestBody,
7017+        source: proxySource,
7018+        seq,
7019+        status: response.status,
7020+        stream_id: streamId,
7021+        ts: Date.now(),
7022+        url: detail.url
7023+      }, rule);
7024+    }
7025+  }
7026+
7027+  if (!legacyIntercepted) {
7028+    addWindowListener("__baa_proxy_request__", async (event) => {
7029+      let detail = event.detail || {};
7030+      if (typeof detail === "string") {
7031+        try {
7032+          detail = JSON.parse(detail);
7033+        } catch (_) {
7034+          detail = {};
7035+        }
7036+      }
7037+
7038+      const id = detail.id;
7039+      const method = String(detail.method || "GET").toUpperCase();
7040+      const rawPath = detail.path || detail.url || location.href;
7041+      const responseMode = String(detail.response_mode || detail.responseMode || "buffered").toLowerCase();
7042+      const proxySource = trimToNull(detail.source) || "proxy";
7043+
7044+      if (!id) return;
7045+
7046+      const proxyAbortController = new AbortController();
7047+      activeProxyControllers.set(id, proxyAbortController);
7048+
7049+      try {
7050+        const url = new URL(rawPath, location.origin).href;
7051+        try { console.log("[BAA]", "proxy_fetch", method, new URL(url).pathname); } catch (_) {}
7052+        const context = getRequestContext(url);
7053+        const headers = new Headers();
7054+        const startedAt = Date.now();
7055+
7056+        for (const [name, value] of Object.entries(detail.headers || {})) {
7057+          if (!name || value == null || value === "") continue;
7058+          if (isForbiddenProxyHeader(name)) continue;
7059+          headers.set(String(name).toLowerCase(), String(value));
7060+        }
7061+
7062+        let body = null;
7063+        if (method !== "GET" && method !== "HEAD" && Object.prototype.hasOwnProperty.call(detail, "body")) {
7064+          if (typeof detail.body === "string") {
7065+            body = detail.body;
7066+          } else if (detail.body != null) {
7067+            if (!headers.has("content-type")) headers.set("content-type", "application/json");
7068+            body = JSON.stringify(detail.body);
7069+          }
7070+        }
7071+
7072+        const response = await originalFetch.call(window, url, {
7073+          method,
7074+          headers,
7075+          body,
7076+          credentials: "include",
7077+          signal: proxyAbortController.signal
7078+        });
7079+        const resHeaders = readHeaders(response.headers);
7080+        const contentType = response.headers.get("content-type") || "";
7081+        const isSse = context ? context.rule.isSse(context.parsed.pathname, contentType) : false;
7082+        const reqHeaders = readHeaders(headers);
7083+        const reqBody = typeof body === "string" ? trim(body) : null;
7084+
7085+        if (responseMode === "sse") {
7086+          emitNet({
7087+            url,
7088+            method,
7089+          reqHeaders,
7090+          reqBody,
7091+          status: response.status,
7092+          resHeaders,
7093+          resBody: null,
7094+          duration: Date.now() - startedAt,
7095+          sse: true,
7096+          source: proxySource
7097+        }, pageRule);
7098+
7099+          const replayDetail = {
7100+            ...detail,
7101+            id,
7102+            method,
7103+            stream_id: detail.stream_id || detail.streamId || id,
7104+            source: proxySource,
7105+            url
7106+          };
7107+          await streamProxyResponse(replayDetail, response, startedAt, pageRule, reqBody);
7108+          return;
7109+        }
7110+
7111+        const responseBody = await response.text();
7112+        const trimmedResponseBody = trim(responseBody);
7113+
7114+        emitNet({
7115+          url,
7116+          method,
7117+          reqHeaders,
7118+          reqBody,
7119+          status: response.status,
7120+          resHeaders,
7121+          resBody: isSse && pageRule.platform !== "gemini" ? null : trimmedResponseBody,
7122+          duration: Date.now() - startedAt,
7123+          sse: isSse,
7124+          source: proxySource
7125+        }, pageRule);
7126+
7127+        if (isSse && trimmedResponseBody) {
7128+          emitSse({
7129+            url,
7130+            method,
7131+            reqBody,
7132+            chunk: trimmedResponseBody,
7133+            source: proxySource,
7134+            ts: Date.now()
7135+          }, pageRule);
7136+          emitSse({
7137+            url,
7138+            method,
7139+            reqBody,
7140+            done: true,
7141+            source: proxySource,
7142+            ts: Date.now(),
7143+            duration: Date.now() - startedAt
7144+          }, pageRule);
7145+        }
7146+
7147+        emit("__baa_proxy_response__", {
7148+          id,
7149+          platform: pageRule.platform,
7150+          url,
7151+          method,
7152+          ok: response.ok,
7153+          status: response.status,
7154+          body: responseBody
7155+        }, pageRule);
7156+      } catch (error) {
7157+        emitNet({
7158+          url: rawPath,
7159+          method,
7160+          reqHeaders: readHeaders(detail.headers || {}),
7161+          reqBody: typeof detail.body === "string" ? trim(detail.body) : trimBodyValue(detail.body),
7162+          error: error.message,
7163+          source: proxySource
7164+        }, pageRule);
7165+
7166+        if (responseMode === "sse") {
7167+          emitSse({
7168+            error: error.message,
7169+            id,
7170+            method,
7171+            reqBody: typeof detail.body === "string" ? trim(detail.body) : trimBodyValue(detail.body),
7172+            source: proxySource,
7173+            status: null,
7174+            stream_id: detail.stream_id || detail.streamId || id,
7175+            ts: Date.now(),
7176+            url: rawPath
7177+          }, pageRule);
7178+          return;
7179+        }
7180+
7181+        emit("__baa_proxy_response__", {
7182+          id,
7183+          platform: pageRule.platform,
7184+          url: rawPath,
7185+          method,
7186+          ok: false,
7187+          error: error.message
7188+        }, pageRule);
7189+      } finally {
7190+        activeProxyControllers.delete(id);
7191+      }
7192+    });
7193+
7194+    addWindowListener("__baa_proxy_cancel__", (event) => {
7195+      let detail = event.detail || {};
7196+
7197+      if (typeof detail === "string") {
7198+        try {
7199+          detail = JSON.parse(detail);
7200+        } catch (_) {
7201+          detail = {};
7202+        }
7203+      }
7204+
7205+      const id = detail?.id || detail?.requestId;
7206+      if (!id) return;
7207+
7208+      const controller = activeProxyControllers.get(id);
7209+      if (!controller) return;
7210+
7211+      activeProxyControllers.delete(id);
7212+      controller.abort(detail?.reason || "browser_request_cancelled");
7213+    });
7214+  }
7215+
7216+  window.fetch = async function patchedFetch(input, init) {
7217+    const url = input instanceof Request ? input.url : String(input);
7218+    const context = getRequestContext(url);
7219+    if (!context || !context.rule.shouldTrack(context.parsed.pathname)) {
7220+      return originalFetch.apply(this, arguments);
7221+    }
7222+
7223+    const method = ((init && init.method) || (input instanceof Request ? input.method : "GET")).toUpperCase();
7224+    const startedAt = Date.now();
7225+    const reqHeaders = readHeaders(init && init.headers ? init.headers : (input instanceof Request ? input.headers : null));
7226+    const reqBody = await readRequestBody(input, init);
7227+    emitDiagnostic("fetch_intercepted", {
7228+      method,
7229+      source: "page-interceptor",
7230+      url
7231+    }, context.rule);
7232+
7233+    try {
7234+      const response = await originalFetch.apply(this, arguments);
7235+      const resHeaders = readHeaders(response.headers);
7236+      const contentType = response.headers.get("content-type") || "";
7237+      const isSse = context.rule.isSse(context.parsed.pathname, contentType);
7238+      try { console.log("[BAA]", "fetch", method, context.parsed.pathname, isSse ? "SSE" : "buffered", response.status); } catch (_) {}
7239+
7240+      if (isSse) {
7241+        emitNet({
7242+          url,
7243+          method,
7244+          reqHeaders,
7245+          reqBody,
7246+          status: response.status,
7247+          resHeaders,
7248+          duration: Date.now() - startedAt,
7249+          sse: true,
7250+          source: "page"
7251+        }, context.rule);
7252+        streamSse(url, method, reqBody, response, startedAt, context.rule);
7253+        return response;
7254+      }
7255+
7256+      const resBody = await readResponseText(response);
7257+      emitNet({
7258+        url,
7259+        method,
7260+        reqHeaders,
7261+        reqBody,
7262+        status: response.status,
7263+        resHeaders,
7264+        resBody,
7265+        duration: Date.now() - startedAt,
7266+        source: "page"
7267+      }, context.rule);
7268+      return response;
7269+    } catch (error) {
7270+      emitNet({
7271+        url,
7272+        method,
7273+        reqHeaders,
7274+        reqBody,
7275+        error: error.message,
7276+        duration: Date.now() - startedAt,
7277+        source: "page"
7278+      }, context.rule);
7279+      throw error;
7280+    }
7281+  };
7282+
7283+  XMLHttpRequest.prototype.open = function patchedOpen(method, url) {
7284+    this.__baaMethod = String(method || "GET").toUpperCase();
7285+    this.__baaUrl = typeof url === "string" ? url : String(url);
7286+    this.__baaRequestHeaders = {};
7287+    return originalXhrOpen.apply(this, arguments);
7288+  };
7289+
7290+  XMLHttpRequest.prototype.setRequestHeader = function patchedSetRequestHeader(name, value) {
7291+    if (this.__baaRequestHeaders && name) {
7292+      this.__baaRequestHeaders[String(name).toLowerCase()] = String(value || "");
7293+    }
7294+    return originalXhrSetRequestHeader.apply(this, arguments);
7295+  };
7296+
7297+  XMLHttpRequest.prototype.send = function patchedSend(body) {
7298+    const url = this.__baaUrl;
7299+    const context = getRequestContext(url);
7300+    if (!context || !context.rule.shouldTrack(context.parsed.pathname)) {
7301+      return originalXhrSend.apply(this, arguments);
7302+    }
7303+
7304+    const method = this.__baaMethod || "GET";
7305+    try { console.log("[BAA]", "xhr", method, context.parsed.pathname); } catch (_) {}
7306+    const reqBody = trimBodyValue(body);
7307+    const reqHeaders = { ...(this.__baaRequestHeaders || {}) };
7308+    const startedAt = Date.now();
7309+    let finalized = false;
7310+    let terminalError = null;
7311+
7312+    const finalize = () => {
7313+      if (finalized) return;
7314+      finalized = true;
7315+
7316+      const resHeaders = readRawHeaders(this.getAllResponseHeaders());
7317+      const contentType = String(this.getResponseHeader("content-type") || "");
7318+      const duration = Date.now() - startedAt;
7319+      const error = terminalError || (this.status === 0 ? "xhr_failed" : null);
7320+      const isSse = context.rule.isSse(context.parsed.pathname, contentType);
7321+      let responseText = null;
7322+
7323+      try {
7324+        responseText = typeof this.responseText === "string" ? trim(this.responseText) : null;
7325+      } catch (_) {
7326+        responseText = null;
7327+      }
7328+
7329+      emitNet({
7330+        url,
7331+        method,
7332+        reqHeaders,
7333+        reqBody,
7334+        status: this.status || null,
7335+        resHeaders,
7336+        resBody: isSse && context.rule.platform !== "gemini" ? null : responseText,
7337+        error,
7338+        duration,
7339+        sse: isSse,
7340+        source: "xhr"
7341+      }, context.rule);
7342+
7343+      if (isSse && responseText) {
7344+        emitSse({
7345+          url,
7346+          method,
7347+          reqBody,
7348+          chunk: responseText,
7349+          ts: Date.now()
7350+        }, context.rule);
7351+      }
7352+
7353+      if (isSse) {
7354+        emitSse({
7355+          url,
7356+          method,
7357+          reqBody,
7358+          done: true,
7359+          ts: Date.now(),
7360+          duration
7361+        }, context.rule);
7362+      }
7363+    };
7364+
7365+    this.addEventListener("error", () => {
7366+      terminalError = "xhr_error";
7367+    }, { once: true });
7368+    this.addEventListener("abort", () => {
7369+      terminalError = "xhr_aborted";
7370+    }, { once: true });
7371+    this.addEventListener("timeout", () => {
7372+      terminalError = "xhr_timeout";
7373+    }, { once: true });
7374+    this.addEventListener("loadend", finalize, { once: true });
7375+
7376+    return originalXhrSend.apply(this, arguments);
7377+  };
7378+
7379+  const runtime = {
7380+    legacyIntercepted,
7381+    originalFetch,
7382+    originalXhrOpen,
7383+    originalXhrSend,
7384+    originalXhrSetRequestHeader,
7385+    teardown() {
7386+      for (const controller of activeProxyControllers.values()) {
7387+        try {
7388+          controller.abort("observer_reinject");
7389+        } catch (_) {}
7390+      }
7391+      activeProxyControllers.clear();
7392+
7393+      window.fetch = originalFetch;
7394+      XMLHttpRequest.prototype.open = originalXhrOpen;
7395+      XMLHttpRequest.prototype.send = originalXhrSend;
7396+      XMLHttpRequest.prototype.setRequestHeader = originalXhrSetRequestHeader;
7397+
7398+      while (cleanupHandlers.length > 0) {
7399+        const cleanup = cleanupHandlers.pop();
7400+        try {
7401+          cleanup();
7402+        } catch (_) {}
7403+      }
7404+
7405+      if (window.__baaSafariIntercepted__ === runtime) {
7406+        if (legacyIntercepted) {
7407+          window.__baaSafariIntercepted__ = true;
7408+          return;
7409+        }
7410+
7411+        try {
7412+          delete window.__baaSafariIntercepted__;
7413+        } catch (_) {
7414+          window.__baaSafariIntercepted__ = null;
7415+        }
7416+      }
7417+    }
7418+  };
7419+
7420+  window.__baaSafariIntercepted__ = runtime;
7421+})();
7422diff --git a/tasks/T-S077.md b/tasks/T-S077.md
7423new file mode 100644
7424index 0000000000000000000000000000000000000000..6265dfd04a7098ea971b989a16957d11a6a31be4
7425--- /dev/null
7426+++ b/tasks/T-S077.md
7427@@ -0,0 +1,176 @@
7428+# Task T-S077:Safari ChatGPT final-message — 共享 relay 接线
7429+
7430+## 状态
7431+
7432+- 当前状态:`已完成`
7433+- 规模预估:`M`
7434+- 依赖任务:`T-S075`、`T-S076`
7435+- 建议执行者:`Codex`(涉及 shared final-message helper、Safari controller 状态机和 conductor live ingest)
7436+
7437+## 直接给对话的提示词
7438+
7439+读 `/Users/george/code/baa-conductor-safari-plugin/tasks/T-S077.md` 任务文档,完成开发任务。
7440+
7441+如需补背景,再读:
7442+
7443+- `/Users/george/code/baa-conductor-safari-plugin/plans/BAA_SAFARI_PLUGIN_REQUIREMENTS.md`
7444+- `/Users/george/code/baa-conductor-safari-plugin/plugins/baa-firefox/final-message.js`
7445+- `/Users/george/code/baa-conductor-safari-plugin/plugins/baa-firefox/content-script.js`
7446+- `/Users/george/code/baa-conductor-safari-plugin/plugins/baa-firefox/controller.js`
7447+- `/Users/george/code/baa-conductor-safari-plugin/apps/conductor-daemon/src/firefox-ws.ts`
7448+
7449+## 当前基线
7450+
7451+- 仓库:`/Users/george/code/baa-conductor-safari-plugin`
7452+- 分支基线:`main`
7453+- 提交:`9674c30`
7454+
7455+## 分支与 worktree(强制)
7456+
7457+- 分支名:`feat/safari-chatgpt-final-message`
7458+- worktree 路径:`/Users/george/code/baa-conductor-safari-plugin-safari-chatgpt-final-message`
7459+
7460+开工步骤:
7461+
7462+1. `cd /Users/george/code/baa-conductor-safari-plugin`
7463+2. `git worktree add ../baa-conductor-safari-plugin-safari-chatgpt-final-message -b feat/safari-chatgpt-final-message main`
7464+3. `cd ../baa-conductor-safari-plugin-safari-chatgpt-final-message`
7465+
7466+## 目标
7467+
7468+让 Safari 版 ChatGPT 页面在回复完成后,稳定向 conductor 发送与 Firefox 相同合同的 `browser.final_message`。
7469+
7470+## 背景
7471+
7472+Phase 1 的硬验收之一就是:
7473+
7474+- 在 ChatGPT 中发消息并收到回复后,conductor 日志中出现 `browser.final_message`
7475+
7476+Firefox 版这条链路已经存在,但依赖:
7477+
7478+- `page-interceptor.js` 上报 SSE
7479+- `content-script.js` 桥接页面事件
7480+- `controller.js` 持有 final-message relay observer 和 dedupe cache
7481+- WS bridge 把消息继续送进 conductor live ingest
7482+
7483+Safari 版需要复用这条合同,而不是重新发明一套消息格式。
7484+
7485+## 涉及仓库
7486+
7487+- `/Users/george/code/baa-conductor-safari-plugin`
7488+
7489+## 范围
7490+
7491+- 复用或共享 `final-message.js`
7492+- Safari controller 接入 ChatGPT final-message observer
7493+- 维护 replay / dedupe cache
7494+- 通过 Safari WS bridge 发送 `browser.final_message`
7495+- 为后续 Claude / Gemini 复用保留平台抽象
7496+
7497+## 路径约束
7498+
7499+- `final-message.js` 保持纯 JS,不引入 Safari 专属 API
7500+- 发送给 conductor 的 `browser.final_message` 字段合同必须与现有 Firefox 路径兼容
7501+- 首版先以 ChatGPT 打通为准,不要把 Claude / Gemini 假装成已完成
7502+
7503+## 推荐实现边界
7504+
7505+建议优先做:
7506+
7507+- `plugins/baa-safari/final-message.js`
7508+- `plugins/baa-safari/content-script.js`
7509+- `plugins/baa-safari/controller.js`
7510+- `plugins/baa-safari/*.test.cjs`
7511+
7512+## 允许修改的目录
7513+
7514+- `/Users/george/code/baa-conductor-safari-plugin/plugins/baa-safari/`
7515+- `/Users/george/code/baa-conductor-safari-plugin/tasks/T-S077.md`
7516+- `/Users/george/code/baa-conductor-safari-plugin/tasks/TASK_OVERVIEW.md`
7517+
7518+## 尽量不要修改
7519+
7520+- `/Users/george/code/baa-conductor-safari-plugin/apps/conductor-daemon/src/`
7521+- `/Users/george/code/baa-conductor-safari-plugin/plugins/baa-firefox/`
7522+
7523+## 必须完成
7524+
7525+### 1. 共享 final-message helper 接线
7526+
7527+- Safari controller 能创建与 Firefox 相同的平台 relay observer
7528+- ChatGPT SSE 片段能进入 shared helper 并产出最终 assistant message
7529+- relay cache 能抑制页面重载或扩展重载后的 stale replay
7530+
7531+### 2. WS 上送
7532+
7533+- Safari controller 能把 `browser.final_message` 发到 conductor
7534+- 消息里至少包含 `platform`、`assistant_message_id`、`raw_text`
7535+- `client_id` / `node_platform` 维度在 conductor 侧可分辨 Safari 来源
7536+
7537+### 3. 测试
7538+
7539+- 至少补一组 Safari final-message 的单测或 harness 覆盖
7540+- 不得破坏现有 Firefox final-message 语义
7541+
7542+## 需要特别注意
7543+
7544+- 这张卡的目标是 final-message relay,不是 BAA parser
7545+- 不要让 Safari 为了接线去改 shared helper 的平台无关行为
7546+- 如果 ChatGPT 当前路径必须补 Safari 专属兼容层,应收口在 Safari controller,而不是污染 shared helper
7547+- 所有开发必须在 worktree 中进行,不要在主仓库目录修改代码
7548+
7549+## 验收标准
7550+
7551+- Safari 上 ChatGPT 回复完成后,conductor 能收到一条 `browser.final_message`
7552+- 页面刷新或扩展重载后,不会把旧 final-message 当成新消息重复上报
7553+- Safari 与 Firefox 的 `browser.final_message` 合同保持一致
7554+
7555+## 推荐验证命令
7556+
7557+- `cd /Users/george/code/baa-conductor-safari-plugin-safari-chatgpt-final-message && node --test plugins/baa-safari/*.test.cjs`
7558+- `cd /Users/george/code/baa-conductor-safari-plugin-safari-chatgpt-final-message && rg -n "browser.final_message|createRelayState|assistant_message_id|raw_text" plugins/baa-safari`
7559+
7560+## 执行记录
7561+
7562+> 以下内容由执行任务的 AI 填写,创建任务时留空。
7563+
7564+### 开始执行
7565+
7566+- 执行者:`Codex`
7567+- 开始时间:`2026-04-03 08:20:00 +0800`
7568+- 状态变更:`待开始` → `进行中`
7569+
7570+### 完成摘要
7571+
7572+- 完成时间:`2026-04-03 08:56:16 +0800`
7573+- 状态变更:`进行中` → `已完成`
7574+- 修改了哪些文件:
7575+  - `plugins/baa-safari/final-message.js`
7576+  - `plugins/baa-safari/background.js`
7577+  - `plugins/baa-safari/controller.html`
7578+  - `plugins/baa-safari/controller.js`
7579+  - `plugins/baa-safari/manifest.json`
7580+  - `plugins/baa-safari/README.md`
7581+  - `plugins/baa-safari/background.test.cjs`
7582+  - `plugins/baa-safari/final-message.test.cjs`
7583+  - `plugins/baa-safari/baa-safari-host/BAASafariHost.xcodeproj/project.pbxproj`
7584+- 核心实现思路:
7585+  - 直接把 Firefox 的 pure-JS `final-message.js` 复制到 Safari 插件目录,保持 ChatGPT SSE/network 归并和 dedupe 语义一致。
7586+  - 在 `background.js` 建立 ChatGPT final-message relay observer、持久化 replay cache、本地 browser WS 客户端和 `browser.final_message` 上送。
7587+  - controller 页面新增 Local WS / Final Message 观测卡,便于现场确认 Safari 来源的 relay 是否已经到达 conductor。
7588+- 跑了哪些测试:
7589+  - `node --check plugins/baa-safari/background.js`
7590+  - `node --check plugins/baa-safari/controller.js`
7591+  - `node --check plugins/baa-safari/content-script.js`
7592+  - `node --check plugins/baa-safari/final-message.js`
7593+  - `node --check plugins/baa-safari/page-interceptor.js`
7594+  - `node --test plugins/baa-safari/*.test.cjs`
7595+  - `env LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 xcodebuild -project plugins/baa-safari/baa-safari-host/BAASafariHost.xcodeproj -scheme BAASafariHost -configuration Debug build`
7596+
7597+### 执行过程中遇到的问题
7598+
7599+- 任务卡文件不在当前 worktree 基线里,执行时从任务源目录补拷到本分支,避免后续分支看不到执行记录。
7600+
7601+### 剩余风险
7602+
7603+- Claude / Gemini 的 Safari final-message 接入仍可能暴露不同的 SSE 或页面结构兼容问题,需要在 Phase 2 单独收口
7604diff --git a/tests/browser/browser-control-e2e-smoke.test.mjs b/tests/browser/browser-control-e2e-smoke.test.mjs
7605index 7b93d28967dd877abf8fd9e14838adadf0f76bd5..14671eed1b14e4d064c2f04b9ed6dc94e795c605 100644
7606--- a/tests/browser/browser-control-e2e-smoke.test.mjs
7607+++ b/tests/browser/browser-control-e2e-smoke.test.mjs
7608@@ -179,7 +179,7 @@ async function expectQueueTimeout(queue, predicate, timeoutMs = 400) {
7609   );
7610 }
7611 
7612-async function connectFirefoxBridgeClient(wsUrl, clientId) {
7613+async function connectBrowserBridgeClient(wsUrl, clientId, nodePlatform = "firefox") {
7614   const socket = new WebSocket(wsUrl);
7615   const queue = createWebSocketMessageQueue(socket);
7616 
7617@@ -190,7 +190,7 @@ async function connectFirefoxBridgeClient(wsUrl, clientId) {
7618       clientId,
7619       nodeType: "browser",
7620       nodeCategory: "proxy",
7621-      nodePlatform: "firefox"
7622+      nodePlatform
7623     })
7624   );
7625 
7626@@ -213,6 +213,10 @@ async function connectFirefoxBridgeClient(wsUrl, clientId) {
7627   };
7628 }
7629 
7630+async function connectFirefoxBridgeClient(wsUrl, clientId) {
7631+  return await connectBrowserBridgeClient(wsUrl, clientId, "firefox");
7632+}
7633+
7634 async function fetchJson(url, init) {
7635   const response = await fetch(url, init);
7636   const text = await response.text();
7637@@ -2614,6 +2618,70 @@ test("browser control e2e smoke covers metadata read surface plus Claude and Cha
7638   }
7639 });
7640 
7641+test("browser control smoke accepts Safari bridge clients on the canonical websocket path", async () => {
7642+  const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-safari-ws-smoke-"));
7643+  const runtime = new ConductorRuntime(
7644+    {
7645+      nodeId: "mini-main",
7646+      host: "mini",
7647+      role: "primary",
7648+      controlApiBase: "https://conductor.example.test",
7649+      localApiBase: "http://127.0.0.1:0",
7650+      sharedToken: "replace-me",
7651+      paths: {
7652+        runsDir: "/tmp/runs",
7653+        stateDir
7654+      }
7655+    },
7656+    {
7657+      autoStartLoops: false,
7658+      now: () => 100
7659+    }
7660+  );
7661+
7662+  let client = null;
7663+
7664+  try {
7665+    const snapshot = await runtime.start();
7666+    const baseUrl = snapshot.controlApi.localApiBase;
7667+
7668+    client = await connectBrowserBridgeClient(
7669+      snapshot.controlApi.browserWsUrl,
7670+      "safari-browser-control-smoke",
7671+      "safari"
7672+    );
7673+
7674+    assert.equal(client.helloAck.clientId, "safari-browser-control-smoke");
7675+    assert.equal(client.helloAck.protocol, "baa.browser.local");
7676+    assert.equal(client.helloAck.wsUrl, snapshot.controlApi.browserWsUrl);
7677+    assert.deepEqual(client.helloAck.wsCompatUrls, [snapshot.controlApi.firefoxWsUrl]);
7678+    assert.equal(client.initialSnapshot.snapshot.browser.client_count, 1);
7679+    assert.equal(client.initialSnapshot.snapshot.browser.clients[0]?.node_platform, "safari");
7680+    assert.equal(client.initialSnapshot.snapshot.browser.ws_path, "/ws/browser");
7681+    assert.equal(client.credentialRequest.reason, "hello");
7682+
7683+    const browserStatus = await fetchJson(`${baseUrl}/v1/browser`);
7684+    assert.equal(browserStatus.response.status, 200);
7685+    assert.equal(browserStatus.payload.data.bridge.transport, "local_browser_ws");
7686+    assert.equal(browserStatus.payload.data.bridge.ws_path, "/ws/browser");
7687+    assert.equal(browserStatus.payload.data.bridge.ws_url, snapshot.controlApi.browserWsUrl);
7688+    assert.equal(browserStatus.payload.data.current_client.client_id, "safari-browser-control-smoke");
7689+    assert.equal(browserStatus.payload.data.current_client.node_platform, "safari");
7690+  } finally {
7691+    client?.queue.stop();
7692+
7693+    if (client?.socket && client.socket.readyState < WebSocket.CLOSING) {
7694+      client.socket.close(1000, "done");
7695+    }
7696+
7697+    await runtime.stop();
7698+    rmSync(stateDir, {
7699+      force: true,
7700+      recursive: true
7701+    });
7702+  }
7703+});
7704+
7705 test("browser delivery bridge uses proxy delivery on the routed business page and records target context", async () => {
7706   const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-smoke-"));
7707   const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-host-"));
7708@@ -2681,9 +2749,15 @@ test("browser delivery bridge uses proxy delivery on the routed business page an
7709     assert.equal(proxyDelivery.shell_page, false);
7710     assert.equal(proxyDelivery.target_tab_id, 51);
7711     assert.match(proxyDelivery.message_text, /\[BAA 执行结果\]/u);
7712-    assert.match(proxyDelivery.message_text, /line-1/u);
7713+    assert.equal(
7714+      proxyDelivery.message_text.includes("\"command\": \"i=1; while [ $i -le 260"),
7715+      true
7716+    );
7717     assert.doesNotMatch(proxyDelivery.message_text, /line-260/u);
7718-    assert.match(proxyDelivery.message_text, /超长截断$/u);
7719+    assert.match(
7720+      proxyDelivery.message_text,
7721+      /完整结果:https:\/\/conductor\.example\.test\/artifact\/exec\/[^ \n]+\.txt/u
7722+    );
7723 
7724     await expectQueueTimeout(
7725       client.queue,