export function generateApprovalObserverScript(_port: number): string {
  return `
// ── Gravity Bridge v17: Always Run Auto-Approve + Retry Detection ──
// v17: "Always run" auto-approve at bridge level + Retry button relay to Discord
(function(){
  'use strict';
  var BASE='',_obs=false,_sent={},_ready=false;
  var _scanScheduled=false,_lastScanTs=0;
  var THROTTLE_MS=500;
  var CLEANUP_MS=300000;

  function log(m){
    console.log('[GB Observer] '+m);
    // v19: Relay important logs to extension via HTTP so they appear in extension.log
    if (BASE && (m.indexOf('CV-CLASSES')!==-1 || m.indexOf('CV-CHILDREN')!==-1 || m.indexOf('child[')!==-1 || m.indexOf('CV found')!==-1 || m.indexOf('Conversation view')!==-1 || m.indexOf('BEACON')!==-1 || m.indexOf('ERROR')!==-1 || m.indexOf('chat relay')!==-1 || m.indexOf('user-cls')!==-1)) {
      try { fetch(BASE+'/log', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({msg:m.substring(0,2000)})}); } catch(e){}
    }
  }
  log('v17 Script loaded — Always Run Auto-Approve + Retry Detection');

  // DIAGNOSTIC BEACON: immediate POST to confirm script execution in renderer
  try {
    fetch('http://127.0.0.1:${_port}/ping?beacon=1&t='+Date.now())
      .then(function(r){log('BEACON ping OK: '+r.status);})
      .catch(function(e){log('BEACON ping FAIL: '+e.message);});
  } catch(e){ log('BEACON error: '+e); }

  // React-Compatible Synthetic Clicker
  function dispatchReactClick(el){
    if (!el) return;
    try {
      el.dispatchEvent(new PointerEvent('pointerdown', {bubbles:true, cancelable:true, view:window, composed:true}));
      el.dispatchEvent(new MouseEvent('mousedown', {bubbles:true, cancelable:true, view:window, composed:true}));
      el.dispatchEvent(new PointerEvent('pointerup', {bubbles:true, cancelable:true, view:window, composed:true}));
      el.dispatchEvent(new MouseEvent('mouseup', {bubbles:true, cancelable:true, view:window, composed:true}));
      el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, view:window, composed:true}));
    } catch(e) {
      el.click();
    }
  }

  // ── Noise filter: lines that are UI artifacts, not real content ──
  var NOISE_RE = new RegExp(
    '^(' +
    'chevron_right|chevron_left|arrow_drop_down|arrow_drop_up|arrow_right|arrow_left|' +
    'arrow_forward|arrow_back|expand_more|expand_less|close|more_horiz|more_vert|' +
    'content_copy|content_paste|check|check_circle|error|warning|info|' +
    'keyboard_arrow_up|keyboard_arrow_down|keyboard_arrow_left|keyboard_arrow_right|' +
    'Thought for \\\\d+s?|Thought for a few seconds|Show more|Show less|Copy|Copied!|Edit|Cancel|' +
    'Always run|Always allow|Running command|Running \\\\d+ commands?|' +
    'Deny|Allow|Allow Once|Allow This Conversation|' +
    'Run|Send|Stop|Review Changes|Accept all|Reject all|Accept|Reject' +
    ')$', 'i'
  );
  var NOISE_CODE_RE = /^(declare\\s+(class|function|interface|type|enum|const|var|let)\\s|(import|export|from)\\s|\\s*[{}()\\[\\];]\\s*$|\\.ts:\\d+:|extension.*src.*sdk)/i;

  function isNoiseLine(line) {
    var trimmed = line.trim();
    if (trimmed.length < 2 && !/^[-*+>]$|^[0-9]$/.test(trimmed)) return true;
    if (NOISE_RE.test(trimmed)) return true;
    if (NOISE_CODE_RE.test(trimmed)) return true;
    // Single-word Material icon names (all lowercase, no spaces)
    if (/^[a-z_]+$/.test(trimmed) && trimmed.length < 30) return true;
    return false;
  }

  function cleanLines(text) {
    if (!text) return '';
    // Pre-strip: inline removal of icon names and UI noise that textContent concatenates without newlines
    text = text.replace(/\\b(chevron_right|chevron_left|arrow_drop_down|arrow_drop_up|arrow_right|arrow_left|arrow_forward|arrow_back|expand_more|expand_less|more_horiz|more_vert|content_copy|content_paste|keyboard_arrow_up|keyboard_arrow_down|keyboard_arrow_left|keyboard_arrow_right|slow_motion_video|open_in_new)\\b/g, '\\n');
    text = text.replace(/Thought for \\d+s?/gi, '');
    text = text.replace(/Thought for a few seconds/gi, '');
    var lines = text.split('\\n');
    var clean = [];
    var lastWasEmpty = false;
    for (var i = 0; i < lines.length; i++) {
      var line = lines[i];
      if (line.trim().length === 0) {
        if (!lastWasEmpty && clean.length > 0) {
          clean.push('');
          lastWasEmpty = true;
        }
        continue;
      }
      if (!isNoiseLine(line)) {
        clean.push(line.replace(/\\s+$/, ''));
        lastWasEmpty = false;
      }
    }
    while (clean.length > 0 && clean[clean.length - 1] === '') clean.pop();
    return clean.join('\\n');
  }

  function cleanButtonText(btn) {
    if (!btn) return '';
    var clone = btn.cloneNode(true);
    var icons = clone.querySelectorAll('.google-symbols, .codicon, [class*="icon"], svg, .material-symbols-outlined, .material-icons');
    for(var i=0; i<icons.length; i++) {
        if(icons[i].parentNode) icons[i].parentNode.removeChild(icons[i]);
    }
    var tr = clone.querySelector('.truncate');
    var txt = (tr ? tr.textContent : clone.textContent) || '';
    return txt.trim().replace(/^[\\s\\u200B-\\u200D\\uFEFF\\u00A0]+/, '').replace(/(Alt|Ctrl|Shift|Meta)\\+.*/i,'').trim();
  }

  function btnId(b,type){
    var txt = cleanButtonText(b);
    var parent = b.parentElement;
    var idx=0;
    if(parent){
      var siblings=parent.querySelectorAll('button');
      for(var i=0;i<siblings.length;i++){if(siblings[i]===b){idx=i;break;}}
    }
    return type+'|'+txt+'|'+idx;
  }

  // ══════════════════════════════════════════════════════════════════
  // v7: STEP-AWARE CONTEXT EXTRACTION
  // Find the closest [data-step-index] ancestor, extract step info
  // ══════════════════════════════════════════════════════════════════

  function getStepContainer(el) {
    var node = el;
    for (var depth = 0; depth < 10 && node; depth++) {
      if (node.hasAttribute && node.hasAttribute('data-step-index')) return node;
      node = node.parentElement;
    }
    return null;
  }

  // v14: Climb DOM tree to find context near the button
  // STRICT SCOPE: Only 5 levels up — beyond that we're in unrelated UI territory.
  // JUNK FILTERS: CSS rules, source code, Material icon gluing are all rejected.
  // NO FALLBACK: span/div/p text collection is removed entirely — it always grabs chat/UI text.
  var PROMPT_ONLY_RE = /^[^\\n]*[\\/\>\\xbb$#]\\s*$/;
  // v14: Detect CSS rules, JS source code, or extension internals in code text
  var JUNK_CODE_RE = /(!important|::selection|background-color:|var\\(--|font-size:|border-[a-z]|padding:|margin:|display:\\s|\\{[^}]*:[^}]*\\}|===|!==|\\|\\||\\bfunction\\s*\\(|\\bconst\\s+\\w+\\s*=|\\bvar\\s+\\w+\\s*=|\\bif\\s*\\(|\\breturn\\b|\\bimport\\s|\\bexport\\s|\\bclass\\s+\\w|\\bnew\\s+\\w|\\.test\\(|\\.match\\(|\\.replace\\(|_RE[.\\s]|\\brawDesc\\b|\\brawCmd\\b|\\benrichedCmd\\b|\\bquerySelector)/;
  // v14: Detect Material icon text glued with content
  var ICON_GLUE_RE = /(alternate_email|content_copy|content_paste|check_circle|chevron_right|chevron_left|keyboard_arrow|arrow_drop_down|arrow_drop_up|more_horiz|more_vert|expand_more|expand_less)[a-zA-Z]/;
  function extractContextFromNearby(btn) {
    var node = btn;
    var _debugTrail = [];
    var _bestCodeText = '';
    var _bestCodeHeader = '';
    var _sawCodeEls = false;
    var _allSkipped = true;
    // v22: Increased from 5 to 10 — AG Native command display (SRi) can be many levels up
    for (var depth = 0; depth < 10 && node; depth++) {
      if (!node.querySelector) { _debugTrail.push('d'+depth+':noQS'); node = node.parentElement; continue; }
      // v22: Prioritize pre.font-mono (AG Native command line display from SRi component)
      var codeEls = node.querySelectorAll('pre.font-mono, pre, code, [class*="terminal"]');
      _debugTrail.push('d'+depth+':tag='+((node.tagName||'?').toLowerCase())+',cls='+(((typeof node.className==='string')?node.className:'').substring(0,60))+',codeEls='+codeEls.length);
      for (var ci = 0; ci < codeEls.length; ci++) {
        var codeText = cleanLines((codeEls[ci].textContent || '').trim().substring(0, 500));
        if (!codeText || codeText.length <= 5) continue;
        if (/^Running\\s*\\d/i.test(codeText)) continue;
        _sawCodeEls = true;
        if (PROMPT_ONLY_RE.test(codeText.trim())) {
          _debugTrail.push('skip_prompt_ci='+ci+':'+codeText.substring(0,30));
          continue;
        }
        if (JUNK_CODE_RE.test(codeText)) {
          _debugTrail.push('skip_junk_ci='+ci+':'+codeText.substring(0,30));
          continue;
        }
        if (ICON_GLUE_RE.test(codeText)) {
          _debugTrail.push('skip_iconglue_ci='+ci+':'+codeText.substring(0,30));
          continue;
        }
        _allSkipped = false;
        if (!_bestCodeText || codeText.length > _bestCodeText.length) {
          _bestCodeText = codeText;
          var headerEl = node.querySelector('h1, h2, h3, [class*="header"], [class*="title"], [class*="cursor-pointer"]');
          if (headerEl) {
            var hClone = headerEl.cloneNode(true);
            var hRem = hClone.querySelectorAll('button, svg, [class*="icon"], .google-symbols');
            for (var hi = 0; hi < hRem.length; hi++) {
              if (hRem[hi].parentNode) hRem[hi].parentNode.removeChild(hRem[hi]);
            }
            _bestCodeHeader = cleanLines((hClone.textContent || '').trim().substring(0, 200));
          }
        }
      }
      if (_bestCodeText) {
        var parts = [];
        if (_bestCodeHeader) parts.push(_bestCodeHeader);
        parts.push(_bestCodeText);
        log('CONTEXT-OK d='+depth+' src=code trail='+_debugTrail.join(' > '));
        _lastContextDebug = _debugTrail.join(' > ');
        return parts.join(' \u2014 ');
      }
      // v23: Also search sibling elements at each level
      // AG Native's command display (pre.font-mono) is a SIBLING of footer, not ancestor
      if (node && node.parentElement) {
        var siblings = node.parentElement.children;
        for (var si = 0; si < siblings.length; si++) {
          if (siblings[si] === node) continue;
          if (!siblings[si].querySelector) continue;
          var sibCodeEls = siblings[si].querySelectorAll('pre.font-mono, pre, code');
          for (var sci = 0; sci < sibCodeEls.length; sci++) {
            var sibCode = cleanLines((sibCodeEls[sci].textContent || '').trim().substring(0, 500));
            if (!sibCode || sibCode.length <= 5) continue;
            if (JUNK_CODE_RE.test(sibCode) || ICON_GLUE_RE.test(sibCode)) continue;
            if (PROMPT_ONLY_RE.test(sibCode.trim())) continue;
            _debugTrail.push('sibling_d'+depth+':tag='+siblings[si].tagName.toLowerCase()+',code='+sibCode.substring(0,40));
            _bestCodeText = sibCode;
            _allSkipped = false;
            // Found in sibling — return immediately
            var sibParts = [];
            var sibHdr = siblings[si].querySelector('h1, h2, h3, [class*="header"], [class*="title"], [class*="cursor-pointer"]');
            if (sibHdr) sibParts.push(cleanLines((sibHdr.textContent || '').trim().substring(0, 200)));
            sibParts.push(sibCode);
            log('CONTEXT-OK d='+depth+' src=sibling trail='+_debugTrail.join(' > '));
            _lastContextDebug = _debugTrail.join(' > ');
            return sibParts.join(' \u2014 ');
          }
        }
      }
      node = node.parentElement;
    }
    if (_sawCodeEls && _allSkipped) {
      log('CONTEXT-SKIP-ALL trail='+_debugTrail.join(' > '));
      _lastContextDebug = _debugTrail.join(' > ');
      return cleanButtonText(btn);
    }
    var ariaLabel = btn.getAttribute('aria-label') || btn.getAttribute('title') || '';
    log('CONTEXT-FAIL trail='+_debugTrail.join(' > '));
    _lastContextDebug = _debugTrail.join(' > ');
    if (ariaLabel && ariaLabel.length > 5) return ariaLabel;
    return cleanButtonText(btn);
  }
  var _lastContextDebug = '';

  function extractStepContext(btn) {
    var stepEl = getStepContainer(btn);
    if (!stepEl) {
      // v9 FALLBACK: no data-step-index — climb DOM for pre/code blocks
      return extractContextFromNearby(btn);
    }

    var stepIdx = stepEl.getAttribute('data-step-index') || '?';

    // Get step header text (first line, usually has the tool name/command)
    var header = stepEl.querySelector('[class*="cursor-pointer"]');
    var headerText = '';
    if (header) {
      // Clone and strip icons/buttons
      var hClone = header.cloneNode(true);
      var hRemove = hClone.querySelectorAll('button, svg, [class*="icon"], .google-symbols, .material-symbols-outlined');
      for (var i = 0; i < hRemove.length; i++) {
        if (hRemove[i].parentNode) hRemove[i].parentNode.removeChild(hRemove[i]);
      }
      headerText = (hClone.textContent || '').trim().substring(0, 300);
      // Clean noise
      headerText = cleanLines(headerText);
    }

    // Try to get code/pre content (command detail)
    var codeEl = stepEl.querySelector('pre, code');
    var codeText = '';
    if (codeEl) {
      codeText = cleanLines((codeEl.textContent || '').trim().substring(0, 400));
    }

    // Try aria-label on button
    var ariaLabel = btn.getAttribute('aria-label') || btn.getAttribute('title') || '';

    var parts = [];
    if (headerText) parts.push(headerText);
    if (codeText && !headerText.includes(codeText.substring(0, 20))) parts.push(codeText);
    if (ariaLabel && ariaLabel.length > 5 && !headerText.includes(ariaLabel)) parts.push(ariaLabel);

    var result = parts.join(' — ');
    if (!result) result = cleanButtonText(btn);
    return 'Step #' + stepIdx + ': ' + result;
  }

  function extractContext(b) {
    return extractStepContext(b);
  }

  var ACTION_WORDS = ['Allow', 'Run', 'Approve', 'Accept', 'Always allow', 'Always run', 'Retry'];
  var REJECT_WORDS = ['Reject', 'Cancel', 'Deny', 'Stop', 'Decline', 'Dismiss'];

  function isActionBtn(txt) {
    for(var i=0; i<ACTION_WORDS.length; i++) {
      if(txt.indexOf(ACTION_WORDS[i]) !== -1) return true;
    }
    // v9: Removed "Running N commands" — it's a group header, not an approval button
    return false;
  }
  function isRejectBtn(txt) {
    for(var i=0; i<REJECT_WORDS.length; i++) {
      if(txt.indexOf(REJECT_WORDS[i]) !== -1) return true;
    }
    return false;
  }

  function collectSiblingButtons(container,triggerBtn){
    if(!container)return [];
    // v11: Try 5 container levels to find Cancel and other approval buttons
    var containers = [container];
    var cur = container;
    for (var lvl = 0; lvl < 5 && cur.parentElement; lvl++) {
      cur = cur.parentElement;
      containers.push(cur);
    }
    var result=[];
    var seen={};
    for(var ci=0;ci<containers.length;ci++){
      var siblings=containers[ci].querySelectorAll('button');
      for(var i=0;i<siblings.length;i++){
        var sb=siblings[i];
        if(sb.disabled||sb.hidden||(!sb.offsetParent&&sb.style.display!=='fixed'))continue;
        var stxt = cleanButtonText(sb);
        if(stxt.length <= 1) continue;
        // Skip group headers
        if (/^Running\\s*\\d+\\s*commands?$/i.test(stxt)) continue;
        if(!isActionBtn(stxt) && !isRejectBtn(stxt)) continue;
        // Dedup by text
        if(seen[stxt])continue;
        seen[stxt]=true;
        result.push({btn:sb,text:stxt,isPrimary:(sb===triggerBtn)});
      }
      // v11: Only stop if we found BOTH action AND reject buttons at this level
      var hasAction = false, hasReject = false;
      for (var ri=0;ri<result.length;ri++) {
        if (isActionBtn(result[ri].text)) hasAction = true;
        if (isRejectBtn(result[ri].text)) hasReject = true;
      }
      if(hasAction && hasReject) break;
    }
    return result;
  }

  var HARDCODED_PORT=${_port};
  function tryPingAsync(port){
    return fetch('http://127.0.0.1:'+port+'/ping?t='+Date.now(),{signal:AbortSignal.timeout(2000)})
      .then(function(r){return r.text();}).then(function(t){return t==='pong';}).catch(function(){return false;});
  }

  function discoverPort(cb){
    var attempts=0;
    var timer=setInterval(function(){
      attempts++;
      var items = document.querySelectorAll('[aria-label^="Gravity Bridge Control"], [title^="Gravity Bridge Control"]');
      if (items.length > 0) {
        var text = items[0].getAttribute('aria-label') || items[0].getAttribute('title') || '';
        var m = text.match(/port:(\\d+)/);
        if (m && m[1]) {
          clearInterval(timer);
          tryPingAsync(parseInt(m[1], 10)).then(function(ok){ cb(ok ? parseInt(m[1],10) : HARDCODED_PORT); });
          return;
        }
      }
      if(attempts>1){
        clearInterval(timer);
        tryPingAsync(HARDCODED_PORT).then(function(){ cb(HARDCODED_PORT); });
      }
    },500);
  }

  discoverPort(function(port){
    BASE='http://127.0.0.1:'+port;
    _ready=true;
    startObserver();
  });

  // ══════════════════════════════════════════════════════════════════
  // v8: FULL DOM STRUCTURE DUMP (unconditional — no selector dependency)
  // Dumps entire document.body tree to /dump-html for real DOM analysis
  // Auto-triggers at 5s, 15s, 60s after load to capture React-rendered state
  // ══════════════════════════════════════════════════════════════════

  var _dumpCount=0;
  var MAX_DUMPS=8;
  var _conversationDumpCount=0;

  function walkNode(el, depth, maxDepth, maxChildren) {
    if (depth > maxDepth) return {tag:'…',text:'depth limit'};
    if (!el || !el.tagName) return null;
    var info = {
      tag: el.tagName ? el.tagName.toLowerCase() : '#text',
      cls: '',
      attrs: {},
      text: '',
      children: []
    };
    // Capture className (string or SVGAnimatedString)
    if (el.className) {
      if (typeof el.className === 'string') info.cls = el.className.substring(0, 300);
      else if (el.className.baseVal) info.cls = el.className.baseVal.substring(0, 300);
    }
    // Capture ALL attributes that might help identify structure
    if (el.attributes) {
      for (var ai = 0; ai < el.attributes.length; ai++) {
        var attr = el.attributes[ai];
        var n = attr.name;
        if (n === 'class' || n === 'style') continue; // class already captured, style is noise
        info.attrs[n] = (attr.value || '').substring(0, 200);
      }
    }
    // For leaf nodes or shallow nodes, capture text content
    if (!el.children || el.children.length === 0) {
      var t = (el.textContent || '').trim();
      if (t.length > 0) info.text = t.substring(0, 200);
    }
    // Recurse children
    if (el.children) {
      var limit = Math.min(el.children.length, maxChildren);
      for (var ci = 0; ci < limit; ci++) {
        var childInfo = walkNode(el.children[ci], depth + 1, maxDepth, maxChildren);
        if (childInfo) info.children.push(childInfo);
      }
      if (el.children.length > limit) {
        info.children.push({tag: '…', text: '+' + (el.children.length - limit) + ' more children'});
      }
    }
    return info;
  }

  function dumpDOMStructure() {
    if (!_ready || _dumpCount >= MAX_DUMPS) return;
    if (!document.body) return;

    // Only dump if there are enough elements (DOM has actually rendered)
    var totalEls = document.querySelectorAll('*').length;
    if (totalEls < 20) {
      log('DOM dump skipped: only ' + totalEls + ' elements (not rendered yet)');
      return;
    }
    _dumpCount++;

    // Walk entire body with generous limits
    var structure = walkNode(document.body, 0, 15, 30);

    // Also gather quick-reference info
    var quickInfo = {
      totalElements: totalEls,
      title: document.title,
      url: window.location.href,
      hasConversationView: !!document.querySelector('[data-testid="conversation-view"]'),
      hasStepIndex: !!document.querySelector('[data-step-index]'),
      hasBotColor: !!document.querySelector('.text-ide-message-block-bot-color'),
      hasMarkdownBody: !!document.querySelector('.markdown-body'),
      hasProse: !!document.querySelector('.prose'),
      buttons: [],
      dataTestIds: [],
      dataAttrs: []
    };

    // Collect all data-testid values in the page
    var testIdEls = document.querySelectorAll('[data-testid]');
    for (var ti = 0; ti < testIdEls.length; ti++) {
      var tid = testIdEls[ti].getAttribute('data-testid');
      if (quickInfo.dataTestIds.indexOf(tid) === -1) quickInfo.dataTestIds.push(tid);
    }

    // Collect all unique data-* attribute names
    var allEls = document.querySelectorAll('*');
    var seenAttrs = {};
    for (var ei = 0; ei < Math.min(allEls.length, 2000); ei++) {
      var elAttrs = allEls[ei].attributes;
      if (!elAttrs) continue;
      for (var ai2 = 0; ai2 < elAttrs.length; ai2++) {
        var aName = elAttrs[ai2].name;
        if (aName.startsWith('data-') && !seenAttrs[aName]) {
          seenAttrs[aName] = true;
          quickInfo.dataAttrs.push(aName + '=' + (elAttrs[ai2].value || '').substring(0, 50));
        }
      }
    }

    // Collect visible buttons
    var allBtns = document.querySelectorAll('button, [role="button"]');
    for (var bi2 = 0; bi2 < Math.min(allBtns.length, 50); bi2++) {
      var btnEl = allBtns[bi2];
      var btnText = (btnEl.textContent || '').trim().substring(0, 80);
      if (btnText.length > 0) {
        quickInfo.buttons.push({
          text: btnText,
          tag: btnEl.tagName.toLowerCase(),
          cls: ((btnEl.className && typeof btnEl.className === 'string') ? btnEl.className : '').substring(0, 150),
          visible: !!(btnEl.offsetParent || btnEl.style.display === 'fixed')
        });
      }
    }

    var payload = JSON.stringify({
      timestamp: new Date().toISOString(),
      source: 'v8_full_body_dump',
      dumpIndex: _dumpCount,
      quickInfo: quickInfo,
      body: structure
    });
    log('DOM dump #' + _dumpCount + ': ' + totalEls + ' elements, ' + payload.length + ' bytes');
    fetch(BASE + '/dump-html', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: payload
    }).catch(function(e) { log('DOM dump failed: ' + e.message); });
  }

  // Auto-dump at multiple intervals to catch React rendering stages
  function scheduleAutoDumps() {
    setTimeout(function(){ log('Auto-dump @5s'); dumpDOMStructure(); }, 5000);
    setTimeout(function(){ log('Auto-dump @15s'); dumpDOMStructure(); }, 15000);
    setTimeout(function(){ log('Auto-dump @60s'); dumpDOMStructure(); }, 60000);
  }

  // ══════════════════════════════════════════════════════════════════
  // v15: AG-NATIVE + CASCADE DUAL CHAT BODY SCANNING
  // AG Native: #conversation > ... > .leading-relaxed.select-text
  // Cascade:   [data-testid="conversation-view"] > [data-step-index]
  // ══════════════════════════════════════════════════════════════════

  var _lastScrapedStepIndex = -1;
  var _lastStepText = '';
  var _lastStepTextTime = 0;
  var _lastStepTextSent = false;
  var _lastResponseBlockCount = 0; // track number of response blocks for AG Native

  function convertNodeToMarkdown(node) {
    if (!node) return '';
    if (node.nodeType === 3) return node.textContent; // Text node
    if (node.nodeType !== 1) return ''; // Skip other node types
    
    var tag = node.tagName.toLowerCase();
    
    // Skip hidden or UI elements
    if (tag === 'style' || tag === 'script' || tag === 'noscript' || tag === 'button' || tag === 'svg') return '';
    var cls = '';
    if (typeof node.className === 'string') cls = node.className;
    else if (node.className && node.className.baseVal) cls = node.className.baseVal;
    
    if (cls && (cls.indexOf('google-symbols') !== -1 || cls.indexOf('material-icons') !== -1 || cls.indexOf('copy') !== -1 || cls.indexOf('codicon') !== -1)) return '';

    var childrenMd = '';
    for (var i = 0; i < node.childNodes.length; i++) {
        childrenMd += convertNodeToMarkdown(node.childNodes[i]);
    }

    // TABLE: Discord doesn't support markdown tables, so convert to fixed-width code block
    if (tag === 'table') {
        var rows = node.querySelectorAll('tr');
        if (!rows || rows.length === 0) return childrenMd;
        var grid = [];
        var colWidths = [];
        for (var ri = 0; ri < rows.length; ri++) {
            var cells = rows[ri].querySelectorAll('th, td');
            var row = [];
            for (var ci = 0; ci < cells.length; ci++) {
                var cellText = (cells[ci].textContent || '').trim();
                row.push(cellText);
                if (!colWidths[ci] || cellText.length > colWidths[ci]) colWidths[ci] = cellText.length;
            }
            grid.push(row);
        }
        // Build fixed-width text
        var tbl = '';
        for (var ri2 = 0; ri2 < grid.length; ri2++) {
            var line = '';
            for (var ci2 = 0; ci2 < colWidths.length; ci2++) {
                var cell = grid[ri2][ci2] || '';
                var pad = colWidths[ci2] - cell.length;
                var padding = '';
                for (var pi = 0; pi < pad; pi++) padding += ' ';
                line += (ci2 > 0 ? ' | ' : '') + cell + padding;
            }
            tbl += line + '\\n';
            // Add separator after header row (first row)
            if (ri2 === 0) {
                var sep = '';
                for (var si2 = 0; si2 < colWidths.length; si2++) {
                    var dashes = '';
                    for (var di = 0; di < colWidths[si2]; di++) dashes += '-';
                    sep += (si2 > 0 ? '-+-' : '') + dashes;
                }
                tbl += sep + '\\n';
            }
        }
        return '\\n' + String.fromCharCode(96,96,96) + '\\n' + tbl + String.fromCharCode(96,96,96) + '\\n';
    }

    switch (tag) {
        case 'h1': return '\\n# ' + childrenMd.trim() + '\\n';
        case 'h2': return '\\n## ' + childrenMd.trim() + '\\n';
        case 'h3': return '\\n### ' + childrenMd.trim() + '\\n';
        case 'h4': return '\\n#### ' + childrenMd.trim() + '\\n';
        case 'p': return '\\n' + childrenMd.trim() + '\\n';
        case 'div': 
            // Treat specific divs as blocks if they end up behaving like paragraphs
            if (cls.indexOf('block') !== -1 || cls.indexOf('message') !== -1) return '\\n' + childrenMd.trim() + '\\n';
            return childrenMd;
        case 'br': return '\\n';
        case 'strong':
        case 'b': return '**' + childrenMd + '**';
        case 'em':
        case 'i': return '*' + childrenMd + '*';
        case 'a': 
            var href = node.getAttribute('href') || '';
            return '[' + childrenMd + '](' + href + ')';
        case 'code': return (node.parentNode && node.parentNode.tagName === 'PRE') ? childrenMd : (String.fromCharCode(96) + childrenMd + String.fromCharCode(96));
        case 'pre': return '\\n' + String.fromCharCode(96,96,96) + '\\n' + childrenMd.trim() + '\\n' + String.fromCharCode(96,96,96) + '\\n';
        case 'li': 
            var prefix = '- ';
            if (node.parentNode && node.parentNode.tagName.toLowerCase() === 'ol') {
               var idx = 1;
               var curr = node.previousSibling;
               while(curr) { if (curr.nodeType === 1 && curr.tagName.toLowerCase() === 'li') idx++; curr = curr.previousSibling; }
               prefix = idx + '. ';
            }
            return '\\n' + prefix + childrenMd.trim();
        case 'ul':
        case 'ol': return '\\n' + childrenMd + '\\n';
        case 'blockquote': return '\\n> ' + childrenMd.trim().split('\\n').join('\\n> ') + '\\n';
        // Table sub-elements: already handled by the table case above via querySelectorAll
        case 'thead':
        case 'tbody':
        case 'tfoot':
        case 'tr':
        case 'th':
        case 'td': return '';
        default: return childrenMd;
    }
  }

  function extractCleanStepText(stepEl) {
    if (!stepEl) return '';

    // Clone the step element so we can strip UI elements without affecting the DOM
    var clone = stepEl.cloneNode(true);

    // v16: Remove style/script/noscript elements FIRST
    var styleEls = clone.querySelectorAll('style, script, noscript, link[rel="stylesheet"]');
    for (var si = 0; si < styleEls.length; si++) {
      if (styleEls[si].parentNode) styleEls[si].parentNode.removeChild(styleEls[si]);
    }

    // Remove all buttons (Run, Allow, Cancel, etc.)
    var buttons = clone.querySelectorAll('button');
    for (var bi = 0; bi < buttons.length; bi++) {
      if (buttons[bi].parentNode) buttons[bi].parentNode.removeChild(buttons[bi]);
    }

    // Remove all SVGs and icon elements
    var icons = clone.querySelectorAll('svg, .google-symbols, .material-symbols-outlined, .material-icons, [class*="codicon"], [class*="icon"]');
    for (var ii = 0; ii < icons.length; ii++) {
      if (icons[ii].parentNode) icons[ii].parentNode.removeChild(icons[ii]);
    }

    // Remove copy buttons and their containers
    var copyBtns = clone.querySelectorAll('[class*="copy"], [aria-label*="copy"], [title*="Copy"]');
    for (var ci = 0; ci < copyBtns.length; ci++) {
      if (copyBtns[ci].parentNode) copyBtns[ci].parentNode.removeChild(copyBtns[ci]);
    }

    // Try to get text from markdown rendering area first
    // AG Native uses .leading-relaxed.select-text, Cascade uses .markdown-body/.prose
    var mdEl = clone.querySelector('.markdown-body, .prose, [class*="markdown"], [class*="rendered"]') || clone;

    // Use our custom DOM-to-Markdown parser instead of innerText
    var rawText = convertNodeToMarkdown(mdEl).trim();

    // v18 FIX: DO NOT apply cleanLines to full markdown content, it destroys valid code blocks
    // Safely remove "Thought for X" lines only
    rawText = rawText.replace(/Thought for \\d+s?/gi, '');
    rawText = rawText.replace(/Thought for a few seconds/gi, '');
    
    // Cleanup multiple empty lines
    var lines = rawText.split('\\n');
    var finalLines = [];
    var lastEmpty = false;
    for (var i = 0; i < lines.length; i++) {
        var line = lines[i].replace(/\\s+$/, '');
        if (line.length === 0) {
            if (!lastEmpty && finalLines.length > 0) {
                finalLines.push('');
                lastEmpty = true;
            }
        } else {
            finalLines.push(line);
            lastEmpty = false;
        }
    }
    
    // v19: Post-process — wrap markdown table patterns in code blocks for Discord
    // AG Native renders tables as divs, not <table> HTML, so DOM-level handler can't catch them.
    // Detect consecutive lines with pipe separators (| col1 | col2 |) and wrap in code block fences
    var bt = String.fromCharCode(96, 96, 96);
    var result = [];
    var tableBlock = [];
    var inCodeBlock = false;
    for (var fi = 0; fi < finalLines.length; fi++) {
        var fl = finalLines[fi];
        // Track existing code blocks to avoid double-wrapping
        if (fl.trim().indexOf(bt) === 0) {
            inCodeBlock = !inCodeBlock;
            // Flush any pending table block before code block marker
            if (tableBlock.length > 0) {
                result.push(bt);
                for (var ti = 0; ti < tableBlock.length; ti++) result.push(tableBlock[ti]);
                result.push(bt);
                tableBlock = [];
            }
            result.push(fl);
            continue;
        }
        if (inCodeBlock) {
            result.push(fl);
            continue;
        }
        // Detect table row: has at least 2 pipe characters and content between them
        var pipeCount = 0;
        for (var pc = 0; pc < fl.length; pc++) { if (fl.charAt(pc) === '|') pipeCount++; }
        var isTableRow = pipeCount >= 2 && fl.trim().charAt(0) === '|';
        var isSeparator = isTableRow && /^[\\s|:-]+$/.test(fl.trim());
        if (isTableRow) {
            tableBlock.push(fl);
        } else {
            // Flush table block if it had enough rows (header + separator + data)
            if (tableBlock.length >= 2) {
                result.push(bt);
                for (var ti2 = 0; ti2 < tableBlock.length; ti2++) result.push(tableBlock[ti2]);
                result.push(bt);
            } else {
                // Not a real table, push lines back normally
                for (var ti3 = 0; ti3 < tableBlock.length; ti3++) result.push(tableBlock[ti3]);
            }
            tableBlock = [];
            result.push(fl);
        }
    }
    // Flush trailing table block
    if (tableBlock.length >= 2) {
        result.push(bt);
        for (var ti4 = 0; ti4 < tableBlock.length; ti4++) result.push(tableBlock[ti4]);
        result.push(bt);
    } else {
        for (var ti5 = 0; ti5 < tableBlock.length; ti5++) result.push(tableBlock[ti5]);
    }
    
    return result.join('\\n').substring(0, 3500);
  }

  function scanChatBodies() {
    if (!_ready) return;

    // One-time DOM dump
    dumpDOMStructure();

    // ── STRATEGY 1: AG Native — #conversation or .antigravity-agent-side-panel ──
    var cv = document.querySelector('#conversation');
    if (!cv) {
      cv = document.querySelector('.antigravity-agent-side-panel');
    }
    // v19: Fallback — find conversation by tracing from known content elements
    if (!cv) {
      var probe = document.querySelector('.leading-relaxed.select-text') || document.querySelector('.text-ide-message-block-bot-color');
      if (probe) {
        // Walk up to find a reasonable container (has overflow-y or is big enough)
        var p = probe.parentElement;
        for (var pi2 = 0; pi2 < 10 && p && p !== document.body; pi2++) {
          var pCls = (typeof p.className === 'string') ? p.className : '';
          if (pCls.indexOf('overflow') !== -1 || p.children.length > 3) {
            cv = p;
            break;
          }
          p = p.parentElement;
        }
        if (!cv && probe.parentElement) cv = probe.parentElement.parentElement || probe.parentElement;
      }
    }
    if (cv) {
      // v20: Dump CV structure for first 3 scans to ensure we capture it (even with stale HTML cache)
      if (_conversationDumpCount < 3) {
        _conversationDumpCount++;
        log('CV found via: ' + (cv.id || (typeof cv.className === 'string' ? cv.className : cv.tagName) || 'unknown').substring(0, 100));
        // Log all unique class names under #conversation for selector discovery
        var allCvEls = cv.querySelectorAll('*');
        var clsSet = {};
        for (var ci2 = 0; ci2 < allCvEls.length; ci2++) {
          var cn = allCvEls[ci2].className;
          if (typeof cn === 'string' && cn.length > 0) {
            var parts = cn.split(/\\s+/);
            for (var pi = 0; pi < parts.length; pi++) {
              if (parts[pi].length > 3 && !clsSet[parts[pi]]) clsSet[parts[pi]] = true;
            }
          }
        }
        var clsList = Object.keys(clsSet).sort().join(', ');
        log('CV-CLASSES (' + Object.keys(clsSet).length + '): ' + clsList.substring(0, 1500));
        // v19: Log direct children to discover message block structure
        var cvKids = cv.children;
        log('CV-CHILDREN (' + cvKids.length + '):');
        for (var ck = 0; ck < Math.min(cvKids.length, 15); ck++) {
          var kid = cvKids[ck];
          var kidCls = (typeof kid.className === 'string') ? kid.className : '';
          var kidText = (kid.textContent || '').trim().substring(0, 60);
          log('  child[' + ck + '] tag=' + kid.tagName + ' cls=' + kidCls.substring(0, 120) + ' text=' + kidText);
        }
        // v22: Deep-dive into gap-8 container to find individual message blocks
        var msgContainer = cv.querySelector('.gap-8') || cv.children[0];
        if (msgContainer) {
          var msgKids = msgContainer.children;
          log('MSG-BLOCKS (' + msgKids.length + '):');
          for (var mk = 0; mk < Math.min(msgKids.length, 30); mk++) {
            var mb = msgKids[mk];
            var mbCls = (typeof mb.className === 'string') ? mb.className : '';
            var mbText = (mb.textContent || '').trim().substring(0, 80);
            var hasLeadingRelaxed = mb.querySelector('.leading-relaxed.select-text') ? 'Y' : 'N';
            var firstChildCls = (mb.children[0] && typeof mb.children[0].className === 'string') ? mb.children[0].className : '';
            log('  msg[' + mk + '] cls=' + mbCls.substring(0, 120) + ' lr=' + hasLeadingRelaxed + ' fc=' + firstChildCls.substring(0, 80) + ' text=' + mbText.substring(0, 60));
          }
        }
        // Force a dump with conversation context
        dumpDOMStructure();
      }

      // v22: AI response = .leading-relaxed.select-text, User message = .select-text.rounded-lg (Esn component, msn class)
      // Source: jetskiAgent/main.js — msn="bg-gray-500/10 border border-gray-500/20 p-2 rounded-lg w-full text-sm select-text"
      var responseBlocks = cv.querySelectorAll('.leading-relaxed.select-text, .select-text.rounded-lg');

      if (responseBlocks.length > 0) {
        // v22: Filter out thinking/reasoning blocks — they have ancestor with max-h-[200px]
        // These are internal AI reasoning and should NOT be relayed to Discord
        var filteredBlocks = [];
        for (var fbi = 0; fbi < responseBlocks.length; fbi++) {
          var isThinking = false;
          var ancestor = responseBlocks[fbi].parentElement;
          for (var depth = 0; ancestor && depth < 5; depth++) {
            var aCls = (typeof ancestor.className === 'string') ? ancestor.className : '';
            if (aCls.indexOf('max-h-[200px]') !== -1 || aCls.indexOf('max-h-[150px]') !== -1) {
              isThinking = true;
              break;
            }
            ancestor = ancestor.parentElement;
          }
          if (!isThinking) filteredBlocks.push(responseBlocks[fbi]);
        }
        if (filteredBlocks.length === 0) return;

        // Process the LAST (most recent) non-thinking response block
        var lastBlock = filteredBlocks[filteredBlocks.length - 1];

        // Skip if already scraped
        if (lastBlock.dataset.agChatScraped === 'true' || lastBlock.dataset.agChatScraped === 'pending') {
          // Check for NEW blocks since last scrape
          if (filteredBlocks.length > _lastResponseBlockCount) {
            // New block appeared — process it
            for (var rbi = filteredBlocks.length - 1; rbi >= 0; rbi--) {
              if (filteredBlocks[rbi].dataset.agChatScraped !== 'true' && filteredBlocks[rbi].dataset.agChatScraped !== 'pending') {
                lastBlock = filteredBlocks[rbi];
                break;
              }
            }
            if (lastBlock.dataset.agChatScraped === 'true' || lastBlock.dataset.agChatScraped === 'pending') return;
          } else {
            return; // Already scraped, no new blocks
          }
        }

        var blockText = extractCleanStepText(lastBlock);
        
        var clsStr = (typeof lastBlock.className === 'string') ? lastBlock.className : '';
        // v19: Log block classes for user message selector discovery
        var parentCls = lastBlock.parentElement ? ((typeof lastBlock.parentElement.className === 'string') ? lastBlock.parentElement.className : '') : '';
        var grandCls = (lastBlock.parentElement && lastBlock.parentElement.parentElement) ? ((typeof lastBlock.parentElement.parentElement.className === 'string') ? lastBlock.parentElement.parentElement.className : '') : '';
        log('user-cls-debug block=' + clsStr.substring(0, 150) + ' | parent=' + parentCls.substring(0, 150) + ' | grand=' + grandCls.substring(0, 150) + ' | text=' + (blockText||'').substring(0, 50));
        // v22: Detect user message: has select-text + rounded-lg but NOT leading-relaxed
        var isUser = (clsStr.indexOf('rounded-lg') !== -1 && clsStr.indexOf('leading-relaxed') === -1) || clsStr.indexOf('user-color') !== -1;
        var role = isUser ? 'user' : 'bot';

        // Bot messages often start empty and stream in. User messages are usually immediate.
        if (blockText && (blockText.length > 30 || isUser && blockText.length > 0)) {
          // QUALITY CHECK: Skip if the text is mostly short lines (UI artifacts), BUT skip this check for user messages
          if (!isUser) {
            var lines = blockText.split('\\n').filter(function(l) { return l.trim().length > 0; });
            var longLines = lines.filter(function(l) { return l.trim().length > 20; });
            if (longLines.length === 0) {
              log('AG-Native: skipped (no long lines, likely UI noise)');
              return;
            }
          }

          // Wait for content to stabilize (3s no change)
          if (_lastStepText !== blockText) {
            _lastStepText = blockText;
            _lastStepTextTime = Date.now();
            _lastStepTextSent = false;
            return; // Wait for next scan cycle
          }

          if (_lastStepTextSent) return;
          // Bot needs 3s to stabilize, User just needs 500ms
          var waitTime = isUser ? 500 : 3000;
          if (Date.now() - _lastStepTextTime < waitTime) return; // Still waiting

          // v21: DOM-based chat relay RE-ENABLED — GetCascadeTrajectorySteps does NOT
          // return steps for in-progress cascades, making Step Probe RT-CAPTURE useless.
          // Observer DOM extraction is the ONLY real-time path for AI response relay.
          _lastStepTextSent = true;
          _lastResponseBlockCount = filteredBlocks.length;
          lastBlock.dataset.agChatScraped = 'pending';

          log('AG-Native chat relay [' + role + ']: blocks=' + filteredBlocks.length + ' text=' + blockText.length + ' chars');
          (function(el, txt, count, r) {
            fetch(BASE + '/chat', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ text: txt, source: 'ag_native_block_' + count, block_index: count, role: r })
            }).then(function() { el.dataset.agChatScraped = 'true'; log('AG-Native chat sent OK'); })
              .catch(function(e) { el.dataset.agChatScraped = 'false'; log('AG-Native chat send error: ' + e.message); });
          })(lastBlock, blockText, filteredBlocks.length, role);
        }
        return; // AG Native path handled — don't fall through to Cascade path
      }
    }

    // ── STRATEGY 2: Cascade — [data-testid="conversation-view"] ──
    cv = document.querySelector('[data-testid="conversation-view"]');
    if (!cv) {
      // FALLBACK: Try older selectors
      cv = document.querySelector('[class*="conversation"], [class*="chat-container"]');
      if (!cv) return;
    }

    // Find ALL step elements within the conversation
    var stepEls = cv.querySelectorAll('[data-step-index]');
    if (stepEls.length === 0) {
      // FALLBACK: Try text-ide-message-block-bot-color directly
      var botMsgs = cv.querySelectorAll('.text-ide-message-block-bot-color');
      if (botMsgs.length === 0) return;
      // Use the last bot message as a pseudo-step
      var lastBot = botMsgs[botMsgs.length - 1];
      if (lastBot.dataset.agChatScraped === 'true' || lastBot.dataset.agChatScraped === 'pending') return;
      var botText = extractCleanStepText(lastBot);
      if (botText && botText.length > 20) {
        if (_lastStepText !== botText) {
          _lastStepText = botText;
          _lastStepTextTime = Date.now();
          _lastStepTextSent = false;
        } else if (!_lastStepTextSent && (Date.now() - _lastStepTextTime > 3000)) {
          _lastStepTextSent = true;
          lastBot.dataset.agChatScraped = 'pending';
          fetch(BASE + '/chat', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ text: botText, source: 'fallback_bot_msg' })
          }).then(function() { lastBot.dataset.agChatScraped = 'true'; })
            .catch(function() { lastBot.dataset.agChatScraped = 'false'; });
        }
      }
      return;
    }

    // Find the LATEST step that has meaningful text content (AI response)
    // Iterate backwards to find the most recent unscraped step
    for (var si = stepEls.length - 1; si >= Math.max(0, stepEls.length - 5); si--) {
      var stepEl = stepEls[si];
      var stepIdx = parseInt(stepEl.getAttribute('data-step-index') || '-1', 10);

      // Skip already scraped
      if (stepEl.dataset.agChatScraped === 'true' || stepEl.dataset.agChatScraped === 'pending') continue;

      // Skip steps we've already processed
      if (stepIdx >= 0 && stepIdx <= _lastScrapedStepIndex) continue;

      // Check if this step has substantial text content (not just a tool call header)
      var stepText = extractCleanStepText(stepEl);
      if (!stepText || stepText.length < 30) continue;

      // QUALITY CHECK: Skip if the text is mostly short lines (UI artifacts)
      var lines = stepText.split('\\n').filter(function(l) { return l.trim().length > 0; });
      var longLines = lines.filter(function(l) { return l.trim().length > 20; });
      if (longLines.length === 0) {
        log('Step ' + stepIdx + ': skipped (no long lines, likely UI noise)');
        continue;
      }

      // Wait for content to stabilize (3s no change)
      if (_lastStepText !== stepText) {
        _lastStepText = stepText;
        _lastStepTextTime = Date.now();
        _lastStepTextSent = false;
        break; // Wait for next scan cycle
      }

      if (_lastStepTextSent) continue;
      if (Date.now() - _lastStepTextTime < 3000) break; // Still waiting

      // Content is stable — send it
      _lastStepTextSent = true;
      _lastScrapedStepIndex = stepIdx;
      stepEl.dataset.agChatScraped = 'pending';

      log('Chat relay: step=' + stepIdx + ' text=' + stepText.length + ' chars');
      (function(el, txt, idx) {
        fetch(BASE + '/chat', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ text: txt, source: 'step_' + idx, step_index: idx })
        }).then(function() { el.dataset.agChatScraped = 'true'; })
          .catch(function() { el.dataset.agChatScraped = 'false'; });
      })(stepEl, stepText, stepIdx);
      break;
    }
  }

  // ══════════════════════════════════════════════════════════════════
  // BUTTON SCANNING (approval detection)
  // ══════════════════════════════════════════════════════════════════

  function scan(){
    if(!_ready)return;
    scanChatBodies();
    var now=Date.now();
    var allBtns=document.querySelectorAll('button');
    if(!allBtns.length)return;

    for(var j=0;j<allBtns.length;j++){
      var b=allBtns[j];
      if(b.disabled||b.hidden||(!b.offsetParent&&b.style.display!=='fixed'))continue;

      var txt=cleanButtonText(b);
      if(txt.length <= 1) continue;

      // v9: Skip group header buttons — not approval buttons
      if (/^Running\\s*\\d+\\s*commands?$/i.test(txt)) continue;

      if(!isActionBtn(txt)) continue;
      // Skip inline code lens buttons
      if (b.closest('.codelens-decoration') && !txt.includes('Accept')) {
          continue;
      }

      var matchedType = txt.includes('Accept') ? 'diff_review' : (txt === 'Retry' ? 'retry' : (txt.includes('Run') || txt.includes('Allow') ? 'command' : 'permission'));

      // v7: Use step-index for more unique group key
      var stepContainer = getStepContainer(b);
      var stepIdx = stepContainer ? stepContainer.getAttribute('data-step-index') : 'none';
      var groupKey = matchedType + '|' + stepIdx + '|' + btnId(b, matchedType);
      if(_sent[groupKey])continue;

      var container=b.parentElement;
      var siblings=collectSiblingButtons(container,b);
      if(siblings.length===0)siblings=[{btn:b,text:txt,isPrimary:true}];

      var buttonsArr=[];
      var btnRefs=[];
      var bidList=[];
      for(var si=0;si<siblings.length;si++){
        var sb=siblings[si];
        var sbid=btnId(sb.btn,matchedType);
        buttonsArr.push({text:sb.text,index:si,is_primary:sb.isPrimary});
        btnRefs.push(sb.btn);
        bidList.push(sbid);
      }

      var desc=extractContext(b);

      var rid=now.toString()+'_'+Math.random().toString(36).substring(2,6);

      _sent[groupKey]={rid:rid,ts:now};
      for(var mk=0;mk<bidList.length;mk++)_sent[bidList[mk]]={rid:rid,ts:now};

      log('DETECTED '+matchedType+': '+txt+' ['+desc.substring(0,80)+'] step='+stepIdx);

      (function(rid2,btnRefs2,bidList2,groupKey2,txt2,desc2,type2,buttonsArr2){
        var payload={
          request_id:rid2,
          command:txt2,
          description:desc2,
          step_type:type2,
          buttons:buttonsArr2,
          _debug_trail:_lastContextDebug||''
        };
        fetch(BASE+'/pending',{
          method:'POST',
          headers:{'Content-Type':'application/json'},
          body:JSON.stringify(payload)
        }).then(function(r){return r.json();}).then(function(d){
          if (!d.ok || d.filtered) {
            delete _sent[groupKey2];
            for(var di=0;di<bidList2.length;di++)delete _sent[bidList2[di]];
            return;
          }
          pollResponseGroup(d.request_id,btnRefs2,bidList2,groupKey2);
        }).catch(function(e){
          delete _sent[groupKey2];
          for(var di=0;di<bidList2.length;di++)delete _sent[bidList2[di]];
        });
      })(rid,btnRefs,bidList,groupKey,txt,desc,matchedType,buttonsArr);
      
      break; 
    }
  }

  function pollResponseGroup(rid,btnRefs,bidList,groupKey){
    var polls=0, maxPolls=200;
    var timer=setInterval(function(){
      polls++;
      var anyAlive=false;
      for(var ai=0;ai<btnRefs.length;ai++){
        if(document.body.contains(btnRefs[ai])){anyAlive=true;break;}
      }
      if(!anyAlive || polls>maxPolls){
        clearInterval(timer);
        delete _sent[groupKey];
        for(var ci=0;ci<bidList.length;ci++)delete _sent[bidList[ci]];
        return;
      }
      fetch(BASE+'/response/'+rid).then(function(r){return r.json();}).then(function(d){
        if(d.waiting)return;
        clearInterval(timer);
        var btnIdx=(typeof d.button_index==='number'&&d.button_index>=0)?d.button_index:-1;
        if(btnIdx>=0&&btnIdx<btnRefs.length){
          log((d.approved?'OK':'NO')+' CHOICE '+rid+' -> clicking button['+btnIdx+']');
          dispatchReactClick(btnRefs[btnIdx]);
        } else if(d.approved){
          log('OK APPROVED '+rid+' -> clicking primary');
          dispatchReactClick(btnRefs[0]);
        } else {
          log('NO REJECTED '+rid+' -> finding reject button');
          clickRejectButton(btnRefs[0]);
        }
        delete _sent[groupKey];
        for(var ri=0;ri<bidList.length;ri++)delete _sent[bidList[ri]];
      }).catch(function(){});
    },1500);
  }

  function clickRejectButton(approveBtn){
    var container=approveBtn.parentElement;
    if(!container) container = approveBtn.parentElement && approveBtn.parentElement.parentElement;
    if(!container)return;
    var siblings=container.querySelectorAll('button');
    for(var i=0;i<siblings.length;i++){
      var t=cleanButtonText(siblings[i]);
      if(isRejectBtn(t)){
        log('Clicking reject: '+t);
        dispatchReactClick(siblings[i]);
        return;
      }
    }
  }

  function scheduleScan(){
    if(!_ready)return;
    var now=Date.now();
    if(now-_lastScanTs>=THROTTLE_MS){
      _lastScanTs=now;
      scan();
    } else if(!_scanScheduled){
      _scanScheduled=true;
      setTimeout(function(){
        _scanScheduled=false;
        _lastScanTs=Date.now();
        scan();
      },THROTTLE_MS-(now-_lastScanTs));
    }
  }

  setInterval(function(){
    var now=Date.now();
    var keys=Object.keys(_sent);
    for(var i=0;i<keys.length;i++){
      var entry=_sent[keys[i]];
      if(entry&&entry.ts&&(now-entry.ts)>CLEANUP_MS){
        delete _sent[keys[i]];
      }
    }
  },60000);

  function startObserver(){
    if(_obs)return;
    log('startObserver() — scheduling auto-dumps and mutation observer');
    scheduleAutoDumps();
    new MutationObserver(function(mutations){
      for(var i=0;i<mutations.length;i++){
        if(mutations[i].addedNodes.length>0){
          scheduleScan();
          return;
        }
      }
    }).observe(document.body,{childList:true,subtree:true});
    setInterval(scheduleScan,3000);

    // ── TRIGGER-CLICK POLLING ──
    (function pollTriggerClick(){
      if(_ready&&BASE){
      fetch(BASE+'/trigger-click?t='+Date.now()).then(function(r){return r.json();}).then(function(d){
        if(!d.action)return;
        var isApprove = (d.action==='approve');
        var btns = document.querySelectorAll('button');
        for(var i=0;i<btns.length;i++){
           var bx = btns[i];
           if(bx.disabled||bx.hidden||(!bx.offsetParent&&bx.style.display!=='fixed'))continue;
           var t = cleanButtonText(bx);
           if(t.length <= 1) continue;
           if (isApprove ? isActionBtn(t) : isRejectBtn(t)) {
              log('Fallback TRIGGER-CLICK on "' + t + '"');
              dispatchReactClick(bx);
              return;
           }
        }
      }).catch(function(){});
      }
      setTimeout(pollTriggerClick, 2000);
    })();

    // ── DEEP-INSPECT POLLING (v8: full body dump) ──
    (function pollDeepInspect(){
      if(_ready&&BASE){
        fetch(BASE+'/deep-inspect-trigger?t='+Date.now()).then(function(r){return r.json();}).then(function(d){
          if(!d.inspect)return;
          log('Deep inspect triggered — full body dump');
          // Force a fresh DOM dump
          _dumpCount = Math.max(0, _dumpCount - 1); // allow one more dump
          dumpDOMStructure();
          // Also send the result to deep-inspect-result endpoint
          var bodyTree = walkNode(document.body, 0, 12, 25);
          var result = {
            timestamp: new Date().toISOString(),
            windowURL: window.location.href,
            title: document.title,
            totalElements: document.body ? document.querySelectorAll('*').length : 0,
            hasConversationView: !!document.querySelector('[data-testid="conversation-view"]'),
            hasStepIndex: !!document.querySelector('[data-step-index]'),
            dataTestIds: [],
            buttons: [],
            bodyTree: bodyTree
          };
          var testIdEls = document.querySelectorAll('[data-testid]');
          for (var dti = 0; dti < testIdEls.length; dti++) {
            var dtid = testIdEls[dti].getAttribute('data-testid');
            if (result.dataTestIds.indexOf(dtid) === -1) result.dataTestIds.push(dtid);
          }
          var allBtns = document.querySelectorAll('button, [role="button"]');
          for (var bi = 0; bi < Math.min(allBtns.length, 50); bi++) {
            var btn = allBtns[bi];
            var btxt = (btn.textContent || '').trim().substring(0, 80);
            if (btxt.length > 0) {
              result.buttons.push({
                text: btxt,
                tag: btn.tagName.toLowerCase(),
                cls: ((btn.className && typeof btn.className === 'string') ? btn.className : '').substring(0, 150),
                visible: !!(btn.offsetParent || btn.style.display === 'fixed'),
              });
            }
          }
          fetch(BASE+'/deep-inspect-result', {
            method: 'POST',
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify(result)
          }).catch(function(){});
        }).catch(function(){});
      }
      setTimeout(pollDeepInspect, 3000);
    })();

    _obs=true;
  }
})();
`;
}
