(function () {
  if (window.__CHATGPT_ASA_BRIDGE_LOADED__) {
    return;
  }
  window.__CHATGPT_ASA_BRIDGE_LOADED__ = true;

  // PL-134: 会話 JSON → ASA 発言形の変換は conversation_parse.js に切り出し、
  // 取込アドオン (ローカルページ) と共有する。manifest で本 file より先に読む。
  const ASA_PARSE = globalThis.ASA_CHATGPT_PARSE;

  // ===== PL-078: AI生成画像の取得 + 別チャネル送出 =====
  // ChatGPT の AI 生成画像は会話 JSON 上 asset_pointer (sediment://file_XXXX) でしか
  // 参照されず、 実体 URL は描画済 <img src> にのみ存在 (= 署名付き短命 URL、 本体側
  // からは認証不能)。 拡張なら user の session cookie で fetch できる。 spike で確定:
  //   - DOM `img[src*="file_XXXX"]` (アンダースコア形) で実体 src が取れる
  //   - その src を fetch(credentials:'include') で 200/image/png バイト取得可
  //
  // 【別チャネル】data_uri は巨大 (1 枚 数MB)。 本体スナップショットに同梱すると
  // content→background→ASA の経路 (chrome message + ASA HTTP 受信) が肥大で失敗する
  // (ASA は 16MB/req、 7 枚 ~30MB の一括は不可)。 そこで snapshot には軽い img_ref
  // (= fileId) のみ載せ、 画像バイトは 1 枚 1 message で background へ送る
  // (background が 1 枚 1 HTTP POST で ASA へ)。 ASA 側は img_ref で snapshot に合流。
  // 取得失敗 (DOM 不在=仮想スクロール上方 / fetch 失敗) は img_ref のみ残り、
  // ASA renderer が [画像: title] 印に degrade (graceful)。
  const _PL078_IMG_CACHE = new Map();   // fileId -> dataURL (tick 跨ぎ refetch 回避)
  const _PL078_SENT = new Set();        // background へ送出済 fileId (毎 tick 再送防止)
  const _PL078_CACHE_MAX = 60;          // cache 件数上限 (memory backstop)
  const _PL078_FETCH_MAX = 20;          // 1 enrich あたり fetch 上限 (暴走防止)
  const _PL078_BYTES_MAX = 8 * 1024 * 1024;  // 1 枚上限 (asa_core sidecar 倉庫と同値)

  function _pl078ResolveDomSrc(fileId) {
    if (!fileId) return '';
    try {
      // PL-119: live (/c/) の生成画像は src に file_<hex> をそのまま含むが、 共有ページ
      // (/share/) は同 id を UUID ハイフン形 (.../files/00000000-d770-..-..-..224c58db/raw)
      // で持つ。 hex 裸文字列で両形を拾うため、 まず hex 部分一致 (live 形を高速 querySelector)、
      // 外したら全 img を hyphen 除去して照合 (共有 UUID 形) する。
      const hex = String(fileId).replace(/^file[_-]/i, '').toLowerCase();
      if (!hex) return '';
      const imgs = document.querySelectorAll('img[src*="' + hex + '"]');
      if (imgs.length > 0 && imgs[0].src) return imgs[0].src;
      const all = document.querySelectorAll('img');
      for (const im of all) {
        const s = im.currentSrc || im.src || '';
        if (s && s.replace(/-/g, '').toLowerCase().indexOf(hex) >= 0) return s;
      }
    } catch (_e) {}
    return '';
  }

  // PL-101: group chat 他人添付画像の img_ref キー生成。 生成画像 (asset_pointer →
  // file_xxxx) と違い group の estuary URL は `/enc/<base64url>` 形で file id が URL
  // 文字列に現れない (= _pl078ResolveDomSrc の `img[src*=fileId]` で逆引き不可)。
  // よって block に src を直保持し、 enrich は src 優先で fetch する。 キーは別送画像
  // との相関 + dedup 用で一意かつ決定的なら何でもよい: enc base64 を decode して内部
  // file id を採れれば採用 (人間可読)、 不可なら enc token 先頭を採用。
  function _pl101GroupImageKey(src) {
    const s = String(src || '');
    try {
      const m = s.match(/\/enc\/([A-Za-z0-9_-]+)/);
      if (m) {
        try {
          const b64 = m[1].replace(/-/g, '+').replace(/_/g, '/');
          const json = atob(b64);
          const fm = json.match(/file[_-][A-Za-z0-9]+/);
          if (fm) return 'gimg_' + fm[0];
        } catch (_e) {}
        return 'gimg_' + m[1].slice(0, 40);
      }
      const fm2 = s.match(/file[_-][A-Za-z0-9]+/);
      if (fm2) return 'gimg_' + fm2[0];
    } catch (_e) {}
    return '';
  }

  // PL-098: 画像 src は DOM の <img> が描画されて初めて解決できる。 ページ切替直後に
  // 画像描画完了前へ保存すると querySelector 0 ヒット = 全画像が src-empty で印 degrade
  // していた (実機 11 枚 fetched=0 確認)。 描画を bounded で待ってから degrade 判定する。
  // 全 key 共有の wall-clock deadline で待つので 11 枚並列でも総待ちは上限内。
  const _PL098_SRC_WAIT_MS = 2500;   // 1 enrich 内の src 出現待ち上限 (全 key 共有)
  const _PL098_SRC_POLL_MS = 150;
  function _pl098Sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
  async function _pl098ResolveDomSrcWaiting(fileId, deadline) {
    let src = _pl078ResolveDomSrc(fileId);
    while (!src && Date.now() < deadline) {
      await _pl098Sleep(_PL098_SRC_POLL_MS);
      src = _pl078ResolveDomSrc(fileId);
    }
    return src;
  }

  async function _pl078FetchDataUrl(src) {
    async function _doFetch(credMode) {
      const ctrl = (typeof AbortController !== 'undefined') ? new AbortController() : null;
      const timer = ctrl ? setTimeout(function () { try { ctrl.abort(); } catch (_e) {} }, 15000) : null;
      try {
        return await fetch(src, {
          credentials: credMode, headers: { Accept: 'image/*' },
          signal: ctrl ? ctrl.signal : undefined,
        });
      } finally {
        if (timer) clearTimeout(timer);
      }
    }
    // PL-119: 共有ページの生成画像は oaiusercontent の SAS 署名 URL (ACAO: *) で配信され、
    // credentials:'include' だと「ACAO=* と credentials 同梱は両立不可」で CORS 弾きされる
    // (SAS が自己認証なので cookie 不要)。 include 優先 (認証必須な chatgpt.com 画像を維持)
    // → 失敗時 omit で再試行 (公開 SAS は omit + ACAO* で権限なしでも合法 CORS で取れる)。
    let resp;
    try {
      resp = await _doFetch('include');
    } catch (_e) {
      resp = await _doFetch('omit');
    }
    if (!resp.ok) throw new Error('http ' + resp.status);
    const blob = await resp.blob();
    if (blob.size > _PL078_BYTES_MAX) throw new Error('too large ' + blob.size);
    return await new Promise(function (resolve, reject) {
      const fr = new FileReader();
      fr.onloadend = function () { resolve(fr.result); };
      fr.onerror = reject;
      fr.readAsDataURL(blob);
    });
  }

  function _pl078SendImage(fileId, dataUrl) {
    // 画像 1 枚を background へ (background が ASA へ 1 HTTP POST)。 send 失敗は印 degrade。
    // Promise を返し、 caller (enrich) が await することで「画像が ASA に届いてから
    // snapshot/widget action を送る」順序を保証する (通常保存 1 発の競合回避)。
    return new Promise(function (resolve) {
      try {
        chrome.runtime.sendMessage({
          type: 'asa_image', key: fileId, data_uri: dataUrl,
          conv_id: getCurrentConversationId(),
        }, function () { void chrome.runtime.lastError; resolve(); });
      } catch (_e) { resolve(); }
    });
  }

  // capture/realtime 開始時に呼ぶ: 送出済フラグを解除し、 新規 spawn された ASA にも
  // 既取得画像が再度届くようにする (ASA は毎回別プロセスで _images 空から始まる)。
  function _pl078ResetSent() { _PL078_SENT.clear(); }

  // snapshot の image_blocks (img_ref 有) について、 画像バイトを DOM src 経由で取得し
  // background へ別送する。 snapshot 自体には data_uri を載せない (肥大回避)。
  async function enrichImageBlocks(messages) {
    if (!Array.isArray(messages)) return 0;
    try {
      // PL-097: 旧実装は画像 1 枚ずつ await fetch→await send で直列。 11 枚で
      // enrich=5.6s となり click→snapshot 送出までブラウザ側が固まっていた
      // (取得中ダイアログも出ない)。 各画像は独立 (DOM src 解決→fetch→cache→送出)
      // なので並列化する。 「送出前に全画像が ASA に届く」 invariant は
      // await Promise.all で維持 → ASA 受信経路 (snapshot_acquire の 1 回 drain 前提)
      // は不変、 画像合流タイミングのモグラ叩きリスクなし。

      // Phase 1: 取得対象の img_ref を順序保持で重複排除 (同期 walk)。
      // PL-101: block.src が非空 (= group chat 他人添付画像の estuary URL) のときは
      // key→src を直保持。 生成画像は src='' で従来通り DOM 逆引き (= directSrc 不在)。
      const seen = new Set();
      const keys = [];
      const directSrc = new Map();
      for (const msg of messages) {
        const blocks = msg && Array.isArray(msg.image_blocks) ? msg.image_blocks : null;
        if (!blocks) continue;
        for (const ib of blocks) {
          if (!ib) continue;
          const key = ib.img_ref;
          if (!key || _PL078_SENT.has(key) || seen.has(key)) continue;
          seen.add(key);
          keys.push(key);
          if (ib.src && !directSrc.has(key)) directSrc.set(key, ib.src);
        }
      }
      if (keys.length === 0) return 0;

      // Phase 2: fetch + send を並列実行。 各 task は最初の await まで同期実行されるため
      // fetchBudget 減算 (実 fetch=cache miss のみに適用) は race なく順に確定する。
      let fetchBudget = _PL078_FETCH_MAX;
      let fetched = 0;
      const _srcDeadline = Date.now() + _PL098_SRC_WAIT_MS;  // PL-098: 描画待ち共有 deadline
      const tasks = keys.map(async (key) => {
        let dataUrl = _PL078_IMG_CACHE.get(key) || '';
        if (!dataUrl) {
          if (fetchBudget <= 0) { return; }    // 取得上限超過 → 印 degrade (件数はダイアログ X/Y に反映)
          fetchBudget--;
          // PL-101: group 画像は block 同梱 src を直使用 (enc URL は DOM 逆引き不可)。
          // 生成画像は directSrc 不在 → PL-098: 描画を bounded 待ちして DOM src 解決。
          const src = directSrc.get(key) || await _pl098ResolveDomSrcWaiting(key, _srcDeadline);
          if (!src) { return; }              // 上限内に未描画 (仮想スクロール上方等) → 印 degrade
          try {
            dataUrl = String((await _pl078FetchDataUrl(src)) || '');
          } catch (_e) { return; }  // 取得失敗 → cache/sent せず次 tick 再試行
          fetched++;
          if (dataUrl) {
            if (_PL078_IMG_CACHE.size >= _PL078_CACHE_MAX) {
              const oldest = _PL078_IMG_CACHE.keys().next().value;
              if (oldest !== undefined) _PL078_IMG_CACHE.delete(oldest);
            }
            _PL078_IMG_CACHE.set(key, dataUrl);
          }
        }
        if (dataUrl) {
          try {
            _PL078_SENT.add(key); await _pl078SendImage(key, dataUrl);
          } catch (_se) {
            _PL078_SENT.delete(key);
          }
        }
      });
      await Promise.all(tasks);
      return fetched;  // 実 fetch 枚数 (= click 同期で取得した画像数)
    } catch (_e) {
      // enrich 失敗は致命でない (画像が印に落ちるだけ)。 caller の buffer 更新を止めない。
      return 0;
    }
  }
  // ===== /PL-078 =====

  const STATUS_INTERVAL_MS = 5000;
  const HEARTBEAT_INTERVAL_MS = 1000;
  const WIDGET_HOST_ID = "asa-widget-host";
  const WIDGET_LEGACY_POS_KEY = "asa_widget_pos";
  const WIDGET_SIDE_KEY = "asa_widget_side";
  const WIDGET_TOPMOST_KEY = "asa_widget_dialog_topmost";
  const WIDGET_PROMPT_SELECTORS = [
    '[data-testid="prompt-textarea"]',
    '#prompt-textarea',
    'form textarea',
    'form [contenteditable="true"]',
  ];
  const WIDGET_ANCHOR_RETRY_MS = 1000;
  const state = {
    lastUrl: location.href,
    lastStatusKey: "",
    realtimeBuffer: [],
    realtimeBufferCid: "",
    realtimeCapturing: false,
    realtimeRefreshTimer: null,
    realtimeLastTurnCount: 0,
    realtimeConvSwitchAt: 0,
    // PL-045 (codex review 反映): single-flight + per-DOM-count retry guard + explicit
    // advancement check で 「stale response を fresh 誤判定して turn count 進行で
    // 同 turn 再試行ロック」 を回避。 末尾 role だけでなく msgCount + tailMsgId
    // 変化で advancement 判定、 stale 検知時は turn count 据置 + cooldown 経由 retry。
    pendingTurnCount: 0,
    pendingSince: 0,
    replayInFlight: false,
    lastReplayAttemptTurnCount: 0,
    lastReplayAttemptAt: 0,
    lastBufferMsgCount: 0,
    lastBufferTailMsgId: '',
    widgetRoot: null,
    widgetHost: null,
    widgetAnchor: null,
    widgetObserver: null,
    widgetSentinelInterval: null,
    widgetLang: "ja",
    // PL-063: ProseMirror fallback (group chat) 用 tracked position state。
    // form anchor 経路 (= 通常 chat) は使用しない。
    widgetAnchorResizeObs: null,
    widgetHostResizeObs: null,
    widgetTrackedScrollListener: null,
    widgetTrackedResizeListener: null,
    widgetRafPending: false,
  };

  try { localStorage.removeItem(WIDGET_LEGACY_POS_KEY); } catch (_e) { void 0; }

  // PL-013 CP3: tooltip 文言は旧 OK prompt 文言を流用 (translations.py の
  // settings.tooltip / realtime.tooltip / 旧 ok.save)。
  // PL-058: scroll_snap tooltip を撤去、 toggle_topmost tooltip を追加。
  const WIDGET_I18N = {
    ja: { save: "会話を保存", settings: "環境設定", realtime: "キャプチャモード", toggle_topmost: "ダイアログを最前面に固定", attached_file: "添付ファイル" },
    en: { save: "Save conversation", settings: "Preferences", realtime: "Capture mode", toggle_topmost: "Pin dialogs to front", attached_file: "Attached File" },
  };

  const citationPlusRe = /^\+\d+\s*$/gm;

  function stripCitationNoise(text) {
    if (!text) {
      return "";
    }
    return String(text).replace(citationPlusRe, "").replace(/\n{3,}/g, "\n\n").trim();
  }

  function challengePresent() {
    try {
      return Boolean(
        document.querySelector('iframe[src*="challenge"], iframe[src*="turnstile"], iframe[id*="challenge"], iframe[id*="turnstile"]') ||
        document.querySelector('input[name="cf-turnstile-response"], input[type="checkbox"][name="cf-turnstile-response"]') ||
        document.querySelector('form[action*="challenge"], [data-testid*="captcha"], [id*="turnstile"], [class*="turnstile"]')
      );
    } catch (_error) {
      return false;
    }
  }

  function currentPageState() {
    const href = String(location.href || "");
    if (!/https:\/\/chatgpt\.com\//.test(href)) {
      return "unsupported_page";
    }
    if (challengePresent()) {
      return "human_check";
    }
    if (hasConversationContent()) {
      return "ready";
    }
    const loginForm = document.querySelector('form button[type="submit"], input[type="email"], input[type="password"]');
    if (loginForm) {
      return "login_required";
    }
    return "fetching";
  }

  // PL-043: ChatGPT 一時 chat 検知。 ?temporary-chat=true URL query で判別、
  // python `_is_temporary_chat_url` と同 logic。 server persist 0 = API endpoint
  // 取得不能なので realtime path は DOM scrape mode に分岐する。
  function isTemporaryChat() {
    try {
      const params = new URLSearchParams(location.search || "");
      const values = params.getAll("temporary-chat").map((v) => String(v || "").trim().toLowerCase());
      return values.some((v) => v === "1" || v === "true" || v === "yes" || v === "on");
    } catch (_e) {
      return false;
    }
  }

  function collectStatusDetails() {
    try {
      const messageNodeCount = document.querySelectorAll('[data-message-author-role], [data-testid^="conversation-turn"], [class*="user-message-bubble-color"]').length;
      const loginFormPresent = Boolean(document.querySelector('form button[type="submit"], input[type="email"], input[type="password"]'));
      const challengePresentNow = challengePresent();
      const mainCount = document.querySelectorAll('main').length;
      const articleCount = document.querySelectorAll('article').length;
      return {
        observerVersion: "2026-03-09-status-v1",
        messageNodeCount,
        loginFormPresent,
        challengePresent: challengePresentNow,
        mainCount,
        articleCount,
        documentTitle: document.title || "",
      };
    } catch (_error) {
      return {};
    }
  }

  // PL-007: <table> を innerText に渡す前に markdown pipe テキストへ置換。
  // DOM 経路で innerText が table を改行連結のみで返すため、python-markdown の
  // tables 拡張がヒットせず保存 HTML で <p> 列にフラット化する問題への対処。
  // markdown spec 通り pad なしで出力 (txt 整形は Python `render_text` が担当)。
  function tableToMarkdown(table) {
    try {
      const rowsRaw = [];
      function pushRow(tr) {
        if (!tr) return;
        const cells = [];
        let hasTh = false;
        for (const c of tr.children) {
          const tag = (c.localName || '').toLowerCase();
          if (tag !== 'td' && tag !== 'th') continue;
          if (tag === 'th') hasTh = true;
          let cellText = (c.innerText || c.textContent || '').replace(/\|/g, '\\|').replace(/\s+/g, ' ').trim();
          cells.push(cellText);
        }
        if (cells.length) rowsRaw.push({ cells, hasTh });
      }
      function walkRows(parent) {
        if (!parent) return;
        for (const child of parent.children) {
          const tag = (child.localName || '').toLowerCase();
          if (tag === 'tr') pushRow(child);
          else if (tag === 'thead' || tag === 'tbody' || tag === 'tfoot') walkRows(child);
        }
      }
      walkRows(table);
      if (!rowsRaw.length) return '';
      const colCount = rowsRaw.reduce((m, r) => Math.max(m, r.cells.length), 0);
      if (colCount === 0) return '';
      for (const r of rowsRaw) {
        while (r.cells.length < colCount) r.cells.push('');
      }
      const lines = [];
      const firstIsHeader = rowsRaw[0].hasTh;
      if (firstIsHeader) {
        lines.push('| ' + rowsRaw[0].cells.join(' | ') + ' |');
        lines.push('| ' + new Array(colCount).fill('---').join(' | ') + ' |');
        for (let i = 1; i < rowsRaw.length; i++) {
          lines.push('| ' + rowsRaw[i].cells.join(' | ') + ' |');
        }
      } else {
        lines.push('| ' + new Array(colCount).fill(' ').join(' | ') + ' |');
        lines.push('| ' + new Array(colCount).fill('---').join(' | ') + ' |');
        for (const r of rowsRaw) {
          lines.push('| ' + r.cells.join(' | ') + ' |');
        }
      }
      return '\n\n' + lines.join('\n') + '\n\n';
    } catch (_e) {
      return '';
    }
  }

  function readMessageText(element) {
    try {
      const clone = element.cloneNode(true);
      // PL-013: citation/footnote container 自体を `textContent = ''` で空に
      // するため、配下 link も同時に消える。旧 `a[href^="http"]` 単独 strip は
      // citation 外の通常 link (URL-only user msg / 本文中 URL) まで巻き込み、
      // 該当 turn の text 全消失 → filter drop の root cause だった。
      clone.querySelectorAll('[data-footnotes], cite, [class*="citation"], [class*="footnote"]').forEach((node) => {
        node.textContent = "";
      });
      clone.querySelectorAll('table').forEach((table) => {
        const md = tableToMarkdown(table);
        if (md) {
          table.replaceWith(document.createTextNode(md));
        }
      });
      return stripCitationNoise(clone.innerText || "");
    } catch (_error) {
      try {
        return stripCitationNoise(element.innerText || element.textContent || "");
      } catch (_error2) {
        return "";
      }
    }
  }

  // PL-035: ChatGPT の <pre> は header (Python ラベル / コピー / 実行する) と
  // CodeMirror viewer (<span>+<br> で構成) を含む。innerText 経由では改行が
  // flatten されるため、inner <code> を辿って <br>→\n で body 化、本文中は
  // marker で置換、本体は code_blocks 配列に積む。md は marker→平文戻し、
  // html のみ fenced code block 化（PL-016 と同じ contract）。
  // PL-035 Step 2: 外側 <pre> 到達時 (alreadyWrapped 経路) に header の
  // div.text-token-text-primary から言語ラベル ("Python"/"JavaScript" 等) を
  // 抽出して code_blocks[idx].lang に格納。renderer (PL-036) が
  // <pre><code class="language-X"> として描画する。3 版 contract 整合
  // (Claude PL-011 / Gemini PL-016 Step 2 と同形)。
  const PL035_UI_SKIP = new Set(['button', 'svg', 'use', 'script', 'style']);

  function deepText(node) {
    const codeBlocks = [];
    function innerCodeText(codeEl) {
      let out = '';
      function walk(n) {
        if (!n) return;
        if (n.nodeType === Node.TEXT_NODE) { out += n.nodeValue || ''; return; }
        const tn = (n.localName || '').toLowerCase();
        if (tn === 'br') { out += '\n'; return; }
        if (PL035_UI_SKIP.has(tn)) return;
        for (const c of n.childNodes) walk(c);
      }
      walk(codeEl);
      return out;
    }
    function collect(n) {
      if (!n) return '';
      if (n.nodeType === Node.TEXT_NODE) { return n.nodeValue || ''; }
      const tn = (n.localName || '').toLowerCase();
      if (PL035_UI_SKIP.has(tn)) return '';
      if (n.getAttribute && n.getAttribute('role') === 'button') return '';
      // PL-038 Phase 3: ChatGPT 折りたたみ UI toggle (`<span class="A_HxFq_showMoreLabel">`
      // / `A_HxFq_showLessLabel`) は button 非 button tag + role 不在で従来 skip
      // すり抜けて user msg 末尾に「表示を増やす表示を減らす」 text leak していた。
      // hashed prefix (A_HxFq) は CSS module rebuild で変動するため substring match
      // で吸収、 subtree skip。 主経路は API hook (mapping から messages parse) で
      // UI noise 不在だが、 API fail → DOM fallback 経路用の防御として残置。
      try {
        const skipCls = String((n && n.className) || '');
        if (typeof skipCls === 'string' && (skipCls.indexOf('showMoreLabel') !== -1 || skipCls.indexOf('showLessLabel') !== -1)) {
          return '';
        }
      } catch (_e) {}
      if (tn === 'br') return '\n';
      // PL-037: file-tile を `[添付ファイル: <name>]` placeholder に置換。
      // 外側 div.group/file-tile は button[aria-label="<filename>"] と sibling 可視
      // text node (file 名 + type label) を併載する構造で、現状の UI_SKIP(button)
      // は button 配下しか落とさず file 名 raw text が markdown 構文と衝突する
      // (`# AIの食卓.txt` → `<h1>` 化)。Gemini PL-017 / Claude PL-012 と同形の
      // placeholder 注入で 3 版 contract を整合。
      try {
        const cls = String((n && n.className) || '');
        if (cls.split(/\s+/).indexOf('group/file-tile') !== -1) {
          const btn = n.querySelector && n.querySelector('button[aria-label]');
          const raw = btn ? String(btn.getAttribute('aria-label') || '').trim() : '';
          if (raw) return '\n[添付ファイル: ' + raw + ']\n';
          const fallback = String(n.innerText || '').trim().split(/\n/)[0];
          if (fallback) return '\n[添付ファイル: ' + fallback + ']\n';
          return '';
        }
      } catch (_e) {}
      if (tn === 'table') {
        const md = tableToMarkdown(n);
        if (md) return md;
      }
      let s = '';
      for (const c of n.childNodes) { s += collect(c); }
      const alreadyWrapped = s.indexOf('[[ASA_CODE_BLOCK_') !== -1;
      if (tn === 'pre') {
        if (alreadyWrapped) {
          // 外側 <pre>: 内側 <pre class="cm-content"> が既に code_blocks 化済 →
          // header / button 由来の noise を排除し marker のみ通す。
          // PL-035 Step 2: header の div.text-token-text-primary から言語ラベル
          // を抽出して 1 件目 marker idx の code_blocks[].lang に注入する。
          const markers = s.match(/\[\[ASA_CODE_BLOCK_\d+\]\]/g) || [];
          if (markers.length) {
            const langDiv = n.querySelector('div.text-token-text-primary');
            const lang = langDiv ? String(langDiv.textContent || '').trim().toLowerCase() : '';
            if (lang) {
              const m = markers[0].match(/\[\[ASA_CODE_BLOCK_(\d+)\]\]/);
              if (m) {
                const idx = parseInt(m[1], 10);
                if (codeBlocks[idx]) codeBlocks[idx].lang = lang;
              }
            }
            return '\n' + markers.join('\n') + '\n';
          }
          return '';
        }
        const codeEl = n.querySelector('code');
        const body = codeEl ? innerCodeText(codeEl) : s.replace(/^\n+|\n+$/g, '');
        const idx = codeBlocks.length;
        codeBlocks.push({ body: body.replace(/^\n+|\n+$/g, ''), lang: '' });
        return '\n[[ASA_CODE_BLOCK_' + idx + ']]\n';
      }
      if (['p', 'div', 'section', 'blockquote', 'tr', 'td', 'th'].includes(tn)) {
        return '\n' + s + '\n';
      }
      if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tn)) {
        return '\n\n' + s.trim() + '\n\n';
      }
      if (tn === 'li') { return '\n- ' + s.trim(); }
      return s;
    }
    const raw = collect(node);
    const text = String(raw).replace(/\r/g, '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
    return { text: stripCitationNoise(text), code_blocks: codeBlocks };
  }

  function readMessageDeep(element) {
    try {
      const clone = element.cloneNode(true);
      // PL-013: 同上 (readMessageText 参照)
      clone.querySelectorAll('[data-footnotes], cite, [class*="citation"], [class*="footnote"]').forEach((node) => { node.textContent = ''; });
      return deepText(clone);
    } catch (_error) {
      return { text: readMessageText(element), code_blocks: [] };
    }
  }

  function hasConversationContent() {
    try {
      return Boolean(
        document.querySelector('[data-message-author-role], [data-testid^="conversation-turn"], [class*="user-message-bubble-color"]')
      );
    } catch (_error) {
      return false;
    }
  }

  function inferMsgId(node) {
    try {
      const direct = String(node.getAttribute('data-message-id') || '').trim();
      if (direct) {
        return direct;
      }
      const roleAncestor = node.closest('[data-message-author-role]');
      if (roleAncestor) {
        const fromAncestor = String(roleAncestor.getAttribute('data-message-id') || '').trim();
        if (fromAncestor) {
          return fromAncestor;
        }
      }
      const nested = node.querySelector('[data-message-id]');
      if (nested) {
        const fromNested = String(nested.getAttribute('data-message-id') || '').trim();
        if (fromNested) {
          return fromNested;
        }
      }
    } catch (_error) {
      return null;
    }
    return null;
  }

  function inferRole(node) {
    const directRole = String(node.getAttribute('data-message-author-role') || '').trim().toLowerCase();
    if (directRole) {
      return directRole;
    }
    if (node.matches('[class*="user-message-bubble-color"]') || node.querySelector('[class*="user-message-bubble-color"]')) {
      return 'user';
    }
    const nestedRoleNode = node.querySelector('[data-message-author-role]');
    if (nestedRoleNode) {
      const nestedRole = String(nestedRoleNode.getAttribute('data-message-author-role') || '').trim().toLowerCase();
      if (nestedRole) {
        return nestedRole;
      }
    }
    // PL-064: group chat 他人発言 bubble 判定 (= [data-message-id] root +
    // bg-token-bg-tertiary 配下、 自分発言印 (= user-message-bubble-color) と
    // assistant 印 (= data-message-author-role) いずれも不在)。 専用 role
    // 'participant' を採用 = renderer 側で 左寄せ + 灰 bubble + 「他のユーザー」
    // label 化、 自分発言の右寄せ緑 bubble と視覚区別。 sender 名は extractMessages
    // 側で text 前 prepend で識別性確保。
    if (node.getAttribute('data-message-id') && node.querySelector('[class*="bg-token-bg-tertiary"]')) {
      return 'participant';
    }
    // PL-064: group chat の system message (= 参加 / 名前変更 / 作成 通知) +
    // 日付 divider。 [data-message-id] root + bubble class 不在 + system text
    // (= p.text-token-text-secondary) or date (= time element) で識別。
    if (node.getAttribute('data-message-id')) {
      if (node.querySelector('p[class*="text-token-text-secondary"]') || node.querySelector('time')) {
        return 'system';
      }
    }
    // PL-064/065: standalone <time> date divider 専用判定。 collectMessageNodes で
    // [data-message-id] root 外の standalone TIME を groupDateDividers として
    // 採用、 ここで 'system' role 化 → 中央寄せ render。 format 揺れ
    // (= 「3月8日 22:20」 / 「今日 21:32」 / 「昨日 HH:MM」 / 英語 等) 全対応で
    // groupDateDividers filter と同じ緩い check (= `\d+:\d+` 含 + 30 字以下)。
    if (node.tagName === 'TIME') {
      const t = (node.textContent || '').trim();
      if (t.length > 0 && t.length <= 30 && /\d+:\d+/.test(t)) {
        return 'system';
      }
    }
    const className = String(node.className || '').toLowerCase();
    if (className.includes('assistant')) {
      return 'assistant';
    }
    if (className.includes('user')) {
      return 'user';
    }
    return 'message';
  }

  function getTextTarget(node) {
    if (node.matches('[data-message-author-role]')) {
      return node;
    }
    // PL-064: group chat 他人発言 bubble は bg-token-bg-tertiary 配下のみ text target
    // (= bubble 外側の sender 名 label / timestamp / reaction emoji が text 本体に混入
    // する罠回避、 「🤯: 梵太郎 St.22:30\nお邪魔します\n🤯」 形式の汚染を防止)。
    // 条件は participant 限定 = data-message-author-role 子 (= assistant) や
    // user-message-bubble-color 子 (= 自分発言) 持ち node は除外、 assistant 経路の
    // text target を bubble 内に狭める副作用 (= 195325 出力で assistant 全消失) 回避。
    if (
      node.getAttribute('data-message-id')
      && !node.querySelector('[class*="user-message-bubble-color"]')
      && !node.querySelector('[data-message-author-role]')
    ) {
      const bubble = node.querySelector('[class*="bg-token-bg-tertiary"]');
      if (bubble) return bubble;
    }

    const candidates = [];
    if (node.matches('[class*="text-message"]')) {
      candidates.push(node);
    }
    node.querySelectorAll('[class*="text-message"]').forEach((candidate) => {
      candidates.push(candidate);
    });

    let bestNode = null;
    let bestLength = 0;
    for (const candidate of candidates) {
      if (candidate !== node && candidate.querySelector('[class*="text-message"]')) {
        continue;
      }
      const text = readMessageText(candidate);
      if (text.length > bestLength) {
        bestNode = candidate;
        bestLength = text.length;
      }
    }

    if (bestNode) {
      return bestNode;
    }
    return node;
  }

  function compareDomOrder(left, right) {
    if (left === right) {
      return 0;
    }
    const position = left.compareDocumentPosition(right);
    if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
      return -1;
    }
    if (position & Node.DOCUMENT_POSITION_PRECEDING) {
      return 1;
    }
    return 0;
  }

  function collectMessageNodes() {
    const directNodes = Array.from(document.querySelectorAll('[data-message-author-role]'));
    const groupNodes = Array.from(document.querySelectorAll('[data-testid^="conversation-turn"]')).filter((node) => {
      return !directNodes.some((direct) => node === direct || node.contains(direct));
    });
    const userBubbleNodes = Array.from(document.querySelectorAll('[class*="user-message-bubble-color"]')).filter((node) => {
      return !node.closest('[data-testid^="conversation-turn"]');
    }).filter((node) => {
      return !directNodes.some((direct) => node === direct || node.contains(direct));
    });
    // PL-064: group chat 他人発言 bubble。 /gg/<id> URL では通常 chat の
    // [data-testid^="conversation-turn"] / [data-message-author-role] selector
    // 全て不在で素通り、 [data-message-id] root + 配下 bg-token-bg-tertiary bubble
    // で識別。 自分発言 (= user-message-bubble-color 配下) + assistant 発言
    // (= data-message-author-role 配下) を除外、 純粋に他人発言 bubble のみ採用。
    const groupOtherBubbleNodes = Array.from(document.querySelectorAll('[data-message-id]')).filter((node) => {
      if (node.querySelector('[class*="user-message-bubble-color"]')) return false;
      if (node.querySelector('[data-message-author-role]')) return false;
      if (!node.querySelector('[class*="bg-token-bg-tertiary"]')) return false;
      return !directNodes.some((direct) => node === direct || node.contains(direct));
    });
    // PL-064: group chat の system message (= 「**XXX**さんがグループチャットに参加
    // しました。」 / 「XXXさんがグループチャットの名前を変更しました。」 / 「XXXさんが
    // グループチャットを作成しました。」) + 日付 divider (= 「3月9日 23:02」 等の TIME
    // element)。 同じ [data-message-id] root だが配下 bubble class 不在、 system msg
    // は p.text-token-text-secondary、 date は time element で識別。 役割 'system'
    // 統合で既存 .msg.system styling (= 左寄せ 灰 bubble) 流用。
    const groupSystemNodes = Array.from(document.querySelectorAll('[data-message-id]')).filter((node) => {
      if (node.querySelector('[class*="user-message-bubble-color"]')) return false;
      if (node.querySelector('[data-message-author-role]')) return false;
      if (node.querySelector('[class*="bg-token-bg-tertiary"]')) return false;
      const hasSystemText = !!node.querySelector('p[class*="text-token-text-secondary"]');
      const hasTime = !!node.querySelector('time');
      if (!hasSystemText && !hasTime) return false;
      return !directNodes.some((direct) => node === direct || node.contains(direct));
    });
    // PL-064: standalone <time> date divider (= 「3月9日 23:02」 / 「3月14日 23:20」
    // 等)。 [data-message-id] root の外側にスタンドアロン配置されるため既存 source で
    // 拾えず素通り。 class に text-token-text-secondary 持ち + text が「X月Y日 H:M」
    // 形式 + [data-message-id] 内に含まれない (= 独立 divider) 場合のみ採用。
    const groupDateDividers = Array.from(document.querySelectorAll('time[class*="text-token-text-secondary"]')).filter((node) => {
      if (node.closest('[data-message-id]')) return false;
      const t = (node.textContent || '').trim();
      // PL-065: format 揺れ対応 (= 「3月8日 22:20」 / 「今日 21:32」 / 「昨日 18:23」 /
      // 英語 locale 「Today HH:MM」 等)。 site の <time> + class signal を信頼、
      // text に time 部 `\d+:\d+` 含 + 30 字以下 で date divider として採用。
      if (t.length === 0 || t.length > 30) return false;
      if (!/\d+:\d+/.test(t)) return false;
      return true;
    });

    const unique = [];
    const seen = new Set();
    for (const node of [...directNodes, ...groupNodes, ...userBubbleNodes, ...groupOtherBubbleNodes, ...groupSystemNodes, ...groupDateDividers]) {
      if (!node || seen.has(node)) {
        continue;
      }
      seen.add(node);
      unique.push(node);
    }
    unique.sort(compareDomOrder);
    return unique;
  }

  function dedupeMessages(items) {
    // Claude PL-014 横展開: text-based adjacency dedup を撤去 (詳細は Claude 版同名関数参照)。
    // 同 DOM node の重複検出は collectMessageNodes の Set で実施済、text-based collapse は冗長 + 異 node 同 text を消す副作用あり。
    const result = [];
    for (const item of items) {
      const role = String(item && item.role || 'message').trim().toLowerCase() || 'message';
      const text = String(item && item.text || '').trim();
      if (!text) {
        continue;
      }
      const msgId = item && item.msg_id ? String(item.msg_id) : null;
      const codeBlocks = Array.isArray(item && item.code_blocks) ? item.code_blocks : [];
      const out = { role, text, msg_id: msgId, code_blocks: codeBlocks };
      // PL-064: display_role field (= group chat 他人発言の sender 名 label override)
      // を維持。 dedupeMessages 固定 field 再構築で drop される回帰防止。
      if (item && item.display_role) out.display_role = String(item.display_role);
      // PL-101: image_blocks (= group chat 他人添付画像) も固定 field 再構築で drop
      // されないよう維持 (display_role と同事情)。
      if (item && Array.isArray(item.image_blocks) && item.image_blocks.length > 0) {
        out.image_blocks = item.image_blocks;
      }
      // PL-101: attachment_only (= file 添付のみ発言、 renderer で bubble 外描画) も維持。
      if (item && item.attachment_only) out.attachment_only = true;
      result.push(out);
    }
    return result;
  }

  // PL-064: group chat system node (= 参加 / 名前変更 / 作成 通知 + 日付 divider)
  // 用 text 抽出。 既存 readMessageDeep が node 全 subtree を取ると site の banner
  // (= 「個人の ChatGPT メモリはグループチャットでは使用されません」 等の info text)
  // や後続 assistant 末尾 が混入する罠あり、 ここでは <time> element の text +
  // 1 番目の p.text-token-text-secondary text (= banner や 2 番目以降の info を
  // skip) のみ採用で noise 除外。
  function extractGroupChatSystemText(node) {
    const parts = [];
    try {
      const time = node.querySelector('time');
      if (time) {
        const t = (time.textContent || '').trim();
        if (t) parts.push(t);
      }
      const ps = Array.from(node.querySelectorAll('p[class*="text-token-text-secondary"]'));
      for (const p of ps) {
        if (time && time.contains(p)) continue;
        // PL-064: calpico raw text に markdown bold notation (= `**name**`) が
        // 残る case あり (= 「**梵太郎 St.**さんがグループチャットに参加しました。」)。
        // system msg は literal text 想定 = bold notation strip で renderer 側
        // markdown 解釈による `<strong>` leak 回避。
        const t = (p.textContent || '').trim().replace(/\*\*([^*]+)\*\*/g, '$1');
        if (t) parts.push(t);
      }
    } catch (_error) {}
    return parts.join('\n');
  }

  // PL-064: group chat 他人発言 bubble に付帯する reaction emoji (= chip 化された
  // user リアクション) を bubble 外 sibling から収集。 sender 名 helper と同様の
  // wrapper walk、 emoji-only leaf (= 通常文字含まない短 text) を採用。 bubble 内
  // text を target に絞った副作用で reaction が消える問題を補完。
  function extractGroupChatReactions(node) {
    const found = [];
    try {
      const bubble = node.querySelector('[class*="bg-token-bg-tertiary"]');
      if (!bubble) return found;
      const seen = new Set();
      let wrapper = bubble.parentElement;
      for (let i = 0; i < 4 && wrapper; i++) {
        const candidates = Array.from(wrapper.querySelectorAll('*'))
          .filter((el) => !bubble.contains(el) && el !== bubble)
          .filter((el) => el.children.length === 0)
          .filter((el) => {
            const t = (el.textContent || '').trim();
            if (t.length === 0 || t.length >= 8) return false;
            // 通常文字 (= ASCII / 日本語 / 中文 / hangul) 含む leaf は除外、
            // emoji / 記号 only のみ採用 (= reaction chip 想定)。
            if (/[a-zA-Z0-9\u3040-\u309f\u30a0-\u30ff\u4e00-\u9fff\uac00-\ud7af]/.test(t)) return false;
            return true;
          });
        for (const c of candidates) {
          const t = c.textContent.trim();
          if (!seen.has(t)) {
            seen.add(t);
            found.push(t);
          }
        }
        wrapper = wrapper.parentElement;
      }
    } catch (_error) {}
    return found;
  }

  // PL-065: group chat 他人発言 bubble から添付物 (= 他人 account が投稿した
  // image / file attachment) を検出して `[添付ファイル]` placeholder 配列で返却。
  // 画像 vs file の媒体別 placeholder 化 (= `[画像]` / `[添付ファイル]`) は user 判断で
  // 統一、 group chat scope で全 attachment を `[添付ファイル]` 系で揃える。
  // 画像 = `<img alt="画像の添付" src="...backend-api/estuary/public_content/...">`
  // (= avatar img は alt="プロファイル画像" + cdn.auth0.com URL で除外)、 filename 情報
  // 不在で plain `[添付ファイル]`。
  // ファイル = `<button class="border-token-border-light ... max-w-sm ...">` 配下に
  // svg icon + filename text (= .txt/.pdf/.docx 等 拡張子問わず共通カード UI)、
  // textContent から filename 抽出して `[添付ファイル: <name>]`。
  // DOM 経路で生 URL / file body 抽出は cookie / 期限切れ依存で復元保証なし、
  // placeholder のみ採用。
  function extractGroupChatAttachments(node) {
    // PL-101: 戻り値を {images, files} 構造化。 images = 実取得対象 (estuary URL +
    // 安定キー)、 files = 従来通り placeholder 文字列。 PL-065 は画像も placeholder
    // 文字列だったが probe で estuary URL が credentialed fetch 200 と確定したため、
    // 画像は生成画像と同じ img_ref + 別送 data_uri 経路 (enrich) に乗せて実表示する。
    const out = { images: [], files: [] };
    // PL-065: ASA UI 言語 (= state.widgetLang) と placeholder 文言を整合、
    // 英語 mode で `[添付ファイル]` のまま日本語 残らないよう dict 経由出力。
    const dict = WIDGET_I18N[state.widgetLang] || WIDGET_I18N.ja;
    const label = dict.attached_file || '添付ファイル';
    try {
      const imgs = Array.from(node.querySelectorAll('img')).filter((el) => {
        const src = String(el.src || '');
        if (/cdn\.auth0\.com/.test(src)) return false;                  // avatar 除外
        if (el.alt === 'プロファイル画像' || el.alt === 'Profile image') return false;
        if (el.alt === '画像の添付') return true;
        if (/\/backend-api\/(?:estuary|files)\//.test(src)) return true;
        return false;
      });
      const seenKey = new Set();
      for (const im of imgs) {
        const src = String(im.src || '');
        const key = _pl101GroupImageKey(src);
        // キー生成不可 (= 想定外 URL 形) は安全側で placeholder に落とす。
        if (!key) { out.files.push(`[${label}]`); continue; }
        if (seenKey.has(key)) continue;
        seenKey.add(key);
        out.images.push({ src, key });
      }
      const fileBtns = Array.from(node.querySelectorAll('button[class*="border-token-border-light"][class*="max-w-sm"]'));
      for (const btn of fileBtns) {
        // PL-065: filename 抽出。 button textContent 直取りは末尾に site native
        // type label (= 「テキスト」/「PDF」/「画像」等の媒体表示) が連結する罠あり、
        // leaf div の最初の non-empty text node を採用 (= 通常 filename が最長 + 先頭)。
        let name = '';
        try {
          const leaves = Array.from(btn.querySelectorAll('div')).filter((d) => d.children.length === 0);
          for (const d of leaves) {
            const t = (d.textContent || '').trim();
            if (t.length >= 3) { name = t.slice(0, 200); break; }
          }
        } catch (_e) {}
        if (!name) {
          name = String(btn.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 200);
        }
        out.files.push(name ? `[${label}: ${name}]` : `[${label}]`);
      }
    } catch (_e) {}
    return out;
  }

  // PL-064/065: group chat 他人発言 bubble から sender 名抽出。
  // **段 1 (PL-065 fix)**: 直接 selector。 sender 名 span は class
  // `text-token-text-secondary` + `text-xs` + title 属性なし、 timestamp span は
  // title 属性ありで分離。 file-only msg case (= text bubble 不在で file button が
  // bg-token-bg-tertiary を保持、 段 2 walk-up が sender 領域に到達不能) を救済。
  // **段 2 (PL-064)**: text bubble 親 wrapper walk-up fallback。 site DOM 構造
  // 変動 risk 想定で残置。
  // **段 3 (PL-065 fix)**: 連続同一 sender 発言で site が 2 件目以降の sender header
  // を省略する慣習 (= chat UI 通例) に対応、 直前 sibling [data-message-id] を最大
  // 20 件遡って sender 名持つ msg を探索 → 継承表示。
  function _pl065TrySenderFromNode(node) {
    try {
      const span = node.querySelector('span[class*="text-token-text-secondary"][class*="text-xs"]:not([title])');
      if (!span) return '';
      const t = (span.textContent || '').trim();
      if (t.length === 0 || t.length >= 40) return '';
      if (!/[a-zA-Z0-9\u3040-\u309f\u30a0-\u30ff\u4e00-\u9fff\uac00-\ud7af]/.test(t)) return '';
      return t;
    } catch (_e) { return ''; }
  }

  function extractGroupChatSenderName(node) {
    try {
      const direct = _pl065TrySenderFromNode(node);
      if (direct) return direct;
      const bubble = node.querySelector('[class*="bg-token-bg-tertiary"]');
      if (bubble) {
        let wrapper = bubble.parentElement;
        for (let i = 0; i < 4 && wrapper; i++) {
          const candidates = Array.from(wrapper.querySelectorAll('*'))
            .filter((el) => !bubble.contains(el) && el !== bubble)
            .filter((el) => el.children.length === 0)
            .filter((el) => {
              const t = (el.textContent || '').trim();
              if (t.length === 0 || t.length >= 40) return false;
              if (!/[a-zA-Z0-9\u3040-\u309f\u30a0-\u30ff\u4e00-\u9fff\uac00-\ud7af]/.test(t)) return false;
              return true;
            });
          if (candidates.length > 0) {
            return candidates[0].textContent.trim();
          }
          wrapper = wrapper.parentElement;
        }
      }
      let prev = node.previousElementSibling;
      for (let i = 0; i < 20 && prev; i++) {
        if (prev.hasAttribute('data-message-id')) {
          const inherited = _pl065TrySenderFromNode(prev);
          if (inherited) return inherited;
        }
        prev = prev.previousElementSibling;
      }
    } catch (_error) {}
    return '';
  }

  function extractMessages() {
    const nodes = collectMessageNodes();
    const mapped = nodes.map((node) => {
      const role = inferRole(node);
      const deep = readMessageDeep(getTextTarget(node));
      const msg_id = inferMsgId(node);
      let text = deep.text;
      // PL-064: group chat 他人発言は sender 名を text 前 prepend で識別性確保。
      // 判定条件 = role='user' + [data-message-id] root + user-message-bubble-color 不在
      // (= 自分発言は user-message-bubble-color 持ちで prepend 対象外)。
      let displayRole = null;
      let imageBlocks = null;
      let attachmentOnly = false;
      if (role === 'participant') {
        // PL-064: group chat 他人発言の sender 名を display_role field に
        // 載せて renderer 側で 「他のユーザー」 label を 実名 (= 「梵太郎 St.」 等)
        // で上書き表示。 本文 prepend は廃止 = label と本文の重複回避。
        const sender = extractGroupChatSenderName(node);
        if (sender) displayRole = sender;
        // PL-101: 他人添付画像は実取得 (生成画像と同 img_ref + 別送 data_uri 経路)、
        // file 添付は従来通り placeholder。 画像は本文へ `[[ASA_IMAGE_N]]` マーカーを
        // 並べ、 image_blocks に src + key を載せる (enrich が src 優先で fetch)。
        // 添付 (画像/file) は caption text の前に置く = site が 1 bubble 内で添付→
        // キャプションの順に描画する慣習に合わせる (PL-101 微調整)。 text 不在 (= 添付
        // のみ送信) でも prefix のみで text 構築、 dedupeMessages の text 空 filter で
        // drop されない。
        const baseText = text;   // PL-101: 添付付与前の本文 (file-only 判定用)
        const att = extractGroupChatAttachments(node);
        let attachPrefix = '';
        if (att.files.length > 0) {
          attachPrefix = att.files.join(' ');
        }
        if (att.images.length > 0) {
          imageBlocks = [];
          let imgMarkers = '';
          att.images.forEach((im) => {
            const idx = imageBlocks.length;
            imgMarkers += `[[ASA_IMAGE_${idx}]]`;
            imageBlocks.push({ alt: '', src: im.src, data_uri: '', title: '', img_ref: im.key });
          });
          attachPrefix = attachPrefix ? (attachPrefix + ' ' + imgMarkers) : imgMarkers;
        }
        if (attachPrefix) {
          text = text ? (attachPrefix + ' ' + text) : attachPrefix;
        }
        // PL-101: file 添付のみ (本文・画像なし) の発言は吹き出しに包まず素貼り。
        // 画像は renderer 側で「画像のみバブル外 + キャプションはバブル内」に分割描画
        // するため、 ここでは attachment_only にしない (image_blocks の有無で判定)。
        if (att.files.length > 0 && !baseText && att.images.length === 0) {
          attachmentOnly = true;
        }
        const reactions = extractGroupChatReactions(node);
        if (reactions.length > 0 && text) {
          text = text + ' ' + reactions.join('');
        }
      } else if (role === 'system' && node.getAttribute('data-message-id')) {
        // PL-064: group chat system node の text を専用 helper で再構築 (= 既存
        // readMessageDeep の broad text 取得で banner / 後続 noise を巻き込む罠回避)。
        const systemText = extractGroupChatSystemText(node);
        if (systemText) text = systemText;
      }
      const out = { role, text, code_blocks: deep.code_blocks, msg_id };
      if (displayRole) out.display_role = displayRole;
      if (imageBlocks && imageBlocks.length > 0) out.image_blocks = imageBlocks;
      if (attachmentOnly) out.attachment_only = true;
      return out;
    });
    return dedupeMessages(mapped.filter((item) => item.text));
  }

  // PL-046 (ephemeral fix): conversation-turn section を identity (= turn_id)
  // で walk、 streaming → completion を通じて安定な data-turn-id を key に。
  // inner data-message-author-role 不在の section (= 中身 unmount 済) は skip
  // (buffer 側に既存 entry があれば merge logic で retain される)。
  function extractEphemeralMessagesByTurnId() {
    const sections = Array.from(document.querySelectorAll('[data-testid^="conversation-turn"]'));
    const out = [];
    const seenTurnIds = new Set();
    for (const section of sections) {
      const turnId = String(section.getAttribute('data-turn-id') || '').trim();
      if (!turnId || seenTurnIds.has(turnId)) continue;
      seenTurnIds.add(turnId);
      const roleEl = section.querySelector('[data-message-author-role]');
      if (!roleEl) continue;
      const role = String(roleEl.getAttribute('data-message-author-role') || '').trim().toLowerCase() || 'message';
      const deep = readMessageDeep(getTextTarget(roleEl));
      const text = deep && deep.text ? String(deep.text).trim() : '';
      if (!text) continue;
      const codeBlocks = deep && Array.isArray(deep.code_blocks) ? deep.code_blocks : [];
      // PL-046: AI streaming 初期は `<pre>` skeleton (= 空 body) のみで text 全体が
      // marker pattern。 raw 化すると preview に `[[ASA_CODE_BLOCK_0]]` 表示 + user
      // 発言が tail から押し出される cosmetic 症状。 marker 除去後 text 空 + 全 body
      // 空なら未完成と判定 skip、 buffer 取込しない (= 完成で本物 text + body 来た時
      // merge 経由で正常追加)。
      const _CB_MARKER_RE = /\[\[ASA_CODE_BLOCK_\d+\]\]/g;
      const stripped = text.replace(_CB_MARKER_RE, '').trim();
      const allBodiesEmpty = codeBlocks.every((cb) => !cb || !String((cb && cb.body) || '').trim());
      if (!stripped && allBodiesEmpty) continue;
      const msgId = inferMsgId(roleEl);
      out.push({
        turn_id: turnId,
        role,
        text,
        msg_id: msgId,
        code_blocks: codeBlocks,
      });
    }
    return out;
  }

  // PL-065: group chat realtime mode 用 DOM scrape fallback。 page_hook は通常
  // conversation URL のみ sniff = group chat (/gg/<id>) の API endpoint は cache
  // 空、 realtime 経路は API 経由不可。 PL-064 で extractMessages は group chat
  // node を既に拾えるため、 毎 tick DOM 全 scan → buffer 上書き で realtime 動作。
  // 一時 chat と違い server persist 済 = buffer shrink 保護不要 (= turn_id key
  // merge ではなく単純置換)。 conv 切替 (= 別 gg id) 時は buffer reset。
  // PL-101: async 化。 他人添付画像 (image_blocks) を生成画像と同じ enrich
  // (DOM/同梱 src 経由 fetch → background 別送) に通すため。 group 経路は元々
  // enrich を一切呼んでおらず placeholder 止まりだった。 enrich は冪等
  // (_PL078_SENT で送出済 key 重複送出を抑止) なので毎 tick 呼んで安全。
  async function mergeGroupChatIntoBuffer() {
    if (!isGroupChat()) return;
    const cid = getGroupChatId();
    if (!cid) return;
    if (state.realtimeBufferCid !== cid) {
      state.realtimeBuffer = [];
      state.realtimeBufferCid = cid;
    }
    const msgs = extractMessages();
    if (Array.isArray(msgs)) {
      await enrichImageBlocks(msgs);
      // PL-101: enrich で別送済 (data_uri は ASA 側で img_ref 合流) のため snapshot 同梱
      // src は不要。 生成画像と同じく src='' に揃える = 取得失敗時に短命 estuary URL の
      // 死にリンクでなくクリーンに印 degrade させる (renderers の src 空→印 契約)。
      for (const m of msgs) {
        const blocks = m && Array.isArray(m.image_blocks) ? m.image_blocks : null;
        if (blocks) { for (const ib of blocks) { if (ib) ib.src = ''; } }
      }
      state.realtimeBuffer = msgs;
      state.realtimeBufferCid = cid;
    }
  }

  // PL-046: ephemeral 永続 buffer 化。 turn_id key で merge、 buffer 絶対 shrink せず、
  // realtime stop/start で消えず、 通常保存 snapshot からも使用。
  // 新一時 chat 検知 = DOM に conversation-turn 0 件 + buffer 非空 → reset。
  function mergeEphemeralIntoBuffer() {
    if (!isTemporaryChat()) return;
    if (!Array.isArray(state.realtimeBuffer)) state.realtimeBuffer = [];
    const sections = Array.from(document.querySelectorAll('[data-testid^="conversation-turn"]'));
    if (sections.length === 0 && state.realtimeBuffer.length > 0) {
      state.realtimeBuffer = [];
      state.realtimeBufferCid = "";
    }
    const msgs = extractEphemeralMessagesByTurnId();
    if (!Array.isArray(msgs)) return;
    const bufferByTurnId = new Map();
    for (const m of state.realtimeBuffer) {
      if (m && m.turn_id) bufferByTurnId.set(m.turn_id, m);
    }
    for (const m of msgs) {
      if (!m || !m.turn_id) continue;
      const existing = bufferByTurnId.get(m.turn_id);
      if (existing) {
        existing.role = m.role;
        existing.text = m.text;
        existing.code_blocks = m.code_blocks;
        existing.msg_id = m.msg_id;
      } else {
        state.realtimeBuffer.push(m);
        bufferByTurnId.set(m.turn_id, m);
      }
    }
    state.realtimeBufferCid = "__ephemeral__";
  }

  // PL-038 Phase 3: page_hook.js (MAIN world) との bridge。
  // ChatGPT site が page load 時 auto-fetch する /backend-api/conversation/<cid>
  // の response を passive sniff で cache、 save click 時に cache 読出 →
  // mapping を parse して messages 化、 auto-scroll なしで全 turn 取得する経路。
  // cache miss 時は active replay (saved Request clone で site auth chain 経由
  // 再 fetch) で穴埋め。
  const PAGE_HOOK_MESSAGE_SOURCE = 'asa-chatgpt-page-hook';
  let _bridgeReqIdCounter = 0;
  const _pendingBridgeRequests = new Map();

  window.addEventListener('message', (event) => {
    if (event.source !== window) return;
    const data = event.data;
    if (!data || data.source !== PAGE_HOOK_MESSAGE_SOURCE) return;
    if (data.type === 'cache-read-response' || data.type === 'replay-response') {
      const reqId = data.reqId;
      const pending = _pendingBridgeRequests.get(reqId);
      if (pending) {
        _pendingBridgeRequests.delete(reqId);
        pending.resolve(data);
      }
    }
  });

  function bridgeRequest(payload, timeoutMs) {
    const timeout = typeof timeoutMs === 'number' ? timeoutMs : 5000;
    return new Promise((resolve, reject) => {
      const reqId = String(++_bridgeReqIdCounter);
      const timer = setTimeout(() => {
        if (_pendingBridgeRequests.has(reqId)) {
          _pendingBridgeRequests.delete(reqId);
          reject(new Error('bridge_timeout'));
        }
      }, timeout);
      _pendingBridgeRequests.set(reqId, {
        resolve: (data) => { clearTimeout(timer); resolve(data); },
      });
      try {
        window.postMessage(Object.assign({}, payload, {
          source: PAGE_HOOK_MESSAGE_SOURCE,
          reqId,
        }), location.origin);
      } catch (e) {
        _pendingBridgeRequests.delete(reqId);
        clearTimeout(timer);
        reject(e);
      }
    });
  }

  async function readConversationCache(cid) {
    try {
      const resp = await bridgeRequest({ type: 'cache-read-request', cid });
      return resp && resp.entry ? resp.entry : null;
    } catch (_e) {
      return null;
    }
  }

  async function requestConversationReplay(cid) {
    try {
      const resp = await bridgeRequest({ type: 'replay-request', cid }, 30000);
      return (resp && resp.result) || { ok: false, reason: 'no_result' };
    } catch (_e) {
      return { ok: false, reason: 'bridge_threw' };
    }
  }

  function requestCacheClear(cid) {
    if (!cid) return;
    try {
      window.postMessage({
        source: PAGE_HOOK_MESSAGE_SOURCE,
        type: 'cache-clear-request',
        cid,
      }, location.origin);
    } catch (_e) {}
  }

  function getCurrentConversationId() {
    try {
      const m = location.pathname.match(/\/c\/([0-9a-fA-F-]{8,})/);
      return m ? m[1] : null;
    } catch (_e) {
      return null;
    }
  }

  // PL-065: group chat (= 複数人 chat、 URL `/gg/<id>`) 判定 + id 抽出。
  // ChatGPT site の group chat は API endpoint が `/backend-api/calpico/...` で
  // page_hook の通常 conversation sniff regex に matche せず cache 空、 realtime
  // mode が機能しない。 PL-064 で DOM 経路 (= collectMessageNodes + extractMessages)
  // は既に group chat 対応済 = realtime mode を DOM scrape fallback に分岐させて
  // 復活させる (= 一時 chat の isTemporaryChat → mergeEphemeralIntoBuffer pattern と
  // 同構造)。
  function isGroupChat() {
    try {
      return /\/gg\/[A-Za-z0-9-]+/.test(location.pathname || '');
    } catch (_e) {
      return false;
    }
  }

  function getGroupChatId() {
    try {
      const m = (location.pathname || '').match(/\/gg\/([A-Za-z0-9-]+)/);
      return m ? m[1] : null;
    } catch (_e) {
      return null;
    }
  }

  // PL-117: stale 判定 / turn 増加検知用の「画面上の発言ターン数」。
  // `[data-testid^="conversation-turn"]` は前方一致のため、 位置検索後に挿入される
  // `conversation-turn-location-footer` (位置情報同意フッター) 等の非発言 UI も拾い、
  // 実発言数より 1 多く数える。 実発言ターンは `conversation-turn-<番号>` のみなので
  // numbered だけ数え、 API parse 後の msgs.length と like-for-like に揃える。
  // (root: footer が +1 され domTurnCount > msgs.length が恒常 true 化 → 一発保存毎に
  //  差を埋められない無駄 replay が走っていた。 probe 実測で footer = 13 番目枠と確定)
  function countMessageTurns() {
    try {
      let n = 0;
      const els = document.querySelectorAll('[data-testid^="conversation-turn"]');
      for (const el of els) {
        const tid = el.getAttribute('data-testid') || '';
        if (/^conversation-turn-\d+$/.test(tid)) n++;
      }
      return n;
    } catch (_e) {
      return 0;
    }
  }

  // ── PL-119: 共有ページ (/share/<id>) の埋込会話構造復号 ──
  // 共有ページは /c/ id を持たず page_hook の cache も空 (backend-api/conversation を
  // sniff しない) ため、 従来は DOM scrape に落ちて生成画像を全 drop していた。 だが
  // ページ HTML には React Router v7 の turbo-stream で会話が丸ごと埋め込まれている:
  //   window.__reactRouterContext.streamController.enqueue("<escaped flat array>")
  // flat array は token table (数字=index 参照 / {"_k":v}=key参照k・値参照v / 文字列・
  // 数値=リテラル)。 これを参照解決して mapping/current_node を復元し、 通常 API 応答と
  // 同形に変換 → parseConversationApiResponse に合流させる (PL-118 の走査修正もそのまま
  // 効く)。 復号ロジックは decode_chatgpt_share.py と 1:1。
  function _pl119IsSharePage() {
    try { return /\/share\/[0-9a-zA-Z-]{8,}/.test(location.pathname || ''); }
    catch (_e) { return false; }
  }
  function _pl119ExtractEnqueuePayload(texts) {
    let out = '';
    const needle = 'streamController.enqueue(';
    for (const t of texts) {
      let i = 0;
      while (true) {
        const k = t.indexOf(needle, i);
        if (k < 0) break;
        const q = t.indexOf('"', k);
        if (q < 0) break;
        let j = q + 1;
        while (j < t.length) {
          if (t[j] === '\\') { j += 2; continue; }
          if (t[j] === '"') break;
          j += 1;
        }
        try { out += JSON.parse(t.slice(q, j + 1)); } catch (_e) { void 0; }
        i = j + 1;
      }
    }
    return out;
  }
  function _pl119ParseTable(payload) {
    // turbo-stream は改行区切り: line0 = 本体 flat array、 後続行は遅延 placeholder。
    for (const ln of String(payload || '').split('\n')) {
      const s = ln.trim();
      if (!s.startsWith('[')) continue;
      try { return JSON.parse(s); } catch (_e) { void 0; }
    }
    return null;
  }
  function _pl119MakeResolver(table) {
    const cache = new Map();
    const stack = new Set();
    function res(ref, depth) {
      if (ref === null || typeof ref === 'boolean') return ref;
      if (typeof ref === 'number' && Number.isInteger(ref)) {
        if (ref < 0 || ref >= table.length) return ref;   // 負値 = sentinel
        if (cache.has(ref)) return cache.get(ref);
        if (stack.has(ref) || depth > 120) return null;   // 循環打切り
        stack.add(ref);
        const v = resolveValue(table[ref], depth + 1);
        stack.delete(ref);
        cache.set(ref, v);
        return v;
      }
      return resolveValue(ref, depth + 1);
    }
    function resolveValue(v, depth) {
      if (Array.isArray(v)) return v.map((e) => res(e, depth));
      if (v && typeof v === 'object') {
        const out = {};
        for (const k of Object.keys(v)) {
          let key = k;
          if (k[0] === '_' && /^-?\d+$/.test(k.slice(1))) key = res(parseInt(k.slice(1), 10), depth);
          out[String(key)] = res(v[k], depth);
        }
        return out;
      }
      return v;
    }
    return res;
  }
  function _pl119FindRoot(table) {
    const res = _pl119MakeResolver(table);
    for (let idx = 0; idx < table.length; idx++) {
      const tok = table[idx];
      if (tok && typeof tok === 'object' && !Array.isArray(tok)) {
        const keyNames = [];
        for (const k of Object.keys(tok)) {
          if (k[0] === '_' && /^-?\d+$/.test(k.slice(1))) {
            const ki = parseInt(k.slice(1), 10);
            keyNames.push(ki < table.length ? table[ki] : null);
          }
        }
        if (keyNames.indexOf('current_node') >= 0 && keyNames.indexOf('linear_conversation') >= 0) {
          return res(idx, 0);
        }
      }
    }
    return null;
  }
  // 共有ページの埋込会話を {mapping, current_node} の JSON 文字列で返す (= 通常 API 応答
  // と同形)。 非共有ページ / 抽出失敗時は null。
  function _pl119GetShareConversationJson() {
    if (!_pl119IsSharePage()) return null;
    try {
      const texts = [];
      const scripts = document.querySelectorAll('script');
      for (const s of scripts) {
        const t = s.textContent || '';
        if (t.indexOf('streamController.enqueue(') >= 0) texts.push(t);
      }
      if (texts.length === 0) return null;
      const table = _pl119ParseTable(_pl119ExtractEnqueuePayload(texts));
      if (!Array.isArray(table)) return null;
      const root = _pl119FindRoot(table);
      if (!root || !root.mapping || !root.current_node) return null;
      return JSON.stringify({ mapping: root.mapping, current_node: root.current_node });
    } catch (_e) {
      return null;
    }
  }

  async function extractMessagesViaApi(opts) {
    // PL-098: skipEnrich=true で parse のみ返す (caller が pending 窓判定 → enrich を
    // 自分で順序制御するため)。 省略時は従来通り enrich 込み (realtime init 等)。
    const skipEnrich = !!(opts && opts.skipEnrich);
    // PL-109: replayIfStale=true で「cache が live tail より遅れている」時に 1 回だけ
    // active replay して再 parse する (一発保存の stale cache 救済、 後述)。
    const replayIfStale = !!(opts && opts.replayIfStale);
    const cid = getCurrentConversationId();
    if (!cid) {
      // PL-119: 共有ページ (/share/) は cid 不在。 埋込 RSC を復号して通常 API 応答と
      // 同形に変換 → 既存 parse に合流 (DOM scrape では落ちる生成画像を救う)。
      const shareJson = _pl119GetShareConversationJson();
      if (!shareJson) return null;
      const shareMsgs = ASA_PARSE.parseConversationApiResponse(shareJson);
      if (!Array.isArray(shareMsgs)) return null;
      if (!skipEnrich) {
        await enrichImageBlocks(shareMsgs);  // 共有 (静的) は replay 不要、 enrich のみ
      }
      return shareMsgs;
    }
    let entry = await readConversationCache(cid);
    if (!entry || !entry.resp_text) {
      // Cache miss → active replay (saved Request clone で site auth chain 経由再 fetch)
      const replayResult = await requestConversationReplay(cid);
      if (replayResult && replayResult.ok) {
        entry = await readConversationCache(cid);
      }
    }
    if (!entry || !entry.resp_text) return null;
    let msgs = ASA_PARSE.parseConversationApiResponse(entry.resp_text);
    // PL-109: 一発保存の stale cache 救済。 site は新発言後に会話 API を自動再取得し
    // ない局面があり (無料版広告セッション等)、 cache が live tail より遅れると保存が
    // 最新発言を落とす。 capture モードは turn 増加 signal → active replay で救うが
    // 一発保存にこの古さ検知が無いのが root (PL-109)。 ここで「画面実 turn 数 >
    // parse 済 msg 数」= cache 遅延 signal を検知し、 1 回だけ replay して再 parse。
    // 1 保存 1 replay = 持続 polling 化しないため BAN 懸念に抵触しない。 replay 失敗 /
    // 改善なしなら元 msgs を維持 (graceful fallback)。
    if (replayIfStale && Array.isArray(msgs)) {
      const domTurnCount = countMessageTurns();   // PL-117: footer 等の非発言枠を除外
      if (domTurnCount > msgs.length) {
        const replayResult = await requestConversationReplay(cid);
        if (replayResult && replayResult.ok) {
          const fresh = await readConversationCache(cid);
          if (fresh && fresh.resp_text) {
            const freshMsgs = ASA_PARSE.parseConversationApiResponse(fresh.resp_text);
            if (Array.isArray(freshMsgs) && freshMsgs.length > msgs.length) {
              msgs = freshMsgs;
            }
          }
        }
      }
    }
    if (!skipEnrich) {
      await enrichImageBlocks(msgs);  // PL-078: AI画像を取得し background へ別送 (snapshot は img_ref のみ)
    }
    return msgs;
  }

  // PL-038 Phase 5: realtime mode 周期 refresh。 毎周期 active replay すると
  // sustained 30 req/min で ChatGPT abuse detect に着弾するため、 replay は
  // 必要時のみ (conv 切替 / 新 turn signal 検知) に絞り、 通常は cache 読出し
  // のみで passive sniff 由来の更新を拾う設計。 新 turn signal = DOM
  // `[data-testid^="conversation-turn"]` count が前回観測値より増加 (lazy
  // mount でも turn element 自体は早期 attach されるため軽量 signal として使用、
  // mount 完了は不要、 count のみ取得で extract は走らせない)。 cache は site の
  // auto-refetch + 拡張の active replay 両経路で更新される、 site が new msg 後
  // 自動 refetch しない場合の counter 更新を新 turn signal trigger 経由 active
  // replay で確保。
  //
  // PL-045 修正: turnGrew 検知時 active replay は page_hook.js の minimal fetch
  // (= saved Request の Authorization のみ抽出 + 最小 headers + cache:'no-store')
  // 経由で行う。 site が付与する conditional GET headers (If-None-Match 等) を
  // 含んだ replay は OpenAI server から cached stale response 引く事象を回避。
  // stale 救済: parse 後 末尾 role = user (= AI 応答未完 or persist 待ち) なら
  // realtimeLastTurnCount を進めず、 pendingSince keep で次 polling tick 再試行。
  const REPLAY_DELAY_MS = 0;
  async function realtimeRefreshViaApi() {
    if (!state.realtimeCapturing) return;
    if (isTemporaryChat()) {
      mergeEphemeralIntoBuffer();
      state.realtimeLastTurnCount = 0;
      state.realtimeConvSwitchAt = 0;
      state.pendingTurnCount = 0;
      state.pendingSince = 0;
      state.lastReplayAttemptTurnCount = 0;
      state.lastReplayAttemptAt = 0;
      state.lastBufferMsgCount = 0;
      state.lastBufferTailMsgId = '';
      return;
    }
    if (isGroupChat()) {
      // PL-065: group chat は page_hook cache 空 = API 経路不可、 DOM scrape fallback
      await mergeGroupChatIntoBuffer();   // PL-101: 他人添付画像 enrich を内包 (async)
      state.realtimeLastTurnCount = 0;
      state.realtimeConvSwitchAt = 0;
      state.pendingTurnCount = 0;
      state.pendingSince = 0;
      state.lastReplayAttemptTurnCount = 0;
      state.lastReplayAttemptAt = 0;
      state.lastBufferMsgCount = 0;
      state.lastBufferTailMsgId = '';
      return;
    }
    const cid = getCurrentConversationId();
    if (!cid) {
      if (state.realtimeBufferCid) {
        state.realtimeBuffer = [];
        state.realtimeBufferCid = "";
        state.realtimeLastTurnCount = 0;
        state.realtimeConvSwitchAt = 0;
        state.pendingTurnCount = 0;
        state.pendingSince = 0;
        state.lastReplayAttemptTurnCount = 0;
        state.lastReplayAttemptAt = 0;
        state.lastBufferMsgCount = 0;
        state.lastBufferTailMsgId = '';
      }
      return;
    }

    const isConvSwitch = state.realtimeBufferCid !== cid;
    if (isConvSwitch && !state.realtimeConvSwitchAt) {
      state.realtimeConvSwitchAt = Date.now();
      state.pendingTurnCount = 0;
      state.pendingSince = 0;
    }

    const domTurnCount = countMessageTurns();   // PL-117: footer 等の非発言枠を除外
    const turnGrew = !isConvSwitch && (domTurnCount > (state.realtimeLastTurnCount || 0));

    if (turnGrew) {
      state.pendingTurnCount = domTurnCount;
      if (!state.pendingSince) {
        state.pendingSince = Date.now();
      }
    }

    let entry = await readConversationCache(cid);
    let needsReplay = false;

    if (isConvSwitch) {
      const GRACE_MS = 3000;
      const elapsed = Date.now() - state.realtimeConvSwitchAt;
      if (!entry || !entry.resp_text) {
        if (elapsed > GRACE_MS) {
          needsReplay = true;
        } else {
          return;
        }
      }
    } else if (state.pendingSince > 0) {
      const sinceElapsed = Date.now() - state.pendingSince;
      if (sinceElapsed >= REPLAY_DELAY_MS) {
        // PL-045 codex review #1: single-flight + per-DOM-count retry guard。
        // 同 domTurnCount で直近 replay attempt から REPLAY_COOLDOWN_MS 経過してない
        // 場合 skip = pinned pending loop 防止 (= 2 秒毎連発 → stale 固定回避)。
        // 新 domTurnCount なら即 replay 許可 (= turn 進行 trigger 即応)。
        const REPLAY_COOLDOWN_MS = 5000;
        const sameTurnAttempted = state.lastReplayAttemptTurnCount === domTurnCount;
        const sinceLastAttempt = Date.now() - state.lastReplayAttemptAt;
        if (!sameTurnAttempted || sinceLastAttempt >= REPLAY_COOLDOWN_MS) {
          needsReplay = true;
        }
      }
    }

    let replayResult = null;
    if (needsReplay) {
      state.lastReplayAttemptTurnCount = domTurnCount;
      state.lastReplayAttemptAt = Date.now();
      state.replayInFlight = true;
      try {
        replayResult = await requestConversationReplay(cid);
      } catch (_e) {
        replayResult = { ok: false, reason: 'thrown' };
      }
      state.replayInFlight = false;
      if (cid !== getCurrentConversationId()) return;
      entry = await readConversationCache(cid);
    }

    if (!entry || !entry.resp_text) {
      // PL-045 diagnostic: entry 不在 path も観察 (= 新規 conv で site が GET 叩か
      // ない case が typical、 root 解決は後 session: POST hook で token 抽出 →
      // 自前 GET fetch で saved Request artificial 構築する path 案あり)
      try {
        console.log('[PL-045-diag]', JSON.stringify({
          t: Date.now(), cid: String(cid).slice(-6),
          domTurnCount, realtimeLastTurnCount: state.realtimeLastTurnCount,
          pendingSince: state.pendingSince, needsReplay,
          replayStatus: replayResult ? (replayResult.ok ? replayResult.status : replayResult.reason) : 'no_replay',
          msgCount: 0, tailRole: null, tailMsgId: null,
          cacheSourceKind: null, fetchedAt: null,
          path: 'no_entry',
        }));
      } catch (_e) {}
      return;
    }
    const msgs = ASA_PARSE.parseConversationApiResponse(entry.resp_text);
    await enrichImageBlocks(msgs);  // PL-078: AI画像を DOM src 経由 fetch で充填
    if (Array.isArray(msgs)) {
      state.realtimeBuffer = msgs;
      state.realtimeBufferCid = cid;
      state.realtimeConvSwitchAt = 0;
      const lastRole = msgs.length > 0 ? msgs[msgs.length - 1].role : null;
      const lastMsgId = msgs.length > 0 ? String(msgs[msgs.length - 1].msg_id || '').slice(-6) : null;
      // PL-045 codex review #2: explicit replay advancement check。 末尾 role だけ
      // だと server stale response (= 古い assistant msg そのまま返却) を fresh 誤判定
      // → realtimeLastTurnCount = domTurnCount で進行ロック → 同 turn 再試行不能。
      // msgs.length 増加 or tailMsgId 変化で 「実際進んだか」 判定、 進んでないなら
      // turn count 据置 + pending keep で次 cooldown 後 retry。
      const prevMsgCount = state.lastBufferMsgCount;
      const prevTailMsgId = state.lastBufferTailMsgId;
      const fullLastMsgId = msgs.length > 0 ? String(msgs[msgs.length - 1].msg_id || '') : '';
      const advanced = msgs.length > prevMsgCount || (fullLastMsgId && fullLastMsgId !== prevTailMsgId);
      if (advanced) {
        state.lastBufferMsgCount = msgs.length;
        state.lastBufferTailMsgId = fullLastMsgId;
        if (lastRole === 'assistant') {
          state.realtimeLastTurnCount = domTurnCount;
          state.pendingTurnCount = 0;
          state.pendingSince = 0;
        } else {
          if (!state.pendingSince) {
            state.pendingSince = Date.now();
          }
          state.pendingTurnCount = domTurnCount;
        }
      } else {
        // stale (= replay 200 だが内容変化なし) → turn count 据置で次 cooldown 後 retry
        if (!state.pendingSince) {
          state.pendingSince = Date.now();
        }
        state.pendingTurnCount = domTurnCount;
      }
      // PL-045 diagnostic (= codex review #4 + advancement check): per tick 状態 log
      try {
        console.log('[PL-045-diag]', JSON.stringify({
          t: Date.now(), cid: String(cid).slice(-6),
          domTurnCount, realtimeLastTurnCount: state.realtimeLastTurnCount,
          pendingSince: state.pendingSince, needsReplay,
          replayStatus: replayResult ? (replayResult.ok ? replayResult.status : replayResult.reason) : 'no_replay',
          msgCount: msgs.length, tailRole: lastRole, tailMsgId: lastMsgId,
          cacheSourceKind: entry.source_kind || null,
          fetchedAt: entry.fetched_at || null,
          path: needsReplay ? 'replay' : 'cache_only',
          advanced, prevMsgCount,
          lastReplayAttemptTurnCount: state.lastReplayAttemptTurnCount,
        }));
      } catch (_e) {}
    }
  }

  function sendRuntimeMessage(message) {
    try {
      chrome.runtime.sendMessage(message, () => {
        void chrome.runtime.lastError;
      });
    } catch (_error) {
      return;
    }
  }

  function reportStatus(force) {
    const pageState = currentPageState();
    const key = `${location.href}::${pageState}`;
    if (key === state.lastStatusKey) {
      if (!force) {
        return;
      }
    }
    state.lastStatusKey = key;
    sendRuntimeMessage({
      type: "page_status",
      pageState,
      url: location.href,
      title: document.title || "ChatGPT_Conversation",
      observedAt: new Date().toISOString(),
      details: collectStatusDetails(),
    });
  }

  function buildSnapshot(messages) {
    const msgs = Array.isArray(messages) ? messages : extractMessages();
    return {
      type: "snapshot_result",
      url: location.href,
      title: document.title || "ChatGPT_Conversation",
      timestamp: new Date().toISOString(),
      messages: msgs,
      pageState: currentPageState(),
      debug: {
        isTemporaryChat: isTemporaryChat(),
      },
    };
  }

  function buildDomMessagesResult() {
    let messages;
    if (isTemporaryChat()) {
      mergeEphemeralIntoBuffer();
      messages = Array.isArray(state.realtimeBuffer) ? state.realtimeBuffer.slice() : [];
    } else {
      messages = extractMessages();
    }
    return {
      type: "dom_messages_result",
      messages,
      url: location.href,
      title: document.title || "ChatGPT_Conversation",
      scrollTop: Math.round(window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0),
      scrollHeight: Math.round(document.documentElement.scrollHeight || document.body.scrollHeight || 0),
      clientHeight: Math.round(window.innerHeight || document.documentElement.clientHeight || 0),
    };
  }

  chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
    if (!message || typeof message !== "object") {
      return false;
    }
    if (message.type === "capture_snapshot") {
      if (isTemporaryChat()) {
        mergeEphemeralIntoBuffer();
        sendResponse(buildSnapshot(Array.isArray(state.realtimeBuffer) ? state.realtimeBuffer.slice() : []));
      } else if (isGroupChat()) {
        // PL-101: group chat 非 realtime 保存も enrich (他人添付画像取得) を通す。
        // mergeGroupChatIntoBuffer が async = buffer 構築 + image 別送を待ってから返す。
        (async () => {
          try { await mergeGroupChatIntoBuffer(); } catch (_e) {}
          sendResponse(buildSnapshot(Array.isArray(state.realtimeBuffer) ? state.realtimeBuffer.slice() : []));
        })();
      } else {
        sendResponse(buildSnapshot());
      }
      return true;
    }
    if (message.type === "start_realtime_capture") {
      (async () => {
        try {
          state.realtimeCapturing = true;
          _pl078ResetSent();  // PL-078: 新 capture session → 画像を fresh ASA へ再 flush
          // PL-046: ephemeral buffer は stop/start で消さず保持 (= virtual scroll
          // unmount された 1 番目 user 発言を保護)。 非 ephemeral の旧 conv 残留
          // buffer のみ clear (= PL-038 Phase 5 conv 切替 race fix)。
          if (state.realtimeBufferCid !== "__ephemeral__") {
            state.realtimeBuffer = [];
            state.realtimeBufferCid = "";
            state.realtimeLastTurnCount = 0;
            state.realtimeConvSwitchAt = 0;
            state.pendingTurnCount = 0;
            state.pendingSince = 0;
          }
          if (isTemporaryChat()) {
            // PL-043: 一時 chat は API path skip、 DOM scrape で初期化
            // PL-046: 既存 ephemeral buffer 維持 + DOM 新 turn を merge
            mergeEphemeralIntoBuffer();
          } else if (isGroupChat()) {
            // PL-065: group chat は API path 不可、 DOM scrape で初期化
            await mergeGroupChatIntoBuffer();   // PL-101: 他人添付画像 enrich 内包
          } else {
            const cidAtStart = getCurrentConversationId();
            const initialMessages = await extractMessagesViaApi();
            if (Array.isArray(initialMessages) && cidAtStart && cidAtStart === getCurrentConversationId()) {
              state.realtimeBuffer = initialMessages;
              state.realtimeBufferCid = cidAtStart;
              try {
                state.realtimeLastTurnCount = countMessageTurns();   // PL-117: footer 等の非発言枠を除外
              } catch (_e) {}
            }
          }
          if (state.realtimeRefreshTimer !== null) {
            clearInterval(state.realtimeRefreshTimer);
            state.realtimeRefreshTimer = null;
          }
          const REALTIME_REFRESH_MS = 2000;
          state.realtimeRefreshTimer = setInterval(() => {
            realtimeRefreshViaApi().catch(() => {});
          }, REALTIME_REFRESH_MS);
        } catch (_e) {}
        sendResponse({ ok: true });
      })();
      return true;
    }
    if (message.type === "stop_realtime_capture") {
      try {
        if (state.realtimeRefreshTimer !== null) {
          clearInterval(state.realtimeRefreshTimer);
          state.realtimeRefreshTimer = null;
        }
        // PL-045 codex review #3 派生: realtime stop 時に page_hook cache reset。
        // realtime 中の stale response が page_hook conversationCache を上書きする
        // 可能性があり、 stop 直後の widget save で stale を引く corner case を
        // 局所塞ぎ。 cache reset は local op = network fetch 0 で BAN 刺激ゼロ。
        const stoppedCid = state.realtimeBufferCid;
        if (stoppedCid && stoppedCid !== "__ephemeral__") {
          requestCacheClear(stoppedCid);
        }
        state.realtimeCapturing = false;
        // PL-046: ephemeral buffer は stop で消さず保持 (= restart 時 1 番目
        // user 発言を unmount 前 entry から復活、 通常保存 snapshot からも参照)。
        // 非 ephemeral のみ clear。
        if (state.realtimeBufferCid !== "__ephemeral__") {
          state.realtimeBuffer = [];
          state.realtimeBufferCid = "";
          state.realtimeLastTurnCount = 0;
          state.realtimeConvSwitchAt = 0;
          state.pendingTurnCount = 0;
          state.pendingSince = 0;
          state.lastReplayAttemptTurnCount = 0;
          state.lastReplayAttemptAt = 0;
          state.lastBufferMsgCount = 0;
          state.lastBufferTailMsgId = '';
        }
      } catch (_e) {}
      sendResponse({ ok: true });
      return true;
    }
    if (message.type === "get_realtime_buffer") {
      // buffer が現 conv の cid に対応しているときのみ返却 (conv 切替直後の
      // stale buffer による counter 累積を防ぐ、 PL-038 Phase 5)。
      // PL-043: 一時 chat は cid 不在 + sentinel "__ephemeral__" 一致で返却。
      let messages = [];
      if (Array.isArray(state.realtimeBuffer)) {
        if (isTemporaryChat()) {
          if (state.realtimeBufferCid === "__ephemeral__") {
            messages = state.realtimeBuffer.slice();
          }
        } else if (isGroupChat()) {
          // PL-065: group chat は gg id を realtimeBufferCid に格納
          const currentCid = getGroupChatId();
          if (currentCid && currentCid === state.realtimeBufferCid) {
            messages = state.realtimeBuffer.slice();
          }
        } else {
          const currentCid = getCurrentConversationId();
          if (currentCid && currentCid === state.realtimeBufferCid) {
            messages = state.realtimeBuffer.slice();
          }
        }
      }
      sendResponse(buildSnapshot(messages));
      return true;
    }
    if (message.type === "get_dom_messages") {
      sendResponse(buildDomMessagesResult());
      return true;
    }
    if (message.type === "ping_content") {
      sendResponse({ ok: true, url: location.href, title: document.title || "ChatGPT_Conversation" });
      return true;
    }
    if (message.type === "popup_action") {
      // PL-095: toolbar popup の「保存 / 設定」ボタン。 widget クリックと同一経路。
      const action = String(message.action || "");
      if (action === "save_open" || action === "settings") {
        Promise.resolve(performWidgetAction(action))
          .then(() => { try { sendResponse({ ok: true }); } catch (_e) {} })
          .catch(() => { try { sendResponse({ ok: false }); } catch (_e) {} });
        return true;
      }
      sendResponse({ ok: false });
      return true;
    }
    return false;
  });

  window.addEventListener("pagehide", () => {
    sendRuntimeMessage({
      type: "tab_closed",
      url: location.href,
      title: document.title || "ChatGPT_Conversation",
      observedAt: new Date().toISOString()
    });
  });

  window.setInterval(() => {
    const current = String(location.href || "");
    if (current !== state.lastUrl) {
      state.lastUrl = current;
      state.lastStatusKey = "";
      reportStatus(true);
    }
  }, 1000);

  window.setInterval(() => {
    reportStatus(false);
  }, STATUS_INTERVAL_MS);

  function sendHeartbeat() {
    try {
      chrome.runtime.sendMessage(
        {
          type: "bridge_heartbeat",
          pageState: currentPageState(),
          url: location.href,
          title: document.title || "ChatGPT_Conversation",
          observedAt: new Date().toISOString(),
        },
        (response) => {
          void chrome.runtime.lastError;
          if (response && response.widget_state) {
            applyWidgetState(response.widget_state);
          }
        }
      );
    } catch (_error) {
      return;
    }
  }

  function sendWidgetAction(action, payload) {
    return new Promise((resolve) => {
      try {
        chrome.runtime.sendMessage(
          {
            type: "widget_action",
            action: String(action || ""),
            url: location.href,
            title: document.title || "ChatGPT_Conversation",
            payload: payload && typeof payload === "object" ? payload : {},
          },
          (response) => {
            void chrome.runtime.lastError;
            if (response && response.widget_state) {
              applyWidgetState(response.widget_state);
            }
            resolve(response || { ok: false, widget_state: {} });
          }
        );
      } catch (_error) {
        resolve({ ok: false, widget_state: {} });
      }
    });
  }

  // PL-095: widget クリックと toolbar popup の両方から同一保存/設定経路を呼ぶため
  // wrap() の中核 (save_open の snapshot build → widget_action 送出) を関数化。
  // popup は別 document でページ DOM/API に触れないため、 content script の本関数へ
  // 委譲する (= subscription downgrade で widget が消えても保存導線を残す)。
  // PL-116: 時間ベース遅延スピナー。 save click 後、 遅延の原因 (API 取得 / 画像 fetch /
  // snapshot 組立) を問わず、 一定時間 (ASA_SAVE_PENDING_DELAY_MS) 経っても work が終わら
  // なければ「取得中…」窓 (save_pending) を Python に出させる。 Python は後続 save_open
  // 受領 (drain) で窓を閉じ保存本番 (= 既存 acquire/render スピナー) へ引き継ぐ。 work が
  // 閾値内に終われば save_pending を送らない → 即完了保存で窓のチラ付き無し (PL-025 不変)。
  // 旧 _pl098NeedAcquireWait の「原因検出ゲート」(描画済 web 画像 / API 取得待ちを取りこぼし、
  // 多くの遅延ケースで窓が出なかった root) を置換。
  const ASA_SAVE_PENDING_DELAY_MS = 250;
  async function performWidgetAction(action) {
    let payload = null;
    if (action === "save_open") {
      // 閾値超過で「取得中…」窓を出す (work 完了で finally が解除 → 未発火なら窓無し)。
      const _pendingTimer = setTimeout(function () {
        try { sendWidgetAction("save_pending", { capture_origin: "widget_click" }); } catch (_pe) { void 0; }
      }, ASA_SAVE_PENDING_DELAY_MS);
      try {
        _pl078ResetSent();  // PL-078: 新 capture session → 画像を fresh ASA へ再 flush
        let messages = null;
        if (isTemporaryChat()) {
          mergeEphemeralIntoBuffer();
          messages = Array.isArray(state.realtimeBuffer) ? state.realtimeBuffer.slice() : null;
        } else if (isGroupChat()) {
          // PL-101: group chat は API 経路不可 (page_hook cache 空)。 DOM scrape で buffer 構築
          // し、 他人添付画像を enrich (estuary URL を fetch → background 別送、 src クリア)。
          // mergeGroupChatIntoBuffer が enrich + src クリアを内包する。
          await mergeGroupChatIntoBuffer();
          messages = Array.isArray(state.realtimeBuffer) ? state.realtimeBuffer.slice() : null;
        } else {
          let apiMessages = null;
          try {
            // PL-098: enrich を後ろ倒し (skipEnrich) し、 画像描画待ちが要るかを先に判定。
            // PL-109: replayIfStale で cache 遅延時に 1 回 active replay (広告セッション
            // 等で site が自動再取得しない時の最新発言欠落を救済)。
            apiMessages = await extractMessagesViaApi({ skipEnrich: true, replayIfStale: true });
          } catch (_apiErr) {
            apiMessages = null;
          }
          messages = (apiMessages && apiMessages.length > 0) ? apiMessages : null;
          if (messages) {
            await enrichImageBlocks(messages);  // 画像取得 (描画 bounded 待ち含む)
          }
        }
        payload = { snapshot: buildSnapshot(messages || undefined), capture_origin: "widget_click" };
      } catch (_e) {
        payload = null;
      } finally {
        clearTimeout(_pendingTimer);  // work 完了 → 未発火なら窓を出さない (閾値内即完了)
      }
    }
    return sendWidgetAction(action, payload);
  }

  function widgetTemplate() {
    return `
<style>
:host { all: initial; color: inherit; font-family: system-ui, -apple-system, sans-serif; font-size: 13px; display: block; }
.asa-dock { display: grid; grid-template-columns: repeat(2, 36px); gap: 4px; padding: 6px; background: transparent; color: inherit; }
.asa-dock button { position: relative; width: 36px; height: 36px; padding: 0; border: none; background: transparent; cursor: pointer; font-size: 18px; border-radius: 4px; line-height: 36px; color: inherit; transition: transform 80ms ease, background-color 80ms ease, box-shadow 80ms ease; }
.asa-dock button:hover { background: rgba(127,127,127,.18); }
.asa-dock button:active { transform: translateY(1px) scale(0.94); background: rgba(127,127,127,.32); box-shadow: inset 0 1px 2px rgba(0,0,0,.25); }
.asa-dock button[data-active="true"] { background: rgba(217,60,62,.22); }
.asa-dock button[data-action="toggle_topmost"] { background: transparent; }
.asa-dock button[data-action="toggle_topmost"][data-active="true"] { background: transparent; }
/* PL-095: save 矢印 (U+2B07) は欧文フォントだと細字・絵文字フォントだと青四角になり
   英語 locale で見た目が崩れる。 日本語フォントの黒太グリフを全 locale で採用させるため
   font-family のみ固定 (位置決め padding-top/line-height/中央寄せは不変、 元配置に忠実)。 */
.asa-dock button[data-action="save_open"] { padding-top: 2px; box-sizing: border-box; font-family: "Yu Gothic", "Meiryo", "MS Gothic", sans-serif; }
:host([data-theme="dark"]) .asa-dock button:hover { background: rgba(255,255,255,.28); }
.asa-dock button[data-tooltip]::after { content: attr(data-tooltip); position: absolute; bottom: 100%; left: 50%; transform: translate(-50%, -6px); background: #1f1f1f; color: #fff; padding: 3px 8px 1px; border-radius: 4px; font-size: 12px; line-height: 1.3; white-space: nowrap; pointer-events: none; opacity: 0; transition: opacity 80ms ease; z-index: 1; }
.asa-dock button[data-tooltip][data-tooltip-pos="down"]::after { top: 100%; bottom: auto; transform: translate(-50%, 6px); }
.asa-dock button[data-tooltip]:hover::after { opacity: 1; transition-delay: 120ms; }
</style>
<div class="asa-dock" role="toolbar">
  <button type="button" data-action="save_open" data-i18n="save" data-i18n-tooltip="true" data-tooltip="会話を保存">⬇</button>
  <button type="button" data-action="settings" data-i18n="settings" data-i18n-tooltip="true" data-tooltip="設定">🛠</button>
  <button type="button" data-action="toggle_realtime" data-i18n="realtime" data-i18n-tooltip="true" data-tooltip="キャプチャモード">⦿</button>
  <button type="button" data-action="toggle_topmost" data-i18n="toggle_topmost" data-i18n-tooltip="true" data-tooltip="ダイアログを最前面に固定">★</button>
</div>`;
  }

  function widgetSide() {
    try {
      const v = localStorage.getItem(WIDGET_SIDE_KEY);
      if (v === "left") return "left";
    } catch (_e) {
      void 0;
    }
    return "right";
  }

  function widgetHostStyle(mode) {
    if (mode === "fixed") {
      // PL-063: group chat 等 composer 全体が overflow:hidden card 内の case、
      // body 直挿入 + position:fixed で composer 制約完全回避。 left/top は
      // updateTrackedWidgetPos() が anchor rect から毎フレーム算出、 viewport
      // pin (= 段 2 reject) でなく composer 横 8px gap tracked floating で
      // 通常 chat と同位置感を実現。 z-index は site overlay より上位の max 値。
      return "position: fixed; left: 0; top: 0; z-index: 2147483647;";
    }
    if (widgetSide() === "left") {
      return "position: absolute; top: 50%; left: 0; transform: translate(calc(-100% - 8px), -50%); z-index: 10;";
    }
    return "position: absolute; top: 50%; right: 0; transform: translate(calc(100% + 8px), -50%); z-index: 10;";
  }

  // PL-063: tracked position update。 composer rect から widget 配置を毎回算出、
  // ResizeObserver / scroll / resize trigger で rAF throttle 経由呼出。
  // form mode (= 通常 chat) では呼出しない (= early return)。
  function updateTrackedWidgetPos() {
    const host = state.widgetHost;
    const anchor = state.widgetAnchor;
    if (!host || !anchor) return;
    if (anchor.tagName === "FORM") return;
    try {
      const aRect = anchor.getBoundingClientRect();
      const hRect = host.getBoundingClientRect();
      const w = hRect.width || 88;
      const h = hRect.height || 88;
      let x;
      if (widgetSide() === "right") {
        x = aRect.right + 8;
      } else {
        x = aRect.left - w - 8;
      }
      let y = aRect.top + aRect.height / 2 - h / 2;
      x = Math.max(8, Math.min(x, window.innerWidth - w - 8));
      y = Math.max(8, Math.min(y, window.innerHeight - h - 8));
      host.style.left = x + "px";
      host.style.top = y + "px";
    } catch (_e) {
      void 0;
    }
  }

  function scheduleTrackedUpdate() {
    if (state.widgetRafPending) return;
    state.widgetRafPending = true;
    try {
      window.requestAnimationFrame(() => {
        state.widgetRafPending = false;
        updateTrackedWidgetPos();
      });
    } catch (_e) {
      state.widgetRafPending = false;
    }
  }

  function attachTrackedListeners() {
    detachTrackedListeners();
    try {
      if (window.ResizeObserver) {
        state.widgetAnchorResizeObs = new ResizeObserver(scheduleTrackedUpdate);
        if (state.widgetAnchor) state.widgetAnchorResizeObs.observe(state.widgetAnchor);
        state.widgetHostResizeObs = new ResizeObserver(scheduleTrackedUpdate);
        if (state.widgetHost) state.widgetHostResizeObs.observe(state.widgetHost);
      }
    } catch (_e) {
      void 0;
    }
    state.widgetTrackedScrollListener = scheduleTrackedUpdate;
    state.widgetTrackedResizeListener = scheduleTrackedUpdate;
    try { window.addEventListener("scroll", state.widgetTrackedScrollListener, true); } catch (_e) { void 0; }
    try { window.addEventListener("resize", state.widgetTrackedResizeListener); } catch (_e) { void 0; }
  }

  function detachTrackedListeners() {
    try { if (state.widgetAnchorResizeObs) state.widgetAnchorResizeObs.disconnect(); } catch (_e) { void 0; }
    try { if (state.widgetHostResizeObs) state.widgetHostResizeObs.disconnect(); } catch (_e) { void 0; }
    state.widgetAnchorResizeObs = null;
    state.widgetHostResizeObs = null;
    if (state.widgetTrackedScrollListener) {
      try { window.removeEventListener("scroll", state.widgetTrackedScrollListener, true); } catch (_e) { void 0; }
      state.widgetTrackedScrollListener = null;
    }
    if (state.widgetTrackedResizeListener) {
      try { window.removeEventListener("resize", state.widgetTrackedResizeListener); } catch (_e) { void 0; }
      state.widgetTrackedResizeListener = null;
    }
  }

  function findComposerAnchor() {
    for (const sel of WIDGET_PROMPT_SELECTORS) {
      const el = document.querySelector(sel);
      if (el) {
        const form = el.closest("form");
        if (form) return form;
      }
    }
    // PL-063: group chat (chatgpt.com/gg/<id>) は <form> 不在 + data-testid 剥離 +
    // ProseMirror editor 単体構造で上記 4 selector 全 null になる。 fallback として
    // ProseMirror editor の祖先を 6 階層遡り、 composer-plus-btn を含む composer
    // wrapper を anchor として返却。
    const editor = document.querySelector('div.ProseMirror[contenteditable="true"]');
    if (editor) {
      let node = editor.parentElement;
      let depth = 0;
      while (node && depth < 6) {
        if (node.querySelector('[data-testid="composer-plus-btn"]')) {
          return node;
        }
        node = node.parentElement;
        depth++;
      }
    }
    return null;
  }

  function ensureAnchorPositioned(anchor) {
    try {
      const computed = getComputedStyle(anchor).position;
      if (computed === "static" || !computed) {
        anchor.style.position = "relative";
      }
    } catch (_e) {
      void 0;
    }
  }

  function buildWidgetHost(mode) {
    const host = document.createElement("div");
    host.id = WIDGET_HOST_ID;
    host.style.cssText = widgetHostStyle(mode);
    const root = host.attachShadow({ mode: "closed" });
    root.innerHTML = widgetTemplate();
    state.widgetRoot = root;
    state.widgetHost = host;
    bindWidgetTheme(host);
    bindWidgetButtons(root);
    applyWidgetI18n(state.widgetLang);
    applyCachedTopmost(root);
    return host;
  }

  function applyCachedTopmost(root) {
    let cached;
    try {
      const v = localStorage.getItem(WIDGET_TOPMOST_KEY);
      if (v === "true") cached = true;
      else if (v === "false") cached = false;
    } catch (_e) {
      void 0;
    }
    if (cached === undefined) return;
    const btn = root.querySelector('[data-action="toggle_topmost"]');
    if (btn) {
      btn.dataset.active = cached ? "true" : "false";
      btn.textContent = cached ? "★" : "☆";
    }
  }

  function injectWidget() {
    if (!document.body) return false;
    const existing = document.getElementById(WIDGET_HOST_ID);
    const anchor = findComposerAnchor();
    if (!anchor) {
      if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
      detachTrackedListeners();
      state.widgetAnchor = null;
      return false;
    }
    // PL-063: form anchor (= 通常 chat) は内部 absolute 配置、 fallback anchor
    // (= group chat の grid wrapper) は composer card 全体が overflow:hidden で
    // clip されるため body 直挿入 + position:fixed + composer rect 追従の tracked
    // floating で 「通常 chat と同位置感」 (= composer 横 8px gap) を実現。
    const isForm = anchor.tagName === "FORM";
    const mode = isForm ? "anchor" : "fixed";
    const parent = isForm ? anchor : document.body;
    if (isForm) {
      ensureAnchorPositioned(anchor);
    }
    if (existing && existing.parentElement === parent) {
      const anchorChanged = state.widgetAnchor !== anchor;
      state.widgetAnchor = anchor;
      if (!isForm) {
        if (anchorChanged) {
          attachTrackedListeners();
        }
        scheduleTrackedUpdate();
      }
      return true;
    }
    if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
    if (isForm) {
      detachTrackedListeners();
    }
    const host = buildWidgetHost(mode);
    parent.appendChild(host);
    state.widgetAnchor = anchor;
    if (!isForm) {
      attachTrackedListeners();
      scheduleTrackedUpdate();
    }
    return true;
  }

  // PL-046: ephemeral auto-capture observer。 content script load 時点から
  // body subtree mutation を監視 (300ms debounce)、 isTemporaryChat 時に
  // mergeEphemeralIntoBuffer を逐次呼出、 realtime ON/OFF と独立して全 turn を
  // buffer 蓄積。 これで realtime 未起動の通常保存でも 1 番目 user 発言を保護
  // (= unmount 前に observer が捕捉済)、 競合拡張と同等の DOM-monitoring 設計。
  function attachEphemeralAutoCapture() {
    if (state.ephemeralAutoObserver) return;
    try {
      let pending = false;
      const trigger = () => {
        if (pending) return;
        pending = true;
        setTimeout(() => {
          pending = false;
          try {
            if (isTemporaryChat()) {
              mergeEphemeralIntoBuffer();
            }
          } catch (_e) {}
        }, 300);
      };
      const obs = new MutationObserver(trigger);
      obs.observe(document.body, { childList: true, subtree: true, characterData: true });
      state.ephemeralAutoObserver = obs;
      trigger();
    } catch (_e) {
      void 0;
    }
  }

  function attachAnchorObserver() {
    if (state.widgetObserver) return;
    try {
      const obs = new MutationObserver(() => {
        const anchor = findComposerAnchor();
        const host = document.getElementById(WIDGET_HOST_ID);
        if (!anchor) return;
        const expectedParent = anchor.tagName === "FORM" ? anchor : document.body;
        if (!host || host.parentElement !== expectedParent) {
          injectWidget();
        }
      });
      obs.observe(document.body, { childList: true, subtree: true });
      state.widgetObserver = obs;
    } catch (_e) {
      void 0;
    }
    if (!state.widgetSentinelInterval) {
      state.widgetSentinelInterval = window.setInterval(() => {
        const anchor = findComposerAnchor();
        const host = document.getElementById(WIDGET_HOST_ID);
        if (!anchor) return;
        const expectedParent = anchor.tagName === "FORM" ? anchor : document.body;
        if (!host || host.parentElement !== expectedParent) {
          injectWidget();
        }
      }, 1000);
    }
  }

  function bindWidgetTheme(host) {
    try {
      const html = document.documentElement;
      const apply = () => {
        const cs = getComputedStyle(html).colorScheme || "";
        host.dataset.theme = cs.includes("dark") ? "dark" : "light";
      };
      apply();
      const obs = new MutationObserver(apply);
      obs.observe(html, { attributes: true, attributeFilter: ["style", "class"] });
    } catch (_e) {
      host.dataset.theme = "light";
    }
  }

  function bindWidgetButtons(root) {
    // PL-024 CP2: save_open click 同期で snapshot を build → widget_action payload に同梱 (push model)。
    // 旧 capture pull (background → tab cache 経由 sendMessage) は tab race の温床、push で sender tab 自確定。
    // PL-025 CP3 で split events 化を試行したが ChatGPT (sync push) では min_show_ms 300ms が
    // 逆 UX 劣化 (本来 ms 完了の sync 経路に visual ack のため待ち時間上乗せ) と判明、撤去。
    // split events は async 経路 (Gemini collectConversation 数分 wait) 限定で CP5 採用予定。
    // PL-038 Phase 3: save_open は API hook 経由 (page_hook.js cache + active
    // replay) で全 turn 取得を試行、 失敗時は DOM extraction に fallback、 auto-scroll
    // は走らせない (PL-036 §invariant #4 整合)。 中核は performWidgetAction に集約
    // (PL-095: popup からも同一経路を呼ぶため)。
    const wrap = (action) => async (e) => {
      e.preventDefault();
      e.stopPropagation();
      await performWidgetAction(action);
    };
    root.querySelector('[data-action="save_open"]').addEventListener("click", wrap("save_open"));
    root.querySelector('[data-action="settings"]').addEventListener("click", wrap("settings"));
    root.querySelector('[data-action="toggle_realtime"]').addEventListener("click", wrap("toggle_realtime"));
    root.querySelector('[data-action="toggle_topmost"]').addEventListener("click", wrap("toggle_topmost"));
    root.querySelectorAll('button[data-i18n-tooltip="true"]').forEach((btn) => {
      btn.addEventListener("mouseenter", () => {
        const rect = btn.getBoundingClientRect();
        btn.dataset.tooltipPos = rect.top < 40 ? "down" : "up";
      });
    });
  }

  function applyWidgetI18n(lang) {
    state.widgetLang = lang;
    if (!state.widgetRoot) return;
    const dict = WIDGET_I18N[lang] || WIDGET_I18N.ja;
    if (state.widgetHost) state.widgetHost.dataset.lang = lang;
    state.widgetRoot.querySelectorAll("[data-i18n]").forEach((el) => {
      const text = dict[el.dataset.i18n];
      if (!text) return;
      if (el.dataset.i18nTooltip === "true") {
        el.dataset.tooltip = text;
        el.setAttribute("aria-label", text);
      } else {
        el.textContent = text;
      }
    });
  }

  function applyWidgetState(ws) {
    if (!state.widgetRoot || !ws || typeof ws !== "object") return;
    if (ws.lang) applyWidgetI18n(String(ws.lang));
    if (ws.capabilities && typeof ws.capabilities === "object") {
      const caps = ws.capabilities;
      const rt = state.widgetRoot.querySelector('[data-action="toggle_realtime"]');
      const tp = state.widgetRoot.querySelector('[data-action="toggle_topmost"]');
      if (rt) rt.style.display = caps.realtime === false ? "none" : "";
      if (tp) tp.style.display = caps.toggle_topmost === false ? "none" : "";
    }
    if (typeof ws.realtime_state === "boolean") {
      const btn = state.widgetRoot.querySelector('[data-action="toggle_realtime"]');
      if (btn) btn.dataset.active = ws.realtime_state ? "true" : "false";
    }
    if (typeof ws.dialog_topmost === "boolean") {
      const btn = state.widgetRoot.querySelector('[data-action="toggle_topmost"]');
      if (btn) {
        btn.dataset.active = ws.dialog_topmost ? "true" : "false";
        btn.textContent = ws.dialog_topmost ? "★" : "☆";
      }
      try {
        localStorage.setItem(WIDGET_TOPMOST_KEY, ws.dialog_topmost ? "true" : "false");
      } catch (_e) {
        void 0;
      }
    }
  }

  function ensureWidget() {
    const tryInject = () => {
      if (!document.body) return;
      attachEphemeralAutoCapture();
      const ok = injectWidget();
      if (ok) {
        attachAnchorObserver();
      } else {
        window.setTimeout(tryInject, WIDGET_ANCHOR_RETRY_MS);
      }
    };
    if (document.body) {
      tryInject();
    } else {
      window.addEventListener("DOMContentLoaded", tryInject, { once: true });
    }
  }

  window.setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);

  sendRuntimeMessage({
    type: "content_ready",
    url: location.href,
    title: document.title || "ChatGPT_Conversation",
    observedAt: new Date().toISOString()
  });
  reportStatus(true);
  ensureWidget();
})();