baa-conductor


baa-conductor / plugins / baa-firefox
im_wower  ·  2026-04-01

controller.js

   1const LEGACY_STORAGE_KEYS = {
   2  claudeTabId: "baaFirefox.claudeTabId",
   3  endpoints: "baaFirefox.endpoints",
   4  lastHeaders: "baaFirefox.lastHeaders",
   5  lastCredentialAt: "baaFirefox.lastCredentialAt"
   6};
   7
   8const CONTROLLER_STORAGE_KEYS = {
   9  clientId: "baaFirefox.clientId",
  10  wsUrl: "baaFirefox.wsUrl",
  11  controlBaseUrl: "baaFirefox.controlBaseUrl",
  12  controlState: "baaFirefox.controlState",
  13  pageControls: "baaFirefox.pageControls",
  14  statusSchemaVersion: "baaFirefox.statusSchemaVersion",
  15  trackedTabs: "baaFirefox.trackedTabs",
  16  desiredTabs: "baaFirefox.desiredTabs",
  17  controllerRuntime: "baaFirefox.controllerRuntime",
  18  endpointsByPlatform: "baaFirefox.endpointsByPlatform",
  19  lastHeadersByPlatform: "baaFirefox.lastHeadersByPlatform",
  20  credentialCapturedAtByPlatform: "baaFirefox.credentialCapturedAtByPlatform",
  21  lastCredentialAtByPlatform: "baaFirefox.lastCredentialAtByPlatform",
  22  lastCredentialUrlByPlatform: "baaFirefox.lastCredentialUrlByPlatform",
  23  lastCredentialTabIdByPlatform: "baaFirefox.lastCredentialTabIdByPlatform",
  24  credentialFingerprintByPlatform: "baaFirefox.credentialFingerprintByPlatform",
  25  accountByPlatform: "baaFirefox.accountByPlatform",
  26  chatgptSendTemplates: "baaFirefox.chatgptSendTemplates",
  27  geminiSendTemplates: "baaFirefox.geminiSendTemplates",
  28  geminiSendTemplate: "baaFirefox.geminiSendTemplate",
  29  claudeState: "baaFirefox.claudeState",
  30  finalMessageRelayCache: "baaFirefox.finalMessageRelayCache"
  31};
  32
  33const DEFAULT_LOCAL_API_BASE = "http://100.71.210.78:4317";
  34const DEFAULT_WS_URL = "ws://100.71.210.78:4317/ws/firefox";
  35const DEFAULT_CONTROL_BASE_URL = "http://100.71.210.78:4317";
  36const STATUS_SCHEMA_VERSION = 5;
  37const CREDENTIAL_SEND_INTERVAL = 30_000;
  38const CREDENTIAL_TTL = 15 * 60_000;
  39const NETWORK_BODY_LIMIT = 5000;
  40const LOG_LIMIT = 500;
  41const PLUGIN_DIAGNOSTIC_BUFFER_LIMIT = 50;
  42const PROXY_MESSAGE_RETRY = 10;
  43const PROXY_MESSAGE_RETRY_DELAY = 400;
  44const CONTROL_REFRESH_INTERVAL = 15_000;
  45const CONTROL_RETRY_DELAYS = [1_000, 3_000, 5_000];
  46const CONTROL_RETRY_SLOW_INTERVAL = 30_000;
  47const CONTROL_RETRY_LOG_INTERVAL = 60_000;
  48const TRACKED_TAB_REFRESH_DELAY = 150;
  49const SHELL_RUNTIME_HEALTHCHECK_INTERVAL = 30_000;
  50const CONTROL_STATUS_BODY_LIMIT = 12_000;
  51const FINAL_MESSAGE_RELAY_CACHE_LIMIT = 20;
  52const STARTUP_OPEN_AI_TAB_RELOAD_DELAY = 2_500;
  53const SESSION_CONTROLLER_BOOT_MARKER_KEY = "baaFirefox.controllerSessionBootMarker";
  54const CONTENT_SCRIPT_INJECTION_FILES = ["delivery-adapters.js", "content-script.js"];
  55const PAGE_INTERCEPTOR_INJECTION_FILES = ["page-interceptor.js"];
  56const WS_RECONNECT_DELAY = 3_000;
  57const MANUAL_WS_RECONNECT_DEFAULT_DISCONNECT_MS = 80;
  58const MANUAL_WS_RECONNECT_MAX_DISCONNECT_MS = 60_000;
  59const MANUAL_WS_RECONNECT_MAX_REPEAT_COUNT = 20;
  60const MANUAL_WS_RECONNECT_MAX_REPEAT_INTERVAL_MS = 60_000;
  61const MANUAL_WS_RECONNECT_OPEN_TIMEOUT = 10_000;
  62const MANUAL_WS_RECONNECT_POLL_INTERVAL = 100;
  63const MANUAL_WS_RECONNECT_START_DELAY_MS = 80;
  64const PROXY_REQUEST_TIMEOUT = 180_000;
  65const DELIVERY_COMMAND_TIMEOUT = 30_000;
  66const CLAUDE_MESSAGE_LIMIT = 20;
  67const CLAUDE_TOOL_PLACEHOLDER_RE = /```\n?This block is not supported on your current device yet\.?\n?```/g;
  68const CLAUDE_THINKING_START_RE = /^(The user|Let me|I need to|I should|I'll|George|User |Looking at|This is a|OK[,.]|Alright|Hmm|Now |Here|So |Wait|Actually|My |Their |His |Her |We |用户|让我|我需要|我来|我想|好的|那|先|接下来)/;
  69const SHELL_TAB_HASH = "#baa-shell";
  70const REDACTED_CREDENTIAL_VALUE = "[redacted]";
  71const ACCOUNT_EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i;
  72const CHATGPT_SEND_TEMPLATE_LIMIT = 12;
  73const CHATGPT_SEND_TEMPLATE_TTL = CREDENTIAL_TTL;
  74const GEMINI_SEND_TEMPLATE_LIMIT = 12;
  75const GEMINI_SEND_TEMPLATE_TTL = CREDENTIAL_TTL;
  76const GEMINI_SHELL_TEMPLATE_KEY = "__shell__";
  77const GEMINI_FALLBACK_TEMPLATE_KEY = "__fallback__";
  78const CHATGPT_SESSION_COOKIE_PATTERNS = [
  79  /__secure-next-auth\.session-token=/i,
  80  /__secure-authjs\.session-token=/i,
  81  /next-auth\.session-token=/i
  82];
  83const FORBIDDEN_PROXY_HEADER_NAMES = new Set([
  84  "accept-encoding",
  85  "connection",
  86  "content-length",
  87  "cookie",
  88  "host",
  89  "origin",
  90  "referer",
  91  "user-agent"
  92]);
  93const DIAGNOSTIC_LOG_DEBUG_PREFIXES = ["[FM-", "[SSE]"];
  94const DIAGNOSTIC_LOG_DEBUG_EVENT_RE = /\b(page_bridge_ready|interceptor_active|fetch_intercepted|sse_stream_start|sse_stream_done)\b/u;
  95
  96function hostnameMatches(hostname, hosts) {
  97  return hosts.some((host) => hostname === host || hostname.endsWith(`.${host}`));
  98}
  99
 100function isLikelyStaticPath(pathname = "") {
 101  const lower = pathname.toLowerCase();
 102  return lower.startsWith("/_next/")
 103    || lower.startsWith("/assets/")
 104    || lower.startsWith("/static/")
 105    || lower.startsWith("/images/")
 106    || lower.startsWith("/fonts/")
 107    || lower.startsWith("/favicon")
 108    || /\.(?:js|mjs|css|map|png|jpe?g|gif|svg|webp|ico|woff2?|ttf|otf|mp4|webm|txt)$/i.test(lower);
 109}
 110
 111const PLATFORMS = {
 112  claude: {
 113    label: "Claude",
 114    rootUrl: "https://claude.ai/",
 115    shellUrl: `https://claude.ai/${SHELL_TAB_HASH}`,
 116    urlPatterns: ["*://claude.ai/*"],
 117    hosts: ["claude.ai"],
 118    requestUrlPatterns: ["*://claude.ai/*"],
 119    requestHosts: ["claude.ai"],
 120    matchesTabHost(hostname) {
 121      return hostnameMatches(hostname, this.hosts);
 122    },
 123    matchesRequestHost(hostname) {
 124      return hostnameMatches(hostname, this.requestHosts);
 125    },
 126    shouldTrackPath(pathname) {
 127      return pathname.includes("/api/");
 128    }
 129  },
 130  chatgpt: {
 131    label: "ChatGPT",
 132    rootUrl: "https://chatgpt.com/",
 133    shellUrl: `https://chatgpt.com/${SHELL_TAB_HASH}`,
 134    urlPatterns: ["*://chatgpt.com/*", "*://*.chatgpt.com/*", "*://chat.openai.com/*", "*://*.chat.openai.com/*"],
 135    hosts: ["chatgpt.com", "chat.openai.com"],
 136    requestUrlPatterns: [
 137      "*://chatgpt.com/*",
 138      "*://*.chatgpt.com/*",
 139      "*://openai.com/*",
 140      "*://*.openai.com/*",
 141      "*://chat.openai.com/*",
 142      "*://*.chat.openai.com/*",
 143      "*://oaiusercontent.com/*",
 144      "*://*.oaiusercontent.com/*"
 145    ],
 146    requestHosts: ["chatgpt.com", "chat.openai.com", "openai.com", "oaiusercontent.com"],
 147    matchesTabHost(hostname) {
 148      return hostnameMatches(hostname, this.hosts);
 149    },
 150    matchesRequestHost(hostname) {
 151      return hostnameMatches(hostname, this.requestHosts);
 152    },
 153    shouldTrackPath(pathname) {
 154      const lower = pathname.toLowerCase();
 155      return pathname.includes("/backend-api/")
 156        || pathname.includes("/backend-anon/")
 157        || pathname.includes("/public-api/")
 158        || lower.includes("/conversation")
 159        || lower.includes("/models")
 160        || lower.includes("/files")
 161        || (!isLikelyStaticPath(pathname) && (lower.includes("/api/") || lower.includes("/backend")));
 162    }
 163  },
 164  gemini: {
 165    label: "Gemini",
 166    rootUrl: "https://gemini.google.com/",
 167    shellUrl: `https://gemini.google.com/${SHELL_TAB_HASH}`,
 168    urlPatterns: ["*://gemini.google.com/*"],
 169    hosts: ["gemini.google.com"],
 170    requestUrlPatterns: ["*://gemini.google.com/*"],
 171    requestHosts: ["gemini.google.com"],
 172    matchesTabHost(hostname) {
 173      return hostnameMatches(hostname, this.hosts);
 174    },
 175    matchesRequestHost(hostname) {
 176      return hostnameMatches(hostname, this.requestHosts);
 177    },
 178    shouldTrackPath(pathname) {
 179      const lower = pathname.toLowerCase();
 180      return pathname.startsWith("/_/")
 181        || pathname.includes("/api/")
 182        || lower.includes("bardchatui")
 183        || lower.includes("streamgenerate")
 184        || lower.includes("generatecontent")
 185        || lower.includes("modelresponse")
 186        || (!isLikelyStaticPath(pathname) && lower.includes("assistant"));
 187    }
 188  }
 189};
 190
 191const PLATFORM_ORDER = Object.keys(PLATFORMS);
 192const PLATFORM_REQUEST_URL_PATTERNS = PLATFORM_ORDER.flatMap((platform) => PLATFORMS[platform].requestUrlPatterns || PLATFORMS[platform].urlPatterns);
 193const pendingProxyRequests = new Map();
 194const FINAL_MESSAGE_HELPERS = globalThis.BAAFinalMessage || null;
 195
 196const state = {
 197  clientId: null,
 198  wsUrl: DEFAULT_WS_URL,
 199  controlBaseUrl: DEFAULT_CONTROL_BASE_URL,
 200  controlState: null,
 201  pageControls: {},
 202  wsState: null,
 203  ws: null,
 204  wsConnected: false,
 205  reconnectTimer: null,
 206  manualWsReconnectSequence: 0,
 207  controlRefreshTimer: null,
 208  controlRefreshInFlight: null,
 209  lastControlFailureLogAt: 0,
 210  lastControlFailureKey: "",
 211  trackedTabRefreshTimer: null,
 212  shellRuntimeTimer: null,
 213  startupOpenAiTabReloadTimer: null,
 214  shellRuntimeLastHealthCheckAt: 0,
 215  trackedTabRefreshRunning: false,
 216  trackedTabRefreshQueued: false,
 217  trackedTabs: createPlatformMap(() => null),
 218  desiredTabs: createPlatformMap((platform) => createDefaultDesiredTabState(platform)),
 219  actualTabs: createPlatformMap(() => createDefaultActualTabState()),
 220  endpoints: createPlatformMap(() => ({})),
 221  lastHeaders: createPlatformMap(() => ({})),
 222  credentialCapturedAt: createPlatformMap(() => 0),
 223  lastCredentialAt: createPlatformMap(() => 0),
 224  lastCredentialUrl: createPlatformMap(() => ""),
 225  lastCredentialTabId: createPlatformMap(() => null),
 226  credentialFingerprint: createPlatformMap(() => ""),
 227  account: createPlatformMap(() => createDefaultAccountState()),
 228  lastCredentialHash: createPlatformMap(() => ""),
 229  lastCredentialSentAt: createPlatformMap(() => 0),
 230  chatgptSendTemplates: {},
 231  geminiSendTemplates: {},
 232  claudeState: createDefaultClaudeState(),
 233  controllerRuntime: createDefaultControllerRuntimeState(),
 234  finalMessageRelayObservers: createPlatformMap((platform) => createFinalMessageRelayObserver(platform)),
 235  logs: [],
 236  pendingPluginDiagnosticLogs: []
 237};
 238
 239const ui = {};
 240
 241function qs(id) {
 242  return document.getElementById(id);
 243}
 244
 245function isRecord(value) {
 246  return !!value && typeof value === "object" && !Array.isArray(value);
 247}
 248
 249function trimTrailingSlash(value) {
 250  return String(value || "").trim().replace(/\/+$/u, "");
 251}
 252
 253function getPlatformShellUrl(platform) {
 254  return PLATFORMS[platform]?.shellUrl || PLATFORMS[platform]?.rootUrl || "";
 255}
 256
 257function matchesPlatformFallbackShellUrl(platform, parsed) {
 258  const pathname = trimTrailingSlash(parsed?.pathname || "") || "/";
 259
 260  switch (platform) {
 261    case "claude":
 262      return pathname === "/new";
 263    case "chatgpt":
 264      return pathname === "/";
 265    case "gemini":
 266      return pathname === "/app";
 267    default:
 268      return false;
 269  }
 270}
 271
 272function isPlatformShellUrl(platform, url, options = {}) {
 273  if (!platform || !PLATFORMS[platform] || !url) return false;
 274  const allowFallback = options.allowFallback === true;
 275
 276  try {
 277    const parsed = new URL(url, PLATFORMS[platform].rootUrl);
 278    const expected = new URL(getPlatformShellUrl(platform));
 279    if (parsed.origin === expected.origin && parsed.pathname === expected.pathname && parsed.hash === expected.hash) {
 280      return true;
 281    }
 282
 283    return allowFallback && parsed.origin === expected.origin && matchesPlatformFallbackShellUrl(platform, parsed);
 284  } catch (_) {
 285    return false;
 286  }
 287}
 288
 289function normalizeSavedControlBaseUrl(value) {
 290  void value;
 291  return DEFAULT_CONTROL_BASE_URL;
 292}
 293
 294function getRuntimeOrigin() {
 295  try {
 296    return trimToNull(new URL(browser.runtime.getURL("controller.html")).origin);
 297  } catch (_) {
 298    return null;
 299  }
 300}
 301
 302function normalizeSavedWsUrl(value) {
 303  void value;
 304  return DEFAULT_WS_URL;
 305}
 306
 307function deriveControlBaseUrl(wsUrl) {
 308  void wsUrl;
 309  return DEFAULT_CONTROL_BASE_URL;
 310}
 311
 312function createDefaultControlState(overrides = {}) {
 313  return {
 314    mode: "unknown",
 315    leader: null,
 316    leaseHolder: null,
 317    queueDepth: null,
 318    activeRuns: null,
 319    ok: false,
 320    error: null,
 321    message: null,
 322    statusCode: null,
 323    syncedAt: 0,
 324    controlConnection: "disconnected",
 325    retryCount: 0,
 326    lastSuccessAt: 0,
 327    lastFailureAt: 0,
 328    nextRetryAt: 0,
 329    source: "bootstrap",
 330    raw: null,
 331    ...overrides
 332  };
 333}
 334
 335function cloneControlState(value) {
 336  if (!isRecord(value)) return createDefaultControlState();
 337  return {
 338    ...createDefaultControlState(),
 339    ...value
 340  };
 341}
 342
 343function normalizeConversationAutomationStatus(value) {
 344  const normalized = String(value || "").trim().toLowerCase();
 345  return normalized === "auto" || normalized === "manual" || normalized === "paused"
 346    ? normalized
 347    : null;
 348}
 349
 350function derivePageControlPaused(automationStatus, fallbackPaused = false) {
 351  const normalizedAutomationStatus = normalizeConversationAutomationStatus(automationStatus);
 352
 353  if (normalizedAutomationStatus == null) {
 354    return fallbackPaused === true;
 355  }
 356
 357  return normalizedAutomationStatus !== "auto";
 358}
 359
 360function createDefaultWsState(overrides = {}) {
 361  return {
 362    connection: "disconnected",
 363    wsUrl: DEFAULT_WS_URL,
 364    localApiBase: DEFAULT_LOCAL_API_BASE,
 365    clientId: null,
 366    protocol: null,
 367    version: null,
 368    serverIdentity: null,
 369    serverHost: null,
 370    serverRole: null,
 371    leaseState: null,
 372    clientCount: 0,
 373    retryCount: 0,
 374    nextRetryAt: 0,
 375    lastOpenAt: 0,
 376    lastMessageAt: 0,
 377    lastSnapshotAt: 0,
 378    lastCloseCode: null,
 379    lastCloseReason: null,
 380    lastError: null,
 381    lastSnapshotReason: null,
 382    raw: null,
 383    ...overrides
 384  };
 385}
 386
 387function cloneWsState(value) {
 388  if (!isRecord(value)) return createDefaultWsState();
 389  return {
 390    ...createDefaultWsState(),
 391    ...value
 392  };
 393}
 394
 395function createDefaultDesiredTabState(platform, overrides = {}) {
 396  return {
 397    exists: false,
 398    shellUrl: getPlatformShellUrl(platform),
 399    source: "bootstrap",
 400    reason: null,
 401    updatedAt: 0,
 402    lastAction: null,
 403    lastActionAt: 0,
 404    ...overrides
 405  };
 406}
 407
 408function cloneDesiredTabState(platform, value) {
 409  if (typeof value === "boolean") {
 410    return createDefaultDesiredTabState(platform, {
 411      exists: value
 412    });
 413  }
 414
 415  if (!isRecord(value)) {
 416    return createDefaultDesiredTabState(platform);
 417  }
 418
 419  return createDefaultDesiredTabState(platform, {
 420    exists: value.exists === true || value.shouldExist === true,
 421    shellUrl: trimToNull(value.shellUrl) || getPlatformShellUrl(platform),
 422    source: trimToNull(value.source) || "storage",
 423    reason: trimToNull(value.reason),
 424    updatedAt: Number(value.updatedAt) || 0,
 425    lastAction: trimToNull(value.lastAction),
 426    lastActionAt: Number(value.lastActionAt) || 0
 427  });
 428}
 429
 430function createDefaultActualTabState(overrides = {}) {
 431  return {
 432    exists: false,
 433    tabId: null,
 434    url: null,
 435    title: null,
 436    windowId: null,
 437    active: false,
 438    status: null,
 439    discarded: false,
 440    hidden: false,
 441    healthy: false,
 442    issue: "missing",
 443    lastSeenAt: 0,
 444    lastReadyAt: 0,
 445    updatedAt: 0,
 446    candidateTabId: null,
 447    candidateUrl: null,
 448    candidateTitle: null,
 449    candidateStatus: null,
 450    ...overrides
 451  };
 452}
 453
 454function cloneActualTabState(value) {
 455  if (!isRecord(value)) {
 456    return createDefaultActualTabState();
 457  }
 458
 459  const hasIssue = Object.prototype.hasOwnProperty.call(value, "issue");
 460  return createDefaultActualTabState({
 461    exists: value.exists === true,
 462    tabId: Number.isInteger(value.tabId) ? value.tabId : null,
 463    url: trimToNull(value.url),
 464    title: trimToNull(value.title),
 465    windowId: Number.isInteger(value.windowId) ? value.windowId : null,
 466    active: value.active === true,
 467    status: trimToNull(value.status),
 468    discarded: value.discarded === true,
 469    hidden: value.hidden === true,
 470    healthy: value.healthy === true,
 471    issue: hasIssue ? trimToNull(value.issue) : "missing",
 472    lastSeenAt: Number(value.lastSeenAt) || 0,
 473    lastReadyAt: Number(value.lastReadyAt) || 0,
 474    updatedAt: Number(value.updatedAt) || 0,
 475    candidateTabId: Number.isInteger(value.candidateTabId) ? value.candidateTabId : null,
 476    candidateUrl: trimToNull(value.candidateUrl),
 477    candidateTitle: trimToNull(value.candidateTitle),
 478    candidateStatus: trimToNull(value.candidateStatus)
 479  });
 480}
 481
 482function createDefaultControllerRuntimeState(overrides = {}) {
 483  return {
 484    tabId: null,
 485    ready: false,
 486    status: "booting",
 487    lastReadyAt: 0,
 488    lastReloadAt: 0,
 489    lastAction: null,
 490    lastActionAt: 0,
 491    extensionOrigin: null,
 492    ...overrides
 493  };
 494}
 495
 496function cloneControllerRuntimeState(value) {
 497  if (!isRecord(value)) {
 498    return createDefaultControllerRuntimeState();
 499  }
 500
 501  return createDefaultControllerRuntimeState({
 502    tabId: Number.isInteger(value.tabId) ? value.tabId : null,
 503    ready: value.ready === true,
 504    status: trimToNull(value.status) || "booting",
 505    lastReadyAt: Number(value.lastReadyAt) || 0,
 506    lastReloadAt: Number(value.lastReloadAt) || 0,
 507    lastAction: trimToNull(value.lastAction),
 508    lastActionAt: Number(value.lastActionAt) || 0,
 509    extensionOrigin: trimToNull(value.extensionOrigin)
 510  });
 511}
 512
 513function createDefaultPageControlState(overrides = {}) {
 514  return {
 515    key: null,
 516    platform: null,
 517    tabId: null,
 518    conversationId: null,
 519    localConversationId: null,
 520    pageUrl: null,
 521    pageTitle: null,
 522    shellPage: false,
 523    automationStatus: null,
 524    lastNonPausedAutomationStatus: null,
 525    paused: false,
 526    pauseSource: null,
 527    pauseReason: null,
 528    updatedAt: 0,
 529    ...overrides
 530  };
 531}
 532
 533function clonePageControlState(value) {
 534  if (!isRecord(value)) {
 535    return createDefaultPageControlState();
 536  }
 537
 538  const platform = trimToNull(value.platform);
 539  const normalizedPlatform = platform && PLATFORMS[platform] ? platform : null;
 540
 541  return createDefaultPageControlState({
 542    key: trimToNull(value.key),
 543    platform: normalizedPlatform,
 544    tabId: Number.isInteger(value.tabId) ? value.tabId : null,
 545    conversationId: trimToNull(value.conversationId),
 546    localConversationId: trimToNull(value.localConversationId),
 547    pageUrl: trimToNull(value.pageUrl),
 548    pageTitle: trimToNull(value.pageTitle),
 549    shellPage: value.shellPage === true,
 550    automationStatus: normalizeConversationAutomationStatus(value.automationStatus),
 551    lastNonPausedAutomationStatus: normalizeConversationAutomationStatus(value.lastNonPausedAutomationStatus),
 552    paused: derivePageControlPaused(
 553      normalizeConversationAutomationStatus(value.automationStatus),
 554      value.paused === true
 555    ),
 556    pauseSource: trimToNull(value.pauseSource),
 557    pauseReason: trimToNull(value.pauseReason),
 558    updatedAt: Number(value.updatedAt) || 0
 559  });
 560}
 561
 562function getPageControlKey(platform, tabId) {
 563  if (!platform || !PLATFORMS[platform] || !Number.isInteger(tabId) || tabId < 0) {
 564    return null;
 565  }
 566
 567  return `${platform}:${tabId}`;
 568}
 569
 570function loadPageControls(raw) {
 571  const next = {};
 572
 573  if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
 574    return next;
 575  }
 576
 577  for (const value of Object.values(raw)) {
 578    const entry = clonePageControlState(value);
 579    const key = getPageControlKey(entry.platform, entry.tabId);
 580
 581    if (!key) {
 582      continue;
 583    }
 584
 585    next[key] = createDefaultPageControlState({
 586      ...entry,
 587      key
 588    });
 589  }
 590
 591  return next;
 592}
 593
 594function listPageControlStates(options = {}) {
 595  const platform = trimToNull(options.platform);
 596  return Object.values(state.pageControls || {})
 597    .map((entry) => clonePageControlState(entry))
 598    .filter((entry) => entry.platform && Number.isInteger(entry.tabId))
 599    .filter((entry) => !platform || entry.platform === platform)
 600    .sort((left, right) => {
 601      const leftKey = `${left.platform}:${String(left.tabId).padStart(12, "0")}`;
 602      const rightKey = `${right.platform}:${String(right.tabId).padStart(12, "0")}`;
 603      return leftKey.localeCompare(rightKey);
 604    });
 605}
 606
 607function getPageControlState(platform, tabId) {
 608  const key = getPageControlKey(platform, tabId);
 609  return key ? clonePageControlState(state.pageControls[key]) : createDefaultPageControlState();
 610}
 611
 612function findPageControlByConversation(platform, conversationId) {
 613  const normalizedConversationId = trimToNull(conversationId);
 614
 615  if (!platform || !normalizedConversationId) {
 616    return null;
 617  }
 618
 619  return listPageControlStates({ platform }).find((entry) => entry.conversationId === normalizedConversationId) || null;
 620}
 621
 622function findPageControlByUrl(platform, pageUrl) {
 623  const normalizedPageUrl = trimToNull(pageUrl);
 624
 625  if (!platform || !normalizedPageUrl) {
 626    return null;
 627  }
 628
 629  return listPageControlStates({ platform }).find((entry) => entry.pageUrl === normalizedPageUrl) || null;
 630}
 631
 632function summarizePageControls(platform) {
 633  const entries = listPageControlStates({ platform });
 634
 635  if (entries.length === 0) {
 636    return "无";
 637  }
 638
 639  const pausedEntries = entries.filter((entry) => entry.paused);
 640
 641  if (pausedEntries.length === 0) {
 642    return `${entries.length} 页观察中`;
 643  }
 644
 645  const labels = pausedEntries.map((entry) => {
 646    const conversation = entry.conversationId ? ` conv=${entry.conversationId.slice(0, 12)}` : "";
 647    return `#${entry.tabId}${conversation}`;
 648  });
 649
 650  return `paused(${pausedEntries.length}/${entries.length}): ${labels.join(", ")}`;
 651}
 652
 653function serializePageControlState(entry) {
 654  const normalized = clonePageControlState(entry);
 655
 656  if (!normalized.platform || !Number.isInteger(normalized.tabId)) {
 657    return null;
 658  }
 659
 660  return {
 661    key: getPageControlKey(normalized.platform, normalized.tabId),
 662    platform: normalized.platform,
 663    tabId: normalized.tabId,
 664    conversationId: normalized.conversationId,
 665    localConversationId: normalized.localConversationId,
 666    pageUrl: normalized.pageUrl,
 667    pageTitle: normalized.pageTitle,
 668    shellPage: normalized.shellPage,
 669    automationStatus: normalized.automationStatus,
 670    lastNonPausedAutomationStatus: normalized.lastNonPausedAutomationStatus,
 671    paused: normalized.paused,
 672    pauseSource: normalized.pauseSource,
 673    pauseReason: normalized.pauseReason,
 674    status: normalized.automationStatus || (normalized.paused ? "paused" : "running"),
 675    updatedAt: normalized.updatedAt || 0
 676  };
 677}
 678
 679function updatePageControlState(input = {}, options = {}) {
 680  const platform = trimToNull(input.platform);
 681  const tabId = Number.isInteger(input.tabId) ? input.tabId : null;
 682  const key = getPageControlKey(platform, tabId);
 683
 684  if (!key) {
 685    return null;
 686  }
 687
 688  const previous = clonePageControlState(state.pageControls[key]);
 689  const next = createDefaultPageControlState({
 690    ...previous,
 691    key,
 692    platform,
 693    tabId
 694  });
 695  let changed = !state.pageControls[key];
 696  let conversationChanged = false;
 697
 698  if (Object.prototype.hasOwnProperty.call(input, "conversationId")) {
 699    const conversationId = trimToNull(input.conversationId);
 700    if (next.conversationId !== conversationId) {
 701      next.conversationId = conversationId;
 702      conversationChanged = true;
 703      changed = true;
 704    }
 705  }
 706
 707  if (Object.prototype.hasOwnProperty.call(input, "localConversationId")) {
 708    const localConversationId = trimToNull(input.localConversationId);
 709    if (next.localConversationId !== localConversationId) {
 710      next.localConversationId = localConversationId;
 711      changed = true;
 712    }
 713  }
 714
 715  if (Object.prototype.hasOwnProperty.call(input, "pageUrl")) {
 716    const pageUrl = trimToNull(input.pageUrl);
 717    if (next.pageUrl !== pageUrl) {
 718      next.pageUrl = pageUrl;
 719      changed = true;
 720    }
 721  }
 722
 723  if (Object.prototype.hasOwnProperty.call(input, "pageTitle")) {
 724    const pageTitle = trimToNull(input.pageTitle);
 725    if (next.pageTitle !== pageTitle) {
 726      next.pageTitle = pageTitle;
 727      changed = true;
 728    }
 729  }
 730
 731  if (Object.prototype.hasOwnProperty.call(input, "shellPage")) {
 732    const shellPage = input.shellPage === true;
 733    if (next.shellPage !== shellPage) {
 734      next.shellPage = shellPage;
 735      changed = true;
 736    }
 737  }
 738
 739  if (Object.prototype.hasOwnProperty.call(input, "automationStatus")) {
 740    const automationStatus = normalizeConversationAutomationStatus(input.automationStatus);
 741    if (next.automationStatus !== automationStatus) {
 742      next.automationStatus = automationStatus;
 743      changed = true;
 744    }
 745  }
 746
 747  if (Object.prototype.hasOwnProperty.call(input, "lastNonPausedAutomationStatus")) {
 748    const lastNonPausedAutomationStatus = normalizeConversationAutomationStatus(input.lastNonPausedAutomationStatus);
 749    if (next.lastNonPausedAutomationStatus !== lastNonPausedAutomationStatus) {
 750      next.lastNonPausedAutomationStatus = lastNonPausedAutomationStatus;
 751      changed = true;
 752    }
 753  }
 754
 755  if (
 756    conversationChanged
 757    && !Object.prototype.hasOwnProperty.call(input, "automationStatus")
 758    && !Object.prototype.hasOwnProperty.call(input, "lastNonPausedAutomationStatus")
 759    && !Object.prototype.hasOwnProperty.call(input, "localConversationId")
 760    && previous.localConversationId != null
 761  ) {
 762    next.localConversationId = null;
 763    next.automationStatus = null;
 764    next.lastNonPausedAutomationStatus = null;
 765    next.paused = false;
 766    next.pauseSource = null;
 767    next.pauseReason = null;
 768    changed = true;
 769  }
 770
 771  if (Object.prototype.hasOwnProperty.call(input, "paused")) {
 772    const paused = input.paused === true;
 773    const pauseSource = paused
 774      ? (trimToNull(input.pauseSource) || trimToNull(options.source) || next.pauseSource)
 775      : (trimToNull(input.pauseSource) || trimToNull(options.source) || null);
 776    const pauseReason = paused
 777      ? (trimToNull(input.pauseReason) || trimToNull(options.reason) || next.pauseReason || "page_paused")
 778      : (trimToNull(input.pauseReason) || trimToNull(options.reason) || null);
 779
 780    if (next.paused !== paused) {
 781      next.paused = paused;
 782      changed = true;
 783    }
 784
 785    if (next.pauseSource !== pauseSource) {
 786      next.pauseSource = pauseSource;
 787      changed = true;
 788    }
 789
 790    if (next.pauseReason !== pauseReason) {
 791      next.pauseReason = pauseReason;
 792      changed = true;
 793    }
 794  }
 795
 796  if (Object.prototype.hasOwnProperty.call(input, "automationStatus")) {
 797    const paused = derivePageControlPaused(next.automationStatus, next.paused);
 798    const pauseSource = paused
 799      ? (trimToNull(input.pauseSource) || trimToNull(options.source) || next.pauseSource)
 800      : null;
 801    const pauseReason =
 802      next.automationStatus === "paused"
 803        ? (trimToNull(input.pauseReason) || trimToNull(options.reason) || next.pauseReason || "user_pause")
 804        : null;
 805
 806    if (next.paused !== paused) {
 807      next.paused = paused;
 808      changed = true;
 809    }
 810
 811    if (next.pauseSource !== pauseSource) {
 812      next.pauseSource = pauseSource;
 813      changed = true;
 814    }
 815
 816    if (next.pauseReason !== pauseReason) {
 817      next.pauseReason = pauseReason;
 818      changed = true;
 819    }
 820  }
 821
 822  if (!changed) {
 823    return serializePageControlState(previous);
 824  }
 825
 826  next.updatedAt = Number(input.updatedAt) || Date.now();
 827  state.pageControls[key] = next;
 828
 829  if (options.persist !== false) {
 830    persistState().catch(() => {});
 831  }
 832
 833  if (options.render !== false) {
 834    render();
 835  }
 836
 837  return serializePageControlState(next);
 838}
 839
 840function removePageControlState(platform, tabId, options = {}) {
 841  const key = getPageControlKey(platform, tabId);
 842
 843  if (!key || !state.pageControls[key]) {
 844    return false;
 845  }
 846
 847  delete state.pageControls[key];
 848
 849  if (options.persist !== false) {
 850    persistState().catch(() => {});
 851  }
 852
 853  if (options.render !== false) {
 854    render();
 855  }
 856
 857  return true;
 858}
 859
 860function removePageControlStatesByTabId(tabId, options = {}) {
 861  if (!Number.isInteger(tabId)) {
 862    return 0;
 863  }
 864
 865  const removedKeys = Object.keys(state.pageControls || {}).filter((key) => {
 866    const entry = state.pageControls[key];
 867    return Number.isInteger(entry?.tabId) && entry.tabId === tabId;
 868  });
 869
 870  if (removedKeys.length === 0) {
 871    return 0;
 872  }
 873
 874  for (const key of removedKeys) {
 875    delete state.pageControls[key];
 876  }
 877
 878  if (options.persist !== false) {
 879    persistState().catch(() => {});
 880  }
 881
 882  if (options.render !== false) {
 883    render();
 884  }
 885
 886  return removedKeys.length;
 887}
 888
 889function findPausedPageControlByConversation(platform, conversationId) {
 890  const normalizedConversationId = trimToNull(conversationId);
 891
 892  if (!platform || !normalizedConversationId) {
 893    return null;
 894  }
 895
 896  return listPageControlStates({ platform }).find((entry) =>
 897    entry.paused && entry.conversationId === normalizedConversationId
 898  ) || null;
 899}
 900
 901function normalizeClaudeMessageRole(value) {
 902  if (value === "human" || value === "user") return "user";
 903  if (value === "assistant") return "assistant";
 904  return typeof value === "string" && value.trim() ? value.trim() : "unknown";
 905}
 906
 907function parseClaudeTimestamp(value) {
 908  if (Number.isFinite(value)) return Number(value);
 909  if (typeof value === "string" && value.trim()) {
 910    const timestamp = Date.parse(value);
 911    return Number.isFinite(timestamp) ? timestamp : null;
 912  }
 913  return null;
 914}
 915
 916function normalizeClaudeMessageContent(message) {
 917  if (Array.isArray(message?.content) && message.content.length > 0) {
 918    const textBlocks = message.content
 919      .filter((block) => block?.type === "text" && typeof block.text === "string")
 920      .map((block) => block.text);
 921    const thinkingBlocks = message.content
 922      .filter((block) => block?.type === "thinking" && typeof block.thinking === "string")
 923      .map((block) => block.thinking);
 924    return {
 925      content: textBlocks.join("").trim() || null,
 926      thinking: thinkingBlocks.join("").trim() || null
 927    };
 928  }
 929
 930  let raw = typeof message?.text === "string" ? message.text : "";
 931  raw = raw.replace(CLAUDE_TOOL_PLACEHOLDER_RE, "").trim();
 932  raw = raw.replace(/\n{3,}/g, "\n\n");
 933
 934  if (raw && normalizeClaudeMessageRole(message?.sender) === "assistant" && CLAUDE_THINKING_START_RE.test(raw)) {
 935    const splitIndex = raw.search(/\n\n(?:[#*\-|>]|\*\*|\d+\.|[\u4e00-\u9fff])/);
 936    if (splitIndex > 20) {
 937      return {
 938        content: raw.slice(splitIndex + 2).trim() || null,
 939        thinking: raw.slice(0, splitIndex).trim() || null
 940      };
 941    }
 942
 943    return {
 944      content: null,
 945      thinking: raw
 946    };
 947  }
 948
 949  return {
 950    content: raw || null,
 951    thinking: null
 952  };
 953}
 954
 955function normalizeClaudeMessage(message, index = 0) {
 956  const parts = normalizeClaudeMessageContent(message);
 957  return {
 958    id: typeof message?.uuid === "string" && message.uuid ? message.uuid : null,
 959    role: normalizeClaudeMessageRole(message?.sender),
 960    content: parts.content,
 961    thinking: parts.thinking,
 962    timestamp: parseClaudeTimestamp(message?.created_at),
 963    seq: Number.isFinite(index) ? Number(index) : null
 964  };
 965}
 966
 967function normalizeClaudeMessages(messages, limit = CLAUDE_MESSAGE_LIMIT) {
 968  const source = Array.isArray(messages) ? messages : [];
 969  const normalized = source.map((message, index) => normalizeClaudeMessage(message, index));
 970  if (normalized.length <= limit) return normalized;
 971  return normalized.slice(-limit);
 972}
 973
 974function createDefaultClaudeState(overrides = {}) {
 975  return {
 976    organizationId: null,
 977    conversationId: null,
 978    title: null,
 979    titleSource: null,
 980    currentUrl: PLATFORMS?.claude?.rootUrl || "https://claude.ai/",
 981    tabId: null,
 982    tabTitle: null,
 983    busy: false,
 984    busyReason: null,
 985    lastError: null,
 986    lastActivityAt: 0,
 987    lastReadAt: 0,
 988    lastSendAt: 0,
 989    lastConversationSource: null,
 990    lastAssistantMessageUuid: null,
 991    model: null,
 992    messages: [],
 993    ...overrides
 994  };
 995}
 996
 997function cloneClaudeState(value) {
 998  if (!isRecord(value)) return createDefaultClaudeState();
 999  return {
1000    ...createDefaultClaudeState(),
1001    ...value,
1002    messages: Array.isArray(value.messages)
1003      ? value.messages.map((message) => ({
1004        id: typeof message?.id === "string" ? message.id : null,
1005        role: typeof message?.role === "string" ? message.role : "unknown",
1006        content: typeof message?.content === "string" ? message.content : null,
1007        thinking: typeof message?.thinking === "string" ? message.thinking : null,
1008        timestamp: Number.isFinite(message?.timestamp) ? Number(message.timestamp) : null,
1009        seq: Number.isFinite(message?.seq) ? Number(message.seq) : null
1010      }))
1011      : []
1012  };
1013}
1014
1015function loadClaudeState(raw) {
1016  const next = cloneClaudeState(raw);
1017  next.busy = false;
1018  next.busyReason = null;
1019  next.lastError = null;
1020  return next;
1021}
1022
1023function createDefaultAccountState(overrides = {}) {
1024  return {
1025    value: null,
1026    kind: null,
1027    source: null,
1028    capturedAt: 0,
1029    lastSeenAt: 0,
1030    priority: 0,
1031    ...overrides
1032  };
1033}
1034
1035function cloneAccountState(value) {
1036  if (!isRecord(value)) {
1037    return createDefaultAccountState();
1038  }
1039
1040  return createDefaultAccountState({
1041    value: trimToNull(value.value),
1042    kind: trimToNull(value.kind),
1043    source: trimToNull(value.source),
1044    capturedAt: Number(value.capturedAt) || 0,
1045    lastSeenAt: Number(value.lastSeenAt) || 0,
1046    priority: Number(value.priority) || 0
1047  });
1048}
1049
1050function trimToNull(value) {
1051  return typeof value === "string" && value.trim() ? value.trim() : null;
1052}
1053
1054function buildRuntimeRequestId(prefix = "runtime") {
1055  if (typeof crypto?.randomUUID === "function") {
1056    return `${prefix}-${crypto.randomUUID()}`;
1057  }
1058  return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1059}
1060
1061function parseClaudeApiContext(url) {
1062  try {
1063    const parsed = new URL(url, PLATFORMS.claude.rootUrl);
1064    const pathname = parsed.pathname || "/";
1065    const organizationMatch = pathname.match(/\/api\/organizations\/([a-f0-9-]{36})(?:\/|$)/i);
1066    const conversationMatch = pathname.match(/\/chat_conversations\/([a-f0-9-]{36})(?:\/|$)/i);
1067    return {
1068      pathname,
1069      organizationId: organizationMatch ? organizationMatch[1] : null,
1070      conversationId: conversationMatch ? conversationMatch[1] : null,
1071      isConversationList: /\/api\/organizations\/[a-f0-9-]{36}\/chat_conversations$/i.test(pathname),
1072      isConversationItem: /\/api\/organizations\/[a-f0-9-]{36}\/chat_conversations\/[a-f0-9-]{36}$/i.test(pathname),
1073      isCompletion: /\/api\/organizations\/[a-f0-9-]{36}\/chat_conversations\/[a-f0-9-]{36}\/completion$/i.test(pathname)
1074    };
1075  } catch (_) {
1076    return {
1077      pathname: "",
1078      organizationId: null,
1079      conversationId: null,
1080      isConversationList: false,
1081      isConversationItem: false,
1082      isCompletion: false
1083    };
1084  }
1085}
1086
1087function extractChatgptConversationIdFromPageUrl(url) {
1088  try {
1089    const parsed = new URL(url, PLATFORMS.chatgpt.rootUrl);
1090    const pathname = parsed.pathname || "/";
1091    const match = pathname.match(/\/c\/([^/?#]+)/u);
1092
1093    if (match?.[1]) {
1094      return match[1];
1095    }
1096
1097    return trimToNull(parsed.searchParams.get("conversation_id"));
1098  } catch (_) {
1099    return null;
1100  }
1101}
1102
1103function extractGeminiConversationIdFromPageUrl(url) {
1104  try {
1105    const parsed = new URL(url, PLATFORMS.gemini.rootUrl);
1106    const pathname = parsed.pathname || "/";
1107    const match = pathname.match(/\/app\/([^/?#]+)/u);
1108
1109    if (match?.[1]) {
1110      return match[1];
1111    }
1112
1113    return trimToNull(parsed.searchParams.get("conversation_id"));
1114  } catch (_) {
1115    return null;
1116  }
1117}
1118
1119function extractClaudeConversationIdFromPageUrl(url) {
1120  try {
1121    const parsed = new URL(url, PLATFORMS.claude.rootUrl);
1122    const match = (parsed.pathname || "").match(/([a-f0-9-]{36})(?:\/)?$/i);
1123    return match ? match[1] : null;
1124  } catch (_) {
1125    return null;
1126  }
1127}
1128
1129function extractConversationIdFromPageUrl(platform, url) {
1130  switch (platform) {
1131    case "claude":
1132      return extractClaudeConversationIdFromPageUrl(url);
1133    case "chatgpt":
1134      return extractChatgptConversationIdFromPageUrl(url);
1135    case "gemini":
1136      return extractGeminiConversationIdFromPageUrl(url);
1137    default:
1138      return null;
1139  }
1140}
1141
1142function extractChatgptConversationIdFromRequestBody(reqBody) {
1143  if (typeof reqBody !== "string" || !reqBody.trim()) {
1144    return null;
1145  }
1146
1147  try {
1148    const parsed = JSON.parse(reqBody);
1149
1150    if (!isRecord(parsed)) {
1151      return null;
1152    }
1153
1154    return trimToNull(parsed.conversation_id) || trimToNull(parsed.conversationId) || null;
1155  } catch (_) {
1156    return null;
1157  }
1158}
1159
1160function extractObservedConversationId(platform, data, context = null) {
1161  if (context?.conversationId) {
1162    return context.conversationId;
1163  }
1164
1165  switch (platform) {
1166    case "claude":
1167      return parseClaudeApiContext(data?.url || "").conversationId || null;
1168    case "chatgpt":
1169      return extractChatgptConversationIdFromRequestBody(data?.reqBody)
1170        || trimToNull(data?.conversation_id)
1171        || trimToNull(data?.conversationId)
1172        || null;
1173    case "gemini":
1174      return trimToNull(data?.conversation_id)
1175        || trimToNull(data?.conversationId)
1176        || null;
1177    default:
1178      return null;
1179  }
1180}
1181
1182function extractClaudeOrgId(headers = {}, requestUrl = "") {
1183  const headerOrgId = trimToNull(headers["x-org-id"]);
1184  if (headerOrgId) return headerOrgId;
1185
1186  const context = parseClaudeApiContext(requestUrl);
1187  if (context.organizationId) return context.organizationId;
1188
1189  const cookie = getHeaderValue(headers, "cookie");
1190  const cookieMatch = cookie.match(/(?:^|;\s*)lastActiveOrg=([a-f0-9-]{36})/i);
1191  if (cookieMatch) return cookieMatch[1];
1192
1193  return trimToNull(state.claudeState.organizationId) || null;
1194}
1195
1196function getClaudeOrgId() {
1197  return extractClaudeOrgId(state.lastHeaders.claude, state.lastCredentialUrl.claude || "");
1198}
1199
1200function getClaudeConversationIdFromState() {
1201  return trimToNull(state.claudeState.conversationId)
1202    || extractClaudeConversationIdFromPageUrl(state.claudeState.currentUrl || "")
1203    || parseClaudeApiContext(state.lastCredentialUrl.claude || "").conversationId
1204    || null;
1205}
1206
1207function getLastClaudeMessageUuid(messages) {
1208  const source = Array.isArray(messages) ? messages : [];
1209  for (let index = source.length - 1; index >= 0; index -= 1) {
1210    if (source[index]?.id) return source[index].id;
1211  }
1212  return null;
1213}
1214
1215function buildClaudeStateSnapshot() {
1216  const snapshot = cloneClaudeState(state.claudeState);
1217  return {
1218    platform: "claude",
1219    organizationId: snapshot.organizationId,
1220    conversationId: snapshot.conversationId,
1221    title: snapshot.title,
1222    currentUrl: snapshot.currentUrl,
1223    tabId: snapshot.tabId,
1224    busy: snapshot.busy,
1225    lastError: snapshot.lastError,
1226    lastActivityAt: snapshot.lastActivityAt || null,
1227    lastReadAt: snapshot.lastReadAt || null,
1228    lastSendAt: snapshot.lastSendAt || null,
1229    recentMessages: Array.isArray(snapshot.messages)
1230      ? snapshot.messages.slice(-CLAUDE_MESSAGE_LIMIT)
1231      : [],
1232    sources: {
1233      title: snapshot.titleSource || null,
1234      messages: snapshot.lastConversationSource || null,
1235      busy: snapshot.busyReason || null,
1236      currentUrl: "tab"
1237    }
1238  };
1239}
1240
1241function updateClaudeState(patch = {}, options = {}) {
1242  state.claudeState = cloneClaudeState({
1243    ...state.claudeState,
1244    ...patch
1245  });
1246  if (options.persist) {
1247    persistState().catch(() => {});
1248  }
1249  if (options.render) {
1250    render();
1251  }
1252  return state.claudeState;
1253}
1254
1255async function refreshClaudeTabState(createIfMissing = false) {
1256  const allowFallbackShell = cloneDesiredTabState("claude", state.desiredTabs.claude).exists;
1257  const tab = createIfMissing
1258    ? await ensurePlatformTab("claude", { focus: false })
1259    : (await resolveTrackedTab("claude", { requireShell: true, allowFallbackShell })) || await findPlatformShellTab("claude", null, { allowFallbackShell });
1260
1261  if (!tab) {
1262    updateClaudeState({
1263      tabId: null,
1264      currentUrl: getPlatformShellUrl("claude"),
1265      tabTitle: null
1266    }, {
1267      persist: true,
1268      render: true
1269    });
1270    return null;
1271  }
1272
1273  updateClaudeState({
1274    tabId: Number.isInteger(tab.id) ? tab.id : null,
1275    currentUrl: tab.url || state.claudeState.currentUrl || getPlatformShellUrl("claude"),
1276    tabTitle: trimToNull(tab.title),
1277    conversationId: isPlatformShellUrl("claude", tab.url || "", { allowFallback: allowFallbackShell })
1278      ? null
1279      : (extractClaudeConversationIdFromPageUrl(tab.url || "") || state.claudeState.conversationId),
1280    title: state.claudeState.title || trimToNull(tab.title),
1281    titleSource: state.claudeState.title ? state.claudeState.titleSource : (trimToNull(tab.title) ? "tab" : null)
1282  }, {
1283    persist: true,
1284    render: true
1285  });
1286
1287  return tab;
1288}
1289
1290function normalizeClaudeConversation(payload, meta = {}) {
1291  const source = isRecord(payload) ? payload : {};
1292  const messages = normalizeClaudeMessages(source.chat_messages || []);
1293  return {
1294    organizationId: trimToNull(meta.organizationId) || extractClaudeOrgId({}, meta.requestUrl || ""),
1295    conversationId: trimToNull(source.uuid) || trimToNull(meta.conversationId) || null,
1296    title: trimToNull(source.name),
1297    model: trimToNull(source.model),
1298    updatedAt: parseClaudeTimestamp(source.updated_at) || Date.now(),
1299    messages,
1300    lastAssistantMessageUuid: getLastClaudeMessageUuid(messages)
1301  };
1302}
1303
1304function applyClaudeConversation(payload, meta = {}) {
1305  const normalized = normalizeClaudeConversation(payload, meta);
1306  const fallbackTitle = trimToNull(state.claudeState.tabTitle);
1307  updateClaudeState({
1308    organizationId: normalized.organizationId || state.claudeState.organizationId,
1309    conversationId: normalized.conversationId || state.claudeState.conversationId,
1310    title: normalized.title || fallbackTitle || state.claudeState.title,
1311    titleSource: normalized.title ? "api" : (fallbackTitle ? "tab" : state.claudeState.titleSource),
1312    model: normalized.model || state.claudeState.model,
1313    messages: normalized.messages,
1314    lastAssistantMessageUuid: normalized.lastAssistantMessageUuid || state.claudeState.lastAssistantMessageUuid,
1315    lastConversationSource: meta.source || "api",
1316    lastReadAt: meta.readAt || Date.now(),
1317    lastActivityAt: normalized.updatedAt || Date.now(),
1318    lastError: null
1319  }, {
1320    persist: true,
1321    render: true
1322  });
1323  return buildClaudeStateSnapshot();
1324}
1325
1326function parseClaudeSseText(text) {
1327  let reply = "";
1328  let thinking = "";
1329  let messageUuid = null;
1330  let stopReason = null;
1331
1332  for (const line of String(text || "").split("\n")) {
1333    if (!line.startsWith("data: ")) continue;
1334    try {
1335      const data = JSON.parse(line.slice(6));
1336      if (data.type === "completion" && data.completion) {
1337        reply += data.completion;
1338        if (data.uuid) messageUuid = data.uuid;
1339      } else if (data.type === "message_start") {
1340        reply = "";
1341        thinking = "";
1342        messageUuid = data.message?.uuid || data.message?.id || messageUuid;
1343      } else if (data.type === "content_block_delta" && data.delta?.type === "text_delta") {
1344        reply += data.delta.text || "";
1345      } else if (data.type === "content_block_delta" && data.delta?.type === "thinking_delta") {
1346        thinking += data.delta.thinking || "";
1347      } else if (data.type === "thinking" && data.thinking) {
1348        thinking += data.thinking;
1349      }
1350
1351      if (!stopReason && typeof data.stop_reason === "string" && data.stop_reason) {
1352        stopReason = data.stop_reason;
1353      }
1354      if (!stopReason && data.type === "message_stop") {
1355        stopReason = "message_stop";
1356      }
1357    } catch (_) {}
1358  }
1359
1360  return {
1361    text: reply.trim(),
1362    thinking: thinking.trim() || null,
1363    messageUuid,
1364    stopReason
1365  };
1366}
1367
1368function parseStreamChunkPayload(chunk) {
1369  const source = String(chunk || "");
1370  const eventMatch = source.match(/(?:^|\n)event:\s*([^\n]+)/u);
1371  const dataLines = source
1372    .split("\n")
1373    .filter((line) => line.startsWith("data:"))
1374    .map((line) => line.slice(5).trimStart());
1375  const dataText = dataLines.join("\n");
1376  const payloadText = dataText || source;
1377
1378  try {
1379    return {
1380      data: JSON.parse(payloadText),
1381      event: eventMatch?.[1]?.trim() || null,
1382      raw: source
1383    };
1384  } catch (_) {
1385    return {
1386      data: payloadText,
1387      event: eventMatch?.[1]?.trim() || null,
1388      raw: source
1389    };
1390  }
1391}
1392
1393function createPlatformMap(factory) {
1394  const out = {};
1395  for (const platform of PLATFORM_ORDER) {
1396    out[platform] = factory(platform);
1397  }
1398  return out;
1399}
1400
1401function createFinalMessageRelayObserver(platform) {
1402  return FINAL_MESSAGE_HELPERS?.createRelayState(platform) || null;
1403}
1404
1405function normalizeRecentRelayKeys(value) {
1406  const source = Array.isArray(value) ? value : [];
1407  const seen = new Set();
1408  const normalized = [];
1409
1410  for (const entry of source) {
1411    const key = trimToNull(entry);
1412    if (!key || seen.has(key)) {
1413      continue;
1414    }
1415
1416    seen.add(key);
1417    normalized.push(key);
1418  }
1419
1420  return normalized.slice(-FINAL_MESSAGE_RELAY_CACHE_LIMIT);
1421}
1422
1423function serializeFinalMessageRelayCache() {
1424  return createPlatformMap((platform) => {
1425    const observer = state.finalMessageRelayObservers[platform];
1426    return observer ? normalizeRecentRelayKeys(observer.recentRelayKeys) : [];
1427  });
1428}
1429
1430function restoreFinalMessageRelayCache(raw) {
1431  const source = hasPlatformShape(raw) ? raw : {};
1432
1433  for (const platform of PLATFORM_ORDER) {
1434    const observer = state.finalMessageRelayObservers[platform];
1435    if (!observer) {
1436      continue;
1437    }
1438
1439    observer.activeStream = null;
1440    observer.recentRelayKeys = normalizeRecentRelayKeys(source[platform]);
1441  }
1442}
1443
1444async function persistFinalMessageRelayCache() {
1445  await browser.storage.local.set({
1446    [CONTROLLER_STORAGE_KEYS.finalMessageRelayCache]: serializeFinalMessageRelayCache()
1447  });
1448}
1449
1450function cloneHeaderMap(value) {
1451  return value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {};
1452}
1453
1454function hasPlatformShape(value) {
1455  return !!value
1456    && typeof value === "object"
1457    && !Array.isArray(value)
1458    && PLATFORM_ORDER.some((platform) => Object.prototype.hasOwnProperty.call(value, platform));
1459}
1460
1461function loadTrackedTabs(raw, legacyClaudeTabId) {
1462  const next = createPlatformMap(() => null);
1463  if (raw && typeof raw === "object" && !Array.isArray(raw)) {
1464    for (const platform of PLATFORM_ORDER) {
1465      next[platform] = Number.isInteger(raw[platform]) ? raw[platform] : null;
1466    }
1467  }
1468  if (Number.isInteger(legacyClaudeTabId) && !Number.isInteger(next.claude)) {
1469    next.claude = legacyClaudeTabId;
1470  }
1471  return next;
1472}
1473
1474function loadDesiredTabs(raw, fallbackTrackedTabs = null) {
1475  const next = createPlatformMap((platform) => createDefaultDesiredTabState(platform));
1476
1477  if (hasPlatformShape(raw)) {
1478    for (const platform of PLATFORM_ORDER) {
1479      next[platform] = cloneDesiredTabState(platform, raw[platform]);
1480    }
1481    return next;
1482  }
1483
1484  for (const platform of PLATFORM_ORDER) {
1485    const shouldExist = Number.isInteger(fallbackTrackedTabs?.[platform]);
1486    next[platform] = createDefaultDesiredTabState(platform, {
1487      exists: shouldExist,
1488      source: shouldExist ? "migration" : "bootstrap",
1489      reason: shouldExist ? "tracked_tab_recovered" : null,
1490      updatedAt: 0
1491    });
1492  }
1493
1494  return next;
1495}
1496
1497function loadObjectMap(raw, legacyValue = null) {
1498  const next = createPlatformMap(() => ({}));
1499  if (hasPlatformShape(raw)) {
1500    for (const platform of PLATFORM_ORDER) {
1501      next[platform] = cloneHeaderMap(raw[platform]);
1502    }
1503    return next;
1504  }
1505
1506  if (raw && typeof raw === "object" && !Array.isArray(raw)) {
1507    next.claude = cloneHeaderMap(raw);
1508    return next;
1509  }
1510
1511  if (legacyValue && typeof legacyValue === "object" && !Array.isArray(legacyValue)) {
1512    next.claude = cloneHeaderMap(legacyValue);
1513  }
1514  return next;
1515}
1516
1517function normalizeEndpointEntry(key, value) {
1518  const fallbackKey = typeof key === "string" ? key : "";
1519  const keyParts = fallbackKey.split(" ");
1520  const fallbackMethod = keyParts.length > 1 ? keyParts[0] : "GET";
1521  const fallbackPath = keyParts.length > 1 ? keyParts.slice(1).join(" ") : fallbackKey;
1522
1523  if (!isRecord(value)) {
1524    return {
1525      key: fallbackKey,
1526      method: fallbackMethod,
1527      path: fallbackPath,
1528      firstObservedAt: 0,
1529      lastObservedAt: 0,
1530      sampleUrl: null
1531    };
1532  }
1533
1534  return {
1535    key: fallbackKey,
1536    method: trimToNull(value.method) || fallbackMethod,
1537    path: trimToNull(value.path) || fallbackPath,
1538    firstObservedAt: Number(value.firstObservedAt) || 0,
1539    lastObservedAt: Number(value.lastObservedAt) || 0,
1540    sampleUrl: trimToNull(value.sampleUrl)
1541  };
1542}
1543
1544function loadEndpointEntries(raw, legacyValue = null) {
1545  const next = createPlatformMap(() => ({}));
1546  const source = hasPlatformShape(raw)
1547    ? raw
1548    : (isRecord(raw) ? { claude: raw } : (isRecord(legacyValue) ? { claude: legacyValue } : {}));
1549
1550  for (const platform of PLATFORM_ORDER) {
1551    const entries = isRecord(source[platform]) ? source[platform] : {};
1552    const normalized = {};
1553    for (const [key, value] of Object.entries(entries)) {
1554      const entry = normalizeEndpointEntry(key, value);
1555      normalized[entry.key] = entry;
1556    }
1557    next[platform] = normalized;
1558  }
1559
1560  return next;
1561}
1562
1563function loadNumberMap(raw, legacyValue = 0) {
1564  const next = createPlatformMap(() => 0);
1565  if (hasPlatformShape(raw)) {
1566    for (const platform of PLATFORM_ORDER) {
1567      next[platform] = Number(raw[platform]) || 0;
1568    }
1569    return next;
1570  }
1571
1572  if (Number.isFinite(raw)) {
1573    next.claude = raw;
1574    return next;
1575  }
1576
1577  if (Number.isFinite(legacyValue)) {
1578    next.claude = legacyValue;
1579  }
1580  return next;
1581}
1582
1583function loadAccountMap(raw) {
1584  const next = createPlatformMap(() => createDefaultAccountState());
1585  if (!hasPlatformShape(raw)) return next;
1586
1587  for (const platform of PLATFORM_ORDER) {
1588    next[platform] = cloneAccountState(raw[platform]);
1589  }
1590
1591  return next;
1592}
1593
1594function loadStringMap(raw, legacyValue = "") {
1595  const next = createPlatformMap(() => "");
1596  if (hasPlatformShape(raw)) {
1597    for (const platform of PLATFORM_ORDER) {
1598      next[platform] = typeof raw[platform] === "string" ? raw[platform] : "";
1599    }
1600    return next;
1601  }
1602
1603  if (typeof raw === "string") {
1604    next.claude = raw;
1605    return next;
1606  }
1607
1608  if (typeof legacyValue === "string") {
1609    next.claude = legacyValue;
1610  }
1611  return next;
1612}
1613
1614function loadTabIdMap(raw, legacyValue = null) {
1615  const next = createPlatformMap(() => null);
1616  if (hasPlatformShape(raw)) {
1617    for (const platform of PLATFORM_ORDER) {
1618      next[platform] = Number.isInteger(raw[platform]) ? raw[platform] : null;
1619    }
1620    return next;
1621  }
1622
1623  if (Number.isInteger(raw)) {
1624    next.claude = raw;
1625    return next;
1626  }
1627
1628  if (Number.isInteger(legacyValue)) {
1629    next.claude = legacyValue;
1630  }
1631  return next;
1632}
1633
1634function loadControlState(raw) {
1635  if (!isRecord(raw)) return createDefaultControlState();
1636  return cloneControlState(raw);
1637}
1638
1639function loadControllerRuntimeState(raw) {
1640  return cloneControllerRuntimeState(raw);
1641}
1642
1643function getNestedValue(source, path) {
1644  const segments = String(path || "").split(".");
1645  let current = source;
1646
1647  for (const segment of segments) {
1648    if (!isRecord(current) || !Object.prototype.hasOwnProperty.call(current, segment)) {
1649      return undefined;
1650    }
1651    current = current[segment];
1652  }
1653
1654  return current;
1655}
1656
1657function getFirstDefinedValue(source, paths) {
1658  for (const path of paths) {
1659    const value = getNestedValue(source, path);
1660    if (value !== undefined && value !== null && value !== "") {
1661      return value;
1662    }
1663  }
1664  return undefined;
1665}
1666
1667function normalizeMode(value) {
1668  const lower = String(value || "").trim().toLowerCase();
1669  if (lower === "running" || lower === "paused" || lower === "draining") {
1670    return lower;
1671  }
1672  return "unknown";
1673}
1674
1675function formatModeLabel(mode) {
1676  switch (normalizeMode(mode)) {
1677    case "running":
1678      return "运行中";
1679    case "paused":
1680      return "已暂停";
1681    case "draining":
1682      return "排空中";
1683    default:
1684      return "未知";
1685  }
1686}
1687
1688function normalizeLeader(value) {
1689  if (typeof value === "string" && value.trim()) return value.trim();
1690  if (isRecord(value)) {
1691    const candidate = getFirstDefinedValue(value, ["host", "node_id", "nodeId", "lease_holder", "leaseHolder", "controller_id", "controllerId"]);
1692    return typeof candidate === "string" && candidate.trim() ? candidate.trim() : null;
1693  }
1694  return null;
1695}
1696
1697function normalizeCount(value) {
1698  if (Number.isFinite(value)) return Number(value);
1699  if (typeof value === "string" && value.trim()) {
1700    const parsed = Number(value);
1701    return Number.isFinite(parsed) ? parsed : null;
1702  }
1703  if (Array.isArray(value)) return value.length;
1704  if (isRecord(value)) {
1705    const candidate = getFirstDefinedValue(value, ["count", "depth", "total", "size", "value"]);
1706    return normalizeCount(candidate);
1707  }
1708  return null;
1709}
1710
1711function truncateControlRaw(value) {
1712  if (value == null) return null;
1713
1714  try {
1715    const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
1716    return text.length > CONTROL_STATUS_BODY_LIMIT
1717      ? `${text.slice(0, CONTROL_STATUS_BODY_LIMIT)}\n`
1718      : text;
1719  } catch (_) {
1720    return String(value);
1721  }
1722}
1723
1724function formatSyncTime(timestamp) {
1725  if (!Number.isFinite(timestamp) || timestamp <= 0) return "未同步";
1726  return new Date(timestamp).toLocaleString("zh-CN", { hour12: false });
1727}
1728
1729function formatRetryDelay(targetTime) {
1730  if (!Number.isFinite(targetTime) || targetTime <= 0) return "";
1731  const remaining = Math.max(0, targetTime - Date.now());
1732  if (remaining < 1_000) return "不到 1 秒";
1733  if (remaining < 60_000) return `${Math.ceil(remaining / 1_000)}`;
1734  return `${Math.ceil(remaining / 60_000)} 分钟`;
1735}
1736
1737function isWsEnabled() {
1738  return true;
1739}
1740
1741function normalizeWsConnection(value) {
1742  switch (String(value || "").trim().toLowerCase()) {
1743    case "connecting":
1744    case "connected":
1745    case "retrying":
1746    case "disconnected":
1747      return String(value || "").trim().toLowerCase();
1748    default:
1749      return "disconnected";
1750  }
1751}
1752
1753function formatWsConnectionLabel(snapshot) {
1754  switch (normalizeWsConnection(snapshot?.connection)) {
1755    case "connecting":
1756      return "连接中";
1757    case "connected":
1758      return "已连接";
1759    case "retrying":
1760      return "重连中";
1761    default:
1762      return "已断开";
1763  }
1764}
1765
1766function wsConnectionClass(snapshot) {
1767  switch (normalizeWsConnection(snapshot?.connection)) {
1768    case "connected":
1769      return "on";
1770    case "connecting":
1771    case "retrying":
1772      return "warn";
1773    default:
1774      return "off";
1775  }
1776}
1777
1778function formatWsMeta(snapshot) {
1779  const parts = [];
1780
1781  if (snapshot.serverIdentity) {
1782    parts.push(`服务端: ${snapshot.serverIdentity}`);
1783  } else if (snapshot.connection === "connecting") {
1784    parts.push("等待本地 bridge 握手");
1785  }
1786
1787  if (snapshot.lastSnapshotAt > 0) {
1788    parts.push(`最近快照: ${formatSyncTime(snapshot.lastSnapshotAt)}`);
1789  }
1790
1791  if (snapshot.connection === "retrying" && snapshot.nextRetryAt > 0) {
1792    parts.push(`下次重连: ${formatRetryDelay(snapshot.nextRetryAt)}`);
1793  }
1794
1795  if (snapshot.lastError) {
1796    parts.push(`错误: ${snapshot.lastError}`);
1797  }
1798
1799  return parts.length > 0 ? parts.join(" · ") : "等待本地 bridge 握手";
1800}
1801
1802function renderWsSnapshot() {
1803  const snapshot = cloneWsState(state.wsState);
1804
1805  return JSON.stringify({
1806    connection: snapshot.connection,
1807    wsUrl: snapshot.wsUrl,
1808    localApiBase: snapshot.localApiBase,
1809    clientId: snapshot.clientId,
1810    protocol: snapshot.protocol,
1811    version: snapshot.version,
1812    serverIdentity: snapshot.serverIdentity,
1813    serverHost: snapshot.serverHost,
1814    serverRole: snapshot.serverRole,
1815    leaseState: snapshot.leaseState,
1816    clientCount: snapshot.clientCount,
1817    retryCount: snapshot.retryCount,
1818    nextRetryAt: snapshot.nextRetryAt ? new Date(snapshot.nextRetryAt).toISOString() : null,
1819    lastOpenAt: snapshot.lastOpenAt ? new Date(snapshot.lastOpenAt).toISOString() : null,
1820    lastMessageAt: snapshot.lastMessageAt ? new Date(snapshot.lastMessageAt).toISOString() : null,
1821    lastSnapshotAt: snapshot.lastSnapshotAt ? new Date(snapshot.lastSnapshotAt).toISOString() : null,
1822    lastSnapshotReason: snapshot.lastSnapshotReason,
1823    lastCloseCode: snapshot.lastCloseCode,
1824    lastCloseReason: snapshot.lastCloseReason,
1825    lastError: snapshot.lastError,
1826    raw: snapshot.raw
1827  }, null, 2);
1828}
1829
1830function normalizeControlConnection(value) {
1831  switch (String(value || "").trim().toLowerCase()) {
1832    case "connecting":
1833    case "connected":
1834    case "retrying":
1835    case "disconnected":
1836      return String(value || "").trim().toLowerCase();
1837    default:
1838      return "disconnected";
1839  }
1840}
1841
1842function formatControlConnectionLabel(snapshot) {
1843  switch (normalizeControlConnection(snapshot?.controlConnection)) {
1844    case "connecting":
1845      return "连接中";
1846    case "connected":
1847      return "已连接";
1848    case "retrying":
1849      return "正在重试";
1850    default:
1851      return "已断开";
1852  }
1853}
1854
1855function controlConnectionClass(snapshot) {
1856  switch (normalizeControlConnection(snapshot?.controlConnection)) {
1857    case "connected":
1858      return "on";
1859    case "connecting":
1860    case "retrying":
1861      return "warn";
1862    default:
1863      return "off";
1864  }
1865}
1866
1867function formatControlMeta(snapshot) {
1868  const parts = [`自动化模式: ${formatModeLabel(snapshot.mode)}`];
1869  const connection = normalizeControlConnection(snapshot.controlConnection);
1870
1871  if (snapshot.lastSuccessAt > 0) {
1872    parts.push(`最近成功: ${formatSyncTime(snapshot.lastSuccessAt)}`);
1873  } else if (connection === "connecting") {
1874    parts.push("浏览器启动后自动连接中");
1875  }
1876
1877  if (connection === "retrying") {
1878    parts.push("最近同步失败");
1879    if (snapshot.lastFailureAt > 0) {
1880      parts.push(`失败时间: ${formatSyncTime(snapshot.lastFailureAt)}`);
1881    }
1882    if (snapshot.nextRetryAt > 0) {
1883      parts.push(`下次重试: ${formatRetryDelay(snapshot.nextRetryAt)}`);
1884    }
1885  }
1886
1887  if (snapshot.error && connection !== "connected") {
1888    parts.push(`错误: ${snapshot.error}`);
1889  }
1890
1891  return parts.join(" · ");
1892}
1893
1894function normalizeControlStatePayload(payload, meta = {}) {
1895  const source = isRecord(payload) ? payload : {};
1896  const mode = normalizeMode(getFirstDefinedValue(source, [
1897    "mode",
1898    "automation.mode",
1899    "system.mode",
1900    "system_state.mode",
1901    "systemState.mode",
1902    "state.mode",
1903    "data.mode",
1904    "data.automation.mode",
1905    "data.system.mode",
1906    "data.system_state.mode",
1907    "value.mode",
1908    "value_json.mode"
1909  ]));
1910  const leader = normalizeLeader(getFirstDefinedValue(source, [
1911    "leader",
1912    "leader.host",
1913    "data.leader",
1914    "data.leader.host",
1915    "leader_host",
1916    "leaderHost",
1917    "lease_holder",
1918    "leaseHolder"
1919  ]));
1920  const leaseHolder = normalizeLeader(getFirstDefinedValue(source, [
1921    "lease_holder",
1922    "leaseHolder",
1923    "leader.lease_holder",
1924    "leader.leaseHolder",
1925    "data.lease_holder",
1926    "data.leaseHolder"
1927  ]));
1928  const queueDepth = normalizeCount(getFirstDefinedValue(source, [
1929    "queue_depth",
1930    "queueDepth",
1931    "queued_tasks",
1932    "queuedTasks",
1933    "queue.depth",
1934    "queue.count",
1935    "stats.queue_depth",
1936    "stats.queueDepth",
1937    "data.queue_depth",
1938    "data.queueDepth",
1939    "data.queued_tasks",
1940    "data.queuedTasks",
1941    "data.queue.depth",
1942    "data.stats.queue_depth",
1943    "data.stats.queueDepth"
1944  ]));
1945  const activeRuns = normalizeCount(getFirstDefinedValue(source, [
1946    "active_runs",
1947    "activeRuns",
1948    "runs.active",
1949    "runs.active_count",
1950    "stats.active_runs",
1951    "stats.activeRuns",
1952    "data.active_runs",
1953    "data.activeRuns",
1954    "data.runs.active",
1955    "data.stats.active_runs",
1956    "data.stats.activeRuns"
1957  ]));
1958  const message = getFirstDefinedValue(source, ["message", "data.message"]);
1959  const apiReportedError = getFirstDefinedValue(source, ["error", "data.error"]);
1960  const ok = meta.ok !== false && source.ok !== false && !apiReportedError;
1961
1962  return createDefaultControlState({
1963    ok,
1964    mode,
1965    leader,
1966    leaseHolder,
1967    queueDepth,
1968    activeRuns,
1969    message: typeof message === "string" ? message : null,
1970    error: ok ? null : String(apiReportedError || message || meta.error || "控制面错误"),
1971    statusCode: Number.isFinite(meta.statusCode) ? meta.statusCode : null,
1972    syncedAt: Date.now(),
1973    source: meta.source || "http",
1974    raw: truncateControlRaw(payload)
1975  });
1976}
1977
1978function genClientId() {
1979  return `firefox-${Math.random().toString(36).slice(2, 8)}`;
1980}
1981
1982function platformLabel(platform) {
1983  return PLATFORMS[platform]?.label || platform;
1984}
1985
1986function detectPlatformFromUrl(url) {
1987  try {
1988    const hostname = new URL(url, location.href).hostname;
1989    for (const platform of PLATFORM_ORDER) {
1990      if (PLATFORMS[platform].matchesTabHost(hostname)) return platform;
1991    }
1992  } catch (_) {}
1993  return null;
1994}
1995
1996function detectPlatformFromRequestUrl(url) {
1997  try {
1998    const hostname = new URL(url, location.href).hostname;
1999    for (const platform of PLATFORM_ORDER) {
2000      if (PLATFORMS[platform].matchesRequestHost(hostname)) return platform;
2001    }
2002  } catch (_) {}
2003  return null;
2004}
2005
2006function isPlatformUrl(platform, url) {
2007  return detectPlatformFromUrl(url) === platform;
2008}
2009
2010function shouldTrackRequest(platform, url) {
2011  const config = PLATFORMS[platform];
2012  if (!config) return false;
2013  try {
2014    const parsed = new URL(url, location.href);
2015    if (!config.matchesRequestHost(parsed.hostname)) return false;
2016    return config.shouldTrackPath(parsed.pathname || "/");
2017  } catch (_) {
2018    return false;
2019  }
2020}
2021
2022function findTrackedPlatformByTabId(tabId) {
2023  if (!Number.isInteger(tabId)) return null;
2024  for (const platform of PLATFORM_ORDER) {
2025    if (state.trackedTabs[platform] === tabId) return platform;
2026  }
2027  return null;
2028}
2029
2030function setDesiredTabState(platform, exists, options = {}) {
2031  const now = Number(options.updatedAt) || Date.now();
2032  const previous = cloneDesiredTabState(platform, state.desiredTabs[platform]);
2033  const nextExists = exists === true;
2034  const nextSource = trimToNull(options.source) || previous.source || "runtime";
2035  const nextReason = trimToNull(options.reason) || previous.reason;
2036  const nextAction = trimToNull(options.action);
2037  const shouldStamp = previous.exists !== nextExists
2038    || previous.source !== nextSource
2039    || previous.reason !== nextReason
2040    || nextAction != null
2041    || options.touch === true;
2042  const next = createDefaultDesiredTabState(platform, {
2043    ...previous,
2044    exists: nextExists,
2045    shellUrl: getPlatformShellUrl(platform),
2046    source: nextSource,
2047    reason: nextReason,
2048    updatedAt: shouldStamp ? now : previous.updatedAt,
2049    lastAction: nextAction || previous.lastAction,
2050    lastActionAt: nextAction ? now : previous.lastActionAt
2051  });
2052
2053  const changed = JSON.stringify(previous) !== JSON.stringify(next);
2054  if (!changed) return false;
2055
2056  state.desiredTabs[platform] = next;
2057
2058  if (options.persist) {
2059    persistState().catch(() => {});
2060  }
2061  if (options.render) {
2062    render();
2063  }
2064
2065  return true;
2066}
2067
2068function shouldAutoRestoreDesiredTab(platform) {
2069  const desired = cloneDesiredTabState(platform, state.desiredTabs[platform]);
2070  const actual = cloneActualTabState(state.actualTabs[platform]);
2071  return desired.exists && desired.source !== "migration" && !actual.exists;
2072}
2073
2074function shouldAdoptPlatformTabAsDesired(platform, url) {
2075  return isPlatformShellUrl(platform, url, { allowFallback: true });
2076}
2077
2078function getManagedSummaryPlatforms() {
2079  return PLATFORM_ORDER.filter((platform) => {
2080    const desired = cloneDesiredTabState(platform, state.desiredTabs[platform]);
2081    const actual = cloneActualTabState(state.actualTabs[platform]);
2082    return desired.exists || actual.exists;
2083  });
2084}
2085
2086function setControllerRuntimeState(patch = {}, options = {}) {
2087  state.controllerRuntime = cloneControllerRuntimeState({
2088    ...state.controllerRuntime,
2089    ...patch
2090  });
2091
2092  if (options.persist) {
2093    persistState().catch(() => {});
2094  }
2095  if (options.render) {
2096    render();
2097  }
2098
2099  return state.controllerRuntime;
2100}
2101
2102function buildActualTabSnapshot(platform, shellTab = null, candidateTab = null, previousSnapshot = null) {
2103  const now = Date.now();
2104  const previous = cloneActualTabState(previousSnapshot || state.actualTabs[platform]);
2105  const allowFallbackShell = cloneDesiredTabState(platform, state.desiredTabs[platform]).exists;
2106
2107  if (shellTab && Number.isInteger(shellTab.id)) {
2108    const isReady = String(shellTab.status || "").toLowerCase() === "complete";
2109    const url = trimToNull(shellTab.url);
2110    const nextBase = createDefaultActualTabState({
2111      exists: true,
2112      tabId: shellTab.id,
2113      url,
2114      title: trimToNull(shellTab.title),
2115      windowId: Number.isInteger(shellTab.windowId) ? shellTab.windowId : null,
2116      active: shellTab.active === true,
2117      status: trimToNull(shellTab.status),
2118      discarded: shellTab.discarded === true,
2119      hidden: shellTab.hidden === true,
2120      healthy: isPlatformShellUrl(platform, url || "", { allowFallback: allowFallbackShell }) && !shellTab.discarded,
2121      issue: isReady ? null : "loading",
2122      candidateTabId: null,
2123      candidateUrl: null,
2124      candidateTitle: null,
2125      candidateStatus: null
2126    });
2127    const isSame = JSON.stringify({
2128      ...nextBase,
2129      lastSeenAt: 0,
2130      lastReadyAt: 0,
2131      updatedAt: 0
2132    }) === JSON.stringify({
2133      ...previous,
2134      lastSeenAt: 0,
2135      lastReadyAt: 0,
2136      updatedAt: 0
2137    });
2138
2139    return createDefaultActualTabState({
2140      ...nextBase,
2141      lastSeenAt: isSame ? (previous.lastSeenAt || now) : now,
2142      lastReadyAt: isReady
2143        ? (isSame ? (previous.lastReadyAt || now) : now)
2144        : previous.lastReadyAt,
2145      updatedAt: isSame ? previous.updatedAt : now
2146    });
2147  }
2148
2149  const nextBase = createDefaultActualTabState({
2150    exists: false,
2151    issue: candidateTab ? "non_shell" : "missing",
2152    candidateTabId: Number.isInteger(candidateTab?.id) ? candidateTab.id : null,
2153    candidateUrl: trimToNull(candidateTab?.url),
2154    candidateTitle: trimToNull(candidateTab?.title),
2155    candidateStatus: trimToNull(candidateTab?.status)
2156  });
2157  const isSame = JSON.stringify({
2158    ...nextBase,
2159    lastSeenAt: 0,
2160    lastReadyAt: 0,
2161    updatedAt: 0
2162  }) === JSON.stringify({
2163    ...previous,
2164    lastSeenAt: 0,
2165    lastReadyAt: 0,
2166    updatedAt: 0
2167  });
2168
2169  return createDefaultActualTabState({
2170    ...nextBase,
2171    lastSeenAt: previous.lastSeenAt,
2172    lastReadyAt: previous.lastReadyAt,
2173    updatedAt: isSame ? previous.updatedAt : now
2174  });
2175}
2176
2177function buildPlatformRuntimeDrift(platform, desiredState = null, actualState = null) {
2178  const desired = cloneDesiredTabState(platform, desiredState || state.desiredTabs[platform]);
2179  const actual = cloneActualTabState(actualState || state.actualTabs[platform]);
2180  const needsRestore = desired.exists && !actual.exists;
2181  const unexpectedActual = !desired.exists && actual.exists;
2182  const loading = actual.exists && actual.issue === "loading";
2183  let reason = "aligned";
2184
2185  if (needsRestore) {
2186    reason = actual.issue === "non_shell" ? "shell_missing" : "missing_actual";
2187  } else if (unexpectedActual) {
2188    reason = "unexpected_actual";
2189  } else if (loading) {
2190    reason = "loading";
2191  }
2192
2193  return {
2194    aligned: !needsRestore && !unexpectedActual && !loading,
2195    needsRestore,
2196    unexpectedActual,
2197    reason
2198  };
2199}
2200
2201function buildPlatformRuntimeSnapshot(platform) {
2202  const desired = cloneDesiredTabState(platform, state.desiredTabs[platform]);
2203  const actual = cloneActualTabState(state.actualTabs[platform]);
2204  const drift = buildPlatformRuntimeDrift(platform, desired, actual);
2205
2206  return {
2207    platform,
2208    desired: {
2209      exists: desired.exists,
2210      shell_url: desired.shellUrl,
2211      source: desired.source,
2212      reason: desired.reason,
2213      updated_at: desired.updatedAt || null,
2214      last_action: desired.lastAction,
2215      last_action_at: desired.lastActionAt || null
2216    },
2217    actual: {
2218      exists: actual.exists,
2219      tab_id: actual.tabId,
2220      url: actual.url,
2221      title: actual.title,
2222      window_id: actual.windowId,
2223      active: actual.active,
2224      status: actual.status,
2225      discarded: actual.discarded,
2226      hidden: actual.hidden,
2227      healthy: actual.healthy,
2228      issue: actual.issue,
2229      last_seen_at: actual.lastSeenAt || null,
2230      last_ready_at: actual.lastReadyAt || null,
2231      candidate_tab_id: actual.candidateTabId,
2232      candidate_url: actual.candidateUrl
2233    },
2234    drift: {
2235      aligned: drift.aligned,
2236      needs_restore: drift.needsRestore,
2237      unexpected_actual: drift.unexpectedActual,
2238      reason: drift.reason
2239    }
2240  };
2241}
2242
2243function getDesiredCount() {
2244  return PLATFORM_ORDER.filter((platform) => cloneDesiredTabState(platform, state.desiredTabs[platform]).exists).length;
2245}
2246
2247function getRuntimeDriftCount() {
2248  return PLATFORM_ORDER.filter((platform) => !buildPlatformRuntimeDrift(platform).aligned).length;
2249}
2250
2251function getPlatformsNeedingShellRestore() {
2252  return PLATFORM_ORDER.filter((platform) => shouldAutoRestoreDesiredTab(platform));
2253}
2254
2255function buildPluginStatusPayload(options = {}) {
2256  const includeVolatile = options.includeVolatile !== false;
2257  const platforms = {};
2258  const pageControls = listPageControlStates()
2259    .map(serializePageControlState)
2260    .filter(Boolean);
2261
2262  for (const platform of PLATFORM_ORDER) {
2263    platforms[platform] = buildPlatformRuntimeSnapshot(platform);
2264  }
2265
2266  const controller = cloneControllerRuntimeState(state.controllerRuntime);
2267  const ws = cloneWsState(state.wsState);
2268  const payload = {
2269    schema_version: 1,
2270    client_id: state.clientId,
2271    summary: {
2272      desired_count: getDesiredCount(),
2273      actual_count: getTrackedCount(),
2274      drift_count: getRuntimeDriftCount(),
2275      paused_page_count: pageControls.filter((entry) => entry.paused).length
2276    },
2277    controller: {
2278      tab_id: controller.tabId,
2279      ready: controller.ready,
2280      status: controller.status,
2281      last_ready_at: controller.lastReadyAt || null,
2282      last_reload_at: controller.lastReloadAt || null,
2283      last_action: controller.lastAction,
2284      last_action_at: controller.lastActionAt || null,
2285      last_health_check_at: includeVolatile ? (state.shellRuntimeLastHealthCheckAt || null) : null
2286    },
2287    ws: {
2288      connected: state.wsConnected,
2289      connection: ws.connection,
2290      protocol: ws.protocol,
2291      version: ws.version,
2292      retry_count: ws.retryCount,
2293      last_open_at: ws.lastOpenAt || null,
2294      last_message_at: ws.lastMessageAt || null,
2295      last_error: ws.lastError
2296    },
2297    page_controls: pageControls.map((entry) => ({
2298      key: entry.key,
2299      platform: entry.platform,
2300      tab_id: entry.tabId,
2301      conversation_id: entry.conversationId,
2302      local_conversation_id: entry.localConversationId,
2303      page_url: entry.pageUrl,
2304      page_title: entry.pageTitle,
2305      shell_page: entry.shellPage,
2306      automation_status: entry.automationStatus,
2307      last_non_paused_automation_status: entry.lastNonPausedAutomationStatus,
2308      paused: entry.paused,
2309      pause_source: entry.pauseSource,
2310      pause_reason: entry.pauseReason,
2311      updated_at: entry.updatedAt
2312    })),
2313    platforms
2314  };
2315
2316  if (includeVolatile) {
2317    payload.generated_at = Date.now();
2318  }
2319
2320  return payload;
2321}
2322
2323function addLog(level, text, sendRemote = true) {
2324  const line = `[${new Date().toLocaleTimeString("zh-CN", { hour12: false })}] [${level}] ${text}`;
2325  state.logs.push(line);
2326  if (state.logs.length > LOG_LIMIT) state.logs.shift();
2327  render();
2328
2329  try {
2330    sendPluginDiagnosticLog(level, text);
2331  } catch (_) {}
2332
2333  if (sendRemote) {
2334    wsSend({
2335      type: "client_log",
2336      clientId: state.clientId,
2337      level,
2338      text
2339    });
2340  }
2341}
2342
2343function shouldForwardDiagnosticLog(level, text) {
2344  const normalizedLevel = trimToNull(level);
2345  const normalizedText = trimToNull(text);
2346
2347  if (!normalizedLevel || !normalizedText) {
2348    return false;
2349  }
2350
2351  switch (normalizedLevel.toLowerCase()) {
2352    case "error":
2353    case "warn":
2354    case "info":
2355      return true;
2356    case "debug":
2357      return DIAGNOSTIC_LOG_DEBUG_PREFIXES.some((prefix) => normalizedText.startsWith(prefix))
2358        || DIAGNOSTIC_LOG_DEBUG_EVENT_RE.test(normalizedText);
2359    default:
2360      return false;
2361  }
2362}
2363
2364function createPluginDiagnosticPayload(level, text) {
2365  if (!shouldForwardDiagnosticLog(level, text)) {
2366    return null;
2367  }
2368
2369  return {
2370    type: "plugin_diagnostic_log",
2371    ts: new Date().toISOString(),
2372    level,
2373    text,
2374    client_id: trimToNull(state.clientId)
2375  };
2376}
2377
2378function trySendPluginDiagnosticPayload(payload) {
2379  try {
2380    return wsSend(payload);
2381  } catch (_) {
2382    return false;
2383  }
2384}
2385
2386function bufferPluginDiagnosticPayload(payload) {
2387  if (!payload || typeof payload !== "object") {
2388    return 0;
2389  }
2390
2391  state.pendingPluginDiagnosticLogs.push(payload);
2392  if (state.pendingPluginDiagnosticLogs.length > PLUGIN_DIAGNOSTIC_BUFFER_LIMIT) {
2393    state.pendingPluginDiagnosticLogs.splice(
2394      0,
2395      state.pendingPluginDiagnosticLogs.length - PLUGIN_DIAGNOSTIC_BUFFER_LIMIT
2396    );
2397  }
2398
2399  return state.pendingPluginDiagnosticLogs.length;
2400}
2401
2402function flushBufferedPluginDiagnosticLogs() {
2403  if (state.pendingPluginDiagnosticLogs.length === 0) {
2404    return 0;
2405  }
2406
2407  if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
2408    return 0;
2409  }
2410
2411  const pending = state.pendingPluginDiagnosticLogs.slice();
2412  state.pendingPluginDiagnosticLogs = [];
2413  let flushedCount = 0;
2414
2415  for (const payload of pending) {
2416    if (!trySendPluginDiagnosticPayload(payload)) {
2417      state.pendingPluginDiagnosticLogs = pending.slice(flushedCount).concat(state.pendingPluginDiagnosticLogs);
2418      break;
2419    }
2420    flushedCount += 1;
2421  }
2422
2423  return flushedCount;
2424}
2425
2426function sendPluginDiagnosticLog(level, text) {
2427  const payload = createPluginDiagnosticPayload(level, text);
2428  if (!payload) {
2429    return false;
2430  }
2431
2432  if (trySendPluginDiagnosticPayload(payload)) {
2433    return true;
2434  }
2435
2436  bufferPluginDiagnosticPayload(payload);
2437  return true;
2438}
2439
2440function normalizePath(url) {
2441  try {
2442    const parsed = new URL(url);
2443    let path = parsed.pathname || "/";
2444    path = path.replace(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/gi, "{id}");
2445    path = path.replace(/\/\d{6,}\b/g, "/{id}");
2446    path = path.replace(/\/[a-f0-9]{24,}\b/gi, "/{id}");
2447    return path;
2448  } catch (_) {
2449    return url;
2450  }
2451}
2452
2453function headerArrayToObject(headers = []) {
2454  const out = {};
2455  for (const header of headers) {
2456    if (!header || !header.name) continue;
2457    out[header.name.toLowerCase()] = header.value || "";
2458  }
2459  return out;
2460}
2461
2462function mergeKnownHeaders(platform, extra = {}) {
2463  const known = state.lastHeaders[platform] || {};
2464  if (Object.keys(known).length === 0) return extra;
2465  return { ...known, ...extra };
2466}
2467
2468function trimBody(body) {
2469  if (body == null) return null;
2470  const text = typeof body === "string" ? body : JSON.stringify(body);
2471  return text.length > NETWORK_BODY_LIMIT ? text.slice(0, NETWORK_BODY_LIMIT) : text;
2472}
2473
2474function normalizeStoredUrl(url, baseUrl = location.href) {
2475  try {
2476    const parsed = new URL(url, baseUrl);
2477    return `${parsed.pathname || "/"}${parsed.search || ""}`;
2478  } catch (_) {
2479    return url;
2480  }
2481}
2482
2483function getRequestPath(url, baseUrl = location.href) {
2484  try {
2485    return new URL(url, baseUrl).pathname || "/";
2486  } catch (_) {
2487    return "";
2488  }
2489}
2490
2491function getHeaderValue(headers, name) {
2492  if (!headers || typeof headers !== "object") return "";
2493  const value = headers[String(name || "").toLowerCase()];
2494  return typeof value === "string" ? value.trim() : "";
2495}
2496
2497function hasHeaderValue(headers, name) {
2498  return getHeaderValue(headers, name) !== "";
2499}
2500
2501function cookieHeaderMatches(headers, patterns) {
2502  const cookie = getHeaderValue(headers, "cookie");
2503  return !!cookie && patterns.some((pattern) => pattern.test(cookie));
2504}
2505
2506function redactCredentialHeaders(headers = {}) {
2507  const out = {};
2508
2509  for (const [name, value] of Object.entries(headers || {})) {
2510    const normalizedName = String(name || "").toLowerCase();
2511    if (!normalizedName || value == null || value === "") continue;
2512    out[normalizedName] = REDACTED_CREDENTIAL_VALUE;
2513  }
2514
2515  return out;
2516}
2517
2518function buildCredentialFingerprintInput(platform, headers = {}) {
2519  const keys = [];
2520  const pushIfPresent = (name) => {
2521    if (hasHeaderValue(headers, name)) {
2522      keys.push([name, getHeaderValue(headers, name)]);
2523    }
2524  };
2525
2526  switch (platform) {
2527    case "claude":
2528      pushIfPresent("authorization");
2529      pushIfPresent("cookie");
2530      pushIfPresent("x-csrf-token");
2531      pushIfPresent("x-org-id");
2532      break;
2533    case "chatgpt":
2534      pushIfPresent("authorization");
2535      pushIfPresent("cookie");
2536      pushIfPresent("openai-sentinel-chat-requirements-token");
2537      pushIfPresent("openai-sentinel-proof-token");
2538      pushIfPresent("x-openai-assistant-app-id");
2539      break;
2540    case "gemini":
2541      pushIfPresent("authorization");
2542      pushIfPresent("cookie");
2543      pushIfPresent("x-goog-authuser");
2544      pushIfPresent("x-same-domain");
2545      break;
2546    default:
2547      for (const name of Object.keys(headers || {}).sort()) {
2548        pushIfPresent(name);
2549      }
2550      break;
2551  }
2552
2553  return keys
2554    .sort(([left], [right]) => left.localeCompare(right))
2555    .map(([name, value]) => `${name}=${value}`)
2556    .join("\n");
2557}
2558
2559async function computeCredentialFingerprint(platform, headers = {}) {
2560  const input = buildCredentialFingerprintInput(platform, headers);
2561  if (!input) return "";
2562
2563  const encoded = new TextEncoder().encode(`${platform}\n${input}`);
2564  const digest = await crypto.subtle.digest("SHA-256", encoded);
2565  return Array.from(new Uint8Array(digest))
2566    .map((value) => value.toString(16).padStart(2, "0"))
2567    .join("");
2568}
2569
2570function normalizeAccountValue(value) {
2571  if (typeof value !== "string") return null;
2572  const trimmed = value.trim();
2573  if (!trimmed) return null;
2574  return ACCOUNT_EMAIL_RE.test(trimmed) ? trimmed.toLowerCase() : trimmed;
2575}
2576
2577function createAccountCandidate(value, meta = {}) {
2578  const normalizedValue = normalizeAccountValue(value);
2579  if (!normalizedValue) return null;
2580
2581  return {
2582    value: normalizedValue,
2583    kind: trimToNull(meta.kind),
2584    source: trimToNull(meta.source),
2585    priority: Number(meta.priority) || 0,
2586    observedAt: Number(meta.observedAt) || Date.now()
2587  };
2588}
2589
2590function selectAccountCandidate(current, next) {
2591  if (!next) return current;
2592  if (!current || !current.value) return next;
2593  if (current.value === next.value) {
2594    return {
2595      ...current,
2596      kind: next.kind || current.kind,
2597      source: next.source || current.source,
2598      priority: Math.max(current.priority || 0, next.priority || 0),
2599      observedAt: Math.max(current.observedAt || 0, next.observedAt || 0)
2600    };
2601  }
2602  if ((next.priority || 0) > (current.priority || 0)) {
2603    return next;
2604  }
2605  return current;
2606}
2607
2608function pathLooksLikeAccountMetadata(url) {
2609  const path = getRequestPath(url).toLowerCase();
2610  if (!path) return false;
2611  if (path.includes("/conversation") || path.includes("/completion") || path.includes("streamgenerate")) {
2612    return false;
2613  }
2614  return path.includes("/account")
2615    || path.includes("/accounts")
2616    || path.includes("/profile")
2617    || path.includes("/session")
2618    || path.includes("/settings")
2619    || path.includes("/user")
2620    || path.includes("/users")
2621    || path.includes("/me")
2622    || path.includes("/auth")
2623    || path.includes("/organizations");
2624}
2625
2626function extractAccountCandidateFromHeaders(platform, headers = {}, requestUrl = "") {
2627  const observedAt = Date.now();
2628  let candidate = null;
2629
2630  const authUser = getHeaderValue(headers, "x-goog-authuser");
2631  if (platform === "gemini" && authUser) {
2632    candidate = selectAccountCandidate(candidate, createAccountCandidate(`google-authuser:${authUser}`, {
2633      kind: "google_authuser",
2634      source: "request_header",
2635      priority: 40,
2636      observedAt
2637    }));
2638  }
2639
2640  const orgId = getHeaderValue(headers, "x-org-id") || trimToNull(extractClaudeOrgId(headers, requestUrl));
2641  if (platform === "claude" && orgId) {
2642    candidate = selectAccountCandidate(candidate, createAccountCandidate(`claude-org:${orgId}`, {
2643      kind: "organization_id",
2644      source: "request_header",
2645      priority: 20,
2646      observedAt
2647    }));
2648  }
2649
2650  return candidate;
2651}
2652
2653function tryParseJson(text) {
2654  if (typeof text !== "string" || !text.trim()) return null;
2655  try {
2656    return JSON.parse(text);
2657  } catch (_) {
2658    return null;
2659  }
2660}
2661
2662function collectAccountCandidates(source, candidates, meta = {}) {
2663  const queue = [{ value: source, path: "", depth: 0 }];
2664  let scanned = 0;
2665
2666  while (queue.length > 0 && scanned < 120) {
2667    const current = queue.shift();
2668    scanned += 1;
2669    if (!current) continue;
2670
2671    const { value, path, depth } = current;
2672    const lowerPath = path.toLowerCase();
2673
2674    if (typeof value === "string") {
2675      const normalized = value.trim();
2676      if (!normalized) continue;
2677
2678      if (ACCOUNT_EMAIL_RE.test(normalized)) {
2679        candidates.push(createAccountCandidate(normalized.match(ACCOUNT_EMAIL_RE)?.[0] || normalized, {
2680          kind: "email",
2681          source: meta.source,
2682          priority: 100,
2683          observedAt: meta.observedAt
2684        }));
2685        continue;
2686      }
2687
2688      if (lowerPath.includes("email")) {
2689        candidates.push(createAccountCandidate(normalized, {
2690          kind: "email_like",
2691          source: meta.source,
2692          priority: 90,
2693          observedAt: meta.observedAt
2694        }));
2695        continue;
2696      }
2697
2698      if (/(account|user|profile|login|username|handle|sub|identifier|id)$/.test(lowerPath)) {
2699        candidates.push(createAccountCandidate(normalized, {
2700          kind: "account_hint",
2701          source: meta.source,
2702          priority: /name$/.test(lowerPath) ? 35 : 60,
2703          observedAt: meta.observedAt
2704        }));
2705      }
2706      continue;
2707    }
2708
2709    if (depth >= 4) continue;
2710
2711    if (Array.isArray(value)) {
2712      for (let index = 0; index < value.length && index < 12; index += 1) {
2713        queue.push({
2714          value: value[index],
2715          path: `${path}[${index}]`,
2716          depth: depth + 1
2717        });
2718      }
2719      continue;
2720    }
2721
2722    if (!isRecord(value)) continue;
2723
2724    for (const [key, child] of Object.entries(value).slice(0, 24)) {
2725      queue.push({
2726        value: child,
2727        path: path ? `${path}.${key}` : key,
2728        depth: depth + 1
2729      });
2730    }
2731  }
2732}
2733
2734function extractAccountCandidateFromBody(body, requestUrl = "") {
2735  if (!pathLooksLikeAccountMetadata(requestUrl)) return null;
2736
2737  const observedAt = Date.now();
2738  const candidates = [];
2739  const parsed = tryParseJson(body);
2740
2741  if (parsed != null) {
2742    collectAccountCandidates(parsed, candidates, {
2743      source: "response_body",
2744      observedAt
2745    });
2746  } else if (typeof body === "string") {
2747    const match = body.match(ACCOUNT_EMAIL_RE);
2748    if (match?.[0]) {
2749      candidates.push(createAccountCandidate(match[0], {
2750        kind: "email",
2751        source: "response_body",
2752        priority: 100,
2753        observedAt
2754      }));
2755    }
2756  }
2757
2758  let best = null;
2759  for (const candidate of candidates) {
2760    best = selectAccountCandidate(best, candidate);
2761  }
2762  return best;
2763}
2764
2765function updateAccountState(platform, candidate) {
2766  if (!platform || !candidate) return false;
2767
2768  const current = cloneAccountState(state.account[platform]);
2769  const selected = selectAccountCandidate({
2770    value: current.value,
2771    kind: current.kind,
2772    source: current.source,
2773    priority: current.priority,
2774    observedAt: current.lastSeenAt || current.capturedAt || 0
2775  }, candidate);
2776
2777  if (!selected || !selected.value) return false;
2778
2779  const sameValue = current.value === selected.value;
2780  const next = createDefaultAccountState({
2781    value: selected.value,
2782    kind: selected.kind || current.kind,
2783    source: selected.source || current.source,
2784    priority: Math.max(current.priority || 0, selected.priority || 0),
2785    capturedAt: sameValue && current.capturedAt > 0 ? current.capturedAt : selected.observedAt,
2786    lastSeenAt: selected.observedAt
2787  });
2788
2789  const changed = JSON.stringify(current) !== JSON.stringify(next);
2790  if (!changed) return false;
2791
2792  state.account[platform] = next;
2793  render();
2794  persistState().catch(() => {});
2795  return true;
2796}
2797
2798function observeAccountMetadata(platform, requestUrl, body = null, headers = null) {
2799  let changed = false;
2800  const headerCandidate = extractAccountCandidateFromHeaders(platform, headers || {}, requestUrl);
2801  if (headerCandidate) {
2802    changed = updateAccountState(platform, headerCandidate) || changed;
2803  }
2804
2805  const bodyCandidate = extractAccountCandidateFromBody(body, requestUrl);
2806  if (bodyCandidate) {
2807    changed = updateAccountState(platform, bodyCandidate) || changed;
2808  }
2809
2810  return changed;
2811}
2812
2813function getCredentialFreshness(reason) {
2814  if (reason === "ok") return "fresh";
2815  if (reason === "stale") return "stale";
2816  return "lost";
2817}
2818
2819function validateCredentialSnapshot(platform, headers, requestUrl = "") {
2820  const path = getRequestPath(requestUrl).toLowerCase();
2821
2822  switch (platform) {
2823    case "chatgpt": {
2824      if (path.includes("/backend-anon/")) {
2825        return {
2826          valid: false,
2827          invalidate: true,
2828          reason: "chatgpt-anon"
2829        };
2830      }
2831
2832      const hasBearer = /^bearer\s+\S+/i.test(getHeaderValue(headers, "authorization"));
2833      const hasSessionCookie = cookieHeaderMatches(headers, CHATGPT_SESSION_COOKIE_PATTERNS);
2834      const hasSentinel = hasHeaderValue(headers, "openai-sentinel-chat-requirements-token")
2835        || hasHeaderValue(headers, "openai-sentinel-proof-token")
2836        || hasHeaderValue(headers, "x-openai-assistant-app-id");
2837
2838      if (hasBearer || hasSessionCookie || (hasSentinel && hasHeaderValue(headers, "cookie"))) {
2839        return {
2840          valid: true,
2841          invalidate: false,
2842          reason: "ok"
2843        };
2844      }
2845
2846      if (path.includes("/backend-api/") || path.includes("/public-api/")) {
2847        return {
2848          valid: false,
2849          invalidate: true,
2850          reason: "chatgpt-missing-auth"
2851        };
2852      }
2853
2854      return {
2855        valid: false,
2856        invalidate: false,
2857        reason: "chatgpt-missing-auth"
2858      };
2859    }
2860    case "claude": {
2861      const hasAuth = hasHeaderValue(headers, "authorization")
2862        || hasHeaderValue(headers, "cookie")
2863        || hasHeaderValue(headers, "x-csrf-token");
2864      return {
2865        valid: hasAuth,
2866        invalidate: false,
2867        reason: hasAuth ? "ok" : "missing-auth"
2868      };
2869    }
2870    case "gemini": {
2871      const hasAuth = hasHeaderValue(headers, "authorization")
2872        || hasHeaderValue(headers, "cookie")
2873        || hasHeaderValue(headers, "x-goog-authuser")
2874        || hasHeaderValue(headers, "x-same-domain");
2875      return {
2876        valid: hasAuth,
2877        invalidate: false,
2878        reason: hasAuth ? "ok" : "missing-auth"
2879      };
2880    }
2881    default:
2882      return {
2883        valid: false,
2884        invalidate: false,
2885        reason: "unknown-platform"
2886      };
2887  }
2888}
2889
2890function describeCredentialReason(reason) {
2891  switch (reason) {
2892    case "ok":
2893      return "可用";
2894    case "missing-headers":
2895      return "无快照";
2896    case "chatgpt-anon":
2897      return "未登录";
2898    case "chatgpt-missing-auth":
2899    case "missing-auth":
2900      return "无登录凭证";
2901    case "missing-tab":
2902      return "无空壳页";
2903    case "tab-mismatch":
2904      return "空壳页已切换";
2905    case "stale":
2906      return "已过期";
2907    case "missing-meta":
2908      return "等待新请求";
2909    default:
2910      return "不可用";
2911  }
2912}
2913
2914function getCredentialState(platform, now = Date.now()) {
2915  const headers = cloneHeaderMap(state.lastHeaders[platform]);
2916  const headerCount = Object.keys(headers).length;
2917  const fingerprint = trimToNull(state.credentialFingerprint[platform]) || null;
2918  const capturedAt = Number(state.credentialCapturedAt[platform]) || 0;
2919  const lastSeenAt = Number(state.lastCredentialAt[platform]) || 0;
2920  const account = cloneAccountState(state.account[platform]);
2921
2922  if (headerCount === 0) {
2923    return {
2924      valid: false,
2925      reason: "missing-headers",
2926      headerCount,
2927      headers,
2928      fingerprint,
2929      capturedAt,
2930      lastSeenAt,
2931      account
2932    };
2933  }
2934
2935  const trackedTabId = Number.isInteger(state.trackedTabs[platform]) ? state.trackedTabs[platform] : null;
2936  if (!Number.isInteger(trackedTabId)) {
2937    return {
2938      valid: false,
2939      reason: "missing-tab",
2940      headerCount,
2941      headers,
2942      fingerprint,
2943      capturedAt,
2944      lastSeenAt,
2945      account
2946    };
2947  }
2948
2949  const credentialTabId = Number.isInteger(state.lastCredentialTabId[platform]) ? state.lastCredentialTabId[platform] : null;
2950  if (!Number.isInteger(credentialTabId)) {
2951    return {
2952      valid: false,
2953      reason: "missing-meta",
2954      headerCount,
2955      headers,
2956      tabId: trackedTabId,
2957      fingerprint,
2958      capturedAt,
2959      lastSeenAt,
2960      account
2961    };
2962  }
2963
2964  if (credentialTabId !== trackedTabId) {
2965    return {
2966      valid: false,
2967      reason: "tab-mismatch",
2968      headerCount,
2969      headers,
2970      tabId: trackedTabId,
2971      credentialTabId,
2972      fingerprint,
2973      capturedAt,
2974      lastSeenAt,
2975      account
2976    };
2977  }
2978
2979  if (capturedAt <= 0 || lastSeenAt <= 0) {
2980    return {
2981      valid: false,
2982      reason: "missing-meta",
2983      headerCount,
2984      headers,
2985      tabId: trackedTabId,
2986      fingerprint,
2987      capturedAt,
2988      lastSeenAt,
2989      account
2990    };
2991  }
2992
2993  if (now - lastSeenAt > CREDENTIAL_TTL) {
2994    return {
2995      valid: false,
2996      reason: "stale",
2997      headerCount,
2998      headers,
2999      tabId: trackedTabId,
3000      capturedAt,
3001      lastSeenAt,
3002      fingerprint,
3003      account
3004    };
3005  }
3006
3007  const requestUrl = state.lastCredentialUrl[platform] || "";
3008  const validation = validateCredentialSnapshot(platform, headers, requestUrl);
3009  if (!validation.valid) {
3010    return {
3011      valid: false,
3012      reason: validation.reason || "missing-auth",
3013      headerCount,
3014      headers,
3015      tabId: trackedTabId,
3016      capturedAt,
3017      lastSeenAt,
3018      url: requestUrl,
3019      fingerprint,
3020      account
3021    };
3022  }
3023
3024  return {
3025    valid: true,
3026    reason: "ok",
3027    headerCount,
3028    headers,
3029    tabId: trackedTabId,
3030    capturedAt,
3031    lastSeenAt,
3032    url: requestUrl,
3033    fingerprint,
3034    account
3035  };
3036}
3037
3038function requireCredentialState(platform) {
3039  const credential = getCredentialState(platform);
3040  if (credential.valid) return credential;
3041  throw new Error(`${platformLabel(platform)} 没有有效凭证:${describeCredentialReason(credential.reason)}`);
3042}
3043
3044function buildCredentialTransportSnapshot(platform) {
3045  const credential = getCredentialState(platform);
3046  const account = cloneAccountState(state.account[platform]);
3047  const fingerprint = trimToNull(state.credentialFingerprint[platform]) || null;
3048  const runtime = buildPlatformRuntimeSnapshot(platform);
3049
3050  return {
3051    platform,
3052    account: account.value,
3053    account_kind: account.kind,
3054    account_source: account.source,
3055    account_captured_at: account.capturedAt > 0 ? account.capturedAt : null,
3056    account_last_seen_at: account.lastSeenAt > 0 ? account.lastSeenAt : null,
3057    credential_fingerprint: fingerprint,
3058    freshness: getCredentialFreshness(credential.reason),
3059    captured_at: credential.capturedAt > 0 ? credential.capturedAt : null,
3060    last_seen_at: credential.lastSeenAt > 0 ? credential.lastSeenAt : null,
3061    timestamp: credential.capturedAt || credential.lastSeenAt || account.lastSeenAt || account.capturedAt || 0,
3062    endpoint_count: getEndpointCount(platform),
3063    headers: redactCredentialHeaders(credential.headers || {}),
3064    header_names: Object.keys(credential.headers || {}).sort(),
3065    shell_runtime: runtime
3066  };
3067}
3068
3069function clearPlatformCredential(platform) {
3070  let changed = false;
3071
3072  if (Object.keys(state.lastHeaders[platform]).length > 0) {
3073    state.lastHeaders[platform] = {};
3074    changed = true;
3075  }
3076  if (state.lastCredentialUrl[platform]) {
3077    state.lastCredentialUrl[platform] = "";
3078    changed = true;
3079  }
3080  if (Number.isInteger(state.lastCredentialTabId[platform])) {
3081    state.lastCredentialTabId[platform] = null;
3082    changed = true;
3083  }
3084
3085  state.lastCredentialHash[platform] = "";
3086  state.lastCredentialSentAt[platform] = 0;
3087  return changed;
3088}
3089
3090function pruneInvalidCredentialState(now = Date.now()) {
3091  let changed = false;
3092
3093  for (const platform of PLATFORM_ORDER) {
3094    const hasSnapshot = Object.keys(state.lastHeaders[platform]).length > 0;
3095    if (!hasSnapshot) continue;
3096
3097    const credential = getCredentialState(platform, now);
3098    if (!credential.valid) {
3099      const cleared = clearPlatformCredential(platform);
3100      if (cleared) {
3101        sendCredentialSnapshot(platform, true);
3102      }
3103      changed = cleared || changed;
3104    }
3105  }
3106
3107  return changed;
3108}
3109
3110function isGeminiStreamGenerateUrl(url) {
3111  try {
3112    const parsed = new URL(url, PLATFORMS.gemini.rootUrl);
3113    return (parsed.pathname || "").toLowerCase().includes("streamgenerate");
3114  } catch (_) {
3115    return String(url || "").toLowerCase().includes("streamgenerate");
3116  }
3117}
3118
3119function hasGeminiTemplateHeaders(headers) {
3120  const source = headers && typeof headers === "object" ? headers : {};
3121  const contentType = String(source["content-type"] || "").toLowerCase();
3122  return contentType.startsWith("application/x-www-form-urlencoded")
3123    && (source["x-same-domain"] === "1" || Object.keys(source).some((name) => name.startsWith("x-goog-ext-")));
3124}
3125
3126function normalizeGeminiTemplateKey(conversationId = null, options = {}) {
3127  const normalizedConversationId = trimToNull(conversationId);
3128  if (normalizedConversationId) {
3129    return normalizedConversationId;
3130  }
3131
3132  return options.shellPage === true ? GEMINI_SHELL_TEMPLATE_KEY : GEMINI_FALLBACK_TEMPLATE_KEY;
3133}
3134
3135function describeGeminiTemplateTarget(templateKey, template = null) {
3136  const conversationId = trimToNull(template?.conversationId);
3137  if (conversationId) {
3138    return `conversation=${conversationId}`;
3139  }
3140
3141  if (templateKey === GEMINI_SHELL_TEMPLATE_KEY || template?.shellPage === true) {
3142    return "shell";
3143  }
3144
3145  return "fallback";
3146}
3147
3148function parseGeminiSendTemplate(context, url, reqBody, reqHeaders = null) {
3149  if (!isGeminiStreamGenerateUrl(url) || typeof reqBody !== "string" || !reqBody) return null;
3150
3151  try {
3152    const normalizedUrl = normalizeStoredUrl(url, PLATFORMS.gemini.rootUrl);
3153    const parsedUrl = new URL(normalizedUrl, PLATFORMS.gemini.rootUrl);
3154    const headers = cloneHeaderMap(reqHeaders);
3155    if (Object.keys(headers).length > 0 && !hasGeminiTemplateHeaders(headers)) return null;
3156    const pageUrl = trimToNull(context?.senderUrl || context?.pageUrl);
3157    const conversationId =
3158      trimToNull(context?.conversationId)
3159      || extractGeminiConversationIdFromPageUrl(pageUrl || "")
3160      || null;
3161    const shellPage =
3162      conversationId == null
3163      && (
3164        context?.isShellPage === true
3165        || (pageUrl != null && isPlatformShellUrl("gemini", pageUrl, { allowFallback: true }))
3166      );
3167    const params = new URLSearchParams(reqBody);
3168    const outerPayload = params.get("f.req");
3169    if (!outerPayload) return null;
3170
3171    const outer = JSON.parse(outerPayload);
3172    if (!Array.isArray(outer) || typeof outer[1] !== "string") return null;
3173
3174    const inner = JSON.parse(outer[1]);
3175    if (!Array.isArray(inner) || !Array.isArray(inner[0])) return null;
3176
3177    return {
3178      conversationId,
3179      credentialFingerprint: trimToNull(state.credentialFingerprint.gemini),
3180      pageUrl,
3181      shellPage,
3182      url: normalizedUrl,
3183      reqBody,
3184      headers,
3185      prompt: typeof inner[0][0] === "string" ? inner[0][0] : "",
3186      reqId: Number(parsedUrl.searchParams.get("_reqid")) || null,
3187      updatedAt: Date.now()
3188    };
3189  } catch (_) {
3190    return null;
3191  }
3192}
3193
3194function normalizeGeminiSendTemplateEntry(value, templateKey = null, now = Date.now()) {
3195  if (!isRecord(value)) {
3196    return null;
3197  }
3198
3199  const reqBody = typeof value.reqBody === "string" && value.reqBody.trim()
3200    ? value.reqBody
3201    : null;
3202  const url = typeof value.url === "string" && value.url.trim()
3203    ? value.url
3204    : null;
3205  const updatedAt = Number.isFinite(Number(value.updatedAt)) && Number(value.updatedAt) > 0
3206    ? Math.round(Number(value.updatedAt))
3207    : 0;
3208
3209  if (!reqBody || !url || updatedAt <= 0 || now - updatedAt > GEMINI_SEND_TEMPLATE_TTL) {
3210    return null;
3211  }
3212
3213  const normalizedPageUrl = trimToNull(value.pageUrl);
3214  const normalizedConversationId =
3215    trimToNull(value.conversationId)
3216    || extractGeminiConversationIdFromPageUrl(normalizedPageUrl || "")
3217    || null;
3218  const shellPage = normalizedConversationId == null
3219    && (
3220      value.shellPage === true
3221      || templateKey === GEMINI_SHELL_TEMPLATE_KEY
3222    );
3223  const parsed = parseGeminiSendTemplate({
3224    conversationId: normalizedConversationId,
3225    isShellPage: shellPage,
3226    senderUrl: normalizedPageUrl
3227  }, url, reqBody, value.headers);
3228
3229  if (!parsed) {
3230    return null;
3231  }
3232
3233  return {
3234    ...parsed,
3235    conversationId: normalizedConversationId,
3236    credentialFingerprint: trimToNull(value.credentialFingerprint) || trimToNull(parsed.credentialFingerprint),
3237    pageUrl: normalizedPageUrl || parsed.pageUrl || null,
3238    shellPage,
3239    updatedAt
3240  };
3241}
3242
3243function loadGeminiSendTemplates(raw, legacyRaw = null, now = Date.now()) {
3244  const source = isRecord(raw) ? raw : {};
3245  const normalized = [];
3246
3247  for (const [templateKey, entry] of Object.entries(source)) {
3248    const template = normalizeGeminiSendTemplateEntry(entry, templateKey, now);
3249    if (!template) continue;
3250
3251    normalized.push({
3252      key: normalizeGeminiTemplateKey(template.conversationId, {
3253        shellPage: template.shellPage === true
3254      }),
3255      ...template
3256    });
3257  }
3258
3259  if (normalized.length === 0) {
3260    const legacyTemplate = normalizeGeminiSendTemplateEntry(
3261      legacyRaw,
3262      GEMINI_FALLBACK_TEMPLATE_KEY,
3263      now
3264    );
3265    if (legacyTemplate) {
3266      normalized.push({
3267        key: normalizeGeminiTemplateKey(legacyTemplate.conversationId, {
3268          shellPage: legacyTemplate.shellPage === true
3269        }),
3270        ...legacyTemplate
3271      });
3272    }
3273  }
3274
3275  normalized.sort((left, right) => right.updatedAt - left.updatedAt);
3276
3277  const next = {};
3278  for (const entry of normalized.slice(0, GEMINI_SEND_TEMPLATE_LIMIT)) {
3279    if (next[entry.key]) {
3280      continue;
3281    }
3282    next[entry.key] = {
3283      conversationId: entry.conversationId,
3284      credentialFingerprint: entry.credentialFingerprint,
3285      headers: entry.headers,
3286      pageUrl: entry.pageUrl,
3287      prompt: entry.prompt,
3288      reqBody: entry.reqBody,
3289      reqId: entry.reqId,
3290      shellPage: entry.shellPage,
3291      updatedAt: entry.updatedAt,
3292      url: entry.url
3293    };
3294  }
3295
3296  return next;
3297}
3298
3299function serializeGeminiSendTemplates(now = Date.now()) {
3300  return loadGeminiSendTemplates(state.geminiSendTemplates, null, now);
3301}
3302
3303function serializeLegacyGeminiSendTemplate(now = Date.now()) {
3304  const entries = Object.values(serializeGeminiSendTemplates(now))
3305    .sort((left, right) => right.updatedAt - left.updatedAt);
3306  return entries[0] || null;
3307}
3308
3309function pruneGeminiSendTemplates(now = Date.now()) {
3310  state.geminiSendTemplates = loadGeminiSendTemplates(state.geminiSendTemplates, null, now);
3311}
3312
3313function invalidateGeminiSendTemplate(templateKey) {
3314  const normalizedKey = trimToNull(templateKey);
3315
3316  if (!normalizedKey || !state.geminiSendTemplates[normalizedKey]) {
3317    return false;
3318  }
3319
3320  delete state.geminiSendTemplates[normalizedKey];
3321  persistState().catch(() => {});
3322  return true;
3323}
3324
3325function rememberGeminiSendTemplate(context, url, reqBody, reqHeaders = null) {
3326  const next = parseGeminiSendTemplate(context, url, reqBody, reqHeaders);
3327  if (!next) return false;
3328
3329  const templateKey = normalizeGeminiTemplateKey(next.conversationId, {
3330    shellPage: next.shellPage === true
3331  });
3332  const previous = normalizeGeminiSendTemplateEntry(
3333    state.geminiSendTemplates[templateKey],
3334    templateKey,
3335    next.updatedAt
3336  );
3337
3338  if (Number.isFinite(previous?.reqId) && Number.isFinite(next.reqId)) {
3339    next.reqId = Math.max(previous.reqId, next.reqId);
3340  } else if (Number.isFinite(previous?.reqId) && !Number.isFinite(next.reqId)) {
3341    next.reqId = previous.reqId;
3342  }
3343
3344  const changed = !previous
3345    || previous.url !== next.url
3346    || previous.reqBody !== next.reqBody
3347    || previous.prompt !== next.prompt
3348    || previous.reqId !== next.reqId
3349    || previous.pageUrl !== next.pageUrl
3350    || previous.conversationId !== next.conversationId
3351    || previous.credentialFingerprint !== next.credentialFingerprint
3352    || JSON.stringify(previous.headers || {}) !== JSON.stringify(next.headers || {});
3353
3354  state.geminiSendTemplates[templateKey] = next;
3355  pruneGeminiSendTemplates(next.updatedAt);
3356  persistState().catch(() => {});
3357
3358  if (changed) {
3359    addLog(
3360      "info",
3361      previous
3362        ? `已更新 Gemini 发送模板 ${describeGeminiTemplateTarget(templateKey, next)}`
3363        : `Gemini 发送模板预热完成 ${describeGeminiTemplateTarget(templateKey, next)}`,
3364      false
3365    );
3366  }
3367  return true;
3368}
3369
3370function getGeminiSendTemplateByKey(templateKey) {
3371  const normalizedKey = trimToNull(templateKey);
3372  if (!normalizedKey) {
3373    return null;
3374  }
3375
3376  const template = normalizeGeminiSendTemplateEntry(
3377    state.geminiSendTemplates[normalizedKey],
3378    normalizedKey
3379  );
3380
3381  if (!template) {
3382    invalidateGeminiSendTemplate(normalizedKey);
3383    return null;
3384  }
3385
3386  const currentFingerprint = trimToNull(state.credentialFingerprint.gemini);
3387
3388  if (
3389    template.credentialFingerprint
3390    && currentFingerprint
3391    && template.credentialFingerprint !== currentFingerprint
3392  ) {
3393    invalidateGeminiSendTemplate(normalizedKey);
3394    return null;
3395  }
3396
3397  state.geminiSendTemplates[normalizedKey] = template;
3398  return {
3399    key: normalizedKey,
3400    ...template
3401  };
3402}
3403
3404function selectMostRecentGeminiSendTemplate(excludedKeys = []) {
3405  const excluded = new Set(excludedKeys.map((key) => trimToNull(key)).filter(Boolean));
3406  const candidates = Object.keys(serializeGeminiSendTemplates())
3407    .filter((key) => !excluded.has(key))
3408    .map((key) => getGeminiSendTemplateByKey(key))
3409    .filter(Boolean)
3410    .sort((left, right) => right.updatedAt - left.updatedAt);
3411
3412  return candidates[0] || null;
3413}
3414
3415function getGeminiSendTemplate(options = {}) {
3416  const conversationId = trimToNull(options.conversationId);
3417  const allowRecentFallback = options.allowRecentFallback === true;
3418  const allowShellFallback = options.allowShellFallback === true;
3419  const attemptedKeys = [];
3420
3421  if (conversationId) {
3422    attemptedKeys.push(conversationId);
3423    const exact = getGeminiSendTemplateByKey(conversationId);
3424    if (exact) {
3425      return {
3426        match: "conversation",
3427        ...exact
3428      };
3429    }
3430  }
3431
3432  if (!conversationId || allowShellFallback) {
3433    attemptedKeys.push(GEMINI_SHELL_TEMPLATE_KEY);
3434    const shell = getGeminiSendTemplateByKey(GEMINI_SHELL_TEMPLATE_KEY);
3435    if (shell) {
3436      return {
3437        match: "shell",
3438        ...shell
3439      };
3440    }
3441  }
3442
3443  if (!allowRecentFallback) {
3444    return null;
3445  }
3446
3447  const recent = selectMostRecentGeminiSendTemplate(attemptedKeys);
3448  if (!recent) {
3449    return null;
3450  }
3451
3452  return {
3453    match: recent.key === GEMINI_FALLBACK_TEMPLATE_KEY ? "fallback" : "recent",
3454    ...recent
3455  };
3456}
3457
3458function extractGeminiXsrfToken(body) {
3459  const match = String(body || "").match(/"xsrf","([^"]+)"/);
3460  return match ? match[1] : null;
3461}
3462
3463function updateGeminiTemplateReqId(templateKey, reqId) {
3464  const normalizedKey = trimToNull(templateKey);
3465  if (!normalizedKey || !Number.isFinite(reqId)) return false;
3466
3467  const template = getGeminiSendTemplateByKey(normalizedKey);
3468  if (!template) return false;
3469
3470  state.geminiSendTemplates[normalizedKey] = {
3471    ...template,
3472    reqId,
3473    updatedAt: Date.now()
3474  };
3475  persistState().catch(() => {});
3476  return true;
3477}
3478
3479function updateGeminiTemplateXsrf(templateKey, xsrfToken) {
3480  const normalizedKey = trimToNull(templateKey);
3481  const template = normalizedKey ? getGeminiSendTemplateByKey(normalizedKey) : null;
3482
3483  if (!xsrfToken || !template?.reqBody) return false;
3484
3485  const params = new URLSearchParams(template.reqBody);
3486  params.set("at", xsrfToken);
3487  state.geminiSendTemplates[normalizedKey] = {
3488    ...template,
3489    reqBody: params.toString(),
3490    updatedAt: Date.now()
3491  };
3492  persistState().catch(() => {});
3493  return true;
3494}
3495
3496function extractPromptFromProxyBody(body) {
3497  if (typeof body === "string") {
3498    const text = body.trim();
3499    if (!text) return null;
3500    if (text.includes("f.req=") || text.includes("&at=") || text.startsWith("{") || text.startsWith("[")) {
3501      return null;
3502    }
3503    return text;
3504  }
3505  if (!body || typeof body !== "object") return null;
3506
3507  const prompt = body.text ?? body.prompt ?? body.message;
3508  return typeof prompt === "string" && prompt.trim() ? prompt.trim() : null;
3509}
3510
3511function cloneJsonValue(value) {
3512  if (value == null) {
3513    return value;
3514  }
3515
3516  try {
3517    return JSON.parse(JSON.stringify(value));
3518  } catch (_) {
3519    return null;
3520  }
3521}
3522
3523function normalizeChatgptSendTemplateEntry(value, fallbackConversationId = null, now = Date.now()) {
3524  if (!isRecord(value)) {
3525    return null;
3526  }
3527
3528  const conversationId = trimToNull(value.conversationId) || trimToNull(fallbackConversationId);
3529  const reqBody = typeof value.reqBody === "string" && value.reqBody.trim()
3530    ? value.reqBody
3531    : null;
3532  const updatedAt = Number.isFinite(Number(value.updatedAt)) && Number(value.updatedAt) > 0
3533    ? Math.round(Number(value.updatedAt))
3534    : 0;
3535
3536  if (!conversationId || !reqBody || updatedAt <= 0 || now - updatedAt > CHATGPT_SEND_TEMPLATE_TTL) {
3537    return null;
3538  }
3539
3540  try {
3541    const parsed = JSON.parse(reqBody);
3542
3543    if (!isRecord(parsed)) {
3544      return null;
3545    }
3546  } catch (_) {
3547    return null;
3548  }
3549
3550  return {
3551    conversationId,
3552    credentialFingerprint: trimToNull(value.credentialFingerprint),
3553    model: trimToNull(value.model),
3554    pageUrl: trimToNull(value.pageUrl),
3555    reqBody,
3556    updatedAt
3557  };
3558}
3559
3560function loadChatgptSendTemplates(raw, now = Date.now()) {
3561  const source = isRecord(raw) ? raw : {};
3562  const normalized = [];
3563
3564  for (const [conversationId, entry] of Object.entries(source)) {
3565    const template = normalizeChatgptSendTemplateEntry(entry, conversationId, now);
3566
3567    if (template) {
3568      normalized.push(template);
3569    }
3570  }
3571
3572  normalized.sort((left, right) => right.updatedAt - left.updatedAt);
3573
3574  const next = {};
3575  for (const entry of normalized.slice(0, CHATGPT_SEND_TEMPLATE_LIMIT)) {
3576    next[entry.conversationId] = entry;
3577  }
3578
3579  return next;
3580}
3581
3582function serializeChatgptSendTemplates(now = Date.now()) {
3583  return loadChatgptSendTemplates(state.chatgptSendTemplates, now);
3584}
3585
3586function pruneChatgptSendTemplates(now = Date.now()) {
3587  state.chatgptSendTemplates = loadChatgptSendTemplates(state.chatgptSendTemplates, now);
3588}
3589
3590function invalidateChatgptSendTemplate(conversationId) {
3591  const normalizedConversationId = trimToNull(conversationId);
3592
3593  if (!normalizedConversationId || !state.chatgptSendTemplates[normalizedConversationId]) {
3594    return false;
3595  }
3596
3597  delete state.chatgptSendTemplates[normalizedConversationId];
3598  persistState().catch(() => {});
3599  return true;
3600}
3601
3602function rememberChatgptSendTemplate(context, reqBody) {
3603  const conversationId = trimToNull(context?.conversationId) || extractChatgptConversationIdFromRequestBody(reqBody);
3604
3605  if (!conversationId || typeof reqBody !== "string" || !reqBody.trim()) {
3606    return false;
3607  }
3608
3609  let parsed = null;
3610  try {
3611    parsed = JSON.parse(reqBody);
3612  } catch (_) {
3613    return false;
3614  }
3615
3616  if (!isRecord(parsed)) {
3617    return false;
3618  }
3619
3620  const next = {
3621    conversationId,
3622    credentialFingerprint: trimToNull(state.credentialFingerprint.chatgpt),
3623    model: trimToNull(parsed.model),
3624    pageUrl: trimToNull(context?.senderUrl),
3625    reqBody,
3626    updatedAt: Date.now()
3627  };
3628  const previous = state.chatgptSendTemplates[conversationId];
3629  const hadUsableTemplate = normalizeChatgptSendTemplateEntry(previous, conversationId, next.updatedAt) != null;
3630  const changed = !previous
3631    || previous.reqBody !== next.reqBody
3632    || previous.credentialFingerprint !== next.credentialFingerprint
3633    || previous.pageUrl !== next.pageUrl
3634    || previous.model !== next.model;
3635
3636  state.chatgptSendTemplates[conversationId] = next;
3637  pruneChatgptSendTemplates(next.updatedAt);
3638  persistState().catch(() => {});
3639
3640  if (changed) {
3641    addLog(
3642      "info",
3643      hadUsableTemplate
3644        ? `已更新 ChatGPT 发送模板 conversation=${conversationId}`
3645        : `ChatGPT 发送模板预热完成 conversation=${conversationId}`,
3646      false
3647    );
3648  }
3649
3650  return true;
3651}
3652
3653function getChatgptSendTemplate(conversationId) {
3654  const normalizedConversationId = trimToNull(conversationId);
3655
3656  if (!normalizedConversationId) {
3657    return null;
3658  }
3659
3660  const template = normalizeChatgptSendTemplateEntry(
3661    state.chatgptSendTemplates[normalizedConversationId],
3662    normalizedConversationId
3663  );
3664
3665  if (!template) {
3666    invalidateChatgptSendTemplate(normalizedConversationId);
3667    return null;
3668  }
3669
3670  const currentFingerprint = trimToNull(state.credentialFingerprint.chatgpt);
3671
3672  if (
3673    template.credentialFingerprint
3674    && currentFingerprint
3675    && template.credentialFingerprint !== currentFingerprint
3676  ) {
3677    invalidateChatgptSendTemplate(normalizedConversationId);
3678    return null;
3679  }
3680
3681  state.chatgptSendTemplates[normalizedConversationId] = template;
3682  return {
3683    ...template
3684  };
3685}
3686
3687function buildChatgptDeliveryRequest(options = {}) {
3688  const conversationId = trimToNull(options.conversationId);
3689  const sourceAssistantMessageId = trimToNull(options.sourceAssistantMessageId);
3690  const messageText = trimToNull(options.messageText);
3691
3692  if (!conversationId) {
3693    throw new Error("delivery.route_missing: ChatGPT delivery requires conversation_id");
3694  }
3695
3696  if (!messageText) {
3697    throw new Error("delivery.invalid_payload: ChatGPT delivery requires message_text");
3698  }
3699
3700  if (!sourceAssistantMessageId) {
3701    throw new Error("delivery.invalid_payload: ChatGPT delivery requires assistant_message_id");
3702  }
3703
3704  const template = getChatgptSendTemplate(conversationId);
3705  if (!template?.reqBody) {
3706    throw new Error("delivery.template_missing: missing ChatGPT send template; send one real ChatGPT message first");
3707  }
3708
3709  let parsed = null;
3710  try {
3711    parsed = JSON.parse(template.reqBody);
3712  } catch (_) {
3713    throw new Error("delivery.template_invalid: stored ChatGPT send template is not valid JSON");
3714  }
3715
3716  if (!isRecord(parsed)) {
3717    throw new Error("delivery.template_invalid: stored ChatGPT send template is not an object");
3718  }
3719
3720  const messageId = typeof crypto?.randomUUID === "function"
3721    ? crypto.randomUUID()
3722    : `chatgpt-message-${Date.now()}`;
3723  const requestId = typeof crypto?.randomUUID === "function"
3724    ? crypto.randomUUID()
3725    : `chatgpt-request-${Date.now()}`;
3726  const nextBody = cloneJsonValue(parsed);
3727
3728  if (!isRecord(nextBody)) {
3729    throw new Error("delivery.template_invalid: failed to clone ChatGPT send template");
3730  }
3731
3732  const templateMessage = Array.isArray(nextBody.messages) && isRecord(nextBody.messages[0])
3733    ? cloneJsonValue(nextBody.messages[0])
3734    : {};
3735  const nextMessage = isRecord(templateMessage) ? templateMessage : {};
3736
3737  nextMessage.id = messageId;
3738  nextMessage.author = {
3739    role: "user"
3740  };
3741  nextMessage.content = {
3742    content_type: "text",
3743    parts: [messageText]
3744  };
3745
3746  nextBody.action = trimToNull(nextBody.action) || "next";
3747  nextBody.conversation_id = conversationId;
3748  nextBody.messages = [nextMessage];
3749  nextBody.parent_message_id = sourceAssistantMessageId;
3750
3751  if (Object.prototype.hasOwnProperty.call(nextBody, "websocket_request_id")) {
3752    nextBody.websocket_request_id = requestId;
3753  }
3754
3755  if (Object.prototype.hasOwnProperty.call(nextBody, "websocketRequestId")) {
3756    nextBody.websocketRequestId = requestId;
3757  }
3758
3759  return {
3760    body: nextBody,
3761    headers: {
3762      ...buildProxyHeaders("chatgpt", "/backend-api/conversation"),
3763      accept: "text/event-stream",
3764      "content-type": "application/json"
3765    },
3766    method: "POST",
3767    path: "/backend-api/conversation"
3768  };
3769}
3770
3771function sleep(ms) {
3772  return new Promise((resolve) => setTimeout(resolve, ms));
3773}
3774
3775function readReconnectActionNumber(source, paths) {
3776  const candidate = getFirstDefinedValue(source, paths);
3777  if (candidate === undefined) return null;
3778  if (Number.isFinite(candidate)) return Number(candidate);
3779  if (typeof candidate === "string" && candidate.trim()) {
3780    const parsed = Number(candidate);
3781    return Number.isFinite(parsed) ? parsed : Number.NaN;
3782  }
3783  return Number.NaN;
3784}
3785
3786function normalizeReconnectActionInteger(value, options = {}) {
3787  const {
3788    allowZero = false,
3789    defaultValue = null,
3790    label = "value",
3791    max = Number.MAX_SAFE_INTEGER,
3792    min = allowZero ? 0 : 1
3793  } = options;
3794
3795  if (value == null) {
3796    return defaultValue;
3797  }
3798
3799  if (!Number.isFinite(value) || Math.round(value) !== value) {
3800    throw new Error(`${label} 必须是整数`);
3801  }
3802
3803  const normalized = Math.round(value);
3804  const lowerBound = allowZero ? Math.max(0, min) : Math.max(1, min);
3805  if (normalized < lowerBound || normalized > max) {
3806    throw new Error(`${label} 必须在 ${lowerBound}${max} 之间`);
3807  }
3808
3809  return normalized;
3810}
3811
3812function resolveWsReconnectActionOptions(source = {}) {
3813  const disconnectMs = normalizeReconnectActionInteger(
3814    readReconnectActionNumber(source, ["disconnectMs", "disconnect_ms", "delayMs", "delay_ms"]),
3815    {
3816      allowZero: true,
3817      defaultValue: MANUAL_WS_RECONNECT_DEFAULT_DISCONNECT_MS,
3818      label: "disconnectMs",
3819      max: MANUAL_WS_RECONNECT_MAX_DISCONNECT_MS
3820    }
3821  );
3822  const repeatCount = normalizeReconnectActionInteger(
3823    readReconnectActionNumber(source, ["repeatCount", "repeat_count"]),
3824    {
3825      defaultValue: 1,
3826      label: "repeatCount",
3827      max: MANUAL_WS_RECONNECT_MAX_REPEAT_COUNT
3828    }
3829  );
3830  const repeatIntervalMs = normalizeReconnectActionInteger(
3831    readReconnectActionNumber(source, ["repeatIntervalMs", "repeat_interval_ms", "intervalMs", "interval_ms"]),
3832    {
3833      allowZero: true,
3834      defaultValue: 0,
3835      label: "repeatIntervalMs",
3836      max: MANUAL_WS_RECONNECT_MAX_REPEAT_INTERVAL_MS
3837    }
3838  );
3839
3840  return {
3841    disconnectMs,
3842    repeatCount,
3843    repeatIntervalMs
3844  };
3845}
3846
3847function describeWsReconnectActionPlan(options) {
3848  return `disconnect_ms=${options.disconnectMs} repeat_count=${options.repeatCount} repeat_interval_ms=${options.repeatIntervalMs}`;
3849}
3850
3851function extractWsReconnectActionOverrides(source = {}) {
3852  return {
3853    disconnectMs: getFirstDefinedValue(source, ["disconnectMs", "disconnect_ms", "delayMs", "delay_ms"]),
3854    repeatCount: getFirstDefinedValue(source, ["repeatCount", "repeat_count"]),
3855    repeatIntervalMs: getFirstDefinedValue(source, ["repeatIntervalMs", "repeat_interval_ms", "intervalMs", "interval_ms"])
3856  };
3857}
3858
3859async function waitForWsConnected(sequenceId, timeoutMs = MANUAL_WS_RECONNECT_OPEN_TIMEOUT) {
3860  const deadline = Date.now() + timeoutMs;
3861
3862  while (Date.now() < deadline) {
3863    if (state.manualWsReconnectSequence !== sequenceId) {
3864      return false;
3865    }
3866
3867    if (state.wsConnected && normalizeWsConnection(state.wsState?.connection) === "connected") {
3868      return true;
3869    }
3870
3871    await sleep(MANUAL_WS_RECONNECT_POLL_INTERVAL);
3872  }
3873
3874  return state.manualWsReconnectSequence === sequenceId
3875    && state.wsConnected
3876    && normalizeWsConnection(state.wsState?.connection) === "connected";
3877}
3878
3879async function runWsReconnectActionSequence(sequenceId, options) {
3880  for (let cycle = 1; cycle <= options.repeatCount; cycle += 1) {
3881    if (state.manualWsReconnectSequence !== sequenceId) {
3882      return;
3883    }
3884
3885    closeWsConnection();
3886    const now = Date.now();
3887    setWsState({
3888      ...cloneWsState(state.wsState),
3889      connection: options.disconnectMs > 0 ? "retrying" : "connecting",
3890      nextRetryAt: options.disconnectMs > 0 ? now + options.disconnectMs : 0,
3891      retryCount: cycle,
3892      lastError: null
3893    });
3894    addLog("info", `本地 WS 重连测试 ${cycle}/${options.repeatCount}${describeWsReconnectActionPlan(options)}`, false);
3895
3896    if (options.disconnectMs > 0) {
3897      await sleep(options.disconnectMs);
3898    }
3899
3900    if (state.manualWsReconnectSequence !== sequenceId) {
3901      return;
3902    }
3903
3904    connectWs({ silentWhenDisabled: true });
3905
3906    if (cycle >= options.repeatCount) {
3907      return;
3908    }
3909
3910    const connected = await waitForWsConnected(sequenceId);
3911    if (state.manualWsReconnectSequence !== sequenceId) {
3912      return;
3913    }
3914
3915    if (!connected) {
3916      addLog("warn", `本地 WS 重连测试在第 ${cycle} 轮后未恢复连接,停止后续循环`, false);
3917      return;
3918    }
3919
3920    if (options.repeatIntervalMs > 0) {
3921      await sleep(options.repeatIntervalMs);
3922    }
3923  }
3924}
3925
3926function scheduleWsReconnectActionSequence(options) {
3927  state.manualWsReconnectSequence += 1;
3928  const sequenceId = state.manualWsReconnectSequence;
3929
3930  setTimeout(() => {
3931    if (state.manualWsReconnectSequence !== sequenceId) {
3932      return;
3933    }
3934
3935    void runWsReconnectActionSequence(sequenceId, options).catch((error) => {
3936      const message = error instanceof Error ? error.message : String(error);
3937      addLog("error", `本地 WS 重连测试失败:${message}`, false);
3938    });
3939  }, MANUAL_WS_RECONNECT_START_DELAY_MS);
3940
3941  return sequenceId;
3942}
3943
3944async function persistState() {
3945  await browser.storage.local.set({
3946    [CONTROLLER_STORAGE_KEYS.clientId]: state.clientId,
3947    [CONTROLLER_STORAGE_KEYS.wsUrl]: state.wsUrl,
3948    [CONTROLLER_STORAGE_KEYS.controlBaseUrl]: state.controlBaseUrl,
3949    [CONTROLLER_STORAGE_KEYS.controlState]: state.controlState,
3950    [CONTROLLER_STORAGE_KEYS.pageControls]: state.pageControls,
3951    [CONTROLLER_STORAGE_KEYS.statusSchemaVersion]: STATUS_SCHEMA_VERSION,
3952    [CONTROLLER_STORAGE_KEYS.trackedTabs]: state.trackedTabs,
3953    [CONTROLLER_STORAGE_KEYS.desiredTabs]: state.desiredTabs,
3954    [CONTROLLER_STORAGE_KEYS.controllerRuntime]: state.controllerRuntime,
3955    [CONTROLLER_STORAGE_KEYS.endpointsByPlatform]: state.endpoints,
3956    [CONTROLLER_STORAGE_KEYS.lastHeadersByPlatform]: state.lastHeaders,
3957    [CONTROLLER_STORAGE_KEYS.credentialCapturedAtByPlatform]: state.credentialCapturedAt,
3958    [CONTROLLER_STORAGE_KEYS.lastCredentialAtByPlatform]: state.lastCredentialAt,
3959    [CONTROLLER_STORAGE_KEYS.lastCredentialUrlByPlatform]: state.lastCredentialUrl,
3960    [CONTROLLER_STORAGE_KEYS.lastCredentialTabIdByPlatform]: state.lastCredentialTabId,
3961    [CONTROLLER_STORAGE_KEYS.credentialFingerprintByPlatform]: state.credentialFingerprint,
3962    [CONTROLLER_STORAGE_KEYS.accountByPlatform]: state.account,
3963    [CONTROLLER_STORAGE_KEYS.chatgptSendTemplates]: serializeChatgptSendTemplates(),
3964    [CONTROLLER_STORAGE_KEYS.geminiSendTemplates]: serializeGeminiSendTemplates(),
3965    [CONTROLLER_STORAGE_KEYS.geminiSendTemplate]: serializeLegacyGeminiSendTemplate(),
3966    [CONTROLLER_STORAGE_KEYS.finalMessageRelayCache]: serializeFinalMessageRelayCache(),
3967    [CONTROLLER_STORAGE_KEYS.claudeState]: {
3968      ...cloneClaudeState(state.claudeState),
3969      busy: false,
3970      busyReason: null,
3971      lastError: null
3972    }
3973  });
3974}
3975
3976function getTrackedCount() {
3977  return PLATFORM_ORDER.filter((platform) => cloneActualTabState(state.actualTabs[platform]).exists).length;
3978}
3979
3980function getAccountCount() {
3981  const managed = new Set(getManagedSummaryPlatforms());
3982  return PLATFORM_ORDER.filter((platform) => managed.has(platform) && !!trimToNull(state.account[platform]?.value)).length;
3983}
3984
3985function getCredentialCount() {
3986  const managed = new Set(getManagedSummaryPlatforms());
3987  return PLATFORM_ORDER.filter((platform) => {
3988    if (!managed.has(platform)) return false;
3989    const credential = getCredentialState(platform);
3990    return credential.valid || !!trimToNull(state.credentialFingerprint[platform]);
3991  }).length;
3992}
3993
3994function getEndpointCount(platform) {
3995  return Object.keys(state.endpoints[platform] || {}).length;
3996}
3997
3998function getTotalEndpointCount() {
3999  return PLATFORM_ORDER.reduce((sum, platform) => sum + getEndpointCount(platform), 0);
4000}
4001
4002function formatTrackedMeta() {
4003  const desiredCount = getDesiredCount();
4004  const actualCount = getTrackedCount();
4005  const driftCount = getRuntimeDriftCount();
4006  const labels = PLATFORM_ORDER
4007    .map((platform) => {
4008      const runtime = buildPlatformRuntimeSnapshot(platform);
4009      if (!runtime.desired.exists && !runtime.actual.exists) return null;
4010      return `${platformLabel(platform)}(desired=${runtime.desired.exists ? "on" : "off"}, actual=${runtime.actual.exists ? `#${runtime.actual.tab_id}` : runtime.actual.issue})`;
4011    })
4012    .filter(Boolean);
4013
4014  const summary = `desired=${desiredCount} · actual=${actualCount} · drift=${driftCount}`;
4015  return labels.length > 0 ? `${summary} · ${labels.join(" · ")}` : `${summary} · 当前没有已建立的空壳页`;
4016}
4017
4018function formatAccountMeta() {
4019  const managed = new Set(getManagedSummaryPlatforms());
4020  const labels = PLATFORM_ORDER
4021    .map((platform) => {
4022      if (!managed.has(platform)) return null;
4023      const value = trimToNull(state.account[platform]?.value);
4024      return value ? `${platformLabel(platform)}(${value})` : null;
4025    })
4026    .filter(Boolean);
4027  return labels.length > 0 ? labels.join(" · ") : "当前还没有识别到账号元数据";
4028}
4029
4030function formatCredentialMeta() {
4031  const managed = new Set(getManagedSummaryPlatforms());
4032  const labels = PLATFORM_ORDER
4033    .map((platform) => {
4034      if (!managed.has(platform)) return null;
4035      const credential = getCredentialState(platform);
4036      const fingerprint = trimToNull(state.credentialFingerprint[platform]);
4037      if (!credential.valid && !fingerprint) return null;
4038      return `${platformLabel(platform)}(${getCredentialFreshness(credential.reason)})`;
4039    })
4040    .filter(Boolean);
4041  return labels.length > 0 ? labels.join(" · ") : "当前没有可汇报的登录态元数据";
4042}
4043
4044function formatEndpointMeta() {
4045  const labels = PLATFORM_ORDER
4046    .map((platform) => {
4047      const count = getEndpointCount(platform);
4048      if (count <= 0) return null;
4049      return `${platformLabel(platform)}(${count})`;
4050    })
4051    .filter(Boolean);
4052  return labels.length > 0 ? labels.join(" · ") : "当前没有发现端点";
4053}
4054
4055function renderPlatformStatus() {
4056  const lines = [];
4057  for (const platform of PLATFORM_ORDER) {
4058    const runtime = buildPlatformRuntimeSnapshot(platform);
4059    const credential = getCredentialState(platform);
4060    const account = trimToNull(state.account[platform]?.value) || "-";
4061    const fingerprint = trimToNull(state.credentialFingerprint[platform]);
4062    const credentialLabel = `${getCredentialFreshness(credential.reason)} / ${describeCredentialReason(credential.reason)}`;
4063    const endpointCount = getEndpointCount(platform);
4064    const pageControlLabel = summarizePageControls(platform);
4065    const actualLabel = runtime.actual.exists
4066      ? `#${runtime.actual.tab_id}${runtime.actual.issue === "loading" ? "/loading" : ""}`
4067      : (runtime.actual.candidate_tab_id ? `missing(candidate#${runtime.actual.candidate_tab_id})` : runtime.actual.issue);
4068    const driftLabel = runtime.drift.reason;
4069    lines.push(
4070      `${platformLabel(platform).padEnd(8)} desired=${String(runtime.desired.exists).padEnd(5)} actual=${actualLabel.padEnd(24)} drift=${driftLabel.padEnd(16)} 账号=${account.padEnd(18)} 登录态=${credentialLabel.padEnd(18)} 指纹=${(fingerprint || "-").slice(0, 12).padEnd(12)} 端点=${endpointCount} 页面=${pageControlLabel}`
4071    );
4072  }
4073  return lines.join("\n");
4074}
4075
4076function renderHeaderSnapshot() {
4077  const snapshot = {};
4078  for (const platform of PLATFORM_ORDER) {
4079    const transport = buildCredentialTransportSnapshot(platform);
4080    const credential = getCredentialState(platform);
4081    if (!transport.credential_fingerprint && !transport.account && !credential.valid) continue;
4082    snapshot[platform] = {
4083      ...transport,
4084      tabId: credential.tabId || null,
4085      url: credential.url || null,
4086      browser_local_credentials_held: credential.valid
4087    };
4088  }
4089  return Object.keys(snapshot).length > 0
4090    ? JSON.stringify(snapshot, null, 2)
4091    : "还没有登录态元数据快照。";
4092}
4093
4094function renderAccountSnapshot() {
4095  const snapshot = {};
4096  for (const platform of PLATFORM_ORDER) {
4097    const account = cloneAccountState(state.account[platform]);
4098    if (!account.value) continue;
4099    snapshot[platform] = {
4100      value: account.value,
4101      kind: account.kind,
4102      source: account.source,
4103      capturedAt: account.capturedAt > 0 ? new Date(account.capturedAt).toISOString() : null,
4104      lastSeenAt: account.lastSeenAt > 0 ? new Date(account.lastSeenAt).toISOString() : null
4105    };
4106  }
4107
4108  return Object.keys(snapshot).length > 0
4109    ? JSON.stringify(snapshot, null, 2)
4110    : "还没有账号元数据。";
4111}
4112
4113function renderEndpointSnapshot() {
4114  const lines = [];
4115  for (const platform of PLATFORM_ORDER) {
4116    const endpoints = Object.values(state.endpoints[platform] || {})
4117      .map((entry) => normalizeEndpointEntry(entry?.key || "", entry))
4118      .sort((left, right) => left.key.localeCompare(right.key));
4119    if (endpoints.length === 0) continue;
4120    lines.push(`[${platformLabel(platform)}]`);
4121    for (const entry of endpoints) {
4122      lines.push(`${entry.method} ${entry.path}  first=${formatSyncTime(entry.firstObservedAt)}  last=${formatSyncTime(entry.lastObservedAt)}`);
4123    }
4124    lines.push("");
4125  }
4126  if (lines.length === 0) return "还没有发现端点。";
4127  if (lines[lines.length - 1] === "") lines.pop();
4128  return lines.join("\n");
4129}
4130
4131function renderControlSnapshot() {
4132  const snapshot = cloneControlState(state.controlState);
4133
4134  return JSON.stringify({
4135    baseUrl: state.controlBaseUrl,
4136    ok: snapshot.ok,
4137    controlConnection: snapshot.controlConnection,
4138    retryCount: snapshot.retryCount,
4139    mode: snapshot.mode,
4140    leader: snapshot.leader,
4141    leaseHolder: snapshot.leaseHolder,
4142    queueDepth: snapshot.queueDepth,
4143    activeRuns: snapshot.activeRuns,
4144    statusCode: snapshot.statusCode,
4145    syncedAt: snapshot.syncedAt ? new Date(snapshot.syncedAt).toISOString() : null,
4146    lastSuccessAt: snapshot.lastSuccessAt ? new Date(snapshot.lastSuccessAt).toISOString() : null,
4147    lastFailureAt: snapshot.lastFailureAt ? new Date(snapshot.lastFailureAt).toISOString() : null,
4148    nextRetryAt: snapshot.nextRetryAt ? new Date(snapshot.nextRetryAt).toISOString() : null,
4149    source: snapshot.source,
4150    error: snapshot.error,
4151    message: snapshot.message,
4152    raw: snapshot.raw,
4153    pageControls: listPageControlStates().map(serializePageControlState).filter(Boolean)
4154  }, null, 2);
4155}
4156
4157function render() {
4158  const wsSnapshot = cloneWsState(state.wsState);
4159  const controlSnapshot = cloneControlState(state.controlState);
4160  const trackedCount = getTrackedCount();
4161  const accountCount = getAccountCount();
4162  const credentialCount = getCredentialCount();
4163  const endpointCount = getTotalEndpointCount();
4164
4165  if (ui.wsStatus) {
4166    ui.wsStatus.textContent = formatWsConnectionLabel(wsSnapshot);
4167    ui.wsStatus.className = `value ${wsConnectionClass(wsSnapshot)}`;
4168  }
4169  if (ui.wsMeta) {
4170    ui.wsMeta.textContent = formatWsMeta(wsSnapshot);
4171  }
4172  if (ui.controlMode) {
4173    ui.controlMode.textContent = formatControlConnectionLabel(controlSnapshot);
4174    ui.controlMode.className = `value ${controlConnectionClass(controlSnapshot)}`;
4175  }
4176  if (ui.controlMeta) {
4177    ui.controlMeta.textContent = formatControlMeta(controlSnapshot);
4178  }
4179  if (ui.trackedCount) {
4180    ui.trackedCount.textContent = String(trackedCount);
4181    ui.trackedCount.className = `value ${trackedCount > 0 ? "on" : "off"}`;
4182  }
4183  if (ui.trackedMeta) {
4184    ui.trackedMeta.textContent = formatTrackedMeta();
4185  }
4186  if (ui.accountCount) {
4187    ui.accountCount.textContent = String(accountCount);
4188    ui.accountCount.className = `value ${accountCount > 0 ? "on" : "off"}`;
4189  }
4190  if (ui.accountMeta) {
4191    ui.accountMeta.textContent = formatAccountMeta();
4192  }
4193  if (ui.credentialCount) {
4194    ui.credentialCount.textContent = String(credentialCount);
4195    ui.credentialCount.className = `value ${credentialCount > 0 ? "on" : "off"}`;
4196  }
4197  if (ui.credentialMeta) {
4198    ui.credentialMeta.textContent = formatCredentialMeta();
4199  }
4200  if (ui.endpointCount) {
4201    ui.endpointCount.textContent = String(endpointCount);
4202    ui.endpointCount.className = `value ${endpointCount > 0 ? "on" : "off"}`;
4203  }
4204  if (ui.endpointMeta) {
4205    ui.endpointMeta.textContent = formatEndpointMeta();
4206  }
4207  if (ui.platformView) {
4208    ui.platformView.textContent = renderPlatformStatus();
4209  }
4210  if (ui.accountView) {
4211    ui.accountView.textContent = renderAccountSnapshot();
4212  }
4213  if (ui.credentialView) {
4214    ui.credentialView.textContent = renderHeaderSnapshot();
4215  }
4216  if (ui.endpointView) {
4217    ui.endpointView.textContent = renderEndpointSnapshot();
4218  }
4219  if (ui.wsView) {
4220    ui.wsView.textContent = renderWsSnapshot();
4221  }
4222  if (ui.controlView) {
4223    ui.controlView.textContent = renderControlSnapshot();
4224  }
4225}
4226
4227async function setControlState(next) {
4228  state.controlState = cloneControlState(next);
4229  await persistState();
4230  render();
4231}
4232
4233async function requestControlPlane(path, options = {}) {
4234  const baseUrl = trimTrailingSlash(state.controlBaseUrl || deriveControlBaseUrl(state.wsUrl));
4235  if (!baseUrl) {
4236    throw new Error("缺少控制 API 地址");
4237  }
4238
4239  const url = `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
4240  const headers = new Headers({
4241    Accept: "application/json"
4242  });
4243
4244  if (options.body != null && !headers.has("Content-Type")) {
4245    headers.set("Content-Type", "application/json");
4246  }
4247
4248  const response = await fetch(url, {
4249    method: options.method || "GET",
4250    headers,
4251    body: options.body == null ? undefined : options.body
4252  });
4253
4254  const responseText = await response.text();
4255  let payload = null;
4256
4257  if (responseText) {
4258    try {
4259      payload = JSON.parse(responseText);
4260    } catch (_) {
4261      payload = responseText;
4262    }
4263  }
4264
4265  if (!response.ok || (isRecord(payload) && payload.ok === false)) {
4266    const message = isRecord(payload)
4267      ? String(payload.message || payload.error || `${response.status}`)
4268      : String(responseText || response.status);
4269    const error = new Error(message);
4270    error.statusCode = response.status;
4271    error.payload = payload;
4272    throw error;
4273  }
4274
4275  return {
4276    statusCode: response.status,
4277    payload
4278  };
4279}
4280
4281function getControlRetryDelay(retryCount) {
4282  const index = Math.max(0, Number(retryCount) - 1);
4283  return CONTROL_RETRY_DELAYS[index] || CONTROL_RETRY_SLOW_INTERVAL;
4284}
4285
4286function resetControlFailureLog() {
4287  state.lastControlFailureKey = "";
4288  state.lastControlFailureLogAt = 0;
4289}
4290
4291function createControlSuccessState(payload, meta = {}, previousSnapshot = null) {
4292  const normalized = normalizeControlStatePayload(payload, meta);
4293  const previous = cloneControlState(previousSnapshot || state.controlState);
4294
4295  return createDefaultControlState({
4296    ...previous,
4297    ...normalized,
4298    ok: true,
4299    error: null,
4300    controlConnection: "connected",
4301    retryCount: 0,
4302    lastSuccessAt: normalized.syncedAt,
4303    lastFailureAt: 0,
4304    nextRetryAt: 0
4305  });
4306}
4307
4308function createControlFailureState(error, meta = {}, previousSnapshot = null) {
4309  const previous = cloneControlState(previousSnapshot || state.controlState);
4310  const normalized = normalizeControlStatePayload(error.payload || error.message, {
4311    ok: false,
4312    statusCode: Number.isFinite(error.statusCode) ? error.statusCode : null,
4313    source: meta.source || "http",
4314    error: error.message
4315  });
4316  const next = createDefaultControlState({
4317    ...previous,
4318    ok: false,
4319    error: error.message,
4320    message: previous.lastSuccessAt > 0
4321      ? "最近同步失败,正在自动重试"
4322      : "控制面暂不可用,正在自动重试",
4323    statusCode: normalized.statusCode,
4324    syncedAt: normalized.syncedAt,
4325    controlConnection: "retrying",
4326    retryCount: meta.retryCount || Math.max(1, previous.retryCount || 0),
4327    lastFailureAt: normalized.syncedAt,
4328    nextRetryAt: meta.nextRetryAt || 0,
4329    source: normalized.source,
4330    raw: normalized.raw
4331  });
4332
4333  if (normalized.mode !== "unknown") next.mode = normalized.mode;
4334  if (normalized.leader) next.leader = normalized.leader;
4335  if (normalized.leaseHolder) next.leaseHolder = normalized.leaseHolder;
4336  if (normalized.queueDepth != null) next.queueDepth = normalized.queueDepth;
4337  if (normalized.activeRuns != null) next.activeRuns = normalized.activeRuns;
4338
4339  return next;
4340}
4341
4342function logControlFailure(snapshot, options = {}) {
4343  const key = `${snapshot.statusCode || "-"}:${snapshot.error || "unknown"}`;
4344  const now = Date.now();
4345  const shouldLog = !options.silent
4346    || snapshot.retryCount <= 1
4347    || key !== state.lastControlFailureKey
4348    || now - state.lastControlFailureLogAt >= CONTROL_RETRY_LOG_INTERVAL;
4349
4350  if (!shouldLog) return;
4351
4352  state.lastControlFailureKey = key;
4353  state.lastControlFailureLogAt = now;
4354
4355  const retryDelay = formatRetryDelay(snapshot.nextRetryAt);
4356  addLog(
4357    "warn",
4358    `控制面同步失败:${snapshot.error}${retryDelay ? `${retryDelay}后继续重试` : ""}`,
4359    false
4360  );
4361}
4362
4363async function refreshControlPlaneState(options = {}) {
4364  if (state.controlRefreshInFlight) {
4365    return state.controlRefreshInFlight;
4366  }
4367
4368  const task = (async () => {
4369    const source = options.source || "http";
4370    const previousSnapshot = cloneControlState(state.controlState);
4371    const previousConnection = normalizeControlConnection(previousSnapshot.controlConnection);
4372
4373    try {
4374      const response = await requestControlPlane("/v1/system/state");
4375      const snapshot = createControlSuccessState(response.payload, {
4376        ok: true,
4377        statusCode: response.statusCode,
4378        source
4379      }, previousSnapshot);
4380      await setControlState(snapshot);
4381      resetControlFailureLog();
4382      restartControlPlaneRefreshTimer(CONTROL_REFRESH_INTERVAL, {
4383        reason: "poll"
4384      });
4385
4386      if (!options.silent) {
4387        addLog("info", `控制面状态已同步:模式=${formatModeLabel(snapshot.mode)},主控=${snapshot.leader || "-"}`, false);
4388      } else if (previousConnection !== "connected") {
4389        addLog("info", `控制面已恢复:模式=${formatModeLabel(snapshot.mode)},主控=${snapshot.leader || "-"}`, false);
4390      }
4391
4392      return snapshot;
4393    } catch (error) {
4394      const retryCount = previousConnection === "retrying"
4395        ? (Number(previousSnapshot.retryCount) || 0) + 1
4396        : 1;
4397      const retryDelay = getControlRetryDelay(retryCount);
4398      const nextRetryAt = Date.now() + retryDelay;
4399      const snapshot = createControlFailureState(error, {
4400        source,
4401        retryCount,
4402        nextRetryAt
4403      }, previousSnapshot);
4404      await setControlState(snapshot);
4405      restartControlPlaneRefreshTimer(retryDelay, {
4406        reason: "retry"
4407      });
4408      logControlFailure(snapshot, options);
4409      throw error;
4410    }
4411  })();
4412
4413  state.controlRefreshInFlight = task;
4414
4415  try {
4416    return await task;
4417  } finally {
4418    if (state.controlRefreshInFlight === task) {
4419      state.controlRefreshInFlight = null;
4420    }
4421  }
4422}
4423
4424function normalizePageControlAction(action) {
4425  const methodName = String(action || "").trim().toLowerCase();
4426  return methodName === "manual" || methodName === "pause" || methodName === "resume"
4427    ? methodName
4428    : null;
4429}
4430
4431function extractRenewalConversationData(payload) {
4432  if (!isRecord(payload)) {
4433    return null;
4434  }
4435
4436  return isRecord(payload.data) ? payload.data : payload;
4437}
4438
4439function updatePageControlFromRenewalDetail(pageControl, detail, options = {}) {
4440  if (!pageControl?.platform || !Number.isInteger(pageControl.tabId) || !isRecord(detail)) {
4441    return pageControl || null;
4442  }
4443
4444  const activeLink = isRecord(detail.active_link) ? detail.active_link : null;
4445  const nextConversationId = trimToNull(activeLink?.remote_conversation_id) || pageControl.conversationId;
4446
4447  return updatePageControlState({
4448    platform: pageControl.platform,
4449    tabId: pageControl.tabId,
4450    conversationId: nextConversationId,
4451    localConversationId: trimToNull(detail.local_conversation_id) || pageControl.localConversationId,
4452    pageUrl: trimToNull(activeLink?.page_url) || pageControl.pageUrl,
4453    pageTitle: trimToNull(activeLink?.page_title) || pageControl.pageTitle,
4454    automationStatus: detail.automation_status,
4455    lastNonPausedAutomationStatus: detail.last_non_paused_automation_status,
4456    pauseSource: options.source || "automation_sync",
4457    pauseReason: detail.automation_status === "paused" ? detail.pause_reason : null,
4458    updatedAt: Number(detail.updated_at) || Date.now()
4459  }, {
4460    persist: options.persist !== false,
4461    render: options.render !== false,
4462    reason: detail.automation_status === "paused"
4463      ? (trimToNull(detail.pause_reason) || "user_pause")
4464      : null,
4465    source: options.source || "automation_sync"
4466  });
4467}
4468
4469async function requestConversationAutomationControl(context, pageControl, action, options = {}) {
4470  const conversationId = trimToNull(pageControl?.conversationId) || trimToNull(context?.conversationId);
4471
4472  if (!context?.platform || !conversationId) {
4473    return null;
4474  }
4475
4476  const response = await requestControlPlane("/v1/internal/automation/conversations/control", {
4477    method: "POST",
4478    body: JSON.stringify({
4479      action,
4480      scope: "current",
4481      ...(action === "manual" || action === "resume"
4482        ? {
4483            action: "mode",
4484            mode: action === "manual" ? "manual" : "auto"
4485          }
4486        : {}),
4487      platform: context.platform,
4488      reason: action === "pause" ? (options.reason || "user_pause") : undefined,
4489      source_conversation_id: conversationId
4490    })
4491  });
4492
4493  return extractRenewalConversationData(response.payload);
4494}
4495
4496async function runPageControlAction(action, sender, options = {}) {
4497  const methodName = normalizePageControlAction(action);
4498
4499  if (!methodName) {
4500    throw new Error(`未知页面控制动作:${action || "-"}`);
4501  }
4502
4503  const context = getSenderContext(sender, detectPlatformFromUrl(sender?.tab?.url || ""));
4504
4505  if (!context) {
4506    throw new Error("当前页面不是受支持的 AI 页面");
4507  }
4508
4509  const currentPage = buildPageControlSnapshotForSender(sender, detectPlatformFromUrl(sender?.tab?.url || ""));
4510  let page = currentPage;
4511
4512  if (currentPage?.conversationId) {
4513    try {
4514      const detail = await requestConversationAutomationControl(context, currentPage, methodName, {
4515        reason: methodName === "pause" ? "user_pause" : null
4516      });
4517
4518      if (detail != null) {
4519        page = updatePageControlFromRenewalDetail(currentPage, detail, {
4520          persist: true,
4521          render: true,
4522          source: options.source || "runtime"
4523        });
4524      }
4525    } catch (error) {
4526      const statusCode = Number.isFinite(Number(error?.statusCode)) ? Number(error.statusCode) : null;
4527
4528      if (methodName === "manual" || statusCode !== 404) {
4529        throw error;
4530      }
4531    }
4532  }
4533
4534  if (page == null || (methodName !== "manual" && page.automationStatus == null && page.localConversationId == null)) {
4535    if (methodName === "manual") {
4536      throw new Error("当前对话还没有 conductor 记录,暂不能设为手动");
4537    }
4538
4539    page = syncPageControlFromContext(context, {
4540      localConversationId: null,
4541      automationStatus: null,
4542      lastNonPausedAutomationStatus: null,
4543      paused: methodName === "pause",
4544      pauseSource: options.source || "runtime",
4545      pauseReason: methodName === "pause"
4546        ? (options.reason || "page_paused_by_user")
4547        : null
4548    }, {
4549      persist: true,
4550      render: true,
4551      reason: options.reason,
4552      source: options.source
4553    });
4554  }
4555
4556  const pageStateLabel = page?.automationStatus || (page?.paused ? "paused" : "auto");
4557  addLog(
4558    "info",
4559    `${platformLabel(context.platform)} 页面控制已更新:state=${pageStateLabel},tab=${context.tabId}${page?.conversationId ? ` conversation=${page.conversationId}` : ""}`,
4560    false
4561  );
4562
4563  return {
4564    action: methodName,
4565    control: cloneControlState(state.controlState),
4566    page
4567  };
4568}
4569
4570async function runControlPlaneAction(action, options = {}) {
4571  const methodName = String(action || "").trim().toLowerCase();
4572
4573  if (!["pause", "resume", "drain"].includes(methodName)) {
4574    throw new Error(`未知控制动作:${action || "-"}`);
4575  }
4576
4577  const requestId = typeof crypto?.randomUUID === "function"
4578    ? crypto.randomUUID()
4579    : `req-${Date.now()}`;
4580  const response = await requestControlPlane(`/v1/system/${methodName}`, {
4581    method: "POST",
4582    body: JSON.stringify({
4583      requested_by: "browser_admin",
4584      source: "firefox_extension",
4585      reason: `human_clicked_${methodName}`,
4586      request_id: requestId
4587    })
4588  });
4589
4590  const nextSnapshot = createControlSuccessState(response.payload, {
4591    ok: true,
4592    statusCode: response.statusCode,
4593    source: options.source || "http_action"
4594  }, state.controlState);
4595  await setControlState(nextSnapshot);
4596  resetControlFailureLog();
4597  restartControlPlaneRefreshTimer(CONTROL_REFRESH_INTERVAL, {
4598    reason: "poll"
4599  });
4600  addLog("info", `控制动作 ${methodName} 已执行(${response.statusCode}`, false);
4601
4602  try {
4603    await refreshControlPlaneState({
4604      source: options.source || "http_action",
4605      silent: true
4606    });
4607  } catch (_) {}
4608
4609  return {
4610    action: methodName,
4611    statusCode: response.statusCode,
4612    payload: response.payload,
4613    snapshot: cloneControlState(state.controlState)
4614  };
4615}
4616
4617async function runScheduledControlPlaneRefresh(reason = "poll") {
4618  await refreshTrackedTabsFromBrowser(reason).catch(() => {});
4619  await refreshControlPlaneState({
4620    source: reason,
4621    silent: true
4622  }).catch(() => {});
4623}
4624
4625function restartControlPlaneRefreshTimer(delay = CONTROL_REFRESH_INTERVAL, options = {}) {
4626  clearTimeout(state.controlRefreshTimer);
4627  state.controlRefreshTimer = setTimeout(() => {
4628    runScheduledControlPlaneRefresh(options.reason || "poll").catch(() => {});
4629  }, Math.max(0, Number(delay) || 0));
4630}
4631
4632async function prepareStartupControlState() {
4633  const previous = cloneControlState(state.controlState);
4634  await setControlState(createDefaultControlState({
4635    ...previous,
4636    ok: previous.lastSuccessAt > 0,
4637    controlConnection: "connecting",
4638    retryCount: 0,
4639    nextRetryAt: 0,
4640    lastFailureAt: 0,
4641    error: null,
4642    message: "浏览器启动后自动连接中",
4643    source: "startup"
4644  }));
4645}
4646
4647async function collapseRecoveredDesiredTabs() {
4648  let changed = false;
4649
4650  for (const platform of PLATFORM_ORDER) {
4651    const desired = cloneDesiredTabState(platform, state.desiredTabs[platform]);
4652    const actual = cloneActualTabState(state.actualTabs[platform]);
4653    if (desired.source !== "migration" || actual.exists) {
4654      continue;
4655    }
4656
4657    changed = setDesiredTabState(platform, false, {
4658      source: "bootstrap",
4659      reason: "startup_migration_cleared"
4660    }) || changed;
4661  }
4662
4663  if (changed) {
4664    await persistState();
4665    render();
4666  }
4667}
4668
4669async function restoreDesiredTabsOnStartup() {
4670  const targets = getPlatformsNeedingShellRestore();
4671  if (targets.length === 0) {
4672    return null;
4673  }
4674
4675  addLog("info", `启动时自动恢复空壳页:${targets.map((platform) => platformLabel(platform)).join("、")}`, false);
4676  return await runPluginManagementAction("tab_restore", {
4677    source: "startup",
4678    reason: "startup_auto_restore"
4679  });
4680}
4681
4682function wsSend(payload) {
4683  if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return false;
4684  state.ws.send(JSON.stringify(payload));
4685  return true;
4686}
4687
4688function sendApiResponse(id, ok, status = null, body = null, error = null) {
4689  wsSend({
4690    type: "api_response",
4691    id,
4692    ok,
4693    status,
4694    body,
4695    error
4696  });
4697}
4698
4699function sendHello() {
4700  wsSend({
4701    type: "hello",
4702    clientId: state.clientId,
4703    nodeType: "browser",
4704    nodeCategory: "proxy",
4705    nodePlatform: "firefox",
4706    capabilities: {
4707      formal_mode: "shell_tab_metadata_proxy",
4708      shell_tabs: true,
4709      page_conversation_runtime: false,
4710      credential_metadata: true,
4711      desired_actual_runtime: true,
4712      plugin_actions: [
4713        "plugin_status",
4714        "ws_reconnect",
4715        "controller_reload",
4716        "tab_open",
4717        "tab_focus",
4718        "tab_reload",
4719        "tab_restore"
4720      ]
4721    },
4722    plugin_status: buildPluginStatusPayload({
4723      includeVolatile: false
4724    })
4725  });
4726}
4727
4728function getTargetPlatforms(platform) {
4729  if (platform && PLATFORMS[platform]) return [platform];
4730  return PLATFORM_ORDER;
4731}
4732
4733function normalizePluginManagementAction(value) {
4734  switch (String(value || "").trim().toLowerCase()) {
4735    case "plugin_status":
4736    case "status":
4737      return "plugin_status";
4738    case "ws_reconnect":
4739    case "reconnect_ws":
4740    case "reconnect":
4741      return "ws_reconnect";
4742    case "controller_reload":
4743    case "reload_controller":
4744      return "controller_reload";
4745    case "tab_open":
4746    case "open_tab":
4747      return "tab_open";
4748    case "tab_focus":
4749    case "focus_tab":
4750      return "tab_focus";
4751    case "tab_reload":
4752    case "reload_tab":
4753      return "tab_reload";
4754    case "tab_restore":
4755    case "restore_tab":
4756      return "tab_restore";
4757    default:
4758      return null;
4759  }
4760}
4761
4762function readPluginActionRequestId(message) {
4763  return trimToNull(message?.requestId) || trimToNull(message?.request_id) || trimToNull(message?.id);
4764}
4765
4766function collectPluginActionShellRuntime(results = [], platform = null) {
4767  const runtimeByPlatform = new Map();
4768
4769  for (const entry of Array.isArray(results) ? results : []) {
4770    const targetPlatform = trimToNull(entry?.platform);
4771    const shellRuntime = isRecord(entry?.shell_runtime)
4772      ? entry.shell_runtime
4773      : targetPlatform
4774        ? buildPlatformRuntimeSnapshot(targetPlatform)
4775        : null;
4776
4777    if (shellRuntime?.platform) {
4778      runtimeByPlatform.set(shellRuntime.platform, shellRuntime);
4779    }
4780  }
4781
4782  if (platform && !runtimeByPlatform.has(platform)) {
4783    runtimeByPlatform.set(platform, buildPlatformRuntimeSnapshot(platform));
4784  }
4785
4786  if (!platform && runtimeByPlatform.size === 0) {
4787    for (const target of PLATFORM_ORDER) {
4788      runtimeByPlatform.set(target, buildPlatformRuntimeSnapshot(target));
4789    }
4790  }
4791
4792  return [...runtimeByPlatform.values()].sort((left, right) =>
4793    String(left?.platform || "").localeCompare(String(right?.platform || ""))
4794  );
4795}
4796
4797function normalizeDeliveryAckLevel(value) {
4798  const numeric = Number(value);
4799
4800  if (!Number.isInteger(numeric)) {
4801    return 0;
4802  }
4803
4804  return Math.min(3, Math.max(0, numeric));
4805}
4806
4807function normalizePluginDeliveryAck(value) {
4808  if (!isRecord(value)) {
4809    return null;
4810  }
4811
4812  const confirmedAt = Number.isFinite(Number(value.confirmedAt))
4813    ? Number(value.confirmedAt)
4814    : Number.isFinite(Number(value.confirmed_at))
4815      ? Number(value.confirmed_at)
4816      : null;
4817  const statusCode = Number.isFinite(Number(value.statusCode))
4818    ? Number(value.statusCode)
4819    : Number.isFinite(Number(value.status_code))
4820      ? Number(value.status_code)
4821      : null;
4822
4823  return {
4824    confirmed_at: confirmedAt,
4825    failed: value.failed === true,
4826    level: normalizeDeliveryAckLevel(value.level),
4827    reason: trimToNull(value.reason) || null,
4828    status_code: statusCode
4829  };
4830}
4831
4832function buildPluginActionResultPayload(actionResult, options = {}) {
4833  const requestId = trimToNull(options.requestId) || trimToNull(actionResult?.requestId);
4834  if (!requestId) return null;
4835
4836  const action = normalizePluginManagementAction(options.action || actionResult?.action)
4837    || trimToNull(options.action)
4838    || trimToNull(actionResult?.action)
4839    || "plugin_status";
4840  const requestedPlatform = trimToNull(options.platform || actionResult?.platform);
4841  const normalizedResults = (Array.isArray(actionResult?.results) ? actionResult.results : []).map((entry) => {
4842    const targetPlatform = trimToNull(entry?.platform);
4843    const shellRuntime = isRecord(entry?.shell_runtime)
4844      ? entry.shell_runtime
4845      : targetPlatform
4846        ? buildPlatformRuntimeSnapshot(targetPlatform)
4847        : null;
4848    const deliveryAck = normalizePluginDeliveryAck(entry?.delivery_ack || entry?.deliveryAck);
4849
4850    return {
4851      delivery_ack: deliveryAck,
4852      ok: entry?.ok !== false,
4853      platform: targetPlatform,
4854      restored: typeof entry?.restored === "boolean" ? entry.restored : null,
4855      shell_runtime: shellRuntime,
4856      skipped: trimToNull(entry?.skipped) || null,
4857      tab_id: Number.isInteger(entry?.tabId) ? entry.tabId : Number.isInteger(entry?.tab_id) ? entry.tab_id : null
4858    };
4859  });
4860  const shellRuntime = collectPluginActionShellRuntime(normalizedResults, requestedPlatform);
4861  const skippedReasons = [...new Set(normalizedResults.map((entry) => entry.skipped).filter(Boolean))].sort((left, right) =>
4862    String(left).localeCompare(String(right))
4863  );
4864
4865  return {
4866    type: "action_result",
4867    requestId,
4868    action,
4869    command_type: trimToNull(options.commandType) || trimToNull(actionResult?.commandType) || action,
4870    accepted: options.accepted !== false,
4871    completed: options.completed !== false,
4872    failed: options.failed === true || normalizedResults.some((entry) => entry.ok === false),
4873    reason: trimToNull(options.reason) || trimToNull(actionResult?.reason) || null,
4874    target: {
4875      platform: requestedPlatform,
4876      requested_client_id: state.clientId,
4877      requested_platform: requestedPlatform
4878    },
4879    result: {
4880      actual_count: shellRuntime.filter((entry) => entry?.actual?.exists).length,
4881      desired_count: shellRuntime.filter((entry) => entry?.desired?.exists).length,
4882      drift_count: shellRuntime.filter((entry) => entry?.drift?.aligned === false).length,
4883      failed_count: normalizedResults.filter((entry) => entry.ok === false).length,
4884      ok_count: normalizedResults.filter((entry) => entry.ok).length,
4885      platform_count: new Set([
4886        ...normalizedResults.map((entry) => entry.platform).filter(Boolean),
4887        ...shellRuntime.map((entry) => entry.platform).filter(Boolean)
4888      ]).size,
4889      restored_count: normalizedResults.filter((entry) => entry.restored === true).length,
4890      skipped_reasons: skippedReasons
4891    },
4892    results: normalizedResults,
4893    shell_runtime: shellRuntime
4894  };
4895}
4896
4897function sendPluginActionResult(actionResult, options = {}) {
4898  const payload = buildPluginActionResultPayload(actionResult, options);
4899  if (!payload) return false;
4900  return wsSend(payload);
4901}
4902
4903function extractPluginManagementMessage(message) {
4904  const messageType = String(message?.type || "").trim().toLowerCase();
4905  const platform = trimToNull(message?.platform);
4906  const explicitAction = normalizePluginManagementAction(message?.action);
4907  const requestId = readPluginActionRequestId(message);
4908
4909  if (messageType === "open_tab") {
4910    return {
4911      action: explicitAction || (platform ? "tab_focus" : "tab_open"),
4912      commandType: "open_tab",
4913      platform,
4914      requestId,
4915      source: "ws_open_tab"
4916    };
4917  }
4918
4919  if (messageType === "reload") {
4920    return {
4921      action: explicitAction || (platform ? "tab_reload" : "controller_reload"),
4922      commandType: "reload",
4923      platform,
4924      requestId,
4925      source: "ws_reload"
4926    };
4927  }
4928
4929  const directAction = normalizePluginManagementAction(messageType);
4930  if (directAction) {
4931    return {
4932      action: directAction,
4933      commandType: messageType,
4934      ...(directAction === "ws_reconnect" ? extractWsReconnectActionOverrides(message) : {}),
4935      platform,
4936      requestId,
4937      source: "ws_direct"
4938    };
4939  }
4940
4941  if (explicitAction && ["plugin_action", "browser_action", "tab_action", "action_request"].includes(messageType)) {
4942    return {
4943      action: explicitAction,
4944      commandType: messageType,
4945      platform,
4946      requestId,
4947      source: messageType
4948    };
4949  }
4950
4951  return null;
4952}
4953
4954function resolvePluginActionPlatforms(action, platform = null) {
4955  if (platform && !PLATFORMS[platform]) {
4956    throw new Error(`未知平台:${platform}`);
4957  }
4958
4959  if (action === "tab_focus" && !platform) {
4960    throw new Error("tab_focus 需要明确 platform");
4961  }
4962
4963  return getTargetPlatforms(platform);
4964}
4965
4966function restartShellRuntimeHealthTimer(delay = SHELL_RUNTIME_HEALTHCHECK_INTERVAL) {
4967  clearTimeout(state.shellRuntimeTimer);
4968  state.shellRuntimeTimer = setTimeout(() => {
4969    runShellRuntimeHealthCheck("poll").catch((error) => {
4970      addLog("error", `空壳页 runtime 巡检失败:${error.message}`);
4971    });
4972  }, Math.max(0, Number(delay) || 0));
4973}
4974
4975async function runShellRuntimeHealthCheck(reason = "poll") {
4976  try {
4977    await refreshTrackedTabsFromBrowser(reason);
4978  } finally {
4979    restartShellRuntimeHealthTimer(SHELL_RUNTIME_HEALTHCHECK_INTERVAL);
4980  }
4981}
4982
4983async function runPluginManagementAction(action, options = {}) {
4984  const methodName = normalizePluginManagementAction(action);
4985  if (!methodName) {
4986    throw new Error(`未知插件动作:${action || "-"}`);
4987  }
4988
4989  const source = trimToNull(options.source) || "runtime";
4990  const reason = trimToNull(options.reason) || "plugin_action";
4991  const actionAt = Date.now();
4992  const results = [];
4993
4994  if (methodName === "plugin_status" || methodName.startsWith("tab_")) {
4995    await refreshTrackedTabsFromBrowser(`${methodName}_pre`);
4996  }
4997
4998  switch (methodName) {
4999    case "plugin_status":
5000      break;
5001    case "ws_reconnect":
5002      const reconnectOptions = resolveWsReconnectActionOptions(options);
5003      addLog("info", `正在重连本地 WS:${describeWsReconnectActionPlan(reconnectOptions)}`, false);
5004      scheduleWsReconnectActionSequence(reconnectOptions);
5005      return {
5006        action: methodName,
5007        platform: trimToNull(options.platform),
5008        deferred: true,
5009        reason: `scheduled ${describeWsReconnectActionPlan(reconnectOptions)}`,
5010        results,
5011        scheduled: true,
5012        snapshot: buildPluginStatusPayload()
5013      };
5014    case "controller_reload":
5015      setControllerRuntimeState({
5016        ready: false,
5017        status: "reloading",
5018        lastReloadAt: actionAt,
5019        lastAction: methodName,
5020        lastActionAt: actionAt
5021      }, {
5022        persist: true,
5023        render: true
5024      });
5025      sendCredentialSnapshot(null, true);
5026      addLog("warn", "插件管理页即将重载", false);
5027      setTimeout(() => {
5028        window.location.reload();
5029      }, 80);
5030      return {
5031        action: methodName,
5032        platform: null,
5033        results,
5034        scheduled: true,
5035        snapshot: buildPluginStatusPayload()
5036      };
5037    case "tab_open":
5038    case "tab_focus":
5039    case "tab_reload":
5040    case "tab_restore": {
5041      const targets = resolvePluginActionPlatforms(methodName, trimToNull(options.platform));
5042      for (const target of targets) {
5043        if (methodName !== "tab_restore") {
5044          setDesiredTabState(target, true, {
5045            source,
5046            reason,
5047            action: methodName
5048          });
5049        }
5050
5051        if (methodName === "tab_restore") {
5052          const desired = cloneDesiredTabState(target, state.desiredTabs[target]);
5053          const actual = cloneActualTabState(state.actualTabs[target]);
5054          if (!desired.exists) {
5055            results.push({
5056              platform: target,
5057              ok: true,
5058              restored: false,
5059              skipped: "desired_missing"
5060            });
5061            continue;
5062          }
5063
5064          if (actual.exists) {
5065            results.push({
5066              platform: target,
5067              ok: true,
5068              restored: false,
5069              tabId: actual.tabId,
5070              skipped: "actual_present"
5071            });
5072            continue;
5073          }
5074
5075          const restoredTab = await ensurePlatformTab(target, {
5076            focus: false,
5077            reloadIfExisting: false,
5078            recordDesired: false,
5079            source,
5080            reason,
5081            action: methodName
5082          });
5083          results.push({
5084            platform: target,
5085            ok: true,
5086            restored: true,
5087            tabId: restoredTab?.id ?? null
5088          });
5089          continue;
5090        }
5091
5092        const previousActual = cloneActualTabState(state.actualTabs[target]);
5093        const tab = await ensurePlatformTab(target, {
5094          focus: methodName === "tab_focus",
5095          reloadIfExisting: methodName === "tab_reload",
5096          recordDesired: false,
5097          source,
5098          reason,
5099          action: methodName
5100        });
5101        const observerRefresh = methodName === "tab_reload"
5102          ? summarizeObserverRefresh(await reinjectPlatformTabs(target, {
5103              excludeTabIds: Number.isInteger(tab?.id) ? [tab.id] : [],
5104              source: methodName
5105            }))
5106          : null;
5107        results.push({
5108          platform: target,
5109          ok: true,
5110          tabId: tab?.id ?? null,
5111          restored: methodName === "tab_open" ? !previousActual.exists : undefined,
5112          observer_refresh: observerRefresh
5113        });
5114      }
5115      break;
5116    }
5117    default:
5118      break;
5119  }
5120
5121  if (methodName !== "plugin_status") {
5122    setControllerRuntimeState({
5123      ready: true,
5124      status: "ready",
5125      lastAction: methodName,
5126      lastActionAt: actionAt
5127    });
5128  }
5129
5130  if (methodName !== "plugin_status") {
5131    await refreshTrackedTabsFromBrowser(`${methodName}_post`);
5132  }
5133  if (methodName !== "plugin_status") {
5134    await persistState();
5135    render();
5136    sendCredentialSnapshot(null, true);
5137  } else if (source.startsWith("ws")) {
5138    sendCredentialSnapshot(null, true);
5139  }
5140
5141  if (methodName !== "plugin_status") {
5142    addLog("info", `插件动作 ${methodName} 已执行`, false);
5143  }
5144
5145  const normalizedResults = results.map((entry) => ({
5146    ...entry,
5147    shell_runtime: isRecord(entry?.shell_runtime)
5148      ? entry.shell_runtime
5149      : entry?.platform
5150        ? buildPlatformRuntimeSnapshot(entry.platform)
5151        : null
5152  }));
5153
5154  return {
5155    action: methodName,
5156    platform: trimToNull(options.platform),
5157    results: normalizedResults,
5158    snapshot: buildPluginStatusPayload()
5159  };
5160}
5161
5162function getProxyHeaderPath(apiPath) {
5163  try {
5164    return new URL(apiPath, "https://example.invalid").pathname || "/";
5165  } catch (_) {
5166    return apiPath || "/";
5167  }
5168}
5169
5170function isForbiddenProxyHeader(name) {
5171  return FORBIDDEN_PROXY_HEADER_NAMES.has(name) || name.startsWith("sec-");
5172}
5173
5174function copyProxyHeaders(sourceHeaders = {}) {
5175  const out = {};
5176
5177  for (const [name, value] of Object.entries(sourceHeaders || {})) {
5178    const lower = String(name || "").toLowerCase();
5179    if (!lower || value == null || value === "" || isForbiddenProxyHeader(lower)) continue;
5180    out[lower] = value;
5181  }
5182  return out;
5183}
5184
5185function buildProxyHeaders(platform, apiPath, sourceHeaders = null) {
5186  const targetPath = getProxyHeaderPath(apiPath);
5187  const credential = sourceHeaders ? null : requireCredentialState(platform);
5188  const out = copyProxyHeaders(sourceHeaders || credential.headers || {});
5189
5190  if (platform === "chatgpt") {
5191    out["x-openai-target-path"] = targetPath;
5192    out["x-openai-target-route"] = targetPath;
5193    if (!out["x-conduit-token"]) out["x-conduit-token"] = "no-token";
5194    if (!out["x-oai-turn-trace-id"] && typeof crypto?.randomUUID === "function") {
5195      out["x-oai-turn-trace-id"] = crypto.randomUUID();
5196    }
5197  }
5198
5199  return out;
5200}
5201
5202function buildClaudeHeaders(apiPath, overrides = {}) {
5203  const headers = buildProxyHeaders("claude", apiPath);
5204  for (const [name, value] of Object.entries(overrides || {})) {
5205    const lower = String(name || "").toLowerCase();
5206    if (!lower || value == null || value === "" || isForbiddenProxyHeader(lower)) continue;
5207    headers[lower] = String(value);
5208  }
5209  return headers;
5210}
5211
5212function buildGeminiRequestFromTemplate(templateSelection, prompt) {
5213  if (!templateSelection?.url || !templateSelection?.reqBody) {
5214    throw new Error("missing Gemini send template");
5215  }
5216  const credential = requireCredentialState("gemini");
5217
5218  try {
5219    const url = new URL(templateSelection.url, PLATFORMS.gemini.rootUrl);
5220    const params = new URLSearchParams(templateSelection.reqBody);
5221    const outerPayload = params.get("f.req");
5222    if (!outerPayload) throw new Error("template missing f.req");
5223
5224    const outer = JSON.parse(outerPayload);
5225    if (!Array.isArray(outer) || typeof outer[1] !== "string") {
5226      throw new Error("invalid Gemini outer payload");
5227    }
5228
5229    const inner = JSON.parse(outer[1]);
5230    if (!Array.isArray(inner) || !Array.isArray(inner[0])) {
5231      throw new Error("无效的 Gemini 提示词元组");
5232    }
5233
5234    inner[0][0] = prompt;
5235    outer[1] = JSON.stringify(inner);
5236    params.set("f.req", JSON.stringify(outer));
5237
5238    const currentReqId = Number(url.searchParams.get("_reqid"));
5239    const baseReqId = Number.isFinite(templateSelection.reqId) ? templateSelection.reqId : currentReqId;
5240    if (Number.isFinite(baseReqId)) {
5241      const nextReqId = baseReqId + 100000;
5242      url.searchParams.set("_reqid", String(nextReqId));
5243      updateGeminiTemplateReqId(templateSelection.key, nextReqId);
5244    }
5245
5246    const path = `${url.pathname || "/"}${url.search || ""}`;
5247    const headerSource = hasGeminiTemplateHeaders(templateSelection.headers)
5248      ? templateSelection.headers
5249      : credential.headers;
5250    const headers = buildProxyHeaders("gemini", path, headerSource);
5251    if (!hasGeminiTemplateHeaders(headers)) {
5252      throw new Error("缺少 Gemini 请求头;请先手动发送一条真实 Gemini 消息");
5253    }
5254
5255    return {
5256      body: params.toString(),
5257      headers,
5258      path,
5259      templateKey: templateSelection.key
5260    };
5261  } catch (error) {
5262    throw new Error(`构建 Gemini 请求失败:${error.message}`);
5263  }
5264}
5265
5266function buildGeminiAutoRequest(prompt, options = {}) {
5267  const conversationId = trimToNull(options.conversationId);
5268  const templateSelection = getGeminiSendTemplate({
5269    conversationId,
5270    allowRecentFallback: options.allowRecentFallback !== false,
5271    allowShellFallback: options.allowShellFallback === true
5272  });
5273
5274  if (!templateSelection?.url || !templateSelection?.reqBody) {
5275    throw new Error(
5276      conversationId
5277        ? `missing Gemini send template for conversation=${conversationId}; send one real Gemini message first`
5278        : "missing Gemini send template; send one real Gemini message first"
5279    );
5280  }
5281
5282  return buildGeminiRequestFromTemplate(templateSelection, prompt);
5283}
5284
5285function buildGeminiDeliveryRequest(options = {}) {
5286  const conversationId = trimToNull(options.conversationId);
5287  const messageText = trimToNull(options.messageText);
5288  const shellPage = options.shellPage === true;
5289
5290  if (!messageText) {
5291    throw new Error("delivery.invalid_payload: Gemini proxy_delivery requires message_text");
5292  }
5293
5294  const templateSelection = getGeminiSendTemplate({
5295    conversationId,
5296    allowRecentFallback: conversationId == null,
5297    allowShellFallback: shellPage
5298  });
5299
5300  if (!templateSelection?.url || !templateSelection?.reqBody) {
5301    throw new Error(
5302      conversationId
5303        ? `delivery.template_missing: missing Gemini send template for conversation=${conversationId}; send one real Gemini message in this conversation first`
5304        : "delivery.template_missing: missing Gemini send template; send one real Gemini message first"
5305    );
5306  }
5307
5308  const request = buildGeminiRequestFromTemplate(templateSelection, messageText);
5309
5310  return {
5311    body: request.body,
5312    headers: request.headers,
5313    method: "POST",
5314    path: request.path,
5315    templateKey: request.templateKey
5316  };
5317}
5318
5319function createPendingProxyRequest(id, meta = {}) {
5320  let ackSettled = false;
5321  let settled = false;
5322  let ackResolveFn = null;
5323  let ackRejectFn = null;
5324  let timer = null;
5325  let resolveFn = null;
5326  let rejectFn = null;
5327  const ackResponse = new Promise((resolve, reject) => {
5328    ackResolveFn = resolve;
5329    ackRejectFn = reject;
5330  });
5331  const response = new Promise((resolve, reject) => {
5332    resolveFn = resolve;
5333    rejectFn = reject;
5334  });
5335  void ackResponse.catch(() => {});
5336  void response.catch(() => {});
5337
5338  const finishAck = (callback, value) => {
5339    if (ackSettled) return false;
5340    ackSettled = true;
5341    callback(value);
5342    return true;
5343  };
5344
5345  const normalizePendingError = (error) =>
5346    error instanceof Error ? error : new Error(String(error || "proxy_failed"));
5347
5348  const finish = (callback, value) => {
5349    if (settled) return false;
5350    settled = true;
5351    clearTimeout(timer);
5352    pendingProxyRequests.delete(id);
5353    callback(value);
5354    return true;
5355  };
5356
5357  const rejectAll = (error) => {
5358    const nextError = normalizePendingError(error);
5359    finishAck(ackRejectFn, nextError);
5360    return finish(rejectFn, nextError);
5361  };
5362
5363  timer = setTimeout(() => {
5364    rejectAll(new Error(`${platformLabel(meta.platform || "claude")} 代理超时`));
5365  }, Math.max(1_000, Number(meta.timeoutMs) || PROXY_REQUEST_TIMEOUT));
5366
5367  const entry = {
5368    id,
5369    ...meta,
5370    ackResponse,
5371    response,
5372    resolve(value) {
5373      finishAck(ackResolveFn, value);
5374      return finish(resolveFn, value);
5375    },
5376    resolveAck(value) {
5377      return finishAck(ackResolveFn, value);
5378    },
5379    reject(error) {
5380      return rejectAll(error);
5381    },
5382    rejectAck(error) {
5383      const nextError = normalizePendingError(error);
5384      return finishAck(ackRejectFn, nextError);
5385    }
5386  };
5387
5388  pendingProxyRequests.set(id, entry);
5389  return entry;
5390}
5391
5392function cancelPendingProxyRequest(id, reason = "browser_request_cancelled") {
5393  const pending = pendingProxyRequests.get(id);
5394
5395  if (!pending) {
5396    return false;
5397  }
5398
5399  const tabId = Number.isInteger(pending.tabId) ? pending.tabId : null;
5400
5401  if (tabId != null) {
5402    browser.tabs.sendMessage(tabId, {
5403      type: "baa_page_proxy_cancel",
5404      data: {
5405        id,
5406        reason
5407      }
5408    }).catch(() => {});
5409  }
5410
5411  pending.reject(new Error(reason));
5412  return true;
5413}
5414
5415async function executeProxyRequest(payload, meta = {}) {
5416  const platform = payload?.platform;
5417  if (!platform || !PLATFORMS[platform]) {
5418    throw new Error(`未知平台:${platform || "-"}`);
5419  }
5420
5421  const tab = await ensurePlatformTab(platform, { focus: false });
5422  const id = payload.id || buildRuntimeRequestId(platform);
5423  const entry = createPendingProxyRequest(id, {
5424    ...meta,
5425    platform,
5426    method: payload.method,
5427    path: payload.path,
5428    tabId: tab.id
5429  });
5430
5431  try {
5432    await postProxyRequestToTab(tab.id, {
5433      ...payload,
5434      id
5435    });
5436  } catch (error) {
5437    entry.reject(error);
5438    throw error;
5439  }
5440
5441  return entry.response;
5442}
5443
5444function sendEndpointSnapshot(platform = null) {
5445  for (const target of getTargetPlatforms(platform)) {
5446    const endpointEntries = Object.values(state.endpoints[target] || {})
5447      .map((entry) => normalizeEndpointEntry(entry?.key || "", entry))
5448      .sort((left, right) => left.key.localeCompare(right.key));
5449    if (endpointEntries.length === 0) continue;
5450
5451    const account = cloneAccountState(state.account[target]);
5452    const fingerprint = trimToNull(state.credentialFingerprint[target]) || null;
5453    wsSend({
5454      type: "api_endpoints",
5455      platform: target,
5456      account: account.value,
5457      credential_fingerprint: fingerprint,
5458      updated_at: Math.max(...endpointEntries.map((entry) => entry.lastObservedAt || 0), 0) || null,
5459      endpoints: endpointEntries.map((entry) => entry.key),
5460      shell_runtime: buildPlatformRuntimeSnapshot(target),
5461      endpoint_metadata: endpointEntries.map((entry) => ({
5462        method: entry.method,
5463        path: entry.path,
5464        first_seen_at: entry.firstObservedAt || null,
5465        last_seen_at: entry.lastObservedAt || null
5466      }))
5467    });
5468  }
5469}
5470
5471function sendCredentialSnapshot(platform = null, force = false) {
5472  for (const target of getTargetPlatforms(platform)) {
5473    const payload = buildCredentialTransportSnapshot(target);
5474    if (
5475      !payload.account
5476      && !payload.credential_fingerprint
5477      && payload.header_names.length === 0
5478      && !Number.isInteger(state.trackedTabs[target])
5479      && !cloneDesiredTabState(target, state.desiredTabs[target]).exists
5480    ) {
5481      continue;
5482    }
5483
5484    const now = Date.now();
5485    const serialized = JSON.stringify(payload);
5486    if (!force && serialized === state.lastCredentialHash[target] && now - state.lastCredentialSentAt[target] < CREDENTIAL_SEND_INTERVAL) {
5487      continue;
5488    }
5489
5490    state.lastCredentialHash[target] = serialized;
5491    state.lastCredentialSentAt[target] = now;
5492
5493    wsSend({
5494      type: "credentials",
5495      ...payload
5496    });
5497  }
5498}
5499
5500function setWsState(next) {
5501  state.wsState = cloneWsState(next);
5502  render();
5503}
5504
5505function handleWsHelloAck(message) {
5506  const previous = cloneWsState(state.wsState);
5507  const version = Number.isFinite(Number(message.version)) ? Number(message.version) : null;
5508
5509  setWsState({
5510    ...previous,
5511    connection: "connected",
5512    wsUrl: typeof message.wsUrl === "string" && message.wsUrl.trim() ? message.wsUrl.trim() : state.wsUrl,
5513    localApiBase: typeof message.localApiBase === "string" && message.localApiBase.trim()
5514      ? message.localApiBase.trim()
5515      : previous.localApiBase,
5516    clientId: typeof message.clientId === "string" && message.clientId.trim()
5517      ? message.clientId.trim()
5518      : (state.clientId || previous.clientId),
5519    protocol: typeof message.protocol === "string" && message.protocol.trim() ? message.protocol.trim() : null,
5520    version,
5521    retryCount: 0,
5522    nextRetryAt: 0,
5523    lastMessageAt: Date.now(),
5524    lastError: null,
5525    raw: truncateControlRaw(message)
5526  });
5527}
5528
5529function findMatchingAutomationSnapshot(pageControl, snapshots) {
5530  if (!pageControl?.platform || !Array.isArray(snapshots) || snapshots.length === 0) {
5531    return null;
5532  }
5533
5534  const conversationId = trimToNull(pageControl.conversationId);
5535  const pageUrl = trimToNull(pageControl.pageUrl);
5536
5537  return snapshots.find((entry) =>
5538    entry?.platform === pageControl.platform
5539    && (
5540      (conversationId && entry.remoteConversationId === conversationId)
5541      || (pageUrl && entry.pageUrl === pageUrl)
5542    )
5543  ) || null;
5544}
5545
5546function normalizeAutomationConversationSnapshot(value) {
5547  if (!isRecord(value)) {
5548    return null;
5549  }
5550
5551  const activeLink = isRecord(value.active_link) ? value.active_link : null;
5552  return {
5553    automationStatus: normalizeConversationAutomationStatus(value.automation_status),
5554    lastNonPausedAutomationStatus: normalizeConversationAutomationStatus(value.last_non_paused_automation_status),
5555    localConversationId: trimToNull(value.local_conversation_id),
5556    pageTitle: trimToNull(activeLink?.page_title),
5557    pageUrl: trimToNull(activeLink?.page_url),
5558    pauseReason: trimToNull(value.pause_reason),
5559    platform: trimToNull(value.platform),
5560    remoteConversationId: trimToNull(value.remote_conversation_id) || trimToNull(activeLink?.remote_conversation_id),
5561    updatedAt: Number(value.updated_at) || 0
5562  };
5563}
5564
5565function applyWsAutomationConversationSnapshot(browserSnapshot) {
5566  const rawEntries = Array.isArray(browserSnapshot?.automation_conversations)
5567    ? browserSnapshot.automation_conversations
5568    : [];
5569  const snapshots = rawEntries
5570    .map(normalizeAutomationConversationSnapshot)
5571    .filter((entry) => entry?.platform && entry.automationStatus);
5572  let changed = false;
5573
5574  for (const pageControl of listPageControlStates()) {
5575    const match = findMatchingAutomationSnapshot(pageControl, snapshots);
5576
5577    if (!match) {
5578      continue;
5579    }
5580
5581    const previous = serializePageControlState(pageControl);
5582    const next = updatePageControlState({
5583      platform: pageControl.platform,
5584      tabId: pageControl.tabId,
5585      conversationId: match.remoteConversationId || pageControl.conversationId,
5586      localConversationId: match.localConversationId,
5587      pageUrl: match.pageUrl || pageControl.pageUrl,
5588      pageTitle: match.pageTitle || pageControl.pageTitle,
5589      automationStatus: match.automationStatus,
5590      lastNonPausedAutomationStatus: match.lastNonPausedAutomationStatus,
5591      pauseReason: match.automationStatus === "paused" ? match.pauseReason : null,
5592      pauseSource: "ws_snapshot",
5593      updatedAt: match.updatedAt || Date.now()
5594    }, {
5595      persist: false,
5596      render: false,
5597      reason: match.automationStatus === "paused" ? match.pauseReason : null,
5598      source: "ws_snapshot"
5599    });
5600
5601    if (JSON.stringify(previous) !== JSON.stringify(next)) {
5602      changed = true;
5603    }
5604  }
5605
5606  return changed;
5607}
5608
5609function handleWsStateSnapshot(message) {
5610  const previousWsState = cloneWsState(state.wsState);
5611  const previousControlState = cloneControlState(state.controlState);
5612  const snapshot = isRecord(message.snapshot) ? message.snapshot : {};
5613  const server = isRecord(snapshot.server) ? snapshot.server : {};
5614  const browserSnapshot = isRecord(snapshot.browser) ? snapshot.browser : {};
5615  const clientCount = normalizeCount(getFirstDefinedValue(browserSnapshot, ["client_count", "clients"]));
5616  const nextWsState = createDefaultWsState({
5617    ...previousWsState,
5618    connection: "connected",
5619    wsUrl: typeof server.ws_url === "string" && server.ws_url.trim() ? server.ws_url.trim() : state.wsUrl,
5620    localApiBase: typeof server.local_api_base === "string" && server.local_api_base.trim()
5621      ? server.local_api_base.trim()
5622      : previousWsState.localApiBase,
5623    serverIdentity: typeof server.identity === "string" && server.identity.trim() ? server.identity.trim() : null,
5624    serverHost: typeof server.host === "string" && server.host.trim() ? server.host.trim() : null,
5625    serverRole: typeof server.role === "string" && server.role.trim() ? server.role.trim() : null,
5626    leaseState: typeof server.lease_state === "string" && server.lease_state.trim() ? server.lease_state.trim() : null,
5627    clientCount: clientCount ?? previousWsState.clientCount,
5628    retryCount: 0,
5629    nextRetryAt: 0,
5630    lastMessageAt: Date.now(),
5631    lastSnapshotAt: Date.now(),
5632    lastSnapshotReason: typeof message.reason === "string" && message.reason.trim() ? message.reason.trim() : null,
5633    lastError: null,
5634    raw: truncateControlRaw(snapshot)
5635  });
5636  let renderChanged = JSON.stringify(previousWsState) !== JSON.stringify(nextWsState);
5637  let persistChanged = false;
5638
5639  state.wsState = nextWsState;
5640
5641  if (isRecord(snapshot.system)) {
5642    const nextControlState = createControlSuccessState(snapshot.system, {
5643      ok: true,
5644      statusCode: 200,
5645      source: "ws_snapshot"
5646    }, previousControlState);
5647
5648    if (JSON.stringify(previousControlState) !== JSON.stringify(nextControlState)) {
5649      state.controlState = nextControlState;
5650      renderChanged = true;
5651      persistChanged = true;
5652    }
5653  }
5654
5655  if (applyWsAutomationConversationSnapshot(browserSnapshot)) {
5656    renderChanged = true;
5657    persistChanged = true;
5658  }
5659
5660  if (persistChanged) {
5661    persistState().catch(() => {});
5662  }
5663
5664  if (renderChanged) {
5665    render();
5666  }
5667}
5668
5669function closeWsConnection() {
5670  clearTimeout(state.reconnectTimer);
5671
5672  if (state.ws) {
5673    state.ws.onopen = null;
5674    state.ws.onmessage = null;
5675    state.ws.onclose = null;
5676    state.ws.onerror = null;
5677    try {
5678      state.ws.close();
5679    } catch (_) {}
5680  }
5681
5682  state.ws = null;
5683  state.wsConnected = false;
5684}
5685
5686function connectWs(options = {}) {
5687  const { silentWhenDisabled = false } = options;
5688  closeWsConnection();
5689  setWsState({
5690    ...cloneWsState(state.wsState),
5691    connection: "connecting",
5692    wsUrl: state.wsUrl,
5693    localApiBase: DEFAULT_LOCAL_API_BASE,
5694    clientId: state.clientId,
5695    nextRetryAt: 0,
5696    lastError: null
5697  });
5698
5699  if (!isWsEnabled()) {
5700    if (!silentWhenDisabled) {
5701      addLog("info", "本地 WS 已被禁用", false);
5702    }
5703    return;
5704  }
5705
5706  addLog("info", `正在连接本地 WS:${state.wsUrl}`, false);
5707
5708  try {
5709    state.ws = new WebSocket(state.wsUrl);
5710  } catch (error) {
5711    const message = error instanceof Error ? error.message : String(error);
5712    setWsState({
5713      ...cloneWsState(state.wsState),
5714      connection: "disconnected",
5715      lastError: message
5716    });
5717    addLog("error", `本地 WS 创建失败:${message}`, false);
5718    scheduleReconnect(message);
5719    return;
5720  }
5721
5722  state.ws.onopen = () => {
5723    state.wsConnected = true;
5724    setWsState({
5725      ...cloneWsState(state.wsState),
5726      connection: "connected",
5727      wsUrl: state.wsUrl,
5728      localApiBase: DEFAULT_LOCAL_API_BASE,
5729      clientId: state.clientId,
5730      retryCount: 0,
5731      nextRetryAt: 0,
5732      lastOpenAt: Date.now(),
5733      lastMessageAt: Date.now(),
5734      lastError: null
5735    });
5736    sendHello();
5737    flushBufferedPluginDiagnosticLogs();
5738    addLog("info", "本地 WS 已连接", false);
5739    sendCredentialSnapshot(null, true);
5740    sendEndpointSnapshot();
5741  };
5742
5743  state.ws.onmessage = (event) => {
5744    let message = null;
5745    try {
5746      message = JSON.parse(event.data);
5747    } catch (_) {
5748      return;
5749    }
5750
5751    if (!message || typeof message !== "object") return;
5752
5753    setWsState({
5754      ...cloneWsState(state.wsState),
5755      lastMessageAt: Date.now(),
5756      lastError: null
5757    });
5758
5759    if (message.type === "browser.proxy_delivery") {
5760      runProxyDeliveryAction(message).then((result) => {
5761        sendPluginActionResult(result, {
5762          action: "proxy_delivery",
5763          commandType: message.type,
5764          completed: true,
5765          platform: result.platform,
5766          requestId: readPluginActionRequestId(message)
5767        });
5768      }).catch((error) => {
5769        const messageText = error instanceof Error ? error.message : String(error);
5770        addLog("error", `proxy_delivery 失败:${messageText}`, false);
5771        sendPluginActionResult({
5772          action: "proxy_delivery",
5773          platform: trimToNull(message.platform),
5774          results: []
5775        }, {
5776          accepted: true,
5777          action: "proxy_delivery",
5778          commandType: message.type,
5779          completed: true,
5780          failed: true,
5781          platform: trimToNull(message.platform),
5782          reason: messageText,
5783          requestId: readPluginActionRequestId(message)
5784        });
5785      });
5786      return;
5787    }
5788
5789    if (message.type === "browser.inject_message" || message.type === "browser.send_message") {
5790      const command = message.type === "browser.inject_message" ? "inject_message" : "send_message";
5791
5792      runDeliveryAction(message, command).then((result) => {
5793        sendPluginActionResult(result, {
5794          action: command,
5795          commandType: message.type,
5796          completed: true,
5797          platform: result.platform,
5798          requestId: readPluginActionRequestId(message)
5799        });
5800      }).catch((error) => {
5801        const messageText = error instanceof Error ? error.message : String(error);
5802        addLog("error", `${command} 失败:${messageText}`, false);
5803        sendPluginActionResult({
5804          action: command,
5805          platform: trimToNull(message.platform),
5806          results: []
5807        }, {
5808          accepted: true,
5809          action: command,
5810          commandType: message.type,
5811          completed: true,
5812          failed: true,
5813          platform: trimToNull(message.platform),
5814          reason: messageText,
5815          requestId: readPluginActionRequestId(message)
5816        });
5817      });
5818      return;
5819    }
5820
5821    const pluginAction = extractPluginManagementMessage(message);
5822    if (pluginAction) {
5823      runPluginManagementAction(pluginAction.action, {
5824        ...(pluginAction.action === "ws_reconnect"
5825          ? extractWsReconnectActionOverrides(pluginAction)
5826          : {}),
5827        platform: pluginAction.platform,
5828        source: pluginAction.source,
5829        reason: trimToNull(message.reason) || "ws_plugin_action"
5830      }).then((result) => {
5831        sendPluginActionResult(result, {
5832          action: pluginAction.action,
5833          commandType: pluginAction.commandType,
5834          completed: result?.deferred !== true,
5835          platform: pluginAction.platform,
5836          requestId: pluginAction.requestId
5837        });
5838      }).catch((error) => {
5839        const messageText = error instanceof Error ? error.message : String(error);
5840        addLog("error", `插件动作 ${pluginAction.action} 失败:${messageText}`, false);
5841        sendPluginActionResult({
5842          action: pluginAction.action,
5843          platform: pluginAction.platform,
5844          results: []
5845        }, {
5846          accepted: true,
5847          action: pluginAction.action,
5848          commandType: pluginAction.commandType,
5849          completed: true,
5850          failed: true,
5851          platform: pluginAction.platform,
5852          reason: messageText,
5853          requestId: pluginAction.requestId
5854        });
5855      });
5856      return;
5857    }
5858
5859    switch (message.type) {
5860      case "hello_ack":
5861        handleWsHelloAck(message);
5862        break;
5863      case "state_snapshot":
5864        handleWsStateSnapshot(message);
5865        break;
5866      case "action_result":
5867        if (message.ok) {
5868          addLog("info", `本地 WS 动作确认:${message.action || "-"}`, false);
5869        } else {
5870          addLog("warn", `本地 WS 动作失败:${message.message || message.error || message.action || "-"}`, false);
5871        }
5872        break;
5873      case "open_tab": {
5874        const targets = getTargetPlatforms(message.platform);
5875        for (const target of targets) {
5876          ensurePlatformTab(target, { focus: targets.length === 1 }).catch(() => {});
5877        }
5878        break;
5879      }
5880      case "api_request":
5881        if (String(message.response_mode || message.responseMode || "buffered").toLowerCase() === "sse") {
5882          proxyApiRequest(message).catch((error) => {
5883            if (error?.streamReported === true) {
5884              addLog("error", `代理流 ${message.platform || "未知"} ${message.method || "GET"} ${message.path || "-"} 失败:${error.message}`);
5885              return;
5886            }
5887
5888            const streamId = trimToNull(message.stream_id) || trimToNull(message.streamId) || message.id;
5889            const errorCode = error.message === "browser_request_cancelled" ? "request_cancelled" : "proxy_failed";
5890            wsSend({
5891              type: "stream_error",
5892              id: message.id,
5893              stream_id: streamId,
5894              status: null,
5895              code: errorCode,
5896              message: error.message
5897            });
5898            addLog("error", `代理流 ${message.platform || "未知"} ${message.method || "GET"} ${message.path || "-"} 失败:${error.message}`);
5899          });
5900          break;
5901        }
5902
5903        proxyApiRequest(message).then((result) => {
5904          sendApiResponse(message.id, result.ok, result.status, result.body, result.error);
5905          if (!result.ok) {
5906            addLog("error", `代理 ${message.platform || "未知"} ${message.method || "GET"} ${message.path || "-"} 失败:${result.error || result.status || "proxy_failed"}`);
5907          }
5908        }).catch((error) => {
5909          sendApiResponse(message.id, false, null, null, error.message);
5910          addLog("error", `代理 ${message.platform || "未知"} ${message.method || "GET"} ${message.path || "-"} 失败:${error.message}`);
5911        });
5912        break;
5913      case "api_request_cancel":
5914      case "request_cancel": {
5915        const requestId = trimToNull(message.requestId) || trimToNull(message.id);
5916        const cancelled = requestId
5917          ? cancelPendingProxyRequest(requestId, trimToNull(message.reason) || "browser_request_cancelled")
5918          : false;
5919
5920        if (cancelled) {
5921          addLog("info", `已取消本地代理请求 ${requestId}`, false);
5922        } else if (requestId) {
5923          addLog("warn", `待取消的本地代理请求不存在:${requestId}`, false);
5924        }
5925        break;
5926      }
5927      case "request_credentials":
5928        try {
5929          const requestedPlatform = trimToNull(message.platform);
5930          sendCredentialSnapshot(requestedPlatform || null, true);
5931          sendPluginActionResult({
5932            action: "request_credentials",
5933            platform: requestedPlatform,
5934            results: getTargetPlatforms(requestedPlatform).map((target) => ({
5935              ok: true,
5936              platform: target,
5937              shell_runtime: buildPlatformRuntimeSnapshot(target)
5938            }))
5939          }, {
5940            action: "request_credentials",
5941            commandType: "request_credentials",
5942            platform: requestedPlatform,
5943            requestId: readPluginActionRequestId(message)
5944          });
5945        } catch (error) {
5946          const messageText = error instanceof Error ? error.message : String(error);
5947          addLog("error", `刷新凭证快照失败:${messageText}`, false);
5948          sendPluginActionResult({
5949            action: "request_credentials",
5950            platform: trimToNull(message.platform),
5951            results: []
5952          }, {
5953            accepted: true,
5954            action: "request_credentials",
5955            commandType: "request_credentials",
5956            completed: true,
5957            failed: true,
5958            platform: trimToNull(message.platform),
5959            reason: messageText,
5960            requestId: readPluginActionRequestId(message)
5961          });
5962        }
5963        break;
5964      case "error":
5965        setWsState({
5966          ...cloneWsState(state.wsState),
5967          lastMessageAt: Date.now(),
5968          lastError: String(message.message || message.code || "ws_error"),
5969          raw: truncateControlRaw(message)
5970        });
5971        addLog("error", `本地 WS 返回错误:${message.message || message.code || "未知错误"}`, false);
5972        break;
5973      case "reload":
5974        addLog("warn", "收到重载命令");
5975        window.location.reload();
5976        break;
5977      default:
5978        break;
5979    }
5980  };
5981
5982  state.ws.onclose = (event) => {
5983    state.wsConnected = false;
5984    setWsState({
5985      ...cloneWsState(state.wsState),
5986      connection: "disconnected",
5987      lastCloseCode: event.code || 0,
5988      lastCloseReason: event.reason || null,
5989      lastError: event.reason || `closed_${event.code || 0}`
5990    });
5991    addLog("warn", `本地 WS 已关闭 code=${event.code || 0} reason=${event.reason || "-"}`, false);
5992    scheduleReconnect(event.reason || `closed_${event.code || 0}`);
5993  };
5994
5995  state.ws.onerror = () => {
5996    state.wsConnected = false;
5997    setWsState({
5998      ...cloneWsState(state.wsState),
5999      lastError: "连接错误"
6000    });
6001    addLog("error", `本地 WS 错误:${state.wsUrl}`, false);
6002  };
6003}
6004
6005function scheduleReconnect(reason = null) {
6006  clearTimeout(state.reconnectTimer);
6007  if (!isWsEnabled()) return;
6008  const previous = cloneWsState(state.wsState);
6009  const nextRetryAt = Date.now() + WS_RECONNECT_DELAY;
6010  setWsState({
6011    ...previous,
6012    connection: "retrying",
6013    retryCount: (Number(previous.retryCount) || 0) + 1,
6014    nextRetryAt,
6015    lastError: reason || previous.lastError
6016  });
6017  state.reconnectTimer = setTimeout(() => {
6018    connectWs({ silentWhenDisabled: true });
6019  }, WS_RECONNECT_DELAY);
6020}
6021
6022async function resolveTrackedTab(platform, options = {}) {
6023  const { requireShell = false, allowFallbackShell = false } = options;
6024  const tabId = state.trackedTabs[platform];
6025  if (!Number.isInteger(tabId)) return null;
6026  try {
6027    const tab = await browser.tabs.get(tabId);
6028    if (!tab || !tab.url || !isPlatformUrl(platform, tab.url)) return null;
6029    if (requireShell && !isPlatformShellUrl(platform, tab.url, { allowFallback: allowFallbackShell })) return null;
6030    return tab;
6031  } catch (_) {
6032    return null;
6033  }
6034}
6035
6036function sortTabsByRecency(tabs = []) {
6037  return [...tabs].sort((left, right) => {
6038    if (!!left.active !== !!right.active) return left.active ? -1 : 1;
6039    const leftAccess = Number(left.lastAccessed) || 0;
6040    const rightAccess = Number(right.lastAccessed) || 0;
6041    return rightAccess - leftAccess;
6042  });
6043}
6044
6045function dedupeTabsById(tabs = []) {
6046  const seen = new Set();
6047  const unique = [];
6048
6049  for (const tab of tabs) {
6050    if (!Number.isInteger(tab?.id) || seen.has(tab.id)) {
6051      continue;
6052    }
6053
6054    seen.add(tab.id);
6055    unique.push(tab);
6056  }
6057
6058  return unique;
6059}
6060
6061async function queryPlatformTabs(platform) {
6062  return sortTabsByRecency(await browser.tabs.query({ url: PLATFORMS[platform].urlPatterns }));
6063}
6064
6065async function queryOpenBusinessPlatformTabs(platform) {
6066  return dedupeTabsById(await queryPlatformTabs(platform)).filter((tab) => {
6067    if (!Number.isInteger(tab?.id) || !trimToNull(tab.url)) {
6068      return false;
6069    }
6070
6071    return !isPlatformShellUrl(platform, tab.url || "", { allowFallback: false });
6072  });
6073}
6074
6075async function findPlatformTab(platform) {
6076  const tabs = await queryPlatformTabs(platform);
6077  if (tabs.length === 0) return null;
6078  return tabs[0];
6079}
6080
6081async function findPlatformShellTab(platform, preferredTabId = null, options = {}) {
6082  const allowFallbackShell = options.allowFallbackShell === true;
6083  const tabs = await queryPlatformTabs(platform);
6084  const shellTabs = tabs.filter((tab) => isPlatformShellUrl(platform, tab.url || "", { allowFallback: allowFallbackShell }));
6085  if (shellTabs.length === 0) return null;
6086
6087  const canonical = Number.isInteger(preferredTabId)
6088    ? (shellTabs.find((tab) => tab.id === preferredTabId) || shellTabs[0])
6089    : shellTabs[0];
6090  const duplicateIds = shellTabs
6091    .map((tab) => tab.id)
6092    .filter((tabId) => Number.isInteger(tabId) && tabId !== canonical.id);
6093
6094  if (duplicateIds.length > 0) {
6095    browser.tabs.remove(duplicateIds).catch(() => {});
6096  }
6097
6098  return canonical;
6099}
6100
6101async function injectObserverScriptsIntoTab(tabId) {
6102  const result = {
6103    tabId,
6104    ok: false,
6105    contentScriptInjected: false,
6106    interceptorInjected: false,
6107    error: null
6108  };
6109
6110  if (!browser.scripting?.executeScript) {
6111    result.error = "scripting_execute_script_unavailable";
6112    return result;
6113  }
6114
6115  const errors = [];
6116
6117  try {
6118    await browser.scripting.executeScript({
6119      target: { tabId },
6120      files: CONTENT_SCRIPT_INJECTION_FILES
6121    });
6122    result.contentScriptInjected = true;
6123  } catch (error) {
6124    errors.push(`content=${error instanceof Error ? error.message : String(error)}`);
6125  }
6126
6127  try {
6128    await browser.scripting.executeScript({
6129      target: { tabId },
6130      files: PAGE_INTERCEPTOR_INJECTION_FILES,
6131      world: "MAIN"
6132    });
6133    result.interceptorInjected = true;
6134  } catch (error) {
6135    errors.push(`interceptor=${error instanceof Error ? error.message : String(error)}`);
6136  }
6137
6138  result.ok = result.contentScriptInjected && result.interceptorInjected;
6139  result.error = errors.length > 0 ? errors.join("; ") : null;
6140  return result;
6141}
6142
6143function summarizeObserverRefresh(results = []) {
6144  const source = Array.isArray(results) ? results : [];
6145  const refreshed = source.filter((entry) => entry.ok);
6146  const failed = source.filter((entry) => !entry.ok);
6147
6148  return {
6149    attempted_count: source.length,
6150    refreshed_count: refreshed.length,
6151    failed_count: failed.length,
6152    tab_ids: source.map((entry) => entry.tabId),
6153    refreshed_tab_ids: refreshed.map((entry) => entry.tabId),
6154    failed_tab_ids: failed.map((entry) => entry.tabId)
6155  };
6156}
6157
6158async function reinjectPlatformTabs(platform, options = {}) {
6159  if (!PLATFORMS[platform]) return [];
6160
6161  const excludeTabIds = new Set(
6162    Array.isArray(options.excludeTabIds)
6163      ? options.excludeTabIds.filter((tabId) => Number.isInteger(tabId))
6164      : []
6165  );
6166  const allowFallbackShell = cloneDesiredTabState(platform, state.desiredTabs[platform]).exists;
6167  const tabs = dedupeTabsById(
6168    Array.isArray(options.tabs)
6169      ? sortTabsByRecency(options.tabs)
6170      : await queryPlatformTabs(platform)
6171  );
6172  const results = [];
6173
6174  for (const tab of tabs) {
6175    if (!Number.isInteger(tab?.id) || excludeTabIds.has(tab.id)) {
6176      continue;
6177    }
6178
6179    const injection = await injectObserverScriptsIntoTab(tab.id);
6180    results.push({
6181      ...injection,
6182      isShellPage: isPlatformShellUrl(platform, tab.url || "", { allowFallback: allowFallbackShell }),
6183      platform,
6184      url: trimToNull(tab.url)
6185    });
6186  }
6187
6188  if (results.length > 0) {
6189    const summary = summarizeObserverRefresh(results);
6190    addLog(
6191      "info",
6192      `已刷新 ${platformLabel(platform)} 页面观察脚本 ${summary.refreshed_count}/${summary.attempted_count} 个标签页,来源 ${trimToNull(options.source) || "runtime"}`,
6193      false
6194    );
6195
6196    if (summary.failed_count > 0) {
6197      const failedTabs = results
6198        .filter((entry) => !entry.ok)
6199        .map((entry) => `${entry.tabId}:${entry.error || "unknown"}`)
6200        .join(", ");
6201      addLog("warn", `${platformLabel(platform)} 页面观察脚本刷新失败:${failedTabs}`, false);
6202    }
6203  }
6204
6205  return results;
6206}
6207
6208async function reinjectAllOpenPlatformTabs(options = {}) {
6209  const results = [];
6210
6211  for (const platform of PLATFORM_ORDER) {
6212    results.push(...await reinjectPlatformTabs(platform, options));
6213  }
6214
6215  return results;
6216}
6217
6218function describeStartupOpenAiTabReloadDecision(reason) {
6219  switch (reason) {
6220    case "initial_install":
6221      return "首次安装";
6222    case "extension_reload":
6223      return "插件重载";
6224    case "browser_startup":
6225      return "浏览器启动";
6226    case "controller_reopen":
6227      return "controller 页面重新打开";
6228    case "session_storage_unavailable":
6229      return "session 存储不可用";
6230    default:
6231      return "未命中自动刷新条件";
6232  }
6233}
6234
6235async function resolveStartupOpenAiTabReloadPlan(previousExtensionOrigin = null) {
6236  const currentExtensionOrigin = getRuntimeOrigin();
6237  const sessionStorage = browser.storage?.session;
6238  let firstControllerInBrowserSession = true;
6239  let sessionStorageAvailable = false;
6240
6241  if (sessionStorage?.get && sessionStorage?.set) {
6242    sessionStorageAvailable = true;
6243
6244    try {
6245      const saved = await sessionStorage.get(SESSION_CONTROLLER_BOOT_MARKER_KEY);
6246      const existingMarker = trimToNull(saved?.[SESSION_CONTROLLER_BOOT_MARKER_KEY]);
6247      firstControllerInBrowserSession = !existingMarker;
6248
6249      if (!existingMarker) {
6250        await sessionStorage.set({
6251          [SESSION_CONTROLLER_BOOT_MARKER_KEY]: currentExtensionOrigin || String(Date.now())
6252        });
6253      }
6254    } catch (error) {
6255      firstControllerInBrowserSession = true;
6256      addLog(
6257        "warn",
6258        `无法读取 controller session 标记,自动刷新将退化为基于扩展 origin 的判断:${error instanceof Error ? error.message : String(error)}`,
6259        false
6260      );
6261    }
6262  }
6263
6264  const normalizedPreviousOrigin = trimToNull(previousExtensionOrigin);
6265  const initialInstall = !normalizedPreviousOrigin;
6266  const sameBrowserSession = !!currentExtensionOrigin && currentExtensionOrigin === normalizedPreviousOrigin;
6267  const shouldReload = firstControllerInBrowserSession && (initialInstall || sameBrowserSession);
6268  let reason = "unknown";
6269
6270  if (shouldReload) {
6271    reason = initialInstall ? "initial_install" : "extension_reload";
6272  } else if (!firstControllerInBrowserSession) {
6273    reason = "controller_reopen";
6274  } else if (!sessionStorageAvailable) {
6275    reason = "session_storage_unavailable";
6276  } else {
6277    reason = "browser_startup";
6278  }
6279
6280  return {
6281    shouldReload,
6282    reason,
6283    previousExtensionOrigin: normalizedPreviousOrigin,
6284    currentExtensionOrigin
6285  };
6286}
6287
6288async function reloadOpenBusinessPlatformTabs(options = {}) {
6289  const source = trimToNull(options.source) || "startup";
6290  const candidates = [];
6291
6292  for (const platform of PLATFORM_ORDER) {
6293    const tabs = await queryOpenBusinessPlatformTabs(platform);
6294    for (const tab of tabs) {
6295      candidates.push({
6296        platform,
6297        tabId: tab.id,
6298        url: trimToNull(tab.url)
6299      });
6300    }
6301  }
6302
6303  if (candidates.length === 0) {
6304    addLog("info", `无需刷新已打开的 AI 页面,来源 ${source}`, false);
6305    return [];
6306  }
6307
6308  const reloaded = [];
6309  for (const candidate of candidates) {
6310    try {
6311      await browser.tabs.reload(candidate.tabId);
6312      reloaded.push(candidate);
6313      addLog(
6314        "info",
6315        `已自动刷新 ${platformLabel(candidate.platform)} 标签页 ${candidate.tabId},URL ${candidate.url || "-"},来源 ${source}`,
6316        false
6317      );
6318    } catch (error) {
6319      addLog(
6320        "warn",
6321        `自动刷新 ${platformLabel(candidate.platform)} 标签页 ${candidate.tabId} 失败,URL ${candidate.url || "-"}${error instanceof Error ? error.message : String(error)}`,
6322        false
6323      );
6324    }
6325  }
6326
6327  addLog("info", `已自动刷新 ${reloaded.length}/${candidates.length} 个 AI 业务标签页,来源 ${source}`, false);
6328  return reloaded;
6329}
6330
6331function scheduleStartupOpenAiTabReload(plan = null) {
6332  clearTimeout(state.startupOpenAiTabReloadTimer);
6333
6334  if (!plan?.shouldReload) {
6335    addLog("info", `跳过已打开 AI 页面自动刷新:${describeStartupOpenAiTabReloadDecision(plan?.reason)}`, false);
6336    return false;
6337  }
6338
6339  addLog(
6340    "info",
6341    `检测到${describeStartupOpenAiTabReloadDecision(plan.reason)},将在 ${STARTUP_OPEN_AI_TAB_RELOAD_DELAY}ms 后自动刷新已打开的 AI 业务页`,
6342    false
6343  );
6344
6345  state.startupOpenAiTabReloadTimer = setTimeout(() => {
6346    reloadOpenBusinessPlatformTabs({
6347      source: plan.reason
6348    }).catch((error) => {
6349      addLog(
6350        "error",
6351        `自动刷新已打开 AI 页面失败:${error instanceof Error ? error.message : String(error)}`,
6352        false
6353      );
6354    });
6355  }, STARTUP_OPEN_AI_TAB_RELOAD_DELAY);
6356
6357  return true;
6358}
6359
6360async function setTrackedTab(platform, tab) {
6361  state.trackedTabs[platform] = tab ? tab.id : null;
6362  state.actualTabs[platform] = buildActualTabSnapshot(
6363    platform,
6364    tab || null,
6365    null,
6366    state.actualTabs[platform]
6367  );
6368  pruneInvalidCredentialState();
6369  if (platform === "claude") {
6370    const allowFallbackShell = cloneDesiredTabState(platform, state.desiredTabs[platform]).exists;
6371    updateClaudeState({
6372      tabId: tab?.id ?? null,
6373      currentUrl: tab?.url || getPlatformShellUrl(platform),
6374      tabTitle: trimToNull(tab?.title),
6375      conversationId: isPlatformShellUrl(platform, tab?.url || "", { allowFallback: allowFallbackShell })
6376        ? null
6377        : (extractClaudeConversationIdFromPageUrl(tab?.url || "") || state.claudeState.conversationId)
6378    }, {
6379      render: true
6380    });
6381  }
6382  await persistState();
6383  render();
6384}
6385
6386async function ensurePlatformTab(platform, options = {}) {
6387  const {
6388    focus = false,
6389    reloadIfExisting = false,
6390    recordDesired = true
6391  } = options;
6392
6393  if (recordDesired) {
6394    setDesiredTabState(platform, true, {
6395      source: options.source || "runtime",
6396      reason: options.reason || "shell_requested",
6397      action: options.action
6398    });
6399  }
6400
6401  let tab = await resolveTrackedTab(platform);
6402  if (!tab) {
6403    tab = await findPlatformShellTab(platform, null, { allowFallbackShell: true });
6404  }
6405
6406  const shellUrl = getPlatformShellUrl(platform);
6407  let created = false;
6408  let updatedToShell = false;
6409  if (!tab) {
6410    created = true;
6411    tab = await browser.tabs.create({
6412      url: shellUrl,
6413      active: focus
6414    });
6415    addLog("info", `已打开 ${platformLabel(platform)} 空壳页 ${tab.id}`);
6416  } else if (!isPlatformShellUrl(platform, tab.url || "", { allowFallback: true })) {
6417    tab = await browser.tabs.update(tab.id, {
6418      url: shellUrl,
6419      active: focus
6420    });
6421    updatedToShell = true;
6422    addLog("info", `已将 ${platformLabel(platform)} 标签页 ${tab.id} 收口为空壳页`);
6423  } else if (focus) {
6424    tab = await findPlatformShellTab(platform, tab.id, { allowFallbackShell: true }) || tab;
6425    await browser.tabs.update(tab.id, { active: true });
6426    if (tab.windowId != null) {
6427      await browser.windows.update(tab.windowId, { focused: true });
6428    }
6429  } else {
6430    tab = await findPlatformShellTab(platform, tab.id, { allowFallbackShell: true }) || tab;
6431  }
6432
6433  await setTrackedTab(platform, tab);
6434
6435  if ((updatedToShell || reloadIfExisting) && !created) {
6436    try {
6437      await browser.tabs.reload(tab.id);
6438      addLog("info", `已重新加载 ${platformLabel(platform)} 空壳页 ${tab.id}`);
6439    } catch (error) {
6440      addLog("error", `重新加载 ${platformLabel(platform)} 空壳页失败:${error.message}`);
6441    }
6442  }
6443
6444  return tab;
6445}
6446
6447async function ensureAllPlatformTabs(options = {}) {
6448  for (const platform of PLATFORM_ORDER) {
6449    await ensurePlatformTab(platform, { focus: false, ...options });
6450  }
6451}
6452
6453async function refreshTrackedTabsFromBrowser(reason = "sync") {
6454  if (state.trackedTabRefreshRunning) {
6455    state.trackedTabRefreshQueued = true;
6456    return;
6457  }
6458
6459  state.trackedTabRefreshRunning = true;
6460
6461  try {
6462    do {
6463      state.trackedTabRefreshQueued = false;
6464      state.shellRuntimeLastHealthCheckAt = Date.now();
6465
6466      const next = createPlatformMap(() => null);
6467      const nextActual = createPlatformMap(() => createDefaultActualTabState());
6468      for (const platform of PLATFORM_ORDER) {
6469        const trackedCandidate = await resolveTrackedTab(platform);
6470        const allowFallbackShell = cloneDesiredTabState(platform, state.desiredTabs[platform]).exists;
6471        let shellTab = trackedCandidate && isPlatformShellUrl(platform, trackedCandidate.url || "", { allowFallback: allowFallbackShell })
6472          ? trackedCandidate
6473          : await resolveTrackedTab(platform, { requireShell: true, allowFallbackShell });
6474
6475        if (!shellTab) {
6476          shellTab = await findPlatformShellTab(platform, null, { allowFallbackShell });
6477        }
6478
6479        let candidateTab = null;
6480        if (!shellTab) {
6481          candidateTab = trackedCandidate || await findPlatformTab(platform);
6482        }
6483
6484        next[platform] = shellTab ? shellTab.id : null;
6485        nextActual[platform] = buildActualTabSnapshot(
6486          platform,
6487          shellTab || null,
6488          candidateTab,
6489          state.actualTabs[platform]
6490        );
6491      }
6492
6493      let changed = false;
6494      let actualChanged = false;
6495      for (const platform of PLATFORM_ORDER) {
6496        if (state.trackedTabs[platform] !== next[platform]) {
6497          state.trackedTabs[platform] = next[platform];
6498          changed = true;
6499        }
6500
6501        if (JSON.stringify(state.actualTabs[platform]) !== JSON.stringify(nextActual[platform])) {
6502          state.actualTabs[platform] = nextActual[platform];
6503          actualChanged = true;
6504        }
6505      }
6506
6507      const credentialChanged = pruneInvalidCredentialState();
6508      if (changed || actualChanged || credentialChanged) {
6509        await persistState();
6510      }
6511      if (changed || actualChanged || credentialChanged) {
6512        sendCredentialSnapshot(null, true);
6513      }
6514      if (changed || actualChanged || credentialChanged || reason === "poll") {
6515        render();
6516      }
6517    } while (state.trackedTabRefreshQueued);
6518  } finally {
6519    state.trackedTabRefreshRunning = false;
6520  }
6521}
6522
6523function scheduleTrackedTabRefresh(reason = "tabs") {
6524  clearTimeout(state.trackedTabRefreshTimer);
6525  state.trackedTabRefreshTimer = setTimeout(() => {
6526    refreshTrackedTabsFromBrowser(reason).catch((error) => {
6527      addLog("error", `刷新平台空壳页失败:${error.message}`);
6528    });
6529  }, TRACKED_TAB_REFRESH_DELAY);
6530}
6531
6532function collectEndpoint(platform, method, url) {
6533  if (!shouldTrackRequest(platform, url)) return;
6534
6535  const key = `${(method || "GET").toUpperCase()} ${normalizePath(url)}`;
6536  const now = Date.now();
6537  const current = state.endpoints[platform][key];
6538  const entry = normalizeEndpointEntry(key, current);
6539  const isNew = !current;
6540
6541  state.endpoints[platform][key] = {
6542    ...entry,
6543    method: (method || "GET").toUpperCase(),
6544    path: normalizePath(url),
6545    firstObservedAt: entry.firstObservedAt || now,
6546    lastObservedAt: now,
6547    sampleUrl: trimToNull(url)
6548  };
6549
6550  persistState().catch(() => {});
6551  render();
6552  if (isNew) {
6553    addLog("info", `已发现 ${platformLabel(platform)} 端点 ${key}`);
6554  }
6555  sendEndpointSnapshot(platform);
6556}
6557
6558function buildNetworkEntry(platform, data, tabId) {
6559  return {
6560    ts: new Date().toISOString(),
6561    platform,
6562    tabId,
6563    url: data.url,
6564    method: data.method,
6565    reqHeaders: redactCredentialHeaders(mergeKnownHeaders(platform, data.reqHeaders || {})),
6566    reqBody: trimBody(data.reqBody),
6567    status: data.status,
6568    resHeaders: data.resHeaders || null,
6569    resBody: trimBody(data.resBody),
6570    error: data.error || null,
6571    duration: data.duration || null,
6572    source: data.source || "page"
6573  };
6574}
6575
6576function ensureTrackedTabId(platform, tabId, source, senderUrl = "") {
6577  if (!Number.isInteger(tabId) || tabId < 0) return false;
6578  const desired = cloneDesiredTabState(platform, state.desiredTabs[platform]);
6579  const adoptableShell = shouldAdoptPlatformTabAsDesired(platform, senderUrl);
6580  const allowFallbackShell = desired.exists || adoptableShell;
6581
6582  if (!desired.exists && adoptableShell) {
6583    setDesiredTabState(platform, true, {
6584      source: source || "page",
6585      reason: "page_shell_detected",
6586      action: "tab_adopt",
6587      persist: true,
6588      render: true
6589    });
6590  }
6591
6592  const current = state.trackedTabs[platform];
6593  if (current === tabId) {
6594    return senderUrl ? isPlatformShellUrl(platform, senderUrl, { allowFallback: allowFallbackShell }) : true;
6595  }
6596  if (!isPlatformShellUrl(platform, senderUrl, { allowFallback: allowFallbackShell })) return false;
6597
6598  state.trackedTabs[platform] = tabId;
6599  pruneInvalidCredentialState();
6600  persistState().catch(() => {});
6601  render();
6602
6603  if (!Number.isInteger(current)) {
6604    addLog("info", `已识别 ${platformLabel(platform)} 空壳页 ${tabId},来源 ${source}`);
6605  } else {
6606    addLog("warn", `已切换 ${platformLabel(platform)} 空壳页 ${current} -> ${tabId},来源 ${source}`);
6607  }
6608  return true;
6609}
6610
6611function isSenderShellContext(platform, senderUrl = "") {
6612  const desired = cloneDesiredTabState(platform, state.desiredTabs[platform]);
6613  const adoptableShell = shouldAdoptPlatformTabAsDesired(platform, senderUrl);
6614  const allowFallbackShell = desired.exists || adoptableShell;
6615  return isPlatformShellUrl(platform, senderUrl, { allowFallback: allowFallbackShell });
6616}
6617
6618function getSenderContext(sender, fallbackPlatform = null) {
6619  const tabId = sender?.tab?.id;
6620  const senderUrl = sender?.tab?.url || "";
6621  const senderPlatform = detectPlatformFromUrl(senderUrl);
6622  const platform = senderPlatform || fallbackPlatform;
6623  if (!platform || !Number.isInteger(tabId) || tabId < 0) return null;
6624
6625  const isShellPage = isSenderShellContext(platform, senderUrl);
6626  if (isShellPage) {
6627    if (!ensureTrackedTabId(platform, tabId, "message", senderUrl)) return null;
6628  } else if (senderUrl && !isPlatformUrl(platform, senderUrl)) {
6629    return null;
6630  }
6631
6632  return {
6633    platform,
6634    tabId,
6635    senderUrl,
6636    isShellPage,
6637    conversationId: isShellPage ? null : extractConversationIdFromPageUrl(platform, senderUrl),
6638    pageTitle: trimToNull(sender?.tab?.title)
6639  };
6640}
6641
6642function syncPageControlFromContext(context, overrides = {}, options = {}) {
6643  if (!context?.platform || !Number.isInteger(context.tabId)) {
6644    return null;
6645  }
6646
6647  const input = {
6648    platform: context.platform,
6649    tabId: context.tabId,
6650    conversationId: Object.prototype.hasOwnProperty.call(overrides, "conversationId")
6651      ? overrides.conversationId
6652      : context.conversationId,
6653    pageUrl: Object.prototype.hasOwnProperty.call(overrides, "pageUrl")
6654      ? overrides.pageUrl
6655      : context.senderUrl,
6656    pageTitle: Object.prototype.hasOwnProperty.call(overrides, "pageTitle")
6657      ? overrides.pageTitle
6658      : context.pageTitle,
6659    shellPage: Object.prototype.hasOwnProperty.call(overrides, "shellPage")
6660      ? overrides.shellPage
6661      : context.isShellPage
6662  };
6663
6664  if (Object.prototype.hasOwnProperty.call(overrides, "localConversationId")) {
6665    input.localConversationId = overrides.localConversationId;
6666  }
6667
6668  if (Object.prototype.hasOwnProperty.call(overrides, "paused")) {
6669    input.paused = overrides.paused;
6670  }
6671
6672  if (Object.prototype.hasOwnProperty.call(overrides, "pauseSource")) {
6673    input.pauseSource = overrides.pauseSource;
6674  }
6675
6676  if (Object.prototype.hasOwnProperty.call(overrides, "pauseReason")) {
6677    input.pauseReason = overrides.pauseReason;
6678  }
6679
6680  if (Object.prototype.hasOwnProperty.call(overrides, "automationStatus")) {
6681    input.automationStatus = overrides.automationStatus;
6682  }
6683
6684  if (Object.prototype.hasOwnProperty.call(overrides, "lastNonPausedAutomationStatus")) {
6685    input.lastNonPausedAutomationStatus = overrides.lastNonPausedAutomationStatus;
6686  }
6687
6688  if (Object.prototype.hasOwnProperty.call(overrides, "updatedAt")) {
6689    input.updatedAt = overrides.updatedAt;
6690  }
6691
6692  return updatePageControlState(input, options);
6693}
6694
6695function buildPageControlSnapshotForSender(sender, fallbackPlatform = null) {
6696  const context = getSenderContext(sender, fallbackPlatform);
6697
6698  if (!context) {
6699    return null;
6700  }
6701
6702  return syncPageControlFromContext(context, {}, {
6703    persist: false,
6704    render: false
6705  });
6706}
6707
6708function resolvePlatformFromRequest(details) {
6709  return findTrackedPlatformByTabId(details.tabId) || null;
6710}
6711
6712function getObservedPagePlatform(sender, fallbackPlatform = null) {
6713  const senderUrl = sender?.tab?.url || "";
6714  return detectPlatformFromUrl(senderUrl) || fallbackPlatform || null;
6715}
6716
6717function buildPageDiagnosticLogText(data, sender, context = null) {
6718  const eventName = trimToNull(data?.event);
6719
6720  if (!eventName) {
6721    return null;
6722  }
6723
6724  const platform = context?.platform
6725    || getObservedPagePlatform(sender, trimToNull(data?.platform) || null)
6726    || "unknown";
6727  const tabId = Number.isInteger(context?.tabId)
6728    ? context.tabId
6729    : (Number.isInteger(sender?.tab?.id) ? sender.tab.id : null);
6730  const method = trimToNull(data?.method);
6731  const source = trimToNull(data?.source) || "page";
6732  const url = trimToNull(data?.url) || context?.senderUrl || sender?.tab?.url || "";
6733  const parts = [`[PAGE] ${eventName}`, `platform=${platform}`];
6734
6735  if (tabId != null) {
6736    parts.push(`tab=${tabId}`);
6737  }
6738
6739  if (method) {
6740    parts.push(method);
6741  }
6742
6743  if (url) {
6744    parts.push(normalizePath(url));
6745  }
6746
6747  parts.push(`source=${source}`);
6748
6749  if (eventName === "sse_stream_done") {
6750    const duration = Number.isFinite(data?.duration) ? Math.max(0, Math.round(data.duration)) : null;
6751    parts.push(`duration=${duration == null ? "-" : duration}ms`);
6752  }
6753
6754  switch (eventName) {
6755    case "page_bridge_ready":
6756    case "interceptor_active":
6757    case "fetch_intercepted":
6758    case "sse_stream_start":
6759    case "sse_stream_done":
6760      return parts.join(" ");
6761    default:
6762      return null;
6763  }
6764}
6765
6766function handlePageDiagnosticLog(data, sender) {
6767  const senderUrl = sender?.tab?.url || data?.url || "";
6768  const context = getSenderContext(sender, detectPlatformFromUrl(senderUrl) || trimToNull(data?.platform) || null);
6769
6770  if (context) {
6771    syncPageControlFromContext(context, {
6772      conversationId: extractObservedConversationId(context.platform, data, context)
6773    });
6774  }
6775
6776  const text = buildPageDiagnosticLogText(data, sender, context);
6777  if (!text) {
6778    return;
6779  }
6780
6781  addLog("debug", text, false);
6782}
6783
6784function getObservedPageConversationId(context, pageControl) {
6785  return trimToNull(context?.conversationId) || trimToNull(pageControl?.conversationId) || null;
6786}
6787
6788function isObservedFinalMessageStale(relay, context, pageControl) {
6789  const relayConversationId = trimToNull(relay?.payload?.conversation_id);
6790  const currentConversationId = getObservedPageConversationId(context, pageControl);
6791
6792  if (!relayConversationId || !currentConversationId) {
6793    return false;
6794  }
6795
6796  return relayConversationId !== currentConversationId;
6797}
6798
6799function relayObservedFinalMessage(platform, relay, source = "page_observed", context = null, routeMeta = null) {
6800  const observer = state.finalMessageRelayObservers[platform];
6801  if (!observer || !relay?.payload) return false;
6802
6803  const pageControl = context ? getPageControlState(context.platform, context.tabId) : createDefaultPageControlState();
6804
6805  if (pageControl.paused) {
6806    FINAL_MESSAGE_HELPERS?.rememberRelay(observer, relay);
6807    persistFinalMessageRelayCache().catch(() => {});
6808    addLog(
6809      "info",
6810      `${platformLabel(platform)} 最终消息已抑制:页面 #${context.tabId}${pageControl.conversationId ? ` conversation=${pageControl.conversationId}` : ""} 处于暂停状态`,
6811      false
6812    );
6813    return false;
6814  }
6815
6816  if (context && isObservedFinalMessageStale(relay, context, pageControl)) {
6817    const currentConversationId = getObservedPageConversationId(context, pageControl);
6818    FINAL_MESSAGE_HELPERS?.rememberRelay(observer, relay);
6819    persistFinalMessageRelayCache().catch(() => {});
6820    addLog(
6821      "info",
6822      `${platformLabel(platform)} 最终消息已抑制:页面 #${context.tabId} 当前 conversation=${currentConversationId},relay conversation=${relay.payload.conversation_id || "-"},判定为 stale replay`,
6823      false
6824    );
6825    return false;
6826  }
6827
6828  const payload = {
6829    ...relay.payload,
6830    ...(context
6831      ? {
6832          page_title: trimToNull(context.pageTitle),
6833          page_url: trimToNull(context.senderUrl),
6834          shell_page: context.isShellPage === true,
6835          tab_id: context.tabId
6836        }
6837      : {}),
6838    ...(isRecord(routeMeta) ? routeMeta : {})
6839  };
6840
6841  if (!wsSend(payload)) {
6842    addLog("warn", `${platformLabel(platform)} 最终消息未能转发(WS 未连接)`, false);
6843    return false;
6844  }
6845
6846  FINAL_MESSAGE_HELPERS?.rememberRelay(observer, relay);
6847  persistFinalMessageRelayCache().catch(() => {});
6848  addLog(
6849    "info",
6850    `${platformLabel(platform)} 最终消息已转发 assistant=${relay.payload.assistant_message_id} source=${source}`,
6851    false
6852  );
6853  return true;
6854}
6855
6856function observeFinalMessageFromPageNetwork(data, sender, context = null) {
6857  if (!FINAL_MESSAGE_HELPERS || !data || data.source === "proxy" || typeof data.url !== "string") {
6858    return;
6859  }
6860
6861  const platform = context?.platform || getObservedPagePlatform(sender, data.platform || null);
6862  const observer = platform ? state.finalMessageRelayObservers[platform] : null;
6863  if (!observer) {
6864    try { addLog("debug", `[FM-NET] ${platform || "?"} 无 observer,跳过`, false); } catch (_) {}
6865    return;
6866  }
6867
6868  const relay = FINAL_MESSAGE_HELPERS.observeNetwork(observer, data, {
6869    observedAt: Date.now(),
6870    pageUrl: sender?.tab?.url || ""
6871  });
6872
6873  try {
6874    const relevant = typeof FINAL_MESSAGE_HELPERS.isRelevantStreamUrl === "function"
6875      ? FINAL_MESSAGE_HELPERS.isRelevantStreamUrl(platform, data.url) : false;
6876    if (relevant || relay) {
6877      addLog("debug", `[FM-NET] ${platformLabel(platform)} url_relevant=${relevant} relay=${relay ? "产出" : "null"} assistant=${relay?.payload?.assistant_message_id || "-"}`, false);
6878    }
6879  } catch (_) {}
6880
6881  if (relay) {
6882    relayObservedFinalMessage(platform, relay, "page_network", context, platform === "claude"
6883      ? {
6884          organization_id: parseClaudeApiContext(data.url || "").organizationId || trimToNull(state.claudeState.organizationId)
6885        }
6886      : null);
6887  }
6888}
6889
6890function observeFinalMessageFromPageSse(data, sender, context = null) {
6891  if (!FINAL_MESSAGE_HELPERS || !data || data.source === "proxy" || typeof data.url !== "string") {
6892    return;
6893  }
6894
6895  const platform = context?.platform || getObservedPagePlatform(sender, data.platform || null);
6896  const observer = platform ? state.finalMessageRelayObservers[platform] : null;
6897  if (!observer) {
6898    try {
6899      if (data.done === true || data.error) {
6900        addLog("debug", `[FM-SSE] ${platform || "?"} 无 observer,跳过`, false);
6901      }
6902    } catch (_) {}
6903    return;
6904  }
6905
6906  const relay = FINAL_MESSAGE_HELPERS.observeSse(observer, data, {
6907    observedAt: Date.now(),
6908    pageUrl: sender?.tab?.url || ""
6909  });
6910
6911  try {
6912    if (data.done === true || data.error) {
6913      const relevant = typeof FINAL_MESSAGE_HELPERS.isRelevantStreamUrl === "function"
6914        ? FINAL_MESSAGE_HELPERS.isRelevantStreamUrl(platform, data.url) : false;
6915      addLog("debug", `[FM-SSE] ${platformLabel(platform)} ${data.done === true ? "done" : "error"} url_relevant=${relevant} relay=${relay ? "产出" : "null"} assistant=${relay?.payload?.assistant_message_id || "-"}`, false);
6916    }
6917  } catch (_) {}
6918
6919  if (relay) {
6920    relayObservedFinalMessage(platform, relay, "page_sse", context, platform === "claude"
6921      ? {
6922          organization_id: parseClaudeApiContext(data.url || "").organizationId || trimToNull(state.claudeState.organizationId)
6923        }
6924      : null);
6925  }
6926}
6927
6928function setClaudeBusy(isBusy, reason = null) {
6929  updateClaudeState({
6930    busy: !!isBusy,
6931    busyReason: isBusy ? reason || "proxy" : null,
6932    lastActivityAt: Date.now()
6933  }, {
6934    render: true
6935  });
6936}
6937
6938function applyObservedClaudeResponse(data, tabId) {
6939  const context = parseClaudeApiContext(data.url || "");
6940  const patch = {
6941    tabId: Number.isInteger(tabId) ? tabId : state.claudeState.tabId,
6942    organizationId: context.organizationId || state.claudeState.organizationId,
6943    conversationId: context.conversationId || state.claudeState.conversationId,
6944    lastActivityAt: Date.now()
6945  };
6946
6947  if (context.isCompletion) {
6948    patch.busy = true;
6949    patch.busyReason = data.source === "proxy" ? "proxy" : "page_sse";
6950  }
6951
6952  if (context.isConversationItem && typeof data.resBody === "string" && data.resBody) {
6953    try {
6954      const payload = JSON.parse(data.resBody);
6955      applyClaudeConversation(payload, {
6956        source: data.source === "proxy" ? "proxy_api" : "api_observed",
6957        organizationId: context.organizationId,
6958        conversationId: context.conversationId,
6959        readAt: Date.now()
6960      });
6961      return;
6962    } catch (_) {}
6963  }
6964
6965  updateClaudeState(patch, {
6966    persist: !!(patch.organizationId || patch.conversationId),
6967    render: true
6968  });
6969}
6970
6971function applyObservedClaudeSse(data, tabId) {
6972  const context = parseClaudeApiContext(data.url || "");
6973  const parsed = typeof data.chunk === "string" && data.chunk ? parseClaudeSseText(data.chunk) : null;
6974  const patch = {
6975    tabId: Number.isInteger(tabId) ? tabId : state.claudeState.tabId,
6976    organizationId: context.organizationId || state.claudeState.organizationId,
6977    conversationId: context.conversationId || state.claudeState.conversationId,
6978    lastActivityAt: Date.now()
6979  };
6980
6981  if (parsed?.messageUuid) {
6982    patch.lastAssistantMessageUuid = parsed.messageUuid;
6983  }
6984
6985  if (data.done || data.error) {
6986    patch.busy = false;
6987    patch.busyReason = null;
6988    patch.lastError = data.error || null;
6989  } else {
6990    patch.busy = true;
6991    patch.busyReason = "page_sse";
6992  }
6993
6994  updateClaudeState(patch, {
6995    persist: !!(patch.organizationId || patch.conversationId || patch.lastAssistantMessageUuid),
6996    render: true
6997  });
6998}
6999
7000function handlePageNetwork(data, sender) {
7001  const context = getSenderContext(sender, data?.platform || null);
7002  if (context) {
7003    syncPageControlFromContext(context, {
7004      conversationId: extractObservedConversationId(context.platform, data, context)
7005    });
7006  }
7007  observeFinalMessageFromPageNetwork(data, sender, context);
7008  if (!context || !data || !data.url || !data.method) return;
7009  try {
7010    const urlPath = normalizePath(data.url);
7011    const relevant = typeof FINAL_MESSAGE_HELPERS?.isRelevantStreamUrl === "function"
7012      ? FINAL_MESSAGE_HELPERS.isRelevantStreamUrl(context.platform, data.url) : false;
7013    addLog("debug", `[NET] tab=${context.tabId} ${context.platform} ${data.method} ${urlPath} status=${data.status || "-"} sse=${data.sse || false} stream_url=${relevant}`, false);
7014  } catch (_) {}
7015  const observedHeaders = Object.keys(data.reqHeaders || {}).length > 0
7016    ? mergeKnownHeaders(context.platform, data.reqHeaders || {})
7017    : cloneHeaderMap(state.lastHeaders[context.platform]);
7018
7019  if (
7020    context.platform === "chatgpt"
7021    && data.source !== "proxy"
7022    && String(data.method || "GET").toUpperCase() === "POST"
7023    && getRequestPath(data.url, PLATFORMS.chatgpt.rootUrl) === "/backend-api/conversation"
7024  ) {
7025    rememberChatgptSendTemplate(context, data.reqBody);
7026  }
7027
7028  if (context.platform === "claude") {
7029    applyObservedClaudeResponse(data, context.tabId);
7030  }
7031
7032  if (context.platform === "gemini" && typeof data.reqBody === "string" && data.reqBody) {
7033    rememberGeminiSendTemplate(context, data.url, data.reqBody, observedHeaders);
7034  }
7035
7036  collectEndpoint(context.platform, data.method, data.url);
7037  observeAccountMetadata(context.platform, data.url, data.resBody, observedHeaders);
7038
7039  if (data.status >= 400 || data.error) {
7040    addLog("error", `${platformLabel(context.platform)} ${data.method} ${normalizePath(data.url)} -> ${data.status || data.error}`);
7041  }
7042}
7043
7044function handlePageSse(data, sender) {
7045  const context = getSenderContext(sender, data?.platform || null);
7046  if (context) {
7047    syncPageControlFromContext(context, {
7048      conversationId: extractObservedConversationId(context.platform, data, context)
7049    });
7050  }
7051  observeFinalMessageFromPageSse(data, sender, context);
7052  if (!context || !data || !data.url) return;
7053  try {
7054    if (data.done === true || data.error) {
7055      const urlPath = normalizePath(data.url);
7056      addLog("debug", `[SSE] tab=${context.tabId} ${context.platform} ${data.done ? "done" : "error"} ${urlPath} duration=${data.duration || "-"}ms error=${data.error || "-"}`, false);
7057    }
7058  } catch (_) {}
7059
7060  if (context.platform === "claude") {
7061    applyObservedClaudeSse(data, context.tabId);
7062  }
7063
7064  const pending = data.id ? pendingProxyRequests.get(data.id) : null;
7065  if (!pending || pending.responseMode !== "sse") {
7066    return;
7067  }
7068
7069  const streamId = trimToNull(data.stream_id) || pending.streamId || pending.id;
7070  const status = Number.isFinite(data.status) ? data.status : null;
7071
7072  if (!pending.streamOpened || data.open === true) {
7073    pending.streamOpened = true;
7074    pending.streamId = streamId;
7075    pending.resolveAck({
7076      error: status != null && status >= 400 ? `upstream_status_${status}` : null,
7077      id: pending.id,
7078      method: data.method || pending.method || null,
7079      ok: status != null ? status < 400 : data.error == null,
7080      status,
7081      url: data.url || pending.path || null
7082    });
7083    wsSend({
7084      type: "stream_open",
7085      id: pending.id,
7086      stream_id: streamId,
7087      status,
7088      meta: {
7089        method: data.method || pending.method || null,
7090        platform: context.platform,
7091        url: data.url || pending.path || null
7092      }
7093    });
7094  }
7095
7096  if (typeof data.chunk === "string" && data.chunk.trim()) {
7097    const parsedChunk = parseStreamChunkPayload(data.chunk);
7098    const seq = Number.isFinite(data.seq) && data.seq > 0
7099      ? data.seq
7100      : (Number(pending.streamSeq) || 0) + 1;
7101    pending.streamSeq = seq;
7102    wsSend({
7103      type: "stream_event",
7104      id: pending.id,
7105      stream_id: streamId,
7106      seq,
7107      event: parsedChunk.event,
7108      raw: parsedChunk.raw,
7109      data: parsedChunk.data
7110    });
7111  }
7112
7113  if (data.done === true) {
7114    wsSend({
7115      type: "stream_end",
7116      id: pending.id,
7117      stream_id: streamId,
7118      status
7119    });
7120    pending.resolve({
7121      body: null,
7122      error: null,
7123      id: pending.id,
7124      method: data.method || pending.method || null,
7125      ok: true,
7126      status,
7127      url: data.url || pending.path || null
7128    });
7129    return;
7130  }
7131
7132  if (data.error) {
7133    if (!pending.streamOpened) {
7134      if (status != null) {
7135        pending.resolveAck({
7136          error: data.error,
7137          id: pending.id,
7138          method: data.method || pending.method || null,
7139          ok: false,
7140          status,
7141          url: data.url || pending.path || null
7142        });
7143      } else {
7144        pending.rejectAck(new Error(data.error));
7145      }
7146    }
7147    const streamError = new Error(data.error);
7148    streamError.streamReported = true;
7149    wsSend({
7150      type: "stream_error",
7151      id: pending.id,
7152      stream_id: streamId,
7153      status,
7154      code: data.error === "browser_request_cancelled" ? "request_cancelled" : "stream_error",
7155      message: data.error
7156    });
7157    pending.reject(streamError);
7158  }
7159}
7160
7161function handlePageProxyResponse(data, sender) {
7162  const context = getSenderContext(sender, data?.platform || null);
7163  if (context) {
7164    syncPageControlFromContext(context, {
7165      conversationId: extractObservedConversationId(context.platform, data, context)
7166    });
7167  }
7168  if (!context || !data || !data.id) return;
7169  const pending = pendingProxyRequests.get(data.id);
7170  if (!pending) return;
7171  observeAccountMetadata(
7172    context.platform,
7173    data.url || pending.path || "",
7174    typeof data.body === "string" ? data.body : null,
7175    cloneHeaderMap(state.lastHeaders[context.platform])
7176  );
7177
7178  if (context.platform === "claude") {
7179    const parsed = parseClaudeApiContext(data.url || pending.path || "");
7180    updateClaudeState({
7181      organizationId: parsed.organizationId || state.claudeState.organizationId,
7182      conversationId: parsed.conversationId || state.claudeState.conversationId,
7183      busy: false,
7184      busyReason: null,
7185      lastActivityAt: Date.now(),
7186      lastError: data.error || null
7187    }, {
7188      persist: !!(parsed.organizationId || parsed.conversationId),
7189      render: true
7190    });
7191  }
7192
7193  if (
7194    context.platform === "gemini"
7195    && pending.prompt
7196    && pending.attempts < 1
7197    && Number(data.status) === 400
7198  ) {
7199    const xsrfToken = extractGeminiXsrfToken(data.body);
7200    if (updateGeminiTemplateXsrf(pending.templateKey, xsrfToken)) {
7201      pending.attempts += 1;
7202      addLog("info", `Gemini xsrf 已刷新,正在重试代理 ${data.id}`);
7203      try {
7204        const retryTemplate = trimToNull(pending.templateKey)
7205          ? getGeminiSendTemplateByKey(pending.templateKey)
7206          : null;
7207        const retry = retryTemplate
7208          ? buildGeminiRequestFromTemplate(retryTemplate, pending.prompt)
7209          : buildGeminiAutoRequest(pending.prompt, {
7210              allowRecentFallback: true,
7211              allowShellFallback: pending.shellPage === true,
7212              conversationId: pending.conversationId
7213            });
7214        postProxyRequestToTab(context.tabId, {
7215          id: data.id,
7216          platform: "gemini",
7217          method: "POST",
7218          path: retry.path,
7219          body: retry.body,
7220          headers: retry.headers
7221        }).catch((error) => {
7222          pending.reject(error);
7223          addLog("error", `Gemini 重试 ${data.id} 失败:${error.message}`);
7224        });
7225        return;
7226      } catch (error) {
7227        pending.reject(error);
7228        addLog("error", `Gemini 重试 ${data.id} 失败:${error.message}`);
7229        return;
7230      }
7231    }
7232  }
7233
7234  if (pending.responseMode === "sse" && (data.error || (Number.isFinite(data.status) && data.status >= 400))) {
7235    pending.resolveAck({
7236      body: typeof data.body === "string" ? data.body : (data.body == null ? null : JSON.stringify(data.body)),
7237      error: data.error || `upstream_status_${data.status}`,
7238      id: data.id,
7239      method: data.method || pending.method || null,
7240      ok: false,
7241      status: Number.isFinite(data.status) ? data.status : null,
7242      url: data.url || pending.path || null
7243    });
7244    const message = data.error || `upstream_status_${data.status}`;
7245    const streamError = new Error(message);
7246    streamError.streamReported = true;
7247    wsSend({
7248      type: "stream_error",
7249      id: pending.id,
7250      stream_id: pending.streamId || pending.id,
7251      status: Number.isFinite(data.status) ? data.status : null,
7252      code: message === "browser_request_cancelled" ? "request_cancelled" : "stream_error",
7253      message
7254    });
7255    pending.reject(streamError);
7256    return;
7257  }
7258
7259  pending.resolveAck({
7260    body: typeof data.body === "string" ? data.body : (data.body == null ? null : JSON.stringify(data.body)),
7261    error: data.error || null,
7262    id: data.id,
7263    method: data.method || pending.method || null,
7264    ok: data.ok !== false && !data.error,
7265    status: Number.isFinite(data.status) ? data.status : null,
7266    url: data.url || pending.path || null
7267  });
7268  pending.resolve({
7269    id: data.id,
7270    ok: data.ok !== false && !data.error,
7271    status: Number.isFinite(data.status) ? data.status : null,
7272    body: typeof data.body === "string" ? data.body : (data.body == null ? null : JSON.stringify(data.body)),
7273    error: data.error || null,
7274    url: data.url || pending.path || null,
7275    method: data.method || pending.method || null
7276  });
7277
7278  if (!data.ok || (Number.isFinite(data.status) && data.status >= 400)) {
7279    addLog(
7280      "error",
7281      `${platformLabel(context.platform)} 代理 ${data.method || "GET"} ${normalizePath(data.url || data.path || "-")} -> ${data.status || data.error || "失败"}`
7282    );
7283  }
7284}
7285
7286function handlePageBridgeReady(data, sender) {
7287  const senderUrl = sender?.tab?.url || data?.url || "";
7288  const context = getSenderContext(sender, detectPlatformFromUrl(senderUrl) || data?.platform || null);
7289  if (!context) return;
7290
7291  syncPageControlFromContext(context, {
7292    conversationId: context.conversationId
7293  });
7294
7295  if (context.platform === "claude") {
7296    updateClaudeState({
7297      tabId: context.tabId,
7298      currentUrl: senderUrl || getPlatformShellUrl(context.platform),
7299      tabTitle: trimToNull(sender?.tab?.title),
7300      conversationId: context.isShellPage
7301        ? null
7302        : (extractClaudeConversationIdFromPageUrl(senderUrl) || state.claudeState.conversationId)
7303    }, {
7304      persist: true,
7305      render: true
7306    });
7307  }
7308  addLog(
7309    "info",
7310    `${platformLabel(context.platform)} ${context.isShellPage ? "空壳页" : "页面观察"}已就绪,标签页 ${context.tabId},来源 ${data?.source || "未知"},URL ${senderUrl.slice(0, 120)},标题 ${trimToNull(sender?.tab?.title) || "-"}`
7311  );
7312}
7313
7314async function observeCredentialSnapshot(platform, headers, details = {}) {
7315  const validation = validateCredentialSnapshot(platform, headers, details.url || "");
7316
7317  if (!validation.valid) {
7318    if (validation.invalidate && clearPlatformCredential(platform)) {
7319      addLog("info", `${platformLabel(platform)} 凭证已清理:${describeCredentialReason(validation.reason)}`);
7320      render();
7321      sendCredentialSnapshot(platform, true);
7322      persistState().catch(() => {});
7323    }
7324    return;
7325  }
7326
7327  const now = Date.now();
7328  const fingerprint = await computeCredentialFingerprint(platform, headers).catch(() => "");
7329  const previousFingerprint = trimToNull(state.credentialFingerprint[platform]) || "";
7330  const isSameFingerprint = !!fingerprint && fingerprint === previousFingerprint;
7331
7332  state.lastHeaders[platform] = headers;
7333  state.lastCredentialAt[platform] = now;
7334  state.lastCredentialUrl[platform] = details.url || "";
7335  state.lastCredentialTabId[platform] = Number.isInteger(details.tabId) ? details.tabId : null;
7336  state.credentialFingerprint[platform] = fingerprint || previousFingerprint;
7337  state.credentialCapturedAt[platform] = isSameFingerprint && state.credentialCapturedAt[platform] > 0
7338    ? state.credentialCapturedAt[platform]
7339    : now;
7340
7341  observeAccountMetadata(platform, details.url || "", null, headers);
7342  render();
7343  sendCredentialSnapshot(platform, !isSameFingerprint);
7344  persistState().catch(() => {});
7345}
7346
7347function handleBeforeSendHeaders(details) {
7348  const platform = resolvePlatformFromRequest(details);
7349  if (!platform) return;
7350
7351  const headers = headerArrayToObject(details.requestHeaders);
7352  if (platform === "claude") {
7353    const orgId = extractClaudeOrgId(headers, details.url || "");
7354    if (orgId) {
7355      headers["x-org-id"] = orgId;
7356    }
7357  }
7358  if (Object.keys(headers).length === 0) return;
7359
7360  collectEndpoint(platform, details.method || "GET", details.url);
7361  observeCredentialSnapshot(platform, headers, details).catch((error) => {
7362    addLog("error", `${platformLabel(platform)} 凭证指纹计算失败:${error.message}`);
7363  });
7364}
7365
7366function handleCompleted(details) {
7367  const platform = resolvePlatformFromRequest(details);
7368  if (!platform || !shouldTrackRequest(platform, details.url)) return;
7369  collectEndpoint(platform, details.method || "GET", details.url);
7370}
7371
7372function handleErrorOccurred(details) {
7373  const platform = resolvePlatformFromRequest(details);
7374  if (!platform || !shouldTrackRequest(platform, details.url)) return;
7375  if (platform === "claude") {
7376    const context = parseClaudeApiContext(details.url || "");
7377    if (context.isCompletion) {
7378      updateClaudeState({
7379        organizationId: context.organizationId || state.claudeState.organizationId,
7380        conversationId: context.conversationId || state.claudeState.conversationId,
7381        busy: false,
7382        busyReason: null,
7383        lastError: details.error || "request_failed",
7384        lastActivityAt: Date.now()
7385      }, {
7386        persist: true,
7387        render: true
7388      });
7389    }
7390  }
7391  addLog("error", `${platformLabel(platform)} ${details.method} ${normalizePath(details.url)} 失败:${details.error}`);
7392}
7393
7394async function postProxyRequestToTab(tabId, data) {
7395  let lastError = null;
7396  for (let attempt = 0; attempt < PROXY_MESSAGE_RETRY; attempt += 1) {
7397    try {
7398      await browser.tabs.sendMessage(tabId, {
7399        type: "baa_page_proxy_request",
7400        data
7401      });
7402      return;
7403    } catch (error) {
7404      lastError = error;
7405      if (attempt < PROXY_MESSAGE_RETRY - 1) {
7406        await sleep(PROXY_MESSAGE_RETRY_DELAY);
7407      }
7408    }
7409  }
7410  throw lastError || new Error("无法连接内容脚本");
7411}
7412
7413async function sendDeliveryCommandToTab(tabId, data) {
7414  let lastError = null;
7415
7416  for (let attempt = 0; attempt < PROXY_MESSAGE_RETRY; attempt += 1) {
7417    try {
7418      return await browser.tabs.sendMessage(tabId, {
7419        type: "baa_delivery_command",
7420        data
7421      });
7422    } catch (error) {
7423      lastError = error;
7424
7425      if (attempt < PROXY_MESSAGE_RETRY - 1) {
7426        await sleep(PROXY_MESSAGE_RETRY_DELAY);
7427      }
7428    }
7429  }
7430
7431  throw lastError || new Error("无法连接内容脚本");
7432}
7433
7434async function resolveDeliveryTab(platform) {
7435  if (!platform || !["claude", "chatgpt"].includes(platform)) {
7436    throw new Error(`当前 delivery 仅覆盖 claude/chatgpt,收到:${platform || "-"}`);
7437  }
7438
7439  const tab = await ensurePlatformTab(platform, {
7440    action: "delivery_bridge",
7441    focus: true,
7442    reason: "ws_delivery_command",
7443    source: "ws_delivery_bridge"
7444  });
7445
7446  if (!tab?.id) {
7447    throw new Error(`无法定位 ${platformLabel(platform)} shell tab`);
7448  }
7449
7450  await sleep(150);
7451  return tab;
7452}
7453
7454function buildDeliveryShellRuntime(platform) {
7455  try {
7456    return buildPlatformRuntimeSnapshot(platform);
7457  } catch (_) {
7458    return null;
7459  }
7460}
7461
7462function createDeliveryRouteError(code, message) {
7463  return new Error(`delivery.${trimToNull(code) || "route_error"}: ${message}`);
7464}
7465
7466function resolvePausedPageControlForDelivery(platform, conversationId, tabId = null) {
7467  const pausedByConversation = findPausedPageControlByConversation(platform, conversationId);
7468
7469  if (pausedByConversation) {
7470    return pausedByConversation;
7471  }
7472
7473  if (!Number.isInteger(tabId)) {
7474    return null;
7475  }
7476
7477  const pausedByTab = getPageControlState(platform, tabId);
7478  return pausedByTab.paused ? pausedByTab : null;
7479}
7480
7481function createPausedPageDeliveryError(command, pageControl, conversationId = null) {
7482  const targetConversationId = pageControl?.conversationId || trimToNull(conversationId);
7483  const parts = [`${command} blocked: ${platformLabel(pageControl?.platform || "-")} 页面已暂停`];
7484
7485  if (Number.isInteger(pageControl?.tabId)) {
7486    parts.push(`tab=${pageControl.tabId}`);
7487  }
7488
7489  if (targetConversationId) {
7490    parts.push(`conversation=${targetConversationId}`);
7491  }
7492
7493  return createDeliveryRouteError("page_paused", parts.join(" "));
7494}
7495
7496function readDeliveryTargetTabId(message) {
7497  const candidates = [
7498    message?.target_tab_id,
7499    message?.targetTabId,
7500    message?.tab_id,
7501    message?.tabId
7502  ];
7503
7504  for (const candidate of candidates) {
7505    if (Number.isInteger(candidate) && candidate > 0) {
7506      return candidate;
7507    }
7508  }
7509
7510  return null;
7511}
7512
7513function readDeliveryTargetPageUrl(message) {
7514  return trimToNull(message?.target_page_url || message?.targetPageUrl || message?.page_url || message?.pageUrl);
7515}
7516
7517async function resolveDeliveryTargetPage(message, command) {
7518  const platform = trimToNull(message?.platform);
7519  const conversationId = trimToNull(message?.conversation_id || message?.conversationId);
7520  const pageUrl = readDeliveryTargetPageUrl(message);
7521  const targetTabId = readDeliveryTargetTabId(message);
7522  const explicitShellPage = message?.shell_page === true || message?.shellPage === true;
7523
7524  if (!platform || !["claude", "chatgpt"].includes(platform)) {
7525    throw new Error(`当前 delivery 仅覆盖 claude/chatgpt,收到:${platform || "-"}`);
7526  }
7527
7528  if (explicitShellPage) {
7529    throw createDeliveryRouteError("shell_page", `${command} target resolves to shell page`);
7530  }
7531
7532  let pageControl = null;
7533  let tab = null;
7534
7535  if (targetTabId != null) {
7536    pageControl = getPageControlState(platform, targetTabId);
7537    try {
7538      tab = await browser.tabs.get(targetTabId);
7539    } catch (_) {
7540      throw createDeliveryRouteError(
7541        "tab_missing",
7542        `${command} target tab is unavailable: platform=${platform} tab=${targetTabId}`
7543      );
7544    }
7545
7546    if (!isPlatformUrl(platform, tab?.url || "")) {
7547      throw createDeliveryRouteError(
7548        "target_mismatch",
7549        `${command} target tab is not a ${platformLabel(platform)} page: tab=${targetTabId}`
7550      );
7551    }
7552  } else if (conversationId) {
7553    pageControl = findPageControlByConversation(platform, conversationId);
7554    if (pageControl?.tabId != null) {
7555      tab = await browser.tabs.get(pageControl.tabId).catch(() => null);
7556    }
7557  } else if (pageUrl) {
7558    pageControl = findPageControlByUrl(platform, pageUrl);
7559    if (pageControl?.tabId != null) {
7560      tab = await browser.tabs.get(pageControl.tabId).catch(() => null);
7561    }
7562  }
7563
7564  if (!tab?.id) {
7565    if (conversationId || pageUrl || targetTabId != null) {
7566      throw createDeliveryRouteError(
7567        "route_missing",
7568        `${command} missing business-page target: platform=${platform} conversation=${conversationId || "-"}`
7569      );
7570    }
7571
7572    const shellTab = await resolveDeliveryTab(platform);
7573    return {
7574      conversationId,
7575      organizationId: platform === "claude"
7576        ? (trimToNull(message?.organization_id || message?.organizationId) || trimToNull(state.claudeState.organizationId))
7577        : null,
7578      pageControl: getPageControlState(platform, shellTab.id),
7579      pageTitle: trimToNull(shellTab.title),
7580      pageUrl: trimToNull(shellTab.url),
7581      shellPage: true,
7582      tab: shellTab
7583    };
7584  }
7585
7586  const resolvedPageUrl = trimToNull(tab.url);
7587  const resolvedConversationId = extractConversationIdFromPageUrl(platform, resolvedPageUrl || "") || conversationId;
7588  const resolvedShellPage = isPlatformShellUrl(platform, resolvedPageUrl || "", {
7589    allowFallback: true
7590  }) || pageControl?.shellPage === true;
7591
7592  if (resolvedShellPage) {
7593    throw createDeliveryRouteError(
7594      "shell_page",
7595      `${command} target resolves to shell page: platform=${platform} tab=${tab.id}`
7596    );
7597  }
7598
7599  if (pageUrl && resolvedPageUrl && pageUrl !== resolvedPageUrl) {
7600    throw createDeliveryRouteError(
7601      "target_mismatch",
7602      `${command} page_url mismatch: expected=${pageUrl} actual=${resolvedPageUrl}`
7603    );
7604  }
7605
7606  if (conversationId && resolvedConversationId && conversationId !== resolvedConversationId) {
7607    throw createDeliveryRouteError(
7608      "target_mismatch",
7609      `${command} conversation mismatch: expected=${conversationId} actual=${resolvedConversationId}`
7610    );
7611  }
7612
7613  await injectObserverScriptsIntoTab(tab.id).catch(() => null);
7614  await sleep(150);
7615
7616  return {
7617    conversationId: conversationId || resolvedConversationId || null,
7618    organizationId: platform === "claude"
7619      ? (trimToNull(message?.organization_id || message?.organizationId) || trimToNull(state.claudeState.organizationId))
7620      : null,
7621    pageControl: pageControl || getPageControlState(platform, tab.id),
7622    pageTitle: trimToNull(tab.title),
7623    pageUrl: resolvedPageUrl,
7624    shellPage: false,
7625    tab
7626  };
7627}
7628
7629function buildClaudeDeliveryProxyRequest(message, target) {
7630  const conversationId = trimToNull(target?.conversationId);
7631  const organizationId = trimToNull(target?.organizationId);
7632  const assistantMessageId = trimToNull(message?.assistant_message_id || message?.assistantMessageId);
7633  const messageText = trimToNull(message?.message_text || message?.messageText);
7634
7635  if (!conversationId) {
7636    throw createDeliveryRouteError("route_missing", "proxy_delivery missing Claude conversation_id");
7637  }
7638
7639  if (!organizationId) {
7640    throw new Error("delivery.organization_missing: Claude 缺少 org-id,请先在真实 Claude 页面触发一轮请求");
7641  }
7642
7643  if (!assistantMessageId) {
7644    throw new Error("delivery.invalid_payload: Claude proxy_delivery requires assistant_message_id");
7645  }
7646
7647  if (!messageText) {
7648    throw new Error("delivery.invalid_payload: Claude proxy_delivery requires message_text");
7649  }
7650
7651  const path = `/api/organizations/${organizationId}/chat_conversations/${conversationId}/completion`;
7652  return {
7653    body: {
7654      prompt: messageText,
7655      timezone: "Asia/Shanghai",
7656      attachments: [],
7657      files: [],
7658      parent_message_uuid: assistantMessageId
7659    },
7660    headers: buildClaudeHeaders(path, {
7661      accept: "text/event-stream",
7662      "content-type": "application/json"
7663    }),
7664    method: "POST",
7665    path
7666  };
7667}
7668
7669async function runProxyDeliveryAction(message) {
7670  const platform = trimToNull(message?.platform);
7671  const planId = trimToNull(message?.plan_id || message?.planId);
7672  const timeoutMs = Number.isFinite(Number(message?.timeout_ms || message?.timeoutMs))
7673    ? Number(message?.timeout_ms || message?.timeoutMs)
7674    : PROXY_REQUEST_TIMEOUT;
7675
7676  if (!platform) {
7677    throw new Error("proxy_delivery 缺少 platform");
7678  }
7679
7680  if (!planId) {
7681    throw new Error("proxy_delivery 缺少 plan_id");
7682  }
7683
7684  const target = await resolveDeliveryTargetPage(message, "proxy_delivery");
7685  const pausedPage = target?.pageControl?.paused ? target.pageControl : null;
7686
7687  if (pausedPage) {
7688    addLog("info", createPausedPageDeliveryError("proxy_delivery", pausedPage, target.conversationId).message, false);
7689    throw createPausedPageDeliveryError("proxy_delivery", pausedPage, target.conversationId);
7690  }
7691
7692  let request = null;
7693
7694  switch (platform) {
7695    case "claude":
7696      request = buildClaudeDeliveryProxyRequest(message, target);
7697      break;
7698    case "chatgpt":
7699      request = buildChatgptDeliveryRequest({
7700        conversationId: target.conversationId,
7701        messageText: message?.message_text || message?.messageText,
7702        sourceAssistantMessageId: message?.assistant_message_id || message?.assistantMessageId
7703      });
7704      break;
7705    case "gemini":
7706      request = buildGeminiDeliveryRequest({
7707        conversationId: target.conversationId,
7708        messageText: message?.message_text || message?.messageText,
7709        shellPage: target.shellPage === true
7710      });
7711      break;
7712    default:
7713      throw new Error(`未知平台:${platform}`);
7714  }
7715
7716  const proxyRequestId = `${planId}:proxy_delivery`;
7717  const pending = createPendingProxyRequest(proxyRequestId, {
7718    conversationId: target.conversationId,
7719    method: request.method,
7720    path: request.path,
7721    platform,
7722    responseMode: "sse",
7723    shellPage: target.shellPage === true,
7724    tabId: target.tab.id,
7725    templateKey: request.templateKey || null,
7726    timeoutMs
7727  });
7728
7729  try {
7730    await postProxyRequestToTab(target.tab.id, {
7731      id: proxyRequestId,
7732      ...request,
7733      platform,
7734      response_mode: "sse",
7735      source: "proxy_delivery"
7736    });
7737  } catch (error) {
7738    pending.reject(error);
7739    throw error;
7740  }
7741
7742  const deliveryAck = await pending.ackResponse.then(
7743    (response) => {
7744      const statusCode = Number.isFinite(Number(response?.status)) ? Number(response.status) : null;
7745      const reason = trimToNull(response?.error)
7746        || (statusCode != null && statusCode !== 200 ? `downstream_status_${statusCode}` : null);
7747
7748      return {
7749        confirmed_at: Date.now(),
7750        failed: reason != null || statusCode !== 200,
7751        level: statusCode == null ? 0 : 1,
7752        reason,
7753        status_code: statusCode
7754      };
7755    },
7756    (error) => ({
7757      confirmed_at: Date.now(),
7758      failed: true,
7759      level: 0,
7760      reason: trimToNull(error?.message) || String(error || "downstream_ack_failed"),
7761      status_code: Number.isFinite(Number(error?.statusCode)) ? Number(error.statusCode) : null
7762    })
7763  );
7764  addLog(
7765    deliveryAck.failed ? "warn" : "info",
7766    `proxy_delivery 下游确认:${platformLabel(platform)} status=${deliveryAck.status_code ?? "-"} level=${deliveryAck.level} reason=${deliveryAck.reason || "-"}`,
7767    false
7768  );
7769
7770  return {
7771    action: "proxy_delivery",
7772    platform,
7773    results: [
7774      {
7775        delivery_ack: deliveryAck,
7776        ok: true,
7777        platform,
7778        shell_runtime: buildDeliveryShellRuntime(platform),
7779        tabId: target.tab.id
7780      }
7781    ]
7782  };
7783}
7784
7785async function runDeliveryAction(message, command) {
7786  const platform = trimToNull(message?.platform);
7787  const planId = trimToNull(message?.plan_id || message?.planId);
7788  const conversationId = trimToNull(message?.conversation_id || message?.conversationId);
7789
7790  if (!platform) {
7791    throw new Error(`${command} 缺少 platform`);
7792  }
7793
7794  if (!planId) {
7795    throw new Error(`${command} 缺少 plan_id`);
7796  }
7797
7798  const pausedByConversation = resolvePausedPageControlForDelivery(platform, conversationId);
7799
7800  if (pausedByConversation) {
7801    addLog("info", createPausedPageDeliveryError(command, pausedByConversation, conversationId).message, false);
7802    throw createPausedPageDeliveryError(command, pausedByConversation, conversationId);
7803  }
7804
7805  const target = await resolveDeliveryTargetPage(message, command);
7806  const pausedByTab = resolvePausedPageControlForDelivery(platform, target.conversationId, target.tab.id);
7807
7808  if (pausedByTab) {
7809    addLog("info", createPausedPageDeliveryError(command, pausedByTab, target.conversationId).message, false);
7810    throw createPausedPageDeliveryError(command, pausedByTab, target.conversationId);
7811  }
7812
7813  const payload = {
7814    command,
7815    platform,
7816    planId,
7817    pollIntervalMs: Number.isFinite(Number(message?.poll_interval_ms || message?.pollIntervalMs))
7818      ? Number(message?.poll_interval_ms || message?.pollIntervalMs)
7819      : undefined,
7820    retryAttempts: Number.isFinite(Number(message?.retry_attempts || message?.retryAttempts))
7821      ? Number(message?.retry_attempts || message?.retryAttempts)
7822      : undefined,
7823    retryDelayMs: Number.isFinite(Number(message?.retry_delay_ms || message?.retryDelayMs))
7824      ? Number(message?.retry_delay_ms || message?.retryDelayMs)
7825      : undefined,
7826    text: command === "inject_message"
7827      ? trimToNull(message?.message_text || message?.messageText)
7828      : null,
7829    timeoutMs: Number.isFinite(Number(message?.timeout_ms || message?.timeoutMs))
7830      ? Number(message?.timeout_ms || message?.timeoutMs)
7831      : DELIVERY_COMMAND_TIMEOUT
7832  };
7833  const result = await sendDeliveryCommandToTab(target.tab.id, payload);
7834
7835  if (result?.ok !== true) {
7836    throw new Error(trimToNull(result?.reason) || `${command} failed`);
7837  }
7838
7839  return {
7840    action: command,
7841    platform,
7842    results: [
7843      {
7844        ok: true,
7845        platform,
7846        shell_runtime: buildDeliveryShellRuntime(platform),
7847        tabId: target.tab.id
7848      }
7849    ]
7850  };
7851}
7852
7853async function proxyApiRequest(message) {
7854  const {
7855    conversation_id: rawConversationId,
7856    conversationId: rawConversationIdCamel,
7857    id,
7858    platform,
7859    method = "GET",
7860    path: apiPath,
7861    body = null
7862  } = message || {};
7863  if (!id) throw new Error("缺少代理请求 ID");
7864  if (!platform || !PLATFORMS[platform]) throw new Error(`未知平台:${platform || "-"}`);
7865  if (!apiPath) throw new Error("缺少代理请求路径");
7866
7867  const responseMode = String(message?.response_mode || message?.responseMode || "buffered").toLowerCase();
7868  const streamId = trimToNull(message?.stream_id) || trimToNull(message?.streamId) || id;
7869  const conversationId = trimToNull(rawConversationId) || trimToNull(rawConversationIdCamel);
7870  const prompt = platform === "gemini" ? extractPromptFromProxyBody(body) : null;
7871  const geminiAutoRequest = platform === "gemini" && prompt && isGeminiStreamGenerateUrl(apiPath)
7872    ? buildGeminiAutoRequest(prompt, {
7873        allowRecentFallback: true,
7874        allowShellFallback: true,
7875        conversationId
7876      })
7877    : null;
7878
7879  return executeProxyRequest({
7880    id,
7881    platform,
7882    method: geminiAutoRequest ? "POST" : String(method || "GET").toUpperCase(),
7883    path: geminiAutoRequest ? geminiAutoRequest.path : apiPath,
7884    body: geminiAutoRequest ? geminiAutoRequest.body : body,
7885    headers: geminiAutoRequest ? geminiAutoRequest.headers : buildProxyHeaders(platform, apiPath)
7886  }, {
7887    conversationId,
7888    platform,
7889    prompt,
7890    responseMode,
7891    streamId,
7892    attempts: 0,
7893    shellPage: false,
7894    templateKey: geminiAutoRequest?.templateKey || null
7895  });
7896}
7897
7898async function proxyClaudeRequest(method, path, body = null, options = {}) {
7899  const headers = buildClaudeHeaders(path, options.headers || {});
7900  const response = await executeProxyRequest({
7901    id: options.id || buildRuntimeRequestId("claude"),
7902    platform: "claude",
7903    method: String(method || "GET").toUpperCase(),
7904    path,
7905    body,
7906    headers
7907  }, {
7908    platform: "claude",
7909    timeoutMs: options.timeoutMs || PROXY_REQUEST_TIMEOUT
7910  });
7911
7912  if (!response.ok || (Number.isFinite(response.status) && response.status >= 400)) {
7913    const bodyPreview = typeof response.body === "string" && response.body
7914      ? `: ${response.body.slice(0, 240)}`
7915      : "";
7916    throw new Error(`Claude 请求失败 (${response.status || "unknown"})${bodyPreview}`);
7917  }
7918
7919  return response;
7920}
7921
7922async function proxyClaudeJson(method, path, body = null, options = {}) {
7923  const response = await proxyClaudeRequest(method, path, body, {
7924    ...options,
7925    headers: {
7926      accept: "application/json",
7927      ...(options.headers || {})
7928    }
7929  });
7930
7931  if (!response.body) return null;
7932  try {
7933    return JSON.parse(response.body);
7934  } catch (error) {
7935    throw new Error(`Claude JSON 响应解析失败:${error.message}`);
7936  }
7937}
7938
7939async function readClaudeConversation(options = {}) {
7940  await refreshClaudeTabState(options.createTab === true);
7941  const conversationId = trimToNull(options.conversationId) || getClaudeConversationIdFromState();
7942  const organizationId = trimToNull(options.organizationId) || getClaudeOrgId();
7943
7944  if (!organizationId) {
7945    throw new Error("Claude 缺少 org-id,请先在真实 Claude 页面触发一轮请求");
7946  }
7947  if (!conversationId) {
7948    throw new Error("Claude 当前没有可读取的对话 ID");
7949  }
7950
7951  const payload = await proxyClaudeJson(
7952    "GET",
7953    `/api/organizations/${organizationId}/chat_conversations/${conversationId}`,
7954    null
7955  );
7956
7957  return applyClaudeConversation(payload, {
7958    source: "api_read",
7959    organizationId,
7960    conversationId,
7961    readAt: Date.now()
7962  });
7963}
7964
7965async function createClaudeConversation(options = {}) {
7966  const organizationId = trimToNull(options.organizationId) || getClaudeOrgId();
7967  if (!organizationId) {
7968    throw new Error("Claude 缺少 org-id,请先在真实 Claude 页面触发一轮请求");
7969  }
7970
7971  const payload = await proxyClaudeJson(
7972    "POST",
7973    `/api/organizations/${organizationId}/chat_conversations`,
7974    {
7975      name: trimToNull(options.title) || "",
7976      uuid: typeof crypto?.randomUUID === "function" ? crypto.randomUUID() : undefined
7977    }
7978  );
7979
7980  const conversationId = trimToNull(payload?.uuid);
7981  if (!conversationId) {
7982    throw new Error("Claude 创建对话未返回 uuid");
7983  }
7984
7985  updateClaudeState({
7986    organizationId,
7987    conversationId,
7988    title: trimToNull(payload?.name) || trimToNull(options.title) || null,
7989    titleSource: trimToNull(payload?.name) || trimToNull(options.title) ? "api" : null,
7990    messages: [],
7991    lastAssistantMessageUuid: null,
7992    lastConversationSource: "api_create",
7993    lastActivityAt: Date.now(),
7994    lastError: null
7995  }, {
7996    persist: true,
7997    render: true
7998  });
7999
8000  return {
8001    organizationId,
8002    conversationId,
8003    title: trimToNull(payload?.name) || trimToNull(options.title) || null
8004  };
8005}
8006
8007async function resolveClaudeSendTarget(options = {}) {
8008  await refreshClaudeTabState(true);
8009  const organizationId = trimToNull(options.organizationId) || getClaudeOrgId();
8010  if (!organizationId) {
8011    throw new Error("Claude 缺少 org-id,请先在真实 Claude 页面触发一轮请求");
8012  }
8013
8014  let conversationId = trimToNull(options.conversationId);
8015  let conversationSnapshot = null;
8016
8017  if (!conversationId && options.createNew !== true) {
8018    conversationId = getClaudeConversationIdFromState();
8019  }
8020
8021  if (conversationId && options.createNew !== true) {
8022    conversationSnapshot = await readClaudeConversation({
8023      organizationId,
8024      conversationId
8025    }).catch(() => null);
8026  }
8027
8028  if (!conversationId || options.createNew === true) {
8029    const created = await createClaudeConversation({
8030      organizationId,
8031      title: options.title
8032    });
8033    conversationId = created.conversationId;
8034  }
8035
8036  const lastMessageUuid = conversationSnapshot?.recentMessages?.length
8037    ? getLastClaudeMessageUuid(conversationSnapshot.recentMessages)
8038    : (conversationId === state.claudeState.conversationId
8039      ? state.claudeState.lastAssistantMessageUuid || null
8040      : null);
8041
8042  return {
8043    organizationId,
8044    conversationId,
8045    lastMessageUuid
8046  };
8047}
8048
8049async function sendClaudePrompt(message = {}) {
8050  const prompt = trimToNull(message.prompt || message.text || message.message);
8051  if (!prompt) {
8052    throw new Error("Claude prompt 不能为空");
8053  }
8054
8055  const target = await resolveClaudeSendTarget(message);
8056  setClaudeBusy(true, "proxy");
8057
8058  try {
8059    const completionBody = {
8060      prompt,
8061      timezone: "Asia/Shanghai",
8062      attachments: [],
8063      files: []
8064    };
8065
8066    if (target.lastMessageUuid) {
8067      completionBody.parent_message_uuid = target.lastMessageUuid;
8068    }
8069
8070    const response = await proxyClaudeRequest(
8071      "POST",
8072      `/api/organizations/${target.organizationId}/chat_conversations/${target.conversationId}/completion`,
8073      completionBody,
8074      {
8075        headers: {
8076          accept: "text/event-stream",
8077          "content-type": "application/json"
8078        }
8079      }
8080    );
8081
8082    const parsed = parseClaudeSseText(response.body || "");
8083    updateClaudeState({
8084      organizationId: target.organizationId,
8085      conversationId: target.conversationId,
8086      lastAssistantMessageUuid: parsed.messageUuid || state.claudeState.lastAssistantMessageUuid,
8087      lastSendAt: Date.now(),
8088      lastActivityAt: Date.now(),
8089      lastError: null
8090    }, {
8091      persist: true,
8092      render: true
8093    });
8094
8095    let conversation = null;
8096    try {
8097      conversation = await readClaudeConversation({
8098        organizationId: target.organizationId,
8099        conversationId: target.conversationId
8100      });
8101    } catch (error) {
8102      updateClaudeState({
8103        lastError: error.message
8104      }, {
8105        persist: true,
8106        render: true
8107      });
8108      conversation = buildClaudeStateSnapshot();
8109    }
8110
8111    setClaudeBusy(false, null);
8112
8113    return {
8114      ok: true,
8115      organizationId: target.organizationId,
8116      conversationId: target.conversationId,
8117      response: {
8118        role: "assistant",
8119        content: parsed.text,
8120        thinking: parsed.thinking,
8121        timestamp: Date.now(),
8122        messageUuid: parsed.messageUuid || null,
8123        stopReason: parsed.stopReason || null
8124      },
8125      state: conversation
8126    };
8127  } catch (error) {
8128    updateClaudeState({
8129      lastError: error.message
8130    }, {
8131      persist: true,
8132      render: true
8133    });
8134    setClaudeBusy(false, null);
8135    throw error;
8136  }
8137}
8138
8139async function readClaudeState(options = {}) {
8140  const tab = await refreshClaudeTabState(options.createTab === true);
8141  const conversationId = trimToNull(options.conversationId) || getClaudeConversationIdFromState();
8142  const organizationId = trimToNull(options.organizationId) || getClaudeOrgId();
8143
8144  if (organizationId && conversationId && options.refresh !== false) {
8145    try {
8146      return await readClaudeConversation({
8147        organizationId,
8148        conversationId
8149      });
8150    } catch (error) {
8151      updateClaudeState({
8152        lastError: error.message
8153      }, {
8154        persist: true,
8155        render: true
8156      });
8157    }
8158  }
8159
8160  if (!tab) {
8161    throw new Error("Claude 标签页不存在");
8162  }
8163
8164  return buildClaudeStateSnapshot();
8165}
8166
8167function registerWebRequestListeners() {
8168  browser.webRequest.onBeforeSendHeaders.addListener(
8169    handleBeforeSendHeaders,
8170    { urls: PLATFORM_REQUEST_URL_PATTERNS },
8171    ["requestHeaders"]
8172  );
8173
8174  browser.webRequest.onCompleted.addListener(
8175    handleCompleted,
8176    { urls: PLATFORM_REQUEST_URL_PATTERNS },
8177    ["responseHeaders"]
8178  );
8179
8180  browser.webRequest.onErrorOccurred.addListener(
8181    handleErrorOccurred,
8182    { urls: PLATFORM_REQUEST_URL_PATTERNS }
8183  );
8184}
8185
8186function registerRuntimeListeners() {
8187  browser.runtime.onMessage.addListener((message, sender) => {
8188    if (!message || typeof message !== "object") return undefined;
8189
8190    switch (message.type) {
8191      case "baa_page_bridge_ready":
8192        handlePageBridgeReady(message.data, sender);
8193        break;
8194      case "baa_page_network":
8195        handlePageNetwork(message.data, sender);
8196        break;
8197      case "baa_page_sse":
8198        handlePageSse(message.data, sender);
8199        break;
8200      case "baa_diagnostic_log":
8201        handlePageDiagnosticLog(message.data, sender);
8202        break;
8203      case "baa_page_proxy_response":
8204        handlePageProxyResponse(message.data, sender);
8205        break;
8206      case "control_plane_command":
8207        return runControlPlaneAction(message.action, {
8208          source: message.source || "runtime"
8209        }).then((result) => ({
8210          ok: true,
8211          ...result
8212        })).catch((error) => ({
8213          ok: false,
8214          error: error.message,
8215          snapshot: cloneControlState(state.controlState)
8216        }));
8217      case "page_control_command":
8218        return runPageControlAction(message.action, sender, {
8219          source: message.source || "runtime",
8220          reason: message.reason || "page_overlay"
8221        }).then((result) => ({
8222          ok: true,
8223          ...result
8224        })).catch((error) => ({
8225          ok: false,
8226          error: error.message,
8227          control: cloneControlState(state.controlState),
8228          page: buildPageControlSnapshotForSender(sender, detectPlatformFromUrl(sender?.tab?.url || ""))
8229        }));
8230      case "get_page_control_state":
8231        return Promise.resolve({
8232          ok: true,
8233          control: cloneControlState(state.controlState),
8234          page: buildPageControlSnapshotForSender(sender, detectPlatformFromUrl(sender?.tab?.url || ""))
8235        });
8236      case "get_control_plane_state":
8237        return Promise.resolve({
8238          ok: true,
8239          snapshot: cloneControlState(state.controlState)
8240        });
8241      case "plugin_runtime_action":
8242      case "plugin_action":
8243        return runPluginManagementAction(message.action, {
8244          ...(normalizePluginManagementAction(message.action) === "ws_reconnect"
8245            ? extractWsReconnectActionOverrides(message)
8246            : {}),
8247          platform: message.platform,
8248          source: message.source || "runtime_message",
8249          reason: message.reason || "runtime_plugin_action"
8250        }).then((result) => ({
8251          ok: true,
8252          ...result
8253        })).catch((error) => ({
8254          ok: false,
8255          error: error.message,
8256          snapshot: buildPluginStatusPayload()
8257        }));
8258      case "plugin_status":
8259      case "get_plugin_status":
8260      case "get_plugin_runtime_status":
8261        return runPluginManagementAction("plugin_status", {
8262          source: message.source || "runtime_status"
8263        }).then((result) => ({
8264          ok: true,
8265          ...result
8266        })).catch((error) => ({
8267          ok: false,
8268          error: error.message,
8269          snapshot: buildPluginStatusPayload()
8270        }));
8271      case "claude_send":
8272      case "claude_read_conversation":
8273      case "claude_read_state":
8274        return Promise.resolve({
8275          ok: false,
8276          error: "page_conversation_runtime_deprecated",
8277          message: "Firefox 插件正式能力已收口到空壳页、登录态元数据上报和本地 API 代发;页面对话运行时不再作为正式能力。"
8278        });
8279      default:
8280        break;
8281    }
8282
8283    return undefined;
8284  });
8285}
8286
8287function registerTabListeners() {
8288  browser.tabs.onActivated.addListener(() => {
8289    scheduleTrackedTabRefresh("activation");
8290  });
8291
8292  browser.tabs.onCreated.addListener(() => {
8293    scheduleTrackedTabRefresh("create");
8294  });
8295
8296  browser.tabs.onRemoved.addListener((tabId) => {
8297    removePageControlStatesByTabId(tabId, {
8298      persist: true,
8299      render: true
8300    });
8301    scheduleTrackedTabRefresh("remove");
8302  });
8303
8304  browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
8305    const nextUrl = changeInfo.url || tab?.url || "";
8306    const currentPageControlEntries = listPageControlStates().filter((entry) => entry.tabId === tabId);
8307    let pageControlChanged = false;
8308
8309    for (const entry of currentPageControlEntries) {
8310      if (!isPlatformUrl(entry.platform, nextUrl)) {
8311        pageControlChanged = removePageControlState(entry.platform, tabId, {
8312          persist: false,
8313          render: false
8314        }) || pageControlChanged;
8315        continue;
8316      }
8317
8318      const nextConversationId = extractConversationIdFromPageUrl(entry.platform, nextUrl);
8319      const nextShellPage = isPlatformShellUrl(entry.platform, nextUrl, { allowFallback: true });
8320      const nextTitle = trimToNull(tab?.title);
8321
8322      if (
8323        entry.pageUrl !== trimToNull(nextUrl)
8324        || entry.pageTitle !== nextTitle
8325        || entry.conversationId !== nextConversationId
8326        || entry.shellPage !== nextShellPage
8327      ) {
8328        pageControlChanged = true;
8329      }
8330
8331      syncPageControlFromContext({
8332        conversationId: extractConversationIdFromPageUrl(entry.platform, nextUrl),
8333        isShellPage: isPlatformShellUrl(entry.platform, nextUrl, { allowFallback: true }),
8334        pageTitle: trimToNull(tab?.title),
8335        platform: entry.platform,
8336        senderUrl: nextUrl,
8337        tabId
8338      }, {}, {
8339        persist: false,
8340        render: false
8341      });
8342    }
8343
8344    if (pageControlChanged) {
8345      persistState().catch(() => {});
8346      render();
8347    }
8348
8349    const platform = findTrackedPlatformByTabId(tabId);
8350    const urlPlatform = detectPlatformFromUrl(changeInfo.url || tab?.url || "");
8351    if (!platform && !urlPlatform && changeInfo.status !== "complete") return;
8352
8353    const allowFallbackShell = platform
8354      ? cloneDesiredTabState(platform, state.desiredTabs[platform]).exists
8355      : false;
8356
8357    if (platform && changeInfo.url && !isPlatformShellUrl(platform, changeInfo.url, { allowFallback: allowFallbackShell })) {
8358      state.trackedTabs[platform] = null;
8359      persistState().catch(() => {});
8360      render();
8361      addLog("warn", `${platformLabel(platform)} 空壳页 ${tabId} 已偏离壳页 URL,等待重新收口`);
8362    }
8363
8364    if (platform && changeInfo.status === "complete" && tab?.url && isPlatformShellUrl(platform, tab.url, { allowFallback: allowFallbackShell })) {
8365      addLog("info", `${platformLabel(platform)} shell ready ${tabId}`);
8366    }
8367
8368    if (platform || urlPlatform || changeInfo.status === "complete") {
8369      scheduleTrackedTabRefresh("update");
8370    }
8371  });
8372}
8373
8374function bindUi() {
8375  ui.wsStatus = qs("ws-status");
8376  ui.wsMeta = qs("ws-meta");
8377  ui.wsView = qs("ws-view");
8378  ui.controlMode = qs("control-mode");
8379  ui.controlMeta = qs("control-meta");
8380  ui.controlView = qs("control-view");
8381  ui.trackedCount = qs("tracked-count");
8382  ui.trackedMeta = qs("tracked-meta");
8383  ui.accountCount = qs("account-count");
8384  ui.accountMeta = qs("account-meta");
8385  ui.credentialCount = qs("credential-count");
8386  ui.credentialMeta = qs("credential-meta");
8387  ui.endpointCount = qs("endpoint-count");
8388  ui.endpointMeta = qs("endpoint-meta");
8389  ui.platformView = qs("platform-view");
8390  ui.accountView = qs("account-view");
8391  ui.credentialView = qs("credential-view");
8392  ui.endpointView = qs("endpoint-view");
8393
8394  for (const action of ["pause", "resume", "drain"]) {
8395    qs(`${action}-btn`).addEventListener("click", () => {
8396      runControlPlaneAction(action, { source: "manual" }).catch((error) => {
8397        addLog("error", `控制动作 ${action} 失败:${error.message}`);
8398      });
8399    });
8400  }
8401}
8402
8403async function init() {
8404  bindUi();
8405
8406  const saved = await browser.storage.local.get([
8407    ...Object.values(CONTROLLER_STORAGE_KEYS),
8408    ...Object.values(LEGACY_STORAGE_KEYS)
8409  ]);
8410  const savedSchemaVersion = Number(saved[CONTROLLER_STORAGE_KEYS.statusSchemaVersion]) || 0;
8411  const needsStatusReset = savedSchemaVersion < STATUS_SCHEMA_VERSION;
8412
8413  state.clientId = saved[CONTROLLER_STORAGE_KEYS.clientId] || genClientId();
8414  state.wsUrl = normalizeSavedWsUrl(saved[CONTROLLER_STORAGE_KEYS.wsUrl]);
8415  state.controlBaseUrl = normalizeSavedControlBaseUrl(saved[CONTROLLER_STORAGE_KEYS.controlBaseUrl]);
8416  state.controlState = loadControlState(saved[CONTROLLER_STORAGE_KEYS.controlState]);
8417  state.pageControls = loadPageControls(saved[CONTROLLER_STORAGE_KEYS.pageControls]);
8418
8419  state.trackedTabs = loadTrackedTabs(
8420    saved[CONTROLLER_STORAGE_KEYS.trackedTabs],
8421    saved[LEGACY_STORAGE_KEYS.claudeTabId]
8422  );
8423  state.desiredTabs = loadDesiredTabs(
8424    saved[CONTROLLER_STORAGE_KEYS.desiredTabs],
8425    state.trackedTabs
8426  );
8427  state.endpoints = loadEndpointEntries(
8428    saved[CONTROLLER_STORAGE_KEYS.endpointsByPlatform],
8429    saved[LEGACY_STORAGE_KEYS.endpoints]
8430  );
8431  state.lastHeaders = loadObjectMap(
8432    saved[CONTROLLER_STORAGE_KEYS.lastHeadersByPlatform],
8433    saved[LEGACY_STORAGE_KEYS.lastHeaders]
8434  );
8435  state.credentialCapturedAt = loadNumberMap(
8436    saved[CONTROLLER_STORAGE_KEYS.credentialCapturedAtByPlatform]
8437  );
8438  state.lastCredentialAt = loadNumberMap(
8439    saved[CONTROLLER_STORAGE_KEYS.lastCredentialAtByPlatform],
8440    saved[LEGACY_STORAGE_KEYS.lastCredentialAt]
8441  );
8442  state.lastCredentialUrl = loadStringMap(
8443    saved[CONTROLLER_STORAGE_KEYS.lastCredentialUrlByPlatform]
8444  );
8445  state.lastCredentialTabId = loadTabIdMap(
8446    saved[CONTROLLER_STORAGE_KEYS.lastCredentialTabIdByPlatform]
8447  );
8448  state.credentialFingerprint = loadStringMap(
8449    saved[CONTROLLER_STORAGE_KEYS.credentialFingerprintByPlatform]
8450  );
8451  state.account = loadAccountMap(
8452    saved[CONTROLLER_STORAGE_KEYS.accountByPlatform]
8453  );
8454  state.chatgptSendTemplates = loadChatgptSendTemplates(
8455    saved[CONTROLLER_STORAGE_KEYS.chatgptSendTemplates]
8456  );
8457  state.geminiSendTemplates = loadGeminiSendTemplates(
8458    saved[CONTROLLER_STORAGE_KEYS.geminiSendTemplates],
8459    saved[CONTROLLER_STORAGE_KEYS.geminiSendTemplate]
8460  );
8461  restoreFinalMessageRelayCache(saved[CONTROLLER_STORAGE_KEYS.finalMessageRelayCache]);
8462  state.claudeState = loadClaudeState(saved[CONTROLLER_STORAGE_KEYS.claudeState]);
8463  state.controllerRuntime = loadControllerRuntimeState(saved[CONTROLLER_STORAGE_KEYS.controllerRuntime]);
8464  const previousExtensionOrigin = trimToNull(state.controllerRuntime.extensionOrigin);
8465  const startupOpenAiTabReloadPlan = await resolveStartupOpenAiTabReloadPlan(previousExtensionOrigin);
8466  if (needsStatusReset) {
8467    state.lastHeaders = createPlatformMap(() => ({}));
8468    state.credentialCapturedAt = createPlatformMap(() => 0);
8469    state.lastCredentialAt = createPlatformMap(() => 0);
8470    state.lastCredentialUrl = createPlatformMap(() => "");
8471    state.lastCredentialTabId = createPlatformMap(() => null);
8472    state.credentialFingerprint = createPlatformMap(() => "");
8473    state.account = createPlatformMap(() => createDefaultAccountState());
8474  }
8475  state.lastCredentialHash = createPlatformMap((platform) => JSON.stringify(buildCredentialTransportSnapshot(platform)));
8476  state.wsState = createDefaultWsState({
8477    connection: "connecting",
8478    wsUrl: state.wsUrl,
8479    localApiBase: DEFAULT_LOCAL_API_BASE,
8480    clientId: state.clientId
8481  });
8482
8483  registerRuntimeListeners();
8484  registerTabListeners();
8485  registerWebRequestListeners();
8486
8487  let current = null;
8488
8489  try {
8490    current = await browser.tabs.getCurrent();
8491  } catch (error) {
8492    addLog(
8493      "warn",
8494      `控制页无法读取当前标签页:${error instanceof Error ? error.message : String(error)}`,
8495      false
8496    );
8497  }
8498
8499  setControllerRuntimeState({
8500    tabId: current?.id ?? null,
8501    ready: true,
8502    status: "ready",
8503    lastReadyAt: Date.now(),
8504    extensionOrigin: startupOpenAiTabReloadPlan.currentExtensionOrigin
8505  });
8506
8507  if (Number.isInteger(current?.id)) {
8508    try {
8509      await browser.runtime.sendMessage({ type: "controller_ready", tabId: current.id });
8510    } catch (error) {
8511      addLog(
8512        "warn",
8513        `控制页握手失败,但将继续启动:${error instanceof Error ? error.message : String(error)}`,
8514        false
8515      );
8516    }
8517  } else {
8518    addLog("warn", "控制页未拿到当前 tabId,跳过 controller_ready 握手", false);
8519  }
8520
8521  await reinjectAllOpenPlatformTabs({
8522    source: "startup"
8523  });
8524  await refreshTrackedTabsFromBrowser("startup");
8525  await collapseRecoveredDesiredTabs();
8526  await restoreDesiredTabsOnStartup();
8527  await refreshClaudeTabState(false);
8528  await persistState();
8529  render();
8530  addLog("info", `controller ready ${state.clientId}`, false);
8531  if (needsStatusReset) {
8532    addLog("info", "已清理旧版平台状态缓存,等待新的真实请求重新建立凭证", false);
8533  }
8534
8535  connectWs({ silentWhenDisabled: true });
8536  restartShellRuntimeHealthTimer(SHELL_RUNTIME_HEALTHCHECK_INTERVAL);
8537  await prepareStartupControlState();
8538  refreshControlPlaneState({ source: "startup", silent: true }).catch(() => {});
8539  scheduleStartupOpenAiTabReload(startupOpenAiTabReloadPlan);
8540}
8541
8542window.addEventListener("beforeunload", () => {
8543  clearTimeout(state.controlRefreshTimer);
8544  clearTimeout(state.trackedTabRefreshTimer);
8545  clearTimeout(state.shellRuntimeTimer);
8546  clearTimeout(state.startupOpenAiTabReloadTimer);
8547  closeWsConnection();
8548});
8549
8550function exposeControllerTestApi() {
8551  const target = globalThis.__BAA_CONTROLLER_TEST_API__;
8552  if (!target || typeof target !== "object") {
8553    return;
8554  }
8555
8556  Object.assign(target, {
8557    buildGeminiAutoRequest,
8558    buildGeminiDeliveryRequest,
8559    buildChatgptDeliveryRequest,
8560    buildPageControlSnapshotForSender,
8561    connectWs,
8562    createPluginDiagnosticPayload,
8563    flushBufferedPluginDiagnosticLogs,
8564    getChatgptSendTemplate,
8565    getGeminiSendTemplate,
8566    getSenderContext,
8567    handlePageBridgeReady,
8568    handlePageDiagnosticLog,
8569    handlePageNetwork,
8570    handlePageSse,
8571    handleWsStateSnapshot,
8572    loadChatgptSendTemplates,
8573    loadGeminiSendTemplates,
8574    persistFinalMessageRelayCache,
8575    rememberChatgptSendTemplate,
8576    rememberGeminiSendTemplate,
8577    reinjectAllOpenPlatformTabs,
8578    reinjectPlatformTabs,
8579    restoreFinalMessageRelayCache,
8580    runDeliveryAction,
8581    runProxyDeliveryAction,
8582    runPageControlAction,
8583    runPluginManagementAction,
8584    sendPluginDiagnosticLog,
8585    serializeChatgptSendTemplates,
8586    serializeGeminiSendTemplates,
8587    serializeFinalMessageRelayCache,
8588    setDesiredTabState,
8589    syncPageControlFromContext,
8590    state
8591  });
8592}
8593
8594exposeControllerTestApi();
8595
8596if (globalThis.__BAA_SKIP_CONTROLLER_INIT__ !== true) {
8597  init().catch((error) => {
8598    console.error(error);
8599    addLog("error", error.message, false);
8600  });
8601}