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

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

  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";
  // PL-013 CP2: Claude composer anchor。`fieldset.flex.w-full` 1 次、
  // `div.sticky.bottom-0` 2 次 fallback。probe survey (2026-05-02) 結果。
  // /code (Code モード) は別レイアウトで上記が不在 → findComposerAnchor 内で
  // 生 editable から composer を辿る (epitaxy class は session 行で再利用され
  // querySelector では先頭=上部の別物を掴むため、 class 直指定は不可。probe 2026-06-27)。
  const WIDGET_ANCHOR_SELECTORS = [
    'fieldset.flex.w-full',
    'div.sticky.bottom-0',
  ];
  const WIDGET_ANCHOR_RETRY_MS = 1000;
  const _initialIncognito = (function () {
    try {
      return new URLSearchParams(location.search).has("incognito");
    } catch (_e) { return false; }
  })();
  const state = {
    lastUrl: location.href,
    lastStatusKey: "",
    realtimeBuffer: [],
    realtimeCapturing: false,
    realtimeMutationObserver: null,
    realtimeDebounceTimer: null,
    widgetRoot: null,
    widgetHost: null,
    widgetAnchor: null,
    widgetObserver: null,
    widgetSentinelInterval: null,
    widgetLang: "ja",
  };

  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: "ダイアログを最前面に固定" },
    en: { save: "Save conversation", settings: "Preferences", realtime: "Capture mode", toggle_topmost: "Pin dialogs to front" },
  };

  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 hasConversationContent() {
    try {
      return Boolean(
        document.querySelector('[data-testid="user-message"], [data-is-streaming]')
      );
    } catch (_error) {
      return false;
    }
  }

  function currentPageState() {
    const href = String(location.href || "");
    if (!/https:\/\/claude\.ai\//.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";
  }

  function collectStatusDetails() {
    try {
      const userMessageCount = document.querySelectorAll('[data-testid="user-message"]').length;
      const aiResponseCount = document.querySelectorAll('[data-is-streaming]').length;
      const loginFormPresent = Boolean(document.querySelector('form button[type="submit"], input[type="email"], input[type="password"]'));
      const challengePresentNow = challengePresent();
      return {
        observerVersion: "2026-04-05-claude-v1",
        userMessageCount,
        aiResponseCount,
        loginFormPresent,
        challengePresent: challengePresentNow,
        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 '';
    }
  }

  // PL-011: Claude の <pre class="code-block__code"> を code container として
  // 捕捉し、inner <code class="language-X"> から lang 属性 + body を抽出。
  // 本文中は marker [[ASA_CODE_BLOCK_<idx>]] で置換、本体は code_blocks 配列に
  // 積む。Claude DOM は祖先 [role="group"][aria-label*="コード"] が code 全体
  // (lang ラベル div + sticky copy button + <pre>) を wrap しているため、
  // group 内の lang ラベル単独 div は marker pass-through で skip。
  // PL-016 (Gemini) / PL-035 (ChatGPT) と同 contract で md は marker→平文戻し、
  // html のみ fenced code block 化。
  const PL011_UI_SKIP = new Set(['button', 'svg', 'use', 'script', 'style']);

  function deepText(node) {
    const codeBlocks = [];
    function collect(n) {
      if (!n) return '';
      if (n.nodeType === Node.TEXT_NODE) { return n.nodeValue || ''; }
      const tn = (n.localName || '').toLowerCase();
      if (PL011_UI_SKIP.has(tn)) return '';
      if (n.getAttribute && n.getAttribute('role') === 'button') return '';
      if (tn === 'br') return '\n';
      if (tn === 'table') {
        const md = tableToMarkdown(n);
        if (md) return md;
      }
      let s = '';
      for (const c of n.childNodes) { s += collect(c); }
      const cls = (typeof n.className === 'string') ? n.className : '';
      const alreadyWrapped = s.indexOf('[[ASA_CODE_BLOCK_') !== -1;
      if (tn === 'pre') {
        if (alreadyWrapped) {
          const markers = s.match(/\[\[ASA_CODE_BLOCK_\d+\]\]/g) || [];
          return markers.length ? '\n' + markers.join('\n') + '\n' : '';
        }
        const codeEl = n.querySelector('code');
        const body = codeEl ? (codeEl.textContent || '') : s;
        let lang = '';
        if (codeEl) {
          const codeCls = String(codeEl.className || '');
          const m = codeCls.match(/language-([a-zA-Z0-9_+\-]+)/);
          if (m) lang = m[1];
        }
        const idx = codeBlocks.length;
        codeBlocks.push({ body: body.replace(/^\n+|\n+$/g, ''), lang });
        return '\n[[ASA_CODE_BLOCK_' + idx + ']]\n';
      }
      // Claude 固有: code container 全体を wrap する <div role="group"
      // aria-label="...コード"> 内の lang ラベル div / copy button を排除し
      // marker のみ通す。
      const role = n.getAttribute ? (n.getAttribute('role') || '') : '';
      const ariaLabel = n.getAttribute ? (n.getAttribute('aria-label') || '') : '';
      if (role === 'group' && /コード|code/i.test(ariaLabel) && alreadyWrapped) {
        const markers = s.match(/\[\[ASA_CODE_BLOCK_\d+\]\]/g) || [];
        return markers.length ? '\n' + markers.join('\n') + '\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, code_blocks: codeBlocks };
  }

  // PL-012: file-only upload (本文空 + 添付のみ) 専用コンテナの本文生成。
  // 添付要素は 2 系統:
  //   (A) `.txt` 等のテキスト系: `[data-testid="file-thumbnail"]` + 内部
  //       `button[aria-label]` (aria-label 形式 `<name>, <ext>, <NN行>` の
  //       最初のセグメント = file 名)
  //   (B) 画像/PDF 系: `div.group/thumbnail` + 内部 `img[alt]`
  //       (alt 属性に拡張子込み file 名がそのまま入っている)
  // 両系統を同 selector で union し、aria-label 優先 → img.alt 後段
  // → innerText 最終 fallback で file 名を抽出。
  function readAttachmentContainerDeep(container) {
    const lines = [];
    try {
      const thumbs = container.querySelectorAll(
        '[data-testid="file-thumbnail"], div.group\\/thumbnail'
      );
      thumbs.forEach((t) => {
        let name = '';
        const btn = t.querySelector('button[aria-label]');
        if (btn) {
          const raw = String(btn.getAttribute('aria-label') || '').trim();
          if (raw) {
            const firstSeg = raw.split(/,\s+/)[0].trim();
            name = firstSeg || raw;
          }
        }
        if (!name) {
          const img = t.querySelector('img[alt]');
          if (img) name = String(img.alt || '').trim();
        }
        if (!name) {
          const txt = String(t.innerText || '').trim().split(/\n/)[0];
          if (txt) name = txt;
        }
        if (name) lines.push('[添付ファイル: ' + name + ']');
      });
    } catch (_e) {}
    // 複数 file 間は空行を挟む。3 版 (Gemini PL-017 / ChatGPT PL-037) で
    // deepText が `\n[添付ファイル: <name>]\n` を返し連結時に `\n\n` で
    // 空行が入るため、本関数も `\n\n` join で contract を揃える。
    return { text: lines.join('\n\n'), code_blocks: [] };
  }

  function readMessageDeep(element) {
    try {
      if (element && element.matches && element.matches('[data-asa-attachment-only]')) {
        return readAttachmentContainerDeep(element);
      }
      const clone = element.cloneNode(true);
      // PL-XXX: 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(
        '[aria-live], [role="status"], [role="log"], [role="alert"], ' +
        '[class*="sr-only"], [class*="visually-hidden"], [class*="visuallyhidden"]'
      ).forEach((node) => { node.remove(); });
      clone.querySelectorAll(
        'button[aria-expanded], details, [class*="thinking"], [class*="reasoning"]'
      ).forEach((node) => { node.remove(); });
      const result = deepText(clone);
      // PL-012 mixed turn: user-message と同 turn wrapper 内に attachment
      // container がある場合 (本文 + 添付) は file 名 placeholder を本文先頭に
      // 注入する。collectAttachmentOnlyTurns 側は merge marker のみ立てて
      // skip しているため、ここで合流させる。
      if (element && element.matches && element.matches('[data-testid="user-message"]')) {
        const merge = findMergeAttachmentContainer(element);
        if (merge) {
          const inner = readAttachmentContainerDeep(merge);
          if (inner && inner.text) {
            result.text = inner.text + (result.text ? '\n\n' + result.text : '');
          }
        }
      }
      return result;
    } catch (_error) {
      return { text: readMessageText(element), code_blocks: [] };
    }
  }

  function readMessageText(element) {
    try {
      const clone = element.cloneNode(true);
      // PL-XXX: 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));
        }
      });
      // a11y sr-only ノード除去。aria-live / role=status|log|alert / visually-hidden class。
      // Claude の sr-only アナウンス (「Claudeが返答しました:」+冒頭数文) を含む。
      // ノード自身削除に統一（親削除は可視本文を巻き込むリスクあり）。
      clone.querySelectorAll(
        '[aria-live], [role="status"], [role="log"], [role="alert"], ' +
        '[class*="sr-only"], [class*="visually-hidden"], [class*="visuallyhidden"]'
      ).forEach((node) => { node.remove(); });
      // 思考(Extended Thinking) トグル + ラッパー除去:
      //   [data-is-streaming] 内の button[aria-expanded] は思考トグル、<details> / thinking|reasoning class も同様。
      clone.querySelectorAll(
        'button[aria-expanded], details, [class*="thinking"], [class*="reasoning"]'
      ).forEach((node) => { node.remove(); });
      let text = (clone.innerText || "").replace(/\n{3,}/g, "\n\n").trim();
      if (!text) return "";
      // 冒頭重複 dedupe (保険): 冒頭 80 char 以内のオフセット start で
      //   text[start..start+k] === text[start+k..start+2k] となる最長 k を探し、1 コピー落とす。
      // 文末記号の直後のときだけ発動。
      const maxStart = Math.min(80, text.length - 16);
      for (let start = 0; start < maxStart; start++) {
        const probe = text.substr(start, 8);
        if (!probe) break;
        const next = text.indexOf(probe, start + 1);
        if (next === -1) continue;
        const k = next - start;
        if (k < 8 || start + 2 * k > text.length) continue;
        if (text.substr(start, k) !== text.substr(start + k, k)) continue;
        const boundary = text.charAt(start + k - 1);
        if (!/[。.!?！？\n]/.test(boundary)) continue;
        text = (text.substr(0, start) + text.substr(start + k)).replace(/\s+$/, "").replace(/^\s+/, "");
        break;
      }
      return text;
    } catch (_error) {
      try {
        return (element.innerText || element.textContent || "").trim();
      } catch (_error2) {
        return "";
      }
    }
  }

  // PL-012: file-only upload (本文空 + 添付のみ) の user turn は Claude DOM で
  // `[data-testid="user-message"]` を付与されないため通常の collectMessageNodes
  // ルートで漏れる。`[data-testid="file-thumbnail"]` を起点に祖先チェーンから
  // turn container (`flex-wrap` + `justify-end` を持つ祖先) を特定し
  // `data-asa-attachment-only` marker を立てて messages に追加する。
  // composer (送信前 preview) / 既存 user-message と重複する turn は除外。
  function findAttachmentContainer(thumb) {
    let cur = thumb;
    let depth = 0;
    while (cur && depth < 6) {
      const cls = String(cur.className || '');
      if (/\bflex-wrap\b/.test(cls) && /\bjustify-end\b/.test(cls)) {
        return cur;
      }
      cur = cur.parentElement;
      depth++;
    }
    return null;
  }

  function isComposerAttachment(node) {
    let cur = node;
    let depth = 0;
    while (cur && depth < 12) {
      try {
        if (cur.matches && (
          cur.matches('[contenteditable]') ||
          cur.matches('form') ||
          cur.matches('[role="textbox"]') ||
          cur.matches('[data-testid*="composer"]') ||
          cur.matches('[data-testid*="prompt"]')
        )) return true;
      } catch (_e) {}
      cur = cur.parentElement;
      depth++;
    }
    return false;
  }

  function attachmentContainerCoversUserMessage(container, userNodes) {
    // turn wrapper = container.parentElement (典型的に `mb-1 mt-6 group`)。
    // 同じ turn wrapper 内に user-message があれば mixed turn と判定して
    // attachment 側を skip し user-message 経路に処理を委ねる。
    // 旧実装は depth 6 まで遡って他 turn の user-message を巻き込んでいた
    // (会話中に text user-message が 1 件でも存在すれば全 file-only turn が
    // 誤って skip される regression)。turn wrapper 1 段に絞る。
    const turnWrapper = container.parentElement;
    if (!turnWrapper) return false;
    for (const u of userNodes) {
      if (turnWrapper.contains(u) && !container.contains(u)) return true;
    }
    return false;
  }

  function collectAttachmentOnlyTurns(existingUserNodes) {
    // 起点 thumb は 2 系統 union (テキスト系 testid + 画像/PDF 系 group/thumbnail)。
    // findAttachmentContainer は `flex-wrap justify-end` ancestor を要求するため、
    // artifacts panel / file viewer sidebar 内の `group/thumbnail`
    // (depth 0 が `grid grid-cols-...`) は自動的に null 判定で skip される。
    const thumbs = Array.from(document.querySelectorAll(
      '[data-testid="file-thumbnail"], div.group\\/thumbnail'
    ));
    const onlyContainers = new Set();
    const mergeContainers = new Set();
    for (const thumb of thumbs) {
      if (isComposerAttachment(thumb)) continue;
      if (existingUserNodes.some((u) => u.contains(thumb))) continue;
      const container = findAttachmentContainer(thumb);
      if (!container) continue;
      // mixed turn (本文 + 添付): user-message 経路に処理を委ねるが、attachment
      // 側を merge marker で印付けして readMessageDeep の user-message 分岐から
      // 参照させる。skip してしまうと file 名が完全に失われるため。
      if (attachmentContainerCoversUserMessage(container, existingUserNodes)) {
        mergeContainers.add(container);
        continue;
      }
      onlyContainers.add(container);
    }
    Array.from(onlyContainers).forEach((c) => {
      try { c.setAttribute('data-asa-attachment-only', '1'); } catch (_e) {}
    });
    Array.from(mergeContainers).forEach((c) => {
      try { c.setAttribute('data-asa-attachment-merge', '1'); } catch (_e) {}
    });
    return Array.from(onlyContainers);
  }

  // PL-012 mixed turn: user-message と同 turn wrapper 内にある attachment
  // container を検出する。`attachmentContainerCoversUserMessage` の逆方向
  // (container.parentElement.contains(userMessage)) で完全一致判定するため
  // 異 turn の merge container を誤参照しない。
  function findMergeAttachmentContainer(userMessage) {
    const merges = document.querySelectorAll('[data-asa-attachment-merge]');
    for (const m of merges) {
      const tw = m.parentElement;
      if (tw && tw.contains(userMessage) && !m.contains(userMessage)) return m;
    }
    return null;
  }

  function collectMessageNodes() {
    const userNodes = Array.from(document.querySelectorAll('[data-testid="user-message"]'));
    const attachmentOnlyNodes = collectAttachmentOnlyTurns(userNodes);
    const aiNodes = Array.from(document.querySelectorAll('[data-is-streaming]'));
    const fallbackAiNodes = Array.from(document.querySelectorAll('[class*="markdown"]')).filter((node) => {
      return !aiNodes.some((ai) => node === ai || ai.contains(node) || node.contains(ai));
    }).filter((node) => {
      return !userNodes.some((u) => u.contains(node) || node.contains(u));
    }).filter((node) => {
      return !attachmentOnlyNodes.some((a) => a.contains(node) || node.contains(a));
    });
    const unique = [];
    const seen = new Set();
    for (const node of [...userNodes, ...attachmentOnlyNodes, ...aiNodes, ...fallbackAiNodes]) {
      if (!node || seen.has(node)) {
        continue;
      }
      seen.add(node);
      unique.push(node);
    }
    unique.sort((a, b) => {
      if (a === b) return 0;
      const pos = a.compareDocumentPosition(b);
      if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
      if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1;
      return 0;
    });
    return unique;
  }

  function inferRole(node) {
    if (node.matches('[data-testid="user-message"]')) {
      return 'user';
    }
    if (node.matches('[data-asa-attachment-only]')) {
      return 'user';
    }
    if (node.matches('[data-is-streaming]')) {
      return 'assistant';
    }
    const className = String(node.className || '').toLowerCase();
    if (className.includes('user')) {
      return 'user';
    }
    return 'assistant';
  }

  function dedupeMessages(items) {
    // PL-014: 旧実装は `previousKey` 比較で adjacent 同 role+text を collapse して
    // いたが、Claude UI が regenerate / 連投で**異なる DOM node に同 text** を
    // render する場合 (例: 添付ファイル連投で "受信を確認した。" 連続応答) に
    // legit な user-meaningful turn を 1 つ消す副作用があった。
    // 同一 DOM node の重複検出は `collectMessageNodes` の Set-based node identity
    // dedup で既に行っているため、text-based adjacency dedup は冗長 + 過剰。
    // role normalize / code_blocks default の作用のみ残す。
    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 codeBlocks = Array.isArray(item && item.code_blocks) ? item.code_blocks : [];
      result.push({ role, text, code_blocks: codeBlocks });
    }
    return result;
  }

  function extractMessages() {
    const nodes = collectMessageNodes();
    return dedupeMessages(nodes.map((node) => {
      const role = inferRole(node);
      const deep = readMessageDeep(node);
      return { role, text: deep.text, code_blocks: deep.code_blocks };
    }).filter((item) => item.text));
  }

  // === PL-039 Phase 1: API hook bridge (page_hook.js MAIN world) ===
  // 3 版 capture path parity (= ChatGPT PL-038 Phase 3 / Gemini PL-036 と同 contract)。
  // page_hook.js が site の /api/organizations/{org}/chat_conversations/{cid} GET
  // を passive sniff で cache、 save 経路は cache から chat_messages array を
  // parse する path 優先 + DOM fallback (= cache miss + active replay 失敗 等)。
  const PAGE_HOOK_MESSAGE_SOURCE = 'asa-claude-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' || data.type === 'snapshot-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' };
    }
  }

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

  // PL-120: 共有ページ (/share/<id>) 判定 + snapshot id 抽出。
  function getShareSnapshotId() {
    try {
      const m = location.pathname.match(/\/share\/([0-9a-f-]{36})/i);
      return m ? m[1] : null;
    } catch (_e) {
      return null;
    }
  }
  function isClaudeSharePage() {
    return !!getShareSnapshotId();
  }

  // PL-120: 共有ページは cid 不在で通常会話 API 経路が使えないが、 org スコープ snapshot API が
  // widget_code 込みの完全構造を返す (render_all_tools=true、 閲覧者の org 文脈・オーナー不問)。
  // 既存 snapshot parser + rasterize に流し本文・図・SVG を欠落なく保存する。
  async function extractMessagesViaShareSnapshot() {
    const sid = getShareSnapshotId();
    if (!sid) return null;
    const res = await requestSnapshotReplay(sid);
    if (!res || !res.ok || !res.text) return null;
    const msgs = ASA_PARSE.parseClaudeSnapshotApiResponse(res.text);
    if (!Array.isArray(msgs) || msgs.length === 0) return null;
    await _pl078cRasterizeBlocks(msgs);  // 図 SVG → PNG (pdf 対応)
    await _pl115RasterizeWidgets(msgs);  // Chart.js widget → sandbox 描画→画像
    return msgs;
  }

  function getCurrentConversationId() {
    try {
      const m = location.pathname.match(/\/chat\/([0-9a-f-]{36})/i);
      return m ? m[1] : null;
    } catch (_e) {
      return null;
    }
  }

  // PL-136: SVG → PNG のラスタライズ本体は conversation_parse.js へ移した
  // (取り込みアドオンの画面からも同じ後段が要るため)。ここは呼ぶだけ。
  const _pl078cRasterizeBlocks = (msgs) => ASA_PARSE.rasterizeSvgBlocks(msgs);

  // ===== PL-115: Claude の対話型グラフ (Chart.js 等 HTML widget) を画像化 =====
  // visualize:show_widget の widget_code が React/HTML で <svg> でない場合 (PL-078 Phase C は
  // [AI生成:] 印に degrade していた)。Chart.js を使う HTML widget は、拡張同梱の隔離
  // (sandbox) iframe で実描画→canvas を PNG 化し、SVG 救出と同じ image_blocks
  // 経路に合流させる (html/pdf 埋込)。AI 製コードは opaque origin で動くため claude の
  // 権限に触れない。描画不能 (非 Chart.js / 失敗) は従来の [AI生成:] 印に安全 degrade。
  let _pl115Frame = null;
  let _pl115Ready = null;
  let _pl115Seq = 0;
  const _pl115Pending = new Map();

  // 描画を試みるべき widget か (HTML 断片 + Chart.js / canvas)。React/JSX や非対応 lib は false。
  function _pl115EnsureFrame() {
    if (_pl115Ready) return _pl115Ready;
    _pl115Ready = new Promise((resolve) => {
      let settled = false;
      const finish = (v) => { if (!settled) { settled = true; resolve(v); } };
      let url = '';
      try { url = chrome.runtime.getURL('render_sandbox.html'); } catch (_e) { finish(false); return; }
      const f = document.createElement('iframe');
      f.setAttribute('aria-hidden', 'true');
      f.style.cssText = 'position:fixed; left:-99999px; top:0; width:820px; height:1400px; border:0; visibility:hidden; pointer-events:none;';
      f.src = url;
      window.addEventListener('message', (ev) => {
        const d = ev.data;
        if (!d || typeof d !== 'object') return;
        if (d.type === 'asa_pl115_ready' && f.contentWindow && ev.source === f.contentWindow) {
          finish(true);
        } else if (d.type === 'asa_pl115_result') {
          const cb = _pl115Pending.get(d.id);
          if (cb) { _pl115Pending.delete(d.id); cb(Array.isArray(d.pngs) ? d.pngs : []); }
        }
      });
      f.addEventListener('error', () => finish(false));
      (document.documentElement || document.body).appendChild(f);
      _pl115Frame = f;
      setTimeout(() => finish(false), 6000);   // ready timeout (CSP で frame 不可 等)
    });
    return _pl115Ready;
  }

  // widget_code を sandbox で描画して各図の PNG data URI 配列を返す。失敗時 []。
  function _pl115RenderWidget(code) {
    return new Promise((resolve) => {
      _pl115EnsureFrame().then((ok) => {
        if (!ok || !_pl115Frame || !_pl115Frame.contentWindow) { resolve([]); return; }
        const id = ++_pl115Seq;
        let settled = false;
        const finish = (v) => { if (!settled) { settled = true; resolve(Array.isArray(v) ? v : []); } };
        _pl115Pending.set(id, finish);
        try {
          _pl115Frame.contentWindow.postMessage({ type: 'asa_pl115_render', id: id, code: String(code || '') }, '*');
        } catch (_e) { _pl115Pending.delete(id); finish([]); return; }
        setTimeout(() => { if (_pl115Pending.has(id)) { _pl115Pending.delete(id); finish([]); } }, 15000);
      }, () => resolve([]));
    });
  }

  // msgs の _pl115pending (Chart.js widget) を sandbox 描画し、各図を image_blocks に
  // 載せ替えて sentinel [[ASA_WIDGET_i]] を [[ASA_IMAGE_N]] 群へ置換。描画失敗は
  // [AI生成: title] 印に degrade (会話保存を壊さない)。_pl078cRasterizeBlocks と同列の後段。
  async function _pl115RasterizeWidgets(msgs) {
    if (!Array.isArray(msgs)) return msgs;
    for (const m of msgs) {
      const pend = m && m._pl115pending;
      if (!Array.isArray(pend) || !pend.length) { if (m && m._pl115pending) delete m._pl115pending; continue; }
      if (!Array.isArray(m.image_blocks)) m.image_blocks = [];
      for (let i = 0; i < pend.length; i++) {
        const w = pend[i] || {};
        const sentinel = `[[ASA_WIDGET_${i}]]`;
        let pngs = [];
        try { pngs = await _pl115RenderWidget(w.code); } catch (_e) { pngs = []; }
        if (Array.isArray(pngs) && pngs.length) {
          const markers = [];
          for (const png of pngs) {
            const n = m.image_blocks.length;
            m.image_blocks.push({ alt: w.title || '', src: '', data_uri: png, title: w.title || '', img_ref: '', render_as: 'chart' });
            markers.push(`[[ASA_IMAGE_${n}]]`);
          }
          m.text = String(m.text || '').split(sentinel).join(markers.join('\n\n'));
        } else {
          const label = w.title ? `AI生成: ${w.title}` : 'AI生成: ビジュアル';
          m.text = String(m.text || '').split(sentinel).join(`[${label}]`);
        }
      }
      delete m._pl115pending;
    }
    return msgs;
  }

  // ===== /PL-115 =====

  async function extractMessagesViaApi() {
    const cid = getCurrentConversationId();
    if (!cid) return null;
    let entry = await readConversationCache(cid);
    if (!entry || !entry.resp_text) {
      const replayResult = await requestConversationReplay(cid);
      if (replayResult && replayResult.ok) {
        entry = await readConversationCache(cid);
      }
    }
    if (!entry || !entry.resp_text) return null;
    const msgs = ASA_PARSE.parseClaudeConversationApiResponse(entry.resp_text);
    await _pl078cRasterizeBlocks(msgs);  // PL-078 Phase C: 図 SVG → PNG (pdf 対応)
    await _pl115RasterizeWidgets(msgs);  // PL-115: Chart.js widget → sandbox 描画→画像
    return msgs;
  }

  // ===== PL-107: claude.ai/code (Code モード / Remote Control) 全履歴取得 =====
  // /code は dframe 仮想スクロール (画面外 turn が DOM 不在) + user 発言が無印素テキスト
  // で DOM 経路では取れない。会話まるごとは裏 API の events ログに在るため API 専で取得:
  //   GET /v1/code/sessions/{cse_id}            (header anthropic-version 必須) → title 等
  //   GET /v1/code/sessions/{cse_id}/events?limit=500&cursor= → 生イベント (next_cursor 送り)
  // events → ASA メッセージ (選択=完全な作業ログ: 人間発言 + AI本文 + thinking + tool_use
  // + tool_result)。出力は既存 API 経路と同形 (asa_core renderer 無改修、markdown で描く)。
  const _CODE_API_VERSION = '2023-06-01';
  const _CODE_EVENTS_PAGE = 500;            // limit は 1..500 (実測、500 超は 400)
  const _CODE_BLOCK_TRUNC = 8000;           // tool_use 入力 / tool_result 本文 1 件上限
  let _codeSessionTitle = '';               // events fetch 時に stash、getChatTitle が参照

  function getCodeSessionId() {
    try {
      // URL は /code/session_XXXX だが API は cse_XXXX (suffix 共通)
      const m = location.pathname.match(/\/code\/(?:session_|cse_)?([A-Za-z0-9]+)/);
      return m ? ('cse_' + m[1]) : null;
    } catch (_e) { return null; }
  }

  function isCodeMode() { return !!getCodeSessionId(); }

  // /code 配下か (session 有無問わず)。 キャプチャモード不可判定に使う。
  function isCodePage() {
    try { return /^\/code(\/|$)/.test(location.pathname); } catch (_e) { return false; }
  }

  // /code (Code モード) はキャプチャモードが構造的に動かない (会話が DOM 不在・events
  // API 専、 保存ボタンで全取得可)。 ボタンをグレーアウトし理由を tooltip に出す。
  // SPA 遷移でも追従するよう URL 変化監視 + widget 構築時に呼ぶ。
  function updateRealtimeAvailability() {
    if (!state.widgetRoot) return;
    const btn = state.widgetRoot.querySelector('[data-action="toggle_realtime"]');
    if (!btn) return;
    if (isCodePage()) {
      btn.dataset.disabled = "true";
      btn.dataset.active = "false";
      btn.dataset.tooltip = state.widgetLang === "en"
        ? "Not available on this page"
        : "このページには対応していません";
    } else if (btn.dataset.disabled === "true") {
      delete btn.dataset.disabled;
      const dict = WIDGET_I18N[state.widgetLang] || WIDGET_I18N.ja;
      btn.dataset.tooltip = dict.realtime;
    }
  }

  async function _codeApiGet(path) {
    const r = await fetch(path, {
      headers: { accept: 'application/json', 'anthropic-version': _CODE_API_VERSION },
      credentials: 'include',
    });
    if (!r.ok) {
      // 401/403/400 は仕様変更 / 認証経路変更の早期検知シグナル
      console.warn('[ASA-PL107] code api ' + path + ' -> ' + r.status);
      return null;
    }
    return r.json().catch(() => null);
  }

  async function fetchCodeEvents(sid) {
    const base = '/v1/code/sessions/' + sid;
    const meta = await _codeApiGet(base);
    const shape = (meta && (meta.response_shape || meta)) || {};
    _codeSessionTitle = shape.title ? String(shape.title) : '';
    const all = [];
    let cursor = null, guard = 0;
    do {
      const url = base + '/events?limit=' + _CODE_EVENTS_PAGE +
        (cursor ? '&cursor=' + encodeURIComponent(cursor) : '');
      const page = await _codeApiGet(url);
      if (!page) break;
      const d = Array.isArray(page.data) ? page.data : [];
      for (const e of d) all.push(e);
      const prev = cursor;
      cursor = page.next_cursor || null;
      if (!cursor || cursor === prev || d.length === 0) break;
      guard++;
    } while (guard < 40);    // 40 * 500 = 20000 event 上限 (暴走 backstop)
    return all;
  }

  // Claude Code が人間発言に注入するラッパー/機械文を除去。空なら本物の発言なしと判定。
  const _CODE_MACHINERY_RE = [
    /<local-command-caveat>[\s\S]*?<\/local-command-caveat>/gi,
    /<command-name>[\s\S]*?<\/command-name>/gi,
    /<command-message>[\s\S]*?<\/command-message>/gi,
    /<command-args>[\s\S]*?<\/command-args>/gi,
    /<command-stdout>[\s\S]*?<\/command-stdout>/gi,
    /<system-reminder>[\s\S]*?<\/system-reminder>/gi,
  ];
  function _codeCleanHumanText(raw) {
    let s = String(raw || '');
    for (const re of _CODE_MACHINERY_RE) s = s.replace(re, '');
    s = s.replace(/^\s*Base directory for this skill:[^\n]*\n?/i, '');  // skill 前置き
    return s.trim();
  }

  function _codeContentOf(e) {
    const msg = (e && e.payload && e.payload.message) || {};
    return msg.content;
  }

  function _codeTrunc(s) {
    s = String(s || '');
    return s.length > _CODE_BLOCK_TRUNC ? s.slice(0, _CODE_BLOCK_TRUNC) + '\n…(以下省略)' : s;
  }

  // user event を 人間発言テキスト / tool_result ブロック群 に分解。
  function _codeSplitUser(content) {
    const out = { human: null, toolResults: [] };
    if (typeof content === 'string') { out.human = content; return out; }
    if (!Array.isArray(content)) return out;
    const texts = [];
    for (const b of content) {
      if (!b || typeof b !== 'object') continue;
      if (b.type === 'text' && b.text) texts.push(b.text);
      else if (b.type === 'tool_result') out.toolResults.push(b);
    }
    if (texts.length) out.human = texts.join('\n\n');
    return out;
  }

  // tool_result の image ブロック source → image_blocks 用 {data_uri} / {src}。
  // Read が画像を読むと base64 で返る (標準 Anthropic 形)。 url 形も保険で対応。
  function _codeImageDataUri(src) {
    if (!src || typeof src !== 'object') return null;
    if (src.type === 'base64' && src.data) {
      return { data: 'data:' + (src.media_type || 'image/png') + ';base64,' + src.data };
    }
    if (src.type === 'url' && src.url) return { url: String(src.url) };
    return null;
  }

  // PL-124: 画像 Read 等の tool_result は content[].type==='image' で base64 を持つ。
  // 従来 text だけ拾い image を捨て空バブル化していた。 image_blocks に載せ本文は
  // [[ASA_IMAGE_N]] で参照 (マーカーは code fence の外。 fence 内だと renderer が解決不可)。
  function _codeToolResultText(b, imgBuf) {
    const c = b && b.content;
    let s = '';
    const markers = [];
    if (typeof c === 'string') s = c;
    else if (Array.isArray(c)) {
      const parts = [];
      for (const x of c) {
        if (!x) continue;
        if (typeof x === 'string') { parts.push(x); continue; }
        if (x.type === 'text') { parts.push(String(x.text || '')); continue; }
        if (x.type === 'image' && imgBuf) {
          const du = _codeImageDataUri(x.source);
          if (du) {
            markers.push('[[ASA_IMAGE_' + imgBuf.length + ']]');
            imgBuf.push({ alt: '', src: du.url || '', data_uri: du.data || '', title: '', img_ref: '' });
          }
        }
      }
      s = parts.join('\n');
    }
    const err = b && b.is_error ? ' (エラー)' : '';
    const head = '← 結果' + err + ':';
    // text あり=fence で包む。 text 空でも image 無しは従来通り空 fence (挙動温存)。
    const hasText = s && s.trim();
    const body = hasText ? '\n```\n' + _codeTrunc(s) + '\n```' : (markers.length ? '' : '\n```\n\n```');
    const imgs = markers.length ? '\n' + markers.join('\n') : '';
    return head + body + imgs;
  }

  function _codeToolUseText(b) {
    const name = String((b && b.name) || 'tool');
    let body = '';
    try { body = JSON.stringify((b && b.input) || {}, null, 2); } catch (_e) { body = String((b && b.input)); }
    return '🛠 **' + name + '**\n```json\n' + _codeTrunc(body) + '\n```';
  }

  function _codeThinkingText(b) {
    const t = String((b && b.thinking) || '').trim();
    if (!t) return '';
    return '> 💭 **思考**\n' + t.split('\n').map((ln) => '> ' + ln).join('\n');
  }

  // 完全な作業ログ: 人間 turn を区切りに、その間の assistant 作業
  // (text / thinking / tool_use / tool_result) を 1 メッセージへ織り込む。
  function parseClaudeCodeEvents(events) {
    const sorted = (events || []).slice().sort(
      (a, b) => Number(a && a.sequence_num) - Number(b && b.sequence_num));
    const out = [];
    let buf = [];
    let imgBuf = [];   // PL-124: tool_result の画像を当該 assistant メッセージへ集約
    const flush = () => {
      if (!buf.length && !imgBuf.length) return;
      out.push({ role: 'assistant', text: buf.join('\n\n'), code_blocks: [], image_blocks: imgBuf });
      buf = []; imgBuf = [];
    };
    for (const e of sorted) {
      const et = e && e.event_type;
      if (et === 'assistant') {
        const c = _codeContentOf(e);
        if (Array.isArray(c)) {
          for (const b of c) {
            if (!b || typeof b !== 'object') continue;
            if (b.type === 'text') {
              const s = String(b.text || '').trim();
              if (s && s !== '(no content)') buf.push(String(b.text));  // ツールのみ turn の placeholder 除外
            }
            else if (b.type === 'thinking') { const t = _codeThinkingText(b); if (t) buf.push(t); }
            else if (b.type === 'tool_use') buf.push(_codeToolUseText(b));
          }
        } else if (typeof c === 'string' && c) buf.push(c);
      } else if (et === 'user') {
        const split = _codeSplitUser(_codeContentOf(e));
        for (const tr of split.toolResults) buf.push(_codeToolResultText(tr, imgBuf));
        if (split.human !== null) {
          const clean = _codeCleanHumanText(split.human);
          if (clean) { flush(); out.push({ role: 'user', text: clean, code_blocks: [], image_blocks: [] }); }
        }
      }
      // control_request / control_response / result → skip (制御 / ターン終端メタ)
    }
    flush();
    return out;
  }

  async function extractCodeModeMessages() {
    const sid = getCodeSessionId();
    if (!sid) return null;
    try {
      const events = await fetchCodeEvents(sid);
      if (!events || !events.length) return null;
      return parseClaudeCodeEvents(events);
    } catch (e) {
      console.warn('[ASA-PL107] code capture failed: ' + e);
      return null;
    }
  }
  // ===== /PL-107 =====

  async function resolveMessagesForCapture() {
    // PL-120: 共有ページ (/share/<id>) は org スコープ snapshot API を最優先。 widget_code 込みの
    // 完全構造を既存 parse + rasterize に流す。 失敗時は下の DOM 経路へ (本文のみ・図は placeholder)。
    if (isClaudeSharePage()) {
      try {
        const shareMsgs = await extractMessagesViaShareSnapshot();
        if (Array.isArray(shareMsgs) && shareMsgs.length > 0) {
          return { messages: shareMsgs, source: 'share-api' };
        }
      } catch (_e) {}
    }
    // PL-107: claude.ai/code は events API で全履歴 (DOM は仮想スクロールで可視分のみ)。
    // 失敗時は下の DOM 経路へ degrade (可視 Claude turn のみ)。
    if (isCodeMode()) {
      try {
        const codeMsgs = await extractCodeModeMessages();
        if (Array.isArray(codeMsgs) && codeMsgs.length > 0) {
          return { messages: codeMsgs, source: 'code-api' };
        }
      } catch (_e) {}
    }
    try {
      const api = await extractMessagesViaApi();
      if (Array.isArray(api) && api.length > 0) {
        return { messages: api, source: 'api' };
      }
    } catch (_e) {}
    return { messages: extractMessages(), source: 'dom' };
  }

  // ヘッダー innerText は題名の後ろにアイコングリフ (私用領域文字) + 改行を含む
  // case がある。改行は Windows ファイル名で不正 ([Errno 22]) のため題名は先頭の
  // 非空行のみ採用し、残存制御文字 + 私用領域 (アイコン) を除去して源を綺麗にする。
  function _claudeCleanTitle(s) {
    const firstLine = String(s || "").split("\n").map((x) => x.trim()).filter(Boolean)[0] || "";
    return Array.from(firstLine).filter((ch) => {
      const c = ch.charCodeAt(0);
      // C0 制御 + DEL + 私用領域 (アイコングリフ) を除外
      return c > 0x1f && c !== 0x7f && !(c >= 0xe000 && c <= 0xf8ff);
    }).join("").trim();
  }

  function getChatTitle() {
    // PL-107: /code は events fetch 時に session meta.title を stash 済 (DOM 見出し不在)
    if (isCodeMode() && _codeSessionTitle) {
      const t = _claudeCleanTitle(_codeSessionTitle);
      if (t) return t;
    }
    try {
      const pageHeader = document.querySelector('[data-testid="page-header"]');
      if (pageHeader) {
        const raw = (pageHeader.innerText || "").trim();
        if (raw) {
          const parts = raw.split("\n/\n");
          const cleaned = _claudeCleanTitle(parts[parts.length - 1] || raw);
          if (cleaned) return cleaned;
        }
      }
    } catch (_e) {}
    try {
      const titleBtn = document.querySelector('[data-testid="chat-title-button"]');
      if (titleBtn) {
        const text = _claudeCleanTitle(titleBtn.innerText || "");
        if (text) return text;
      }
    } catch (_e) {}
    try {
      let title = document.title.trim();
      title = title.replace(/\s*-\s*Claude\.?\s*$/i, "").trim();
      title = _claudeCleanTitle(title);
      if (title && title.toLowerCase() !== "claude" && title.toLowerCase() !== "claude.ai") {
        return title;
      }
    } catch (_e) {}
    return "";
  }

  function getProjectName() {
    try {
      const pageHeader = document.querySelector('[data-testid="page-header"]');
      if (pageHeader) {
        const raw = (pageHeader.innerText || "").trim();
        if (raw) {
          const parts = raw.split("\n/\n");
          if (parts.length === 2 && parts[0]) {
            return parts[0].trim();
          }
        }
      }
    } catch (_e) {
    }
    return null;
  }

  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: getChatTitle() || document.title || "Claude_Conversation",
      project: getProjectName(),
      observedAt: new Date().toISOString(),
      details: collectStatusDetails(),
    });
  }

  function isTemporaryChat() {
    if (_initialIncognito) {
      return true;
    }
    try {
      if (new URLSearchParams(location.search).has("incognito")) {
        return true;
      }
    } catch (_e) {}
    return false;
  }

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

  function buildDomMessagesResult() {
    return {
      type: "dom_messages_result",
      messages: extractMessages(),
      url: location.href,
      title: getChatTitle() || document.title || "Claude_Conversation",
      project: getProjectName(),
      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") {
      // PL-039 Phase 1: API path 優先 + DOM fallback。 cache hit なら即返却、
      // miss 時は active replay 経由で完整 chat_messages を引いてから build。
      resolveMessagesForCapture().then(({ messages }) => {
        try {
          sendResponse(buildSnapshot(messages));
        } catch (_e) {}
      });
      return true;
    }
    if (message.type === "start_realtime_capture") {
      try {
        state.realtimeBuffer = extractMessages();
        state.realtimeCapturing = true;
        // PL-078 Phase C: capture 開始時に 1 度だけ active replay で API cache を
        // full (render_all_tools=true) に温める。 Claude の図 (SVG) は別 origin iframe
        // 内で DOM から読めず API 応答にのみ在るため、 cache を満たしておけば以降の
        // poll は cache 読出しのみで図を拾える (poll 毎 replay を避け realtime cadence
        // を尊重 = signal 駆動の 1 発)。 一時チャットは API にデータ無しのため skip。
        try {
          if (!isTemporaryChat()) {
            const _cid = getCurrentConversationId();
            if (_cid) { requestConversationReplay(_cid).catch(() => {}); }
          }
        } catch (_e) {}
        if (state.realtimeMutationObserver) {
          try { state.realtimeMutationObserver.disconnect(); } catch (_e) {}
          state.realtimeMutationObserver = null;
        }
        if (state.realtimeDebounceTimer !== null) {
          clearTimeout(state.realtimeDebounceTimer);
          state.realtimeDebounceTimer = null;
        }
        const REALTIME_DEBOUNCE_MS = 500;
        state.realtimeMutationObserver = new MutationObserver(() => {
          try {
            if (state.realtimeDebounceTimer !== null) {
              clearTimeout(state.realtimeDebounceTimer);
            }
            state.realtimeDebounceTimer = setTimeout(() => {
              try {
                state.realtimeDebounceTimer = null;
                state.realtimeBuffer = extractMessages();
              } catch (_e) {}
            }, REALTIME_DEBOUNCE_MS);
          } catch (_e) {}
        });
        state.realtimeMutationObserver.observe(document.documentElement, {
          childList: true,
          subtree: true,
          characterData: true,
        });
      } catch (_e) {}
      sendResponse({ ok: true });
      return true;
    }
    if (message.type === "stop_realtime_capture") {
      try {
        if (state.realtimeMutationObserver) {
          try { state.realtimeMutationObserver.disconnect(); } catch (_e) {}
          state.realtimeMutationObserver = null;
        }
        if (state.realtimeDebounceTimer !== null) {
          clearTimeout(state.realtimeDebounceTimer);
          state.realtimeDebounceTimer = null;
        }
        state.realtimeCapturing = false;
        state.realtimeBuffer = [];
      } catch (_e) {}
      sendResponse({ ok: true });
      return true;
    }
    if (message.type === "get_realtime_buffer") {
      // realtime mode 中の buffer は DOM 由来。 ただし Claude の図 (SVG) は別 origin
      // iframe 内で DOM から読めないため、 PL-078 Phase C で API 経路 (parse 済
      // image_blocks に元 SVG を持つ) を条件付きで優先する:
      //   - 非一時チャットで、 API cache 読出し結果が DOM buffer と同等以上に揃って
      //     いれば API を採用 (図入り + 全 turn 完整)。
      //   - API が追いついてない (= ライブ新 turn で DOM が先行) 間は DOM buffer を
      //     採用 (turn 取りこぼし回避)。
      //   - 一時チャット / cache 不在 → 従来の DOM buffer。
      // poll では replay せず cache 読出しのみ (cache は capture 開始時の 1 発 replay
      // + passive sniff で満たす) = poll 毎 fetch を避け realtime cadence を尊重。
      (async () => {
        const domBuf = Array.isArray(state.realtimeBuffer) ? state.realtimeBuffer.slice() : [];
        try {
          if (!isTemporaryChat()) {
            const cid = getCurrentConversationId();
            if (cid) {
              const entry = await readConversationCache(cid);
              if (entry && entry.resp_text) {
                const api = ASA_PARSE.parseClaudeConversationApiResponse(entry.resp_text);
                if (Array.isArray(api) && api.length && api.length >= domBuf.length) {
                  await _pl078cRasterizeBlocks(api);  // PL-078 Phase C: 図 SVG → PNG (pdf 対応)
                  await _pl115RasterizeWidgets(api);  // PL-115: Chart.js widget → sandbox 描画→画像
                  sendResponse(buildSnapshot(api));
                  return;
                }
              }
            }
          }
        } catch (_e) {}
        if (domBuf.length) {
          sendResponse(buildSnapshot(domBuf));
          return;
        }
        try {
          const { messages } = await resolveMessagesForCapture();
          sendResponse(buildSnapshot(messages));
        } catch (_e) {
          try { sendResponse(buildSnapshot([])); } catch (_e2) {}
        }
      })();
      return true;
    }
    if (message.type === "get_dom_messages") {
      sendResponse(buildDomMessagesResult());
      return true;
    }
    if (message.type === "ping_content") {
      sendResponse({ ok: true, url: location.href, title: getChatTitle() || document.title || "Claude_Conversation", project: getProjectName() });
      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: getChatTitle() || document.title || "Claude_Conversation",
      project: getProjectName(),
      observedAt: new Date().toISOString()
    });
  });

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

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

  function sendHeartbeat() {
    try {
      chrome.runtime.sendMessage(
        {
          type: "bridge_heartbeat",
          pageState: currentPageState(),
          url: location.href,
          title: getChatTitle() || document.title || "Claude_Conversation",
          project: getProjectName(),
          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: getChatTitle() || document.title || "Claude_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 取得 / 図の画像化＝
  // _pl078cRasterizeBlocks・_pl115RasterizeWidgets 約2秒/図 等) を問わず、 一定時間
  // (ASA_SAVE_PENDING_DELAY_MS) 経っても work が終わらなければ「取得中…」窓 (save_pending) を
  // Python に出させる。 Python は後続 save_open 受領 (drain) で窓を閉じ保存本番 (= 既存
  // acquire/render スピナー) へ引き継ぐ。 閾値内に終われば送らない → 即完了保存でチラ付き無し。
  // Claude は従来 save_pending 未配線で拡張側待ち (特に図の実描画) が無アニメだった
  // (ChatGPT は PL-116 で同型 fix / Gemini は常時無条件送出で対処済、 横展開)。
  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 {
        const { messages } = await resolveMessagesForCapture();
        payload = { snapshot: buildSnapshot(messages), 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-disabled="true"] { opacity: .38; cursor: default; }
.asa-dock button[data-disabled="true"]:hover { background: transparent; }
.asa-dock button[data-disabled="true"]:active { transform: none; background: transparent; box-shadow: none; }
.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: #000; }
:host([data-theme="dark"]) .asa-dock button:active { background: #000; }
.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; }
:host([data-theme="dark"]) .asa-dock button[data-tooltip]::after { background: #000; }
.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() {
    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;";
  }

  function findComposerAnchor() {
    for (const sel of WIDGET_ANCHOR_SELECTORS) {
      const el = document.querySelector(sel);
      if (el) return el;
    }
    // /code (Code モード): epitaxy class は session 行で再利用され querySelector が
    // 上部の別 composer を掴む。実入力欄 = 生 editable を持つものだけが本物なので、
    // editable から composer を closest で辿る (probe 2026-06-27、n_composer=5)。
    const editable = document.querySelector('div[contenteditable="true"].tiptap, .epitaxy-prompt [contenteditable="true"]');
    if (editable) {
      // `.epitaxy-prompt` (丸い入力ボックス枠) を優先 = 通常チャット同様に入力欄へ密着。
      // composer-width は repo 選択行まで含み右端が外側で隙間が出るため fallback のみ。
      const anchor = editable.closest('.epitaxy-prompt') || editable.closest('.epitaxy-composer-width');
      if (anchor) return anchor;
    }
    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() {
    const host = document.createElement("div");
    host.id = WIDGET_HOST_ID;
    host.style.cssText = widgetHostStyle();
    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);
      state.widgetAnchor = null;
      return false;
    }
    ensureAnchorPositioned(anchor);
    if (existing && existing.parentElement === anchor) {
      state.widgetAnchor = anchor;
      return true;
    }
    if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
    const host = buildWidgetHost();
    anchor.appendChild(host);
    state.widgetAnchor = anchor;
    return true;
  }

  function attachAnchorObserver() {
    if (state.widgetObserver) return;
    try {
      const obs = new MutationObserver(() => {
        const anchor = findComposerAnchor();
        const host = document.getElementById(WIDGET_HOST_ID);
        if (!anchor) return;
        if (!host || host.parentElement !== anchor) {
          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;
        if (!host || host.parentElement !== anchor) {
          injectWidget();
        }
      }, 1000);
    }
  }

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

  function bindWidgetButtons(root) {
    // PL-024 CP3: save_open click 同期で snapshot を build → widget_action payload に同梱 (push model)。
    // 旧 capture pull (background → tab cache 経由 sendMessage) は tab race の温床、push で sender tab 自確定。
    // PL-039 Phase 1: save_open は API path 優先 + DOM fallback で resolve、
    // build 自体が async になるため wrap 内 await 化。
    // PL-095: save_open の中核は performWidgetAction に集約 (popup からも同一経路を呼ぶため)。
    const wrap = (action) => async (e) => {
      e.preventDefault();
      e.stopPropagation();
      if (e.currentTarget && e.currentTarget.dataset.disabled === "true") return;
      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";
      });
    });
    updateRealtimeAvailability();
  }

  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;
      }
    });
    updateRealtimeAvailability();  // /code の無効化 tooltip を i18n 上書き後に再適用
  }

  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;
      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: getChatTitle() || document.title || "Claude_Conversation",
    project: getProjectName(),
    observedAt: new Date().toISOString()
  });
  reportStatus(true);
  ensureWidget();
})();
