Ghost in the Loop

👻 AI workflow engine — auto-proceed, pipelines, personas, export, diagnostics, roadmap autopilot, handoff capsules. ChatGPT · Claude · Perplexity · Gemini · DeepSeek · Copilot · Grok · Manus + 13 more.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==UserScript==
// @name         Ghost in the Loop
// @namespace    https://github.com/MShneur/ghost-in-the-loop
// @version      8.0.0
// @description  👻 AI workflow engine — auto-proceed, pipelines, personas, export, diagnostics, roadmap autopilot, handoff capsules. ChatGPT · Claude · Perplexity · Gemini · DeepSeek · Copilot · Grok · Manus + 13 more.
// @author       Michael S (CTRL-AI) — Architecture by Claude
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @match        https://www.perplexity.ai/*
// @match        https://gemini.google.com/*
// @match        https://chat.deepseek.com/*
// @match        https://copilot.microsoft.com/*
// @match        https://grok.com/*
// @match        https://claude.ai/*
// @match        https://manus.im/*
// @match        https://www.manus.im/*
// @match        https://chat.mistral.ai/*
// @match        https://kimi.com/*
// @match        https://www.kimi.com/*
// @match        https://kimi.moonshot.cn/*
// @match        https://chat.qwen.ai/*
// @match        https://meta.ai/*
// @match        https://www.meta.ai/*
// @match        https://poe.com/*
// @match        https://huggingface.co/chat*
// @match        https://you.com/*
// @match        https://pi.ai/*
// @match        https://chat.z.ai/*
// @match        https://genspark.ai/*
// @match        https://www.genspark.ai/*
// @match        https://chat.minimax.io/*
// @match        https://lmarena.ai/*
// @match        https://duck.ai/*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_setClipboard
// @grant        GM_notification
// @run-at       document-idle
// @license      AGPL-3.0
// ==/UserScript==

(() => {
'use strict';
if (window.__GITL_V8__) return;
window.__GITL_V8__ = true;

/* ═══════════════════════════════════════════════════════════════
   LAYER 0 — CONSTANTS
   ═══════════════════════════════════════════════════════════════ */
const VER = '8.0.0';
const SUPPORT_URL = 'https://github.com/sponsors/MShneur';
const REPORT_REPO = 'MShneur/ghost-in-the-loop'; // for pre-filled issue URL transport
const REPORT_WORKER_URL = ''; // set to a relay endpoint to enable silent auto-submit; empty = disabled
const SIGIL_PROCEED = '[[GITL::PROCEED]]';
const SIGIL_HALT    = '[[GITL::HALT]]';
const LEGACY_PROCEED = 'PROCEED';
const LEGACY_HALT    = 'SYSTEM_HALT';
const MIN_RESPONSE_LEN = 50;

/* Send-confirmation watchdog (v7.1): after a send, generation must
   actually start within this window. Guards the "Enter swallowed by a
   notification focus-steal" failure where the script thinks it sent
   but the platform never began generating. */
const SEND_CONFIRM_MS  = 9000;  // grace for generation to begin (covers slow first-token)
const SEND_MAX_RETRIES = 2;     // re-fire attempts before pausing

/* ═══════════════════════════════════════════════════════════════
   LAYER 0.5 — BOOT SAFETY + TAB LOCK + FOCUS GUARD
   Fixes v7.0-alpha loading failures: race conditions, multi-tab
   conflicts, background token burn.
   Sources: Kimi Deep Dive, Software Architect GPT, HTML/CSS GPT
   ═══════════════════════════════════════════════════════════════ */
const GITL_TAB_ID = crypto.randomUUID?.() || `tab-${Date.now()}-${Math.random().toString(16).slice(2)}`;
let _tabLockInterval = null;

/* safeBoot: guarantees document.body exists before any DOM work.
   If body isn't ready, retries via rAF. Catches and logs boot errors. */
function safeBoot(fn) {
  const boot = () => {
    try {
      if (!document.body) { requestAnimationFrame(boot); return; }
      fn();
    } catch (err) {
      console.error('[GITL] boot failed:', err);
      try { GM_setValue('lastBootError', JSON.stringify({ msg: String(err?.message||err), stack: String(err?.stack||''), at: new Date().toISOString() })); } catch(_){}
    }
  };
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', boot, { once: true });
  } else { boot(); }
}

/* Tab lock: prevents multi-tab race conditions. Only one GITL
   instance per conversation route can run the loop engine.
   Uses GM_getValue heartbeat with 8s expiry. */
function _tabLockKey() {
  return `gitl:lock:${location.hostname}:${location.pathname.split('/').slice(0,3).join('/')}`;
}

function claimTabLock() {
  const key = _tabLockKey();
  const now = Date.now();
  try {
    const raw = GM_getValue(key, null);
    const lock = raw ? JSON.parse(raw) : null;
    if (lock && lock.tabId !== GITL_TAB_ID && (now - lock.ts < 8000)) {
      return false; // another tab owns it
    }
  } catch(_){}
  GM_setValue(key, JSON.stringify({ tabId: GITL_TAB_ID, ts: now }));
  return true;
}

function releaseTabLock() {
  try {
    const key = _tabLockKey();
    const raw = GM_getValue(key, null);
    if (raw) {
      const lock = JSON.parse(raw);
      if (lock.tabId === GITL_TAB_ID) GM_setValue(key, '');
    }
  } catch(_){}
}

function startTabHeartbeat() {
  if (_tabLockInterval) clearInterval(_tabLockInterval);
  _tabLockInterval = setInterval(() => {
    if (!claimTabLock()) {
      // lost ownership — pause if running
      if (typeof GHOST !== 'undefined' && GHOST.loop.state === 'RUNNING') {
        GHOST.loop.state = 'PAUSED';
        GHOST.loop.detail = '⚠ Tab lock lost — paused';
        if (typeof render === 'function') render();
      }
    }
  }, 5000);
}

/* Focus guard: prevents background tabs from burning tokens
   by auto-sending prompts while user isn't looking. */
function isTabSafeToAct() {
  if (!document.hasFocus()) return false;
  if (document.hidden) return false;
  return claimTabLock();
}

/* Pre-send safety gate: called before every engineSend.
   Returns { ok, reason } */
function assertInteractionSafe() {
  if (!document.hasFocus() && typeof GHOST !== 'undefined' && GHOST.loop.state === 'RUNNING') {
    return { ok: false, reason: 'tab-not-focused' };
  }
  if (!claimTabLock()) {
    return { ok: false, reason: 'tab-lock-held-by-other' };
  }
  return { ok: true, reason: 'ok' };
}

/* Cleanup on tab close */
if (typeof window !== 'undefined') {
  window.addEventListener('beforeunload', () => {
    releaseTabLock();
    if (_tabLockInterval) clearInterval(_tabLockInterval);
  });
}

/* ═══════════════════════════════════════════════════════════════
   LAYER 0.7 — NETWORK INTERCEPTOR (S1)
   Captures AI responses from fetch/XHR streams BEFORE they hit
   the DOM. Supplements DOM-based detection — does NOT replace it.
   Sources: Gemini Phase 0, Kimi Deep Dive, DeepSeek cascade
   ═══════════════════════════════════════════════════════════════ */
const GITL_NET = {
  bus: new EventTarget(),
  lastChunk: '',
  lastComplete: '',
  capturedAt: 0,
  active: false,

  AI_ENDPOINTS: [
    '/backend-api/conversation',   // ChatGPT
    '/api/organizations',          // Claude
    '/socket.io/',                 // Perplexity
    '/api/v1/chat/completions',    // DeepSeek / OpenAI-compat
    '/chat/conversation',          // HuggingChat
    '/api/chat',                   // Generic
    '/bard',                       // Gemini
    '/turn/',                      // Copilot
  ],

  _isChat(url) {
    if (!url) return false;
    const s = typeof url === 'string' ? url : url?.url || String(url);
    return this.AI_ENDPOINTS.some(ep => s.includes(ep));
  },

  _emit(raw, isDone) {
    if (raw === '[DONE]') isDone = true;
    this.lastChunk = raw;
    this.capturedAt = Date.now();
    if (isDone) this.lastComplete = raw;
    this.bus.dispatchEvent(new CustomEvent('gitl:net', {
      detail: { raw, isDone, ts: Date.now() }
    }));
  },

  install() {
    if (this.active) return;
    this.active = true;

    /* Fetch proxy — captures SSE / JSON streams */
    const origFetch = window.fetch;
    const self = this;
    window.fetch = async function(...args) {
      const response = await origFetch.apply(this, args);
      if (self._isChat(args[0])) {
        try {
          const cloned = response.clone();
          if (cloned.body) {
            const reader = cloned.body.getReader();
            const decoder = new TextDecoder('utf-8');
            (async () => {
              let buf = '';
              try {
                while (true) {
                  const { done, value } = await reader.read();
                  if (done) { if (buf) self._emit(buf, true); break; }
                  buf += decoder.decode(value, { stream: true });
                  const lines = buf.split('\n');
                  buf = lines.pop() || '';
                  for (const line of lines) {
                    const trimmed = line.trim();
                    if (trimmed.startsWith('data: ')) {
                      self._emit(trimmed.slice(6), false);
                    }
                  }
                }
              } catch(_) { /* stream aborted — normal on navigation */ }
            })();
          }
        } catch(err) {
          console.warn('[GITL] fetch intercept error:', err);
        }
      }
      return response;
    };

    /* XHR proxy — fallback for platforms not using fetch */
    const origOpen = XMLHttpRequest.prototype.open;
    XMLHttpRequest.prototype.open = function(method, url, ...rest) {
      this._gitlUrl = url;
      return origOpen.call(this, method, url, ...rest);
    };
    const origSend = XMLHttpRequest.prototype.send;
    XMLHttpRequest.prototype.send = function(...args) {
      if (self._isChat(this._gitlUrl)) {
        this.addEventListener('load', function() {
          if (this.status >= 200 && this.status < 300 && this.responseText) {
            self._emit(this.responseText, true);
          }
        });
      }
      return origSend.apply(this, args);
    };

    console.log('[GITL] Network interceptor active');
  }
};

/* Install immediately — safe even before DOM */
GITL_NET.install();

/* ═══════════════════════════════════════════════════════════════
   LAYER 1 — PLATFORM ADAPTERS (all DOM access lives here)
   The loop engine NEVER touches the DOM directly.
   ═══════════════════════════════════════════════════════════════ */
const PROFILES = {
  chatgpt: {
    host: /chatgpt\.com|chat\.openai\.com/,
    label: 'ChatGPT',
    input: ['#prompt-textarea','div[contenteditable="true"][id="prompt-textarea"]','textarea[data-id="root"]','textarea'],
    send: ['button[data-testid="send-button"]','button[aria-label="Send prompt"]','button[aria-label="Send"]','form button[type="submit"]','button[class*="send"]'],
    stop: ['button[aria-label="Stop generating"]','button[data-testid="stop-button"]'],
    assistant: ['div[data-message-author-role="assistant"]','article [data-message-author-role="assistant"]'],
    continueLabels: ['Continue generating','Continue'],
    useCE: false, useNS: true
  },
  perplexity: {
    host: /perplexity\.ai/,
    label: 'Perplexity',
    input: ['textarea[placeholder*="Ask"]','textarea[placeholder*="Follow"]','div[contenteditable="true"][role="textbox"]','div[class*="ProseMirror"]','textarea:not([disabled])'],
    send: ['button[aria-label="Submit"]','button[aria-label="Send"]','button[type="submit"]'],
    stop: ['button[aria-label="Stop"]','[data-testid="stop-button"]'],
    assistant: ['div[class*="prose"]','div[dir="auto"][class*="break-words"]','.pb-md > div'],
    continueLabels: [],
    useCE: true, useNS: false
  },
  gemini: {
    host: /gemini\.google\.com/,
    label: 'Gemini',
    input: ['div.ql-editor[contenteditable="true"]','rich-textarea div[contenteditable="true"]','div[contenteditable="true"]','textarea'],
    send: ['button[aria-label="Send message"]','button[aria-label*="Send"]','button.send-button'],
    stop: ['button[aria-label*="Stop"]'],
    assistant: ['model-response message-content','message-content','div[class*="model-response"]'],
    continueLabels: [],
    useCE: true, useNS: false
  },
  deepseek: {
    host: /chat\.deepseek\.com/,
    label: 'DeepSeek',
    input: ['textarea[placeholder]','#chat-input','textarea'],
    send: ['div[class*="send"]','button[class*="send"]','button[aria-label*="Send"]'],
    stop: ['div[class*="stop"]','button[class*="stop"]'],
    assistant: ['div[class*="markdown"]'],
    continueLabels: [],
    useCE: false, useNS: false
  },
  copilot: {
    host: /copilot\.microsoft\.com/,
    label: 'Copilot',
    input: ['textarea#userInput','#searchbox','textarea[placeholder*="message"]','textarea'],
    send: ['button[aria-label="Submit"]','button[title="Submit"]'],
    stop: ['button[aria-label="Stop Responding"]'],
    assistant: ['cib-message-group[source="bot"]'],
    continueLabels: [],
    useCE: false, useNS: false
  },
  grok: {
    host: /grok\.com/,
    label: 'Grok',
    input: ['textarea[placeholder*="Ask"]','textarea','div[contenteditable="true"]'],
    send: ['button[aria-label="Send"]','button[type="submit"]'],
    stop: ['button[aria-label="Stop"]'],
    assistant: ['div[class*="message"][class*="bot"]','div[data-role="assistant"]'],
    continueLabels: [],
    useCE: false, useNS: false
  },
  claude: {
    host: /claude\.ai/,
    label: 'Claude',
    input: ['div[contenteditable="true"].ProseMirror','div[contenteditable="true"][aria-label*="message"]','div.ProseMirror','div[contenteditable="true"]'],
    send: ['button[aria-label="Send Message"]','button[type="submit"]','button[aria-label*="Send"]'],
    stop: ['button[aria-label="Stop Response"]'],
    assistant: ['div[data-is-streaming]','div.font-claude-message','.claude-message'],
    continueLabels: [],
    useCE: true, useNS: false
  },
  manus: {
    host: /manus\.im/,
    label: 'Manus',
    // Verified against real Manus DOM: Tiptap ProseMirror input; Monaco code viewer has a decoy <textarea>.
    input: ['div.ProseMirror[contenteditable="true"]','div[contenteditable="true"][role="textbox"]','div[contenteditable="true"]:not(.monaco-editor *)'],
    send: ['button[type="submit"]','button[aria-label*="Send" i]','button[data-testid*="send"]'],
    stop: ['button[aria-label*="Stop" i]','button[class*="stop" i]'],
    assistant: ['[data-event-id]','div.manus-markdown'],
    continueLabels: [],
    useCE: true, useNS: false
  }
};

// Known platforms that run on the generic adapter (labeled, no dedicated selectors yet)
const GENERIC_HOSTS = [
  [/chat\.mistral\.ai/, 'Mistral'],
  [/kimi\.com|kimi\.moonshot\.cn/, 'Kimi'],
  [/chat\.qwen\.ai/, 'Qwen'],
  [/meta\.ai/, 'Meta AI'],
  [/poe\.com/, 'Poe'],
  [/huggingface\.co/, 'HuggingChat'],
  [/you\.com/, 'You.com'],
  [/pi\.ai/, 'Pi'],
  [/chat\.z\.ai/, 'Z.ai'],
  [/genspark\.ai/, 'Genspark'],
  [/chat\.minimax\.io/, 'MiniMax'],
  [/lmarena\.ai/, 'LMArena'],
  [/duck\.ai/, 'Duck.ai']
];

// Detect platform or use generic fallback
let PLAT = null;
for (const [, p] of Object.entries(PROFILES)) {
  if (p.host.test(location.hostname)) { PLAT = p; break; }
}
if (!PLAT) {
  let gLabel = 'Generic';
  for (const [rx, label] of GENERIC_HOSTS) { if (rx.test(location.hostname)) { gLabel = label; break; } }
  PLAT = {
    label: gLabel,
    input: ['textarea:not([disabled])','div[contenteditable="true"][role="textbox"]','div[contenteditable="true"]','textarea','input[type="text"]'],
    send: ['button[type="submit"]','button[aria-label*="Send" i]','button[aria-label*="Submit" i]','button[data-testid*="send"]','button[class*="send" i]'],
    stop: ['button[aria-label*="Stop" i]','button[data-testid*="stop"]','button[class*="stop" i]'],
    assistant: ['[data-message-author-role="assistant"]','[role="assistant"]','div[class*="markdown" i]','div[class*="prose" i]','div[class*="assistant" i]','div[class*="response" i]','div[class*="message" i]'],
    continueLabels: [],
    useCE: false, useNS: false
  };
}

// User-defined selector overrides (Settings → Custom sites). Prepended so they win.
// Shape: { "hostname-fragment": { label, input:[], send:[], stop:[], assistant:[], useCE, useNS } }
try {
  const _custom = JSON.parse(GM_getValue('customSites','{}'));
  for (const [hostKey, o] of Object.entries(_custom)) {
    if (hostKey && location.hostname.includes(hostKey)) {
      for (const k of ['input','send','stop','assistant']) {
        if (Array.isArray(o[k]) && o[k].length) PLAT[k] = [...o[k], ...(PLAT[k]||[])];
      }
      if (o.label) PLAT.label = o.label + ' (custom)';
      if (typeof o.useCE === 'boolean') PLAT.useCE = o.useCE;
      if (typeof o.useNS === 'boolean') PLAT.useNS = o.useNS;
      break;
    }
  }
} catch(_){}

// Selector cache with route-change invalidation
const _cache = new Map();
let _lastHref = location.href;

const _deepLast = new Map(); // throttle shadow walks per key
function _shadowQS(sel) {
  const walk = (root, depth) => {
    if (depth > 4) return null;
    for (const host of root.querySelectorAll('*')) {
      if (host.shadowRoot) {
        try { const hit = host.shadowRoot.querySelector(sel); if (hit) return hit; } catch(_){}
        const deep = walk(host.shadowRoot, depth + 1); if (deep) return deep;
      }
    }
    return null;
  };
  try { return walk(document, 0); } catch(_) { return null; }
}

function _isOwnUI(el) {
  // Never match elements inside GITL's own panel (prevents the input/recovery
  // selectors from matching our settings textarea — found by Replit e2e)
  return !!(el && el.closest && el.closest('#gitl'));
}

function _q(key, sels) {
  const c = _cache.get(key);
  if (c?.isConnected && !_isOwnUI(c)) return c;
  _cache.delete(key);
  for (const s of sels || []) {
    try {
      for (const el of document.querySelectorAll(s)) {
        if (el && !_isOwnUI(el)) { _cache.set(key, el); return el; }
      }
    } catch(_){}
  }
  // Shadow DOM fallback (Copilot-style shadow roots) — throttled to once per 5s per key
  const now = Date.now();
  if ((now - (_deepLast.get(key) || 0)) > 5000) {
    _deepLast.set(key, now);
    for (const s of sels || []) {
      const el = _shadowQS(s);
      if (el && !_isOwnUI(el)) { _cache.set(key, el); return el; }
    }
  }
  return null;
}

function _qAll(sels) {
  // Merge all matching elements, deduplicated (fixes v5 qAll bug)
  // Excludes GITL's own UI elements.
  const seen = new Set(), results = [];
  for (const s of (Array.isArray(sels) ? sels : [sels])) {
    try { document.querySelectorAll(s).forEach(el => { if (!seen.has(el) && !_isOwnUI(el)) { seen.add(el); results.push(el); } }); } catch(_){}
  }
  return results;
}

// Adapter — all DOM reads/writes
const Adapter = {
  getInput()      { return _q('in', PLAT.input); },
  getSendBtn()    { return _q('send', PLAT.send); },
  isGenerating()  { return !!_q('gen', PLAT.stop); },
  hasMessages()   { return _qAll(PLAT.assistant).length > 0; },
  getLastText() {
    const els = _qAll(PLAT.assistant);
    return els.length ? (els[els.length-1].innerText || '').trim() : '';
  },
  clickContinue() {
    if (!PLAT.continueLabels?.length) return false;
    for (const btn of document.querySelectorAll('button')) {
      if (PLAT.continueLabels.some(l => btn.textContent.includes(l))) { btn.click(); return true; }
    }
    return false;
  },
  injectText(el, text) {
    if (!el) return false;
    el.focus();
    // Path 1: contenteditable (ProseMirror/Quill/Lexical)
    if (el.getAttribute('contenteditable') === 'true' || PLAT.useCE) {
      // FIX: selectAll+insertText preserves ProseMirror state (innerHTML='' destroys it)
      document.execCommand('selectAll', false, null);
      const ok = document.execCommand('insertText', false, text);
      if (!ok) { el.textContent = text; }
      el.dispatchEvent(new Event('input', { bubbles: true }));
      el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
      DIAG.sendPath = 'contenteditable';
      return true;
    }
    // Path 2: native React setter
    if (PLAT.useNS && el.tagName === 'TEXTAREA') {
      const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
      if (setter) { setter.call(el, text); el.dispatchEvent(new Event('input', { bubbles: true })); DIAG.sendPath = 'native-setter'; return true; }
    }
    // Path 3: direct value
    el.value = text;
    el.dispatchEvent(new Event('input', { bubbles: true }));
    el.dispatchEvent(new Event('change', { bubbles: true }));
    DIAG.sendPath = 'direct-value';
    return true;
  },
  pressEnter(el) {
    ['keydown','keypress','keyup'].forEach(t => {
      el.dispatchEvent(new KeyboardEvent(t, { key:'Enter', code:'Enter', keyCode:13, which:13, bubbles:true }));
    });
  }
};

/* ═══════════════════════════════════════════════════════════════
   LAYER 2 — STATE STORE (single GHOST object)
   ═══════════════════════════════════════════════════════════════ */
const PERSONA_LIBRARY = {
  none:       { label: 'None', inject: '' },
  researcher: { label: 'Researcher', inject: 'Adopt the persona of a rigorous senior researcher: clarify assumptions, gather evidence, compare alternatives, and explicitly note uncertainty when evidence is weak.' },
  builder:    { label: 'Builder', inject: 'Adopt the persona of a senior builder/operator: prefer implementation detail, sequence, dependencies, tradeoffs, and concrete execution steps over vague theory.' },
  redteam:    { label: 'Red Team', inject: 'Adopt the persona of a hostile but fair red-team reviewer: attack weak assumptions, find failure modes, identify exploit paths, and surface how this could go wrong in reality.' },
  devil:      { label: "Devil's Advocate", inject: "Adopt the persona of a devil's advocate: challenge the dominant framing, propose contrarian interpretations, and test whether the current direction is overconfident or incomplete." },
  tester:     { label: 'Tester', inject: 'Adopt the persona of a destructive QA and reliability tester: search for breakage, edge cases, race conditions, user-error paths, and ambiguous states.' },
  customer:   { label: 'Customer Voice', inject: 'Adopt the persona of a skeptical end user/customer: surface confusion, friction, mistrust, negative feedback, missing explanations, and why adoption might fail.' },
  executive:  { label: 'Executive', inject: 'Adopt the persona of an executive operator: prioritize leverage, decision quality, clarity, speed, downside risk, and what matters most if time is limited.' },
  roundtable: { label: 'Round Table', inject: 'Simulate a compact round-table: Researcher, Builder, Red Team, Customer Voice, and Executive. Let each contribute distinct viewpoints, then synthesize a stronger consensus with disagreements preserved.' }
};

// Perplexity (and any model-switcher) variant — a REAL round table across models, not a simulated one.
const ROUNDTABLE_LIVE = 'This is a live multi-model round table. The operator switches the active model between turns using the model selector. You are ONE lens at this table. Give your OWN independent assessment of the work so far — do NOT simply agree with or extend the previous model. Challenge assumptions, fill gaps, add what only you would add. Put all substantive output in a single code block, no fluff, so it carries cleanly to the next model. End with one line naming which model should take the next turn and why, then [[GITL::PROCEED]] — or [[GITL::HALT]] only if genuine consensus is reached.';

function resolvePersonaInject() {
  const sel = GHOST.persona.selected;
  if (sel === 'roundtable' && /Perplexity/i.test(PLAT.label)) return ROUNDTABLE_LIVE;
  return allPersonas()[sel]?.inject || '';
}

const WORKFLOW_LIBRARY = {
  none:          { label: 'Manual', desc: 'Standard Ghost loop — no automatic stage prompts.', stages: [] },
  deep_research: { label: 'Deep Research', desc: 'Research → branch → red team → synthesis.', stages: [
    'You have completed the initial pass. Now expand the research: identify missing angles, weakly supported assumptions, hidden dependencies, and adjacent questions worth investigating.',
    'Generate 3–7 high-value research branches. Rank by upside, risk reduction, and novelty. Pursue the top branch first.',
    'Red-team everything produced so far. Find what is wrong, brittle, naïve, overfit, ungrounded, or likely to fail in reality.',
    'Synthesize the best final output. Preserve the strongest ideas, remove weak ones, deliver the upgraded result with clear reasoning and tradeoffs.'
  ]},
  rd_lab:        { label: 'R&D Lab', desc: 'Invent → prototype → evaluate → converge.', stages: [
    'Shift into R&D mode. Generate ambitious but plausible directions beyond the current framing.',
    'Choose the most promising directions and expand into concrete mechanisms. Explain how each one would actually work.',
    'Prototype-review mode: compare candidates, identify fatal flaws, decide which to merge, cut, or reframe.',
    'Deliver the strongest evolved concept as a coherent final design with rationale and open questions.'
  ]},
  shipyard:      { label: 'Shipyard', desc: 'Concept → execution plan → QA → production-ready.', stages: [
    'Translate the work into an execution plan. Break into milestones, dependencies, and the first shippable version.',
    'Act as QA plus operations. Identify what will fail during implementation, onboarding, edge cases, and scaling.',
    'Rewrite the plan into a production-ready version: streamlined, resilient, and prioritized with rollback thinking.'
  ]},
  debate:        { label: 'Debate', desc: 'Multi-persona challenge and synthesis.', stages: [
    'Run a structured round-table: Researcher, Builder, Red Team, Customer Voice, Executive. Keep viewpoints distinct.',
    'Force disagreement: identify main conflicts, what each persona thinks the others underestimate, which critique matters most.',
    'Resolve the debate and produce the improved answer that best survives all critiques.'
  ]},
  pre_mortem:    { label: 'Pre-Mortem', desc: 'Assume failure → investigate → harden.', stages: [
    'Assume this fails badly in 6 months. Explain exactly how and why: product, technical, human, messaging, and market reasons.',
    'Identify early warning indicators and the smallest interventions that would have prevented that failure.',
    'Rewrite the strategy so it is explicitly hardened against those failure modes.'
  ]},
  trollproof:    { label: 'Trollproof', desc: 'Hostile feedback → filter → harden.', stages: [
    'Simulate the most damaging negative feedback, mocking reactions, bad-faith interpretations, and hostile public criticism this could attract.',
    'Determine which criticisms are unfair noise and which reveal a real weakness that should be fixed.',
    'Rewrite the output so it is clearer, more resilient, and better prepared for hostile interpretation.'
  ]},
  lens_relay:    { label: 'Lens Relay', desc: 'Real model-switch round table. Turn on "Pause between" — swap the model each pause, press ▶.', stages: [
    'New lens turn. Give your OWN independent assessment of all work so far. Do not agree by default — challenge assumptions, surface gaps, add what only your perspective adds. All substantive output in one code block, no fluff. Name which model should go next.',
    'New lens turn. Focus on what every previous lens underestimated or missed entirely. Independent take, code block, no fluff. Name the next model.',
    'New lens turn. Draft the synthesis candidate: merge the strongest points across all lenses, preserve real disagreements explicitly. Code block, no fluff.',
    'Final lens. Verify the synthesis against every prior critique. Deliver the consensus result — complete, deliverable-grade, in one code block.'
  ]}
};

/* ═══════════════════════════════════════════════════════════════
   WORKSHOP (v7.1) — community-content layer
   Custom personas & workflows the user creates or imports from a file.
   Built-ins above are IMMUTABLE; customs layer on top. Import is purely
   additive (built-in ids are protected; custom-id clashes auto-rename),
   so a bad import can never destroy existing items or break the plugin.
   Shared as a single combined .gitl.json bundle.
   ═══════════════════════════════════════════════════════════════ */
const WORKSHOP_SCHEMA = 'gitl-workshop/1';
const WORKSHOP_LIMITS = {
  fileBytes: 512 * 1024,   // reject import files larger than 512 KB before parsing
  maxItems:  200,          // max personas + workflows accepted from one import
  label:     40,
  inject:    4000,
  desc:      200,
  stage:     2000,
  stages:    20
};
const Workshop = {
  personas: {},   // id → { label, inject, custom:true }
  workflows: {},  // id → { label, desc, stages:[], custom:true }

  load() {
    try { this.personas  = JSON.parse(GM_getValue('customPersonas',  '{}')) || {}; } catch(_) { this.personas = {}; }
    try { this.workflows = JSON.parse(GM_getValue('customWorkflows', '{}')) || {}; } catch(_) { this.workflows = {}; }
  },
  _persist() {
    _save('customPersonas',  JSON.stringify(this.personas));
    _save('customWorkflows', JSON.stringify(this.workflows));
  },

  _slug(s) { return String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,'_').replace(/^_|_$/g,'').slice(0,40) || 'item'; },
  _uniqueId(base, taken) {
    let id = this._slug(base), n = 2;
    while (taken.has(id)) { id = `${this._slug(base)}_${n++}`; }
    return id;
  },

  // ── Validation: tolerant but strict enough to never inject garbage ──
  _validPersona(p) {
    return p && typeof p.label === 'string' && p.label.trim().length > 0
             && typeof p.inject === 'string' && p.inject.trim().length > 0;
  },
  _validWorkflow(w) {
    return w && typeof w.label === 'string' && w.label.trim().length > 0
             && Array.isArray(w.stages) && w.stages.length > 0
             && w.stages.every(s => typeof s === 'string' && s.trim().length > 0);
  },

  addPersona(label, inject) {
    const taken = new Set([...Object.keys(PERSONA_LIBRARY), ...Object.keys(this.personas)]);
    const id = this._uniqueId(label, taken);
    this.personas[id] = { label: String(label).slice(0,40), inject: String(inject).slice(0,4000), custom: true };
    this._persist(); return id;
  },
  addWorkflow(label, desc, stages) {
    const taken = new Set([...Object.keys(WORKFLOW_LIBRARY), ...Object.keys(this.workflows)]);
    const id = this._uniqueId(label, taken);
    this.workflows[id] = { label: String(label).slice(0,40), desc: String(desc||'').slice(0,200),
      stages: stages.map(s => String(s).slice(0,2000)).slice(0,20), custom: true };
    this._persist(); return id;
  },
  removePersona(id)  { if (this.personas[id])  { delete this.personas[id];  this._persist(); return true; } return false; },
  removeWorkflow(id) { if (this.workflows[id]) { delete this.workflows[id]; this._persist(); return true; } return false; },

  // ── Export: combined bundle of custom items only ──
  exportBundle() {
    return JSON.stringify({
      schema: WORKSHOP_SCHEMA,
      tool: 'Ghost in the Loop',
      version: VER,
      exported: new Date().toISOString(),
      personas:  Object.entries(this.personas).map(([id,p])  => ({ id, label: p.label, inject: p.inject })),
      workflows: Object.entries(this.workflows).map(([id,w]) => ({ id, label: w.label, desc: w.desc, stages: w.stages }))
    }, null, 2);
  },

  // ── Import: additive, protects built-ins, auto-renames custom clashes ──
  importBundle(text) {
    if (typeof text !== 'string') return { ok:false, error:'No file content' };
    // Reject oversized payloads BEFORE parsing (cheap DoS / paste-bomb guard).
    if (text.length > WORKSHOP_LIMITS.fileBytes) return { ok:false, error:`File too large (max ${Math.round(WORKSHOP_LIMITS.fileBytes/1024)} KB)` };
    let data; try { data = JSON.parse(text); } catch(_) { return { ok:false, error:'Not valid JSON' }; }
    if (!data || typeof data !== 'object') return { ok:false, error:'Empty or malformed file' };
    if (data.schema && !String(data.schema).startsWith('gitl-workshop/')) return { ok:false, error:'Not a Ghost Workshop file' };
    const inP = Array.isArray(data.personas)  ? data.personas  : [];
    const inW = Array.isArray(data.workflows) ? data.workflows : [];
    if (inP.length + inW.length === 0) return { ok:false, error:'No personas or workflows in file' };
    if (inP.length + inW.length > WORKSHOP_LIMITS.maxItems) return { ok:false, error:`Too many items (max ${WORKSHOP_LIMITS.maxItems})` };
    const res = { ok:true, personas:0, workflows:0, skipped:0, renamed:0 };
    const pTaken = new Set([...Object.keys(PERSONA_LIBRARY), ...Object.keys(this.personas)]);
    for (const p of inP) {
      if (!this._validPersona(p)) { res.skipped++; continue; }
      const base = p.id || p.label;
      const id = this._uniqueId(base, pTaken);
      if (this._slug(base) !== id) res.renamed++;
      pTaken.add(id);
      this.personas[id] = { label: p.label.trim().slice(0,WORKSHOP_LIMITS.label), inject: p.inject.trim().slice(0,WORKSHOP_LIMITS.inject), custom: true };
      res.personas++;
    }
    const wTaken = new Set([...Object.keys(WORKFLOW_LIBRARY), ...Object.keys(this.workflows)]);
    for (const w of inW) {
      if (!this._validWorkflow(w)) { res.skipped++; continue; }
      const base = w.id || w.label;
      const id = this._uniqueId(base, wTaken);
      if (this._slug(base) !== id) res.renamed++;
      wTaken.add(id);
      this.workflows[id] = { label: w.label.trim().slice(0,WORKSHOP_LIMITS.label), desc: String(w.desc||'').trim().slice(0,WORKSHOP_LIMITS.desc),
        stages: w.stages.map(s => String(s).trim().slice(0,WORKSHOP_LIMITS.stage)).slice(0,WORKSHOP_LIMITS.stages), custom: true };
      res.workflows++;
    }
    this._persist();
    return res;
  }
};

// Merge accessors — built-ins first, customs layered on top. All read sites
// use these so custom items appear everywhere built-ins do.
function allPersonas()  { return Object.assign({}, PERSONA_LIBRARY,  Workshop.personas); }
function allWorkflows() { return Object.assign({}, WORKFLOW_LIBRARY, Workshop.workflows); }

const GHOST = {
  project: { name: GM_getValue('projectName',''), slug: GM_getValue('projectSlug','') },
  workflow: {
    selected: GM_getValue('wfSelected','none'),
    stageIndex: GM_getValue('wfStage',0),
    autoAdvance: GM_getValue('wfAuto',true),
    pauseBetween: GM_getValue('wfPause',false),
    active: false
  },
  persona: { selected: GM_getValue('persona','none') },
  roadmap: {
    steps: JSON.parse(GM_getValue('rmSteps','[]')),
    index: GM_getValue('rmIndex',0),
    captured: GM_getValue('rmCaptured',false),
    synthSent: false
  },
  loop: {
    state: 'IDLE', // IDLE | RUNNING | PAUSED | LIMIT | COMPLETE | ERROR
    payloadMode: GM_getValue('payloadMode','loop'),
    posture: GM_getValue('posture','standard'),
    round: 0,
    maxRounds: GM_getValue('maxRounds',20),
    limitStep: GM_getValue('maxRounds',20), // how many more rounds each "Continue" grants
    needsPayload: true,
    isSending: false,
    timer: null,
    lastActivity: Date.now(),
    staleTicks: 0,
    lastSignal: 'none',
    lastConfidence: 0,
    lastProgress: null,
    detail: '',
    // v7.1 send-confirmation watchdog
    sendPending: false,      // a send fired; generation not yet confirmed
    sendDeadline: 0,         // Date.now() by which generation must start
    sendRetries: 0,          // re-fire attempts used for the pending send
    lastSentText: '',        // payload of the pending send, for re-fire
    lastTextLen: 0,          // output length at send time, to detect new output
    originalTask: ''         // first task text of the run, for the reground gate
  },
  signals: {
    customProceed: GM_getValue('customProceed',''),
    customStop: GM_getValue('customStop',''),
    windowSize: GM_getValue('sigWindow',400)
  },
  export: {
    format: GM_getValue('expFormat','markdown'),
    filter: GM_getValue('expFilter','all'),
    includeRoles: GM_getValue('expRoles',true),
    thinking: GM_getValue('expThinking',true),
    customSlug: GM_getValue('expSlug','')
  },
  ui: {
    collapsed: GM_getValue('panelCollapsed',false),
    position: GM_getValue('panelPosition','top-right'),
    tab: 'run',
    soundOn: GM_getValue('soundOn',true),
    notifyOn: GM_getValue('notifyOn',false),
    cfgAdv: GM_getValue('cfgAdv',false),
    helpSec: 'start',
    prevTab: null,
    wsNewPersona: false,
    wsNewWorkflow: false,
    qDraft: (()=>{ try { const a = JSON.parse(GM_getValue('qDraft','[""]')); return Array.isArray(a)&&a.length?a:['']; } catch(_){ return ['']; } })(),
    expAdv: GM_getValue('expAdv',false),
    showDiag: false,
    showSites: false,
    firstRun: GM_getValue('firstRun',true)
  },
  report: null /* v7.1: latest Reporter trouble report, or null */
};

const _save = (k,v) => GM_setValue(k,v);

/* ═══════════════════════════════════════════════════════════════
   LAYER 3 — DIAGNOSTICS
   ═══════════════════════════════════════════════════════════════ */
const DIAG = {
  adapter: PLAT.label,
  selector: '',
  sendPath: '',
  lastSignal: '',
  lastTail: '',
  probe: '',
  errors: [],
  push(msg) {
    const e = `[${new Date().toISOString().slice(11,19)}] ${msg}`;
    this.errors.unshift(e);
    if (this.errors.length > 15) this.errors.pop();
    console.warn('[GITL]', msg);
    Timeline.record('diag', { msg });
  },
  runProbe() {
    const out = [];
    for (const k of ['input','send','stop','assistant']) {
      let win = '', n = 0;
      for (const s of PLAT[k] || []) {
        try { const m = document.querySelectorAll(s); if (m.length) { win = s; n = m.length; break; } } catch(_){}
      }
      out.push(n ? `✓ ${k}: ${win} (${n})` : `✗ ${k}: NO MATCH`);
    }
    this.probe = out.join('\n');
  }
};

/* ═══════════════════════════════════════════════════════════════
   S2 — SELECTOR DOCTOR + HEALTH SCORING
   Scores platform readiness 0-100. Exposes 🟢🟡🔴 badge.
   Sources: HTML/CSS GPT capability scoring, Software Architect GPT
   ═══════════════════════════════════════════════════════════════ */
function platformHealth() {
  const input = Adapter.getInput();
  const send  = Adapter.getSendBtn();
  const stop  = _q('gen', PLAT.stop);
  const msgs  = _qAll(PLAT.assistant);
  const canRead   = msgs.length > 0;
  const canInject  = !!input;
  const canSend    = !!send;
  const canExport  = canRead;
  const score = (canRead ? 25 : 0) + (canInject ? 30 : 0) + (canSend ? 30 : 0) + (canExport ? 15 : 0);
  return {
    platform: PLAT.label, score,
    input: canInject, send: canSend, stop: !!stop,
    assistantCount: msgs.length, ready: canInject && canSend,
    badge: score >= 80 ? '🟢' : score >= 40 ? '🟡' : '🔴',
    netActive: GITL_NET.active,
    netAge: GITL_NET.capturedAt ? Date.now() - GITL_NET.capturedAt : -1
  };
}

/* ═══════════════════════════════════════════════════════════════
   S3 — TIMELINE (lightweight event log with capped GM store)
   Append-only log for observability, failure learning, metrics.
   Sources: Kimi Timeline, ChatGPT Export 4 Metrics pattern
   ═══════════════════════════════════════════════════════════════ */
const Timeline = {
  key: 'gitlTimeline',
  _cache: null,
  all() {
    if (this._cache) return this._cache;
    try { this._cache = JSON.parse(GM_getValue(this.key, '[]')); } catch { this._cache = []; }
    return this._cache;
  },
  record(type, data = {}) {
    const items = this.all();
    items.push({
      type, data,
      platform: PLAT?.label || '?',
      wf: (typeof GHOST !== 'undefined' && GHOST.workflow) ? GHOST.workflow.selected : 'none',
      at: new Date().toISOString()
    });
    if (items.length > 500) items.splice(0, items.length - 500);
    this._cache = items;
    GM_setValue(this.key, JSON.stringify(items));
  },
  failures() { return this.all().filter(e => e.type === 'failure' || e.type === 'send_fail'); },
  since(ms) { const cutoff = new Date(Date.now() - ms).toISOString(); return this.all().filter(e => e.at > cutoff); }
};

/* ═══════════════════════════════════════════════════════════════
   S3.5 — REPORTER (v7.1): structured trouble reports
   When the loop hits trouble (send never started, stopped early, probe
   failure) a report is assembled automatically. Transport is pluggable:
     • clipboard     — always available, one-tap copy (default)
     • prefilledURL  — opens a pre-filled GitHub issue (one tap, no token)
     • worker        — silent POST to a relay you control (set REPORT_WORKER_URL)
   No credential is ever embedded in this script.
   ═══════════════════════════════════════════════════════════════ */
const Reporter = {
  last: null,         // most recent assembled report {kind, detail, text, at}
  _seen: new Set(),   // dedupe identical auto-captures within a session

  build(kind, detail) {
    const L = (typeof GHOST !== 'undefined') ? GHOST.loop : {};
    const h = (typeof platformHealth === 'function') ? platformHealth() : {};
    const p = L.lastProgress;
    const tl = (typeof Timeline !== 'undefined') ? Timeline.since(120000).slice(-20) : [];
    const lines = [];
    lines.push(`### Ghost in the Loop — auto report`);
    lines.push(``);
    lines.push(`**Kind:** ${kind}`);
    if (detail) lines.push(`**What happened:** ${detail}`);
    lines.push(``);
    lines.push(`| Field | Value |`);
    lines.push(`|---|---|`);
    lines.push(`| Version | ${VER} |`);
    lines.push(`| Platform | ${PLAT?.label || '?'} |`);
    lines.push(`| Loop state | ${L.state || '?'} |`);
    lines.push(`| Round | ${L.round ?? '?'} / ${L.maxRounds ?? '?'} |`);
    lines.push(`| Progress | ${p ? `${p.step}/${p.total}${p.desc ? ' — ' + p.desc : ''}` : 'n/a'} |`);
    lines.push(`| Last signal | ${L.lastSignal || '?'} (conf ${L.lastConfidence ?? '?'}) |`);
    lines.push(`| Signal scores | ${DIAG?.lastSignal || 'n/a'} |`);
    lines.push(`| Send path | ${DIAG?.sendPath || 'n/a'} |`);
    lines.push(`| Health | ${h.badge || ''} ${h.score ?? '?'}/100 (in:${h.input} send:${h.send} stop:${h.stop} msgs:${h.assistantCount}) |`);
    lines.push(`| Net intercept | ${h.netActive ? 'active' : 'off'} |`);
    lines.push(`| Focus | hasFocus:${typeof document!=='undefined'?document.hasFocus():'?'} hidden:${typeof document!=='undefined'?document.hidden:'?'} |`);
    lines.push(`| UA | ${typeof navigator!=='undefined'?navigator.userAgent:'?'} |`);
    lines.push(`| When | ${new Date().toISOString()} |`);
    lines.push(``);
    if (DIAG?.lastTail) { lines.push(`**Last output tail:**`); lines.push('```'); lines.push(String(DIAG.lastTail).slice(0,300)); lines.push('```'); lines.push(``); }
    if (DIAG?.probe)    { lines.push(`**Selector probe:**`); lines.push('```'); lines.push(DIAG.probe); lines.push('```'); lines.push(``); }
    if (DIAG?.errors?.length) { lines.push(`**Recent diagnostics:**`); lines.push('```'); lines.push(DIAG.errors.slice(0,8).join('\n')); lines.push('```'); lines.push(``); }
    if (tl.length) { lines.push(`**Timeline (last 2 min):**`); lines.push('```'); lines.push(tl.map(e => `${e.at.slice(11,19)} ${e.type} ${JSON.stringify(e.data)}`).join('\n')); lines.push('```'); }
    return lines.join('\n');
  },

  // Auto-capture: assemble + store + surface a non-intrusive affordance.
  capture(kind, detail) {
    const text = this.build(kind, detail);
    this.last = { kind, detail, text, at: Date.now() };
    if (typeof GHOST !== 'undefined') GHOST.report = this.last;
    // Optional silent transport if a relay is configured.
    if (REPORT_WORKER_URL) { this.sendWorker(text, kind).catch(()=>{}); }
    try { if (typeof renderReportBadge === 'function') renderReportBadge(); } catch(_){}
    return this.last;
  },

  copy() {
    const t = this.last?.text || this.build('manual', 'User-triggered report');
    try {
      if (typeof GM_setClipboard === 'function') { GM_setClipboard(t, { type:'text', mimetype:'text/plain' }); return Promise.resolve(true); }
      if (navigator.clipboard?.writeText) return navigator.clipboard.writeText(t).then(()=>true).catch(()=>false);
    } catch(_){}
    return Promise.resolve(false);
  },

  issueURL() {
    const r = this.last || { kind: 'manual', text: this.build('manual','') };
    const title = `[auto] ${r.kind} on ${PLAT?.label || 'platform'} (v${VER})`;
    const body  = (r.text || '').slice(0, 6000); // URL length safety
    return `https://github.com/${REPORT_REPO}/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`;
  },

  openIssue() {
    try { window.open(this.issueURL(), '_blank', 'noopener'); return true; } catch(_) { return false; }
  },

  async sendWorker(text, kind) {
    if (!REPORT_WORKER_URL) return false;
    try {
      const res = await fetch(REPORT_WORKER_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ kind, version: VER, platform: PLAT?.label, report: text })
      });
      Timeline.record('report_sent', { kind, ok: res.ok, status: res.status });
      return res.ok;
    } catch(e) { Timeline.record('report_send_fail', { error: String(e) }); return false; }
  }
};

/* ═══════════════════════════════════════════════════════════════
   S4 — RECOVERY ENGINE (escalating send strategies)
   When primary send fails, tries alternative injection paths
   with exponential backoff. Logs every attempt to Timeline.
   Sources: Kimi Deep Dive, DeepSeek fallback chain
   ═══════════════════════════════════════════════════════════════ */
const RecoveryEngine = {
  async recoverSend(text) {
    const strategies = [
      { name: 'ce-reinsert', fn: () => this._tryCE(text) },
      { name: 'native-setter', fn: () => this._tryNative(text) },
      { name: 'direct-value', fn: () => this._tryDirect(text) },
      { name: 'enter-dispatch', fn: () => this._tryEnterKey(text) },
      { name: 'refocus-retry', fn: () => this._tryRefocus(text) }
    ];
    let attempt = 0;
    for (const s of strategies) {
      attempt++;
      try {
        const result = await s.fn();
        Timeline.record('recovery_attempt', { strategy: s.name, attempt, ok: result.ok });
        if (result.ok) return { ok: true, path: s.name, attempt };
      } catch(e) {
        Timeline.record('recovery_attempt', { strategy: s.name, attempt, ok: false, error: String(e) });
      }
      await new Promise(r => setTimeout(r, 500 * Math.pow(2, attempt - 1)));
    }
    Timeline.record('recovery_exhausted', { text: text.slice(0, 80) });
    return { ok: false, path: 'exhausted', attempt };
  },

  _getInput() { return Adapter.getInput(); },

  async _tryCE(text) {
    const el = this._getInput();
    if (!el || el.getAttribute('contenteditable') !== 'true') return { ok: false };
    el.focus();
    document.execCommand('selectAll', false, null);
    const ok = document.execCommand('insertText', false, text);
    if (ok) el.dispatchEvent(new Event('input', { bubbles: true }));
    await this._clickSend();
    return { ok };
  },

  async _tryNative(text) {
    const el = this._getInput();
    if (!el || el.tagName !== 'TEXTAREA') return { ok: false };
    const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
    if (!setter) return { ok: false };
    el.focus();
    setter.call(el, text);
    el.dispatchEvent(new Event('input', { bubbles: true }));
    await this._clickSend();
    return { ok: true };
  },

  async _tryDirect(text) {
    const el = this._getInput();
    if (!el) return { ok: false };
    el.focus();
    el.value = text;
    el.dispatchEvent(new Event('input', { bubbles: true }));
    el.dispatchEvent(new Event('change', { bubbles: true }));
    await this._clickSend();
    return { ok: true };
  },

  async _tryEnterKey(text) {
    const el = this._getInput();
    if (!el) return { ok: false };
    el.focus();
    Adapter.pressEnter(el);
    return { ok: true };
  },

  async _tryRefocus(text) {
    const el = this._getInput();
    if (!el) return { ok: false };
    el.scrollIntoView({ behavior: 'instant', block: 'center' });
    el.focus();
    await new Promise(r => setTimeout(r, 300));
    el.value = text;
    el.textContent = text;
    el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
    await this._clickSend();
    return { ok: true };
  },

  async _clickSend() {
    await new Promise(r => setTimeout(r, 400));
    const btn = Adapter.getSendBtn();
    if (btn && !btn.disabled) { btn.click(); return true; }
    const input = this._getInput();
    if (input) Adapter.pressEnter(input);
    return true;
  }
};

/* ═══════════════════════════════════════════════════════════════
   S4.5 — GHOST BUS (BroadcastChannel cross-tab relay)
   Enables cooperative multi-tab handoff. User-initiated only —
   never auto-executes received prompts (security).
   Sources: ChatGPT Export 4, Gemini Phase 5 (with security fix)
   ═══════════════════════════════════════════════════════════════ */
const GhostBus = {
  channel: null,
  peers: new Map(),

  init() {
    try {
      this.channel = new BroadcastChannel('gitl.bus.v1');
      this.channel.onmessage = (e) => this._onMessage(e.data);
      this.announce();
    } catch(err) {
      console.warn('[GITL] BroadcastChannel unavailable:', err);
    }
  },

  announce() {
    this._send('discover', { platform: PLAT?.label, url: location.href });
  },

  sendHandoff(text) {
    this._send('handoff', { text, from: PLAT?.label, url: location.href });
    Timeline.record('bus_handoff_sent', { to: 'broadcast', chars: text.length });
  },

  _send(type, payload) {
    if (!this.channel) return;
    this.channel.postMessage({
      type, payload,
      tabId: GITL_TAB_ID,
      at: Date.now()
    });
  },

  _onMessage(msg) {
    if (msg.tabId === GITL_TAB_ID) return; // ignore self
    if (msg.type === 'discover') {
      this.peers.set(msg.tabId, { platform: msg.payload.platform, url: msg.payload.url, seen: Date.now() });
    }
    if (msg.type === 'handoff') {
      // Store received handoff for user to manually apply — NOT auto-injected
      GM_setValue('pendingHandoff', JSON.stringify(msg.payload));
      Timeline.record('bus_handoff_received', { from: msg.payload.from, chars: msg.payload.text?.length });
      if (typeof render === 'function') render();
    }
  },

  getPendingHandoff() {
    try { return JSON.parse(GM_getValue('pendingHandoff', 'null')); } catch { return null; }
  },

  clearPendingHandoff() {
    GM_setValue('pendingHandoff', '');
  }
};

/* ═══════════════════════════════════════════════════════════════
   LAYER 4 — SIGNAL ENGINE (pure logic, no DOM)
   Halt ALWAYS wins. Confidence-scored. Unique sigils first.
   ═══════════════════════════════════════════════════════════════ */
const FUZZY_PROCEED = ['to proceed','shall i continue','should i continue','want me to continue',
  'ready for the next',"type 'continue'",'type "continue"','type continue','say continue',
  'continue?','next section?','go on?','ready to proceed','awaiting your'];

const FUZZY_HALT = ['task complete','all sections complete','all parts complete','that concludes',
  'this concludes','fully complete','everything is complete','all done','sequence complete',
  'final section complete','session complete'];

function parseProgress(text) {
  const m = text.match(/\[(?:Step|Batch|Stage)\s*(\d+)\s*(?:of|\/)\s*(\d+)\](?:\s*[—–\-]\s*(.+))?/i);
  return m ? { step: +m[1], total: +m[2], desc: (m[3]||'').trim() } : null;
}

function detectSignal(fullText) {
  if (!fullText || fullText.length < MIN_RESPONSE_LEN) return { signal: 'short', confidence: 0, progress: null };

  const tail = fullText.slice(-GHOST.signals.windowSize);
  const low = tail.toLowerCase();
  const cStop = GHOST.signals.customStop.split(',').map(s=>s.trim().toLowerCase()).filter(Boolean);
  const cProc = GHOST.signals.customProceed.split(',').map(s=>s.trim().toLowerCase()).filter(Boolean);

  let hScore = 0, pScore = 0;
  const progress = parseProgress(tail);

  // Unique sigils (highest weight)
  if (tail.includes(SIGIL_HALT))     hScore += 4;
  if (tail.includes(SIGIL_PROCEED))  pScore += 4;
  // Legacy keywords — only fire if sigil NOT already present (prevents substring double-count:
  // LEGACY_PROCEED='PROCEED' is a substring of '[[GITL::PROCEED]]' which would otherwise
  // add 3 extra points to pScore when sigil fires, defeating the halt-first invariant)
  if (!tail.includes(SIGIL_HALT)    && tail.includes(LEGACY_HALT))    hScore += 3;
  if (!tail.includes(SIGIL_PROCEED) && tail.includes(LEGACY_PROCEED)) pScore += 3;
  // Fuzzy
  if (FUZZY_HALT.some(p => low.includes(p)))    hScore += 2;
  if (FUZZY_PROCEED.some(p => low.includes(p))) pScore += 2;
  // Custom
  if (cStop.some(p => low.includes(p)))  hScore += 2;
  if (cProc.some(p => low.includes(p)))  pScore += 2;
  // Progress bar
  if (progress && progress.step < progress.total) pScore += 2;
  if (progress && progress.step >= progress.total) hScore += 1;

  DIAG.lastSignal = `h:${hScore} p:${pScore}`;
  DIAG.lastTail = tail.slice(-80);

  // HALT-FIRST: halt wins ties at threshold
  if (hScore >= 3 && hScore >= pScore) return { signal: 'halt', confidence: hScore, progress };
  if (pScore >= 3) return { signal: 'proceed', confidence: pScore, progress };
  return { signal: 'none', confidence: Math.max(hScore, pScore), progress };
}

/* ═══════════════════════════════════════════════════════════════
   PAYLOADS
   ═══════════════════════════════════════════════════════════════ */
const PAYLOADS = {
  loop: {
    label: '▶ Loop',
    hint: 'Step-by-step execution. You set the task.',
    inject: `\n\n---\n[Ghost in the Loop v${VER} — Loop Mode]\nExecute this task step by step. One focused section per response.\n\nAt the end of every response, print:\n████░░░░ [Step X of Y] — one line describing what was completed\n\nThen on a new line:\n- More steps remain → [[GITL::PROCEED]]\n- Fully complete → [[GITL::HALT]]\n\nDo not skip the progress line. Make reasonable assumptions.\n---`,
    preview: '▶ LOOP — Step-by-step execution.\nEnd each response with:\n████░░░░ [Step X of Y]\n[[GITL::PROCEED]] or [[GITL::HALT]]'
  },
  think: {
    label: '🧠 Think First',
    hint: 'AI plans batches at ~80% capacity, then executes.',
    inject: `\n\n---\n[Ghost in the Loop v${VER} — Think First Mode]\nBefore doing any work, read this task and plan how to complete it in focused batches.\n\nKeep each batch to ~80% of your comfortable response length.\n\nYour FIRST response: plan only — list batches briefly, end with [[GITL::PROCEED]]\n\nEach subsequent response: complete one batch, end with:\n████░░░░ [Batch X of Y] — what this batch covered\nThen: [[GITL::PROCEED]] or [[GITL::HALT]]\n\nThe script sends "Continue" automatically.\n---`,
    preview: '🧠 THINK FIRST — AI self-plans.\nResponse 1: plan + batch count.\nEach batch ends with:\n████░░░░ [Batch X of Y]\n[[GITL::PROCEED]] or [[GITL::HALT]]'
  },
  roadmap: {
    label: '🗺 Roadmap',
    hint: 'AI researches → builds a roadmap → Ghost runs every step. Walk away.',
    inject: `\n\n---\n[Ghost in the Loop v${VER} — Roadmap Autopilot]\nPhase 1 (this response): RESEARCH ONLY. Analyze this task deeply — context, constraints, unknowns, best approach. Do no execution work yet.\nThen output a machine-readable roadmap in EXACTLY this format:\n\n[[GITL::ROADMAP]]\n1. first concrete step\n2. second concrete step\n3. ...\n\n(3–12 steps, each one self-contained and executable in a single response)\nEnd with [[GITL::PROCEED]]\n\nPhase 2: The script will then send you each step as its own prompt. Complete each step fully, end each with [[GITL::PROCEED]]. A final synthesis prompt will close the run.\n---`,
    preview: '🗺 ROADMAP — Fire & forget.\nResponse 1: research + numbered\nroadmap under [[GITL::ROADMAP]].\nGhost then auto-runs every step\n+ final synthesis. [[GITL::HALT]] ends.'
  }
};

const RESUME_TEXT = `Continue.\n\n[Ghost reminder: end each response with ████░░░░ [Step X of Y] then [[GITL::PROCEED]] if more remain, or [[GITL::HALT]] when fully done.]`;

/* ── Thinking postures (v7.1) ─────────────────────────────────────
   A user-declared expansion clause appended to whichever mode is running
   (Loop / Think / Roadmap). The model never guesses the posture — the user
   picks it up front, like a reasoning dial. Wording synthesised from the
   uploaded multi-model research relay (OpenAI reasoning + Anthropic context
   guidance, ReAct/Reflexion/Plan-and-Act for mid-run replanning, Self-Refine
   for the end-of-run coverage check, and practitioner anti-runaway guardrails:
   justification gate, minimality rule, ceiling stop-condition). The three
   clauses differ ONLY in how/when expansion is permitted. */
const POSTURES = {
  standard: {
    label: 'Standard',
    short: 'Locked plan',
    desc: 'Locked to the plan it declares. No added steps. Most predictable.',
    clause: `\n\n[Posture: STANDARD — locked plan]\nComplete exactly the steps you declared. Do not add, remove, merge, or reorder steps. If you discover the plan is wrong, finish what you can and report it at the end rather than expanding. Keep your declared Y fixed for the whole run.`
  },
  evolving: {
    label: 'Evolving',
    short: 'Adaptive mid-run',
    desc: 'May add steps DURING the run — but only when a real blocker or gap forces it. (Field term: "adaptive".)',
    clause: `\n\n[Posture: EVOLVING — adaptive mid-run replanning]\nExecute your declared steps one at a time. You MAY add a step during the run ONLY IF a concrete blocker, a missing prerequisite, or a material gap is visible from the work already done and continuing without it would likely fail the original goal.\nBefore adding a step, print on their own lines:\n  Why needed: <one sentence>\n  Why existing steps are insufficient: <one sentence>\nIf that justification is weak, do NOT add the step. Prefer tightening or replacing a future step over adding to the total. Any added step must stay strictly within the ORIGINAL goal — do not expand scope into adjacent topics. Update Y when you legitimately add a step, and keep printing ████░░░░ [Step X of Y].`
  },
  extended: {
    label: 'Extended',
    short: 'End-of-run gap check',
    desc: 'Runs the plan locked, THEN does one gap-check at the end and fills only genuinely valuable holes. (Field term: "review".)',
    clause: `\n\n[Posture: EXTENDED — bounded end-of-run review]\nExecute your declared steps exactly, with no mid-run additions. AFTER the last declared step, perform ONE coverage check against the original goal, its constraints, and the promised deliverable. List only material gaps, errors, or unanswered sub-questions — for each: the gap, why it matters, and the smallest step that closes it. Then complete only those high-value follow-ups. If no material gaps remain, print "No material gaps found" and HALT. Do not invent "nice to have" extras.`
  }
};
// Shared ceiling stop-condition appended to the two expanding postures.
const POSTURE_CEILING = `\nHard ceiling: never exceed the drift-guard limit. If you reach it, STOP and report the single highest-value unresolved gap instead of compressing in more work.`;

/* ── Roadmap Autopilot ───────────────────────────────────────── */
const SIGIL_ROADMAP = '[[GITL::ROADMAP]]';

function resetRoadmap() {
  GHOST.roadmap = { steps: [], index: 0, captured: false, synthSent: false };
  _save('rmSteps','[]'); _save('rmIndex',0); _save('rmCaptured',false);
}

function parseRoadmap(fullText) {
  const at = fullText.lastIndexOf(SIGIL_ROADMAP);
  if (at < 0) return false;
  const after = fullText.slice(at + SIGIL_ROADMAP.length);
  const steps = [];
  for (const line of after.split('\n')) {
    if (line.includes(SIGIL_PROCEED) || line.includes(SIGIL_HALT)) break;
    const m = line.match(/^\s*(?:\d+[.)]\s+|[-*]\s+)(.+)$/);
    if (m && m[1].trim().length > 3) steps.push(m[1].trim());
    if (steps.length >= 30) break;
  }
  if (steps.length < 2) return false;
  GHOST.roadmap.steps = steps; GHOST.roadmap.index = 0;
  GHOST.roadmap.captured = true; GHOST.roadmap.synthSent = false;
  _save('rmSteps', JSON.stringify(steps)); _save('rmIndex', 0); _save('rmCaptured', true);
  return true;
}

function sendRoadmapStep() {
  const R = GHOST.roadmap, i = R.index, n = R.steps.length;
  GHOST.loop.detail = `🗺 Step ${i+1}/${n}`;
  engineSend(`Continue.\n\n[Ghost roadmap — step ${i+1} of ${n}]\n${R.steps[i]}\n\nComplete this step fully and concretely. Deliverable output only, no fluff. End with [[GITL::PROCEED]] when this step is done — or [[GITL::HALT]] only if the ENTIRE roadmap is genuinely finished.`, false)
    .then(ok => { if (ok) { R.index = i + 1; _save('rmIndex', R.index); render(); } });
}

function sendRoadmapSynthesis() {
  GHOST.roadmap.synthSent = true;
  GHOST.loop.detail = '🗺 Final synthesis';
  engineSend(`Continue.\n\n[Ghost roadmap — final synthesis]\nAll roadmap steps are complete. Compile the final deliverable: merge every step's output into one clean, complete, ready-to-use result. No recap of process, no fluff. End with [[GITL::HALT]].`, false);
}

/* ── Walk-away notifications ─────────────────────────────────── */
function notify(body) {
  if (!GHOST.ui.notifyOn) return;
  try {
    if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
      new Notification('👻 Ghost in the Loop', { body });
    }
  } catch(_){}
}

/* ── Auto-probe on adapter failure ───────────────────────────── */
function pauseWithProbe(reason) {
  try { DIAG.runProbe(); GHOST.ui.showDiag = true; } catch(_){}
  // Avoid double-reporting if a richer report was just captured (e.g. send_unconfirmed).
  try { if (!(Reporter.last && Date.now() - Reporter.last.at < 2000)) Reporter.capture('probe_fail', reason); } catch(_){}
  enginePause(reason + ' — probe ran, see ⚙ Diagnostics');
}

/* ═══════════════════════════════════════════════════════════════
   LAYER 5 — LOOP ENGINE (state transitions, no DOM)
   ═══════════════════════════════════════════════════════════════ */
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

function randomDelay(round) {
  // Adaptive: short on round 1 (planning), normal 8–15s on execution rounds
  if (round <= 1) return 2000;
  return (8 + Math.random() * 7) * 1000;
}

async function engineSend(text, skipDelay) {
  const L = GHOST.loop;
  if (L.isSending) { DIAG.push('Send blocked — lock active'); return false; }
  /* S0: pre-send safety gate */
  const safe = assertInteractionSafe();
  if (!safe.ok) { DIAG.push(`Send blocked — ${safe.reason}`); L.detail = `⚠ ${safe.reason}`; render(); return false; }
  L.isSending = true;
  try {
    if (!skipDelay) {
      const delay = randomDelay(L.round);
      L.detail = `Waiting ${(delay/1000).toFixed(0)}s…`;
      render();
      await sleep(delay);
    }
    if (L.state !== 'RUNNING') return false;
    const input = Adapter.getInput();
    if (!input) {
      /* S4: try recovery engine before giving up */
      DIAG.push('No input — trying recovery');
      const r = await RecoveryEngine.recoverSend(text);
      if (r.ok) { _onSendOk(text, `recovery-${r.path}`); return true; }
      pauseWithProbe('Input element missing — recovery exhausted'); return false;
    }
    if (!Adapter.injectText(input, text)) {
      DIAG.push('Inject failed — trying recovery');
      const r = await RecoveryEngine.recoverSend(text);
      if (r.ok) { _onSendOk(text, `recovery-${r.path}`); return true; }
      pauseWithProbe('Text injection failed — recovery exhausted'); return false;
    }
    await sleep(500);
    // 5-path send: button → retry → retry → retry → Enter key
    let sent = false;
    for (let attempt = 0; attempt < 4; attempt++) {
      const btn = Adapter.getSendBtn();
      if (btn && !btn.disabled) { btn.click(); DIAG.sendPath = `btn-${attempt+1}`; sent = true; break; }
      await sleep(600);
    }
    if (!sent) { Adapter.pressEnter(input); DIAG.sendPath = 'enter-key'; }
    _onSendOk(text, DIAG.sendPath);
    return true;
  } catch(e) {
    DIAG.push('Send error: ' + String(e));
    Timeline.record('send_fail', { error: String(e) });
    enginePause('Send failed');
    return false;
  } finally {
    setTimeout(() => { L.isSending = false; }, 1500);
  }
}

/* Records a successful send AND arms the confirmation watchdog.
   The send isn't "done" until generation actually starts — see
   the confirmation branch in engineTick. */
function _onSendOk(text, path) {
  const L = GHOST.loop;
  L.round++;
  L.lastActivity = Date.now();
  L.staleTicks = 0;
  L.detail = '';
  // Arm confirmation: generation must begin before the deadline.
  L.sendPending   = true;
  L.sendDeadline  = Date.now() + SEND_CONFIRM_MS;
  L.lastSentText  = text;
  L.lastTextLen   = (Adapter.getLastText() || '').length;
  L.sendRetries   = 0;
  Timeline.record('send_ok', { round: L.round, path });
  render();
}

/* Clears the pending-send flag once generation is confirmed. */
function _confirmSend() {
  const L = GHOST.loop;
  if (!L.sendPending) return;
  L.sendPending  = false;
  L.sendDeadline = 0;
  L.sendRetries  = 0;
  Timeline.record('send_confirmed', { round: L.round });
}

/* Re-fires the pending send without bumping the round counter.
   Used when a send appears to have been swallowed (no generation). */
async function _refireSend() {
  const L = GHOST.loop;
  const text = L.lastSentText;
  L.sendRetries++;
  DIAG.push(`Send unconfirmed — re-firing (attempt ${L.sendRetries}/${SEND_MAX_RETRIES})`);
  Timeline.record('send_refire', { round: L.round, attempt: L.sendRetries });
  L.detail = `↻ Re-sending (${L.sendRetries}/${SEND_MAX_RETRIES})…`;
  render();
  const input = Adapter.getInput();
  if (input) {
    Adapter.injectText(input, text);
    await sleep(400);
    const btn = Adapter.getSendBtn();
    if (btn && !btn.disabled) btn.click();
    else Adapter.pressEnter(input);
  } else {
    await RecoveryEngine.recoverSend(text);
  }
  // Re-arm the confirmation window for this attempt.
  L.sendDeadline = Date.now() + SEND_CONFIRM_MS;
  L.lastActivity = Date.now();
}

function engineHalt(reason) {
  const L = GHOST.loop;
  L.state = 'COMPLETE'; L.detail = reason; L.needsPayload = true;
  clearInterval(L.timer); L.timer = null;
  Timeline.record('halt', { reason, round: L.round });
  render();
  if (GHOST.ui.soundOn) playBeep();
  notify(reason);
}

function enginePause(reason) {
  const L = GHOST.loop;
  L.state = 'PAUSED'; L.detail = reason;
  clearInterval(L.timer); L.timer = null;
  Timeline.record('pause', { reason, round: L.round });
  render();
  notify('⏸ ' + reason);
}

/* Soft round-limit checkpoint (v7.1): the AI hasn't HALTed but we've
   hit the auto-continue cap. Pause in a dedicated LIMIT state and invite
   one-tap continuation rather than stranding the run. */
function engineLimit() {
  const L = GHOST.loop;
  L.state = 'LIMIT';
  L.detail = `Hit ${L.maxRounds} auto-continues — chat's still going. ▶ to run ${L.limitStep} more.`;
  L.sendPending = false;
  clearInterval(L.timer); L.timer = null;
  Timeline.record('limit', { round: L.round, cap: L.maxRounds });
  render();
  if (GHOST.ui.soundOn) playBeep();
  notify(`▶ ${L.maxRounds} continues reached — tap to keep going`);
}

/* Extends the cap by one increment and resumes. Called by ▶ from either
   the expanded panel or the collapsed mini-bar when in LIMIT state. */
function extendLimit() {
  const L = GHOST.loop;
  L.maxRounds += (L.limitStep || 20);
  L.state = 'RUNNING'; L.detail = ''; L.lastActivity = Date.now();
  Timeline.record('limit_extended', { newCap: L.maxRounds, round: L.round });
  L.timer = setInterval(engineTick, 2500);
  render();
  engineTick();
}

/* Reground (v7.1): at the drift-guard ceiling, instead of blindly continuing,
   re-anchor the AI to the task it started on. Sends a grounding command that
   restates the original goal and asks the model to confirm it's still on-task
   (or correct course) before proceeding. Extends the cap so it can run on. */
function regroundLoop() {
  const L = GHOST.loop;
  const task = (L.originalTask || '').trim();
  const anchor = task
    ? `\n\nThe ORIGINAL task you were given was:\n"""\n${task}\n"""\n`
    : '\n';
  const cmd = `[Ghost reground — drift check]\nYou have run for many steps. Before continuing, re-anchor to the original goal.${anchor}
In 2–3 lines: (1) state what the original task was, (2) confirm whether your recent work is still directly serving it or has drifted, (3) if drifted, correct course now.
Then continue the task. End with ████ [Step X of Y] and [[GITL::PROCEED]] if work remains, or [[GITL::HALT]] if the original task is genuinely complete.`;
  L.maxRounds += (L.limitStep || 20);
  L.state = 'RUNNING'; L.detail = '⊕ Regrounding to original task…'; L.lastActivity = Date.now();
  Timeline.record('reground', { round: L.round, hadTask: !!task });
  L.timer = setInterval(engineTick, 2500);
  render();
  engineSend(cmd, true);
}

function engineTick() {
  const L = GHOST.loop;
  if (L.state !== 'RUNNING') return;

  // ── Send-confirmation watchdog (v7.1) ──────────────────────────
  // A send was fired; confirm generation actually started. Catches the
  // "Enter swallowed by a notification focus-steal" stuck-screen bug.
  if (L.sendPending) {
    const grewOutput = (Adapter.getLastText() || '').length > L.lastTextLen + 4;
    if (Adapter.isGenerating() || grewOutput) {
      _confirmSend();
      L.lastActivity = Date.now();
      // fall through: if generating, the branch below will hold the loop
    } else if (Date.now() >= L.sendDeadline) {
      if (L.sendRetries < SEND_MAX_RETRIES && document.hasFocus() && !document.hidden) {
        _refireSend();           // re-fire; confirm on a later tick
        return;
      }
      // Exhausted re-fires (or tab not actionable) → pause + report
      L.sendPending = false;
      Timeline.record('send_unconfirmed', { round: L.round, retries: L.sendRetries });
      Reporter.capture('send_unconfirmed', `Generation never started after ${L.sendRetries} re-fire(s). Likely the send was swallowed (focus-steal / disabled control).`);
      pauseWithProbe('Send didn’t start — generation never began');
      return;
    }
    // else: still inside the grace window, keep waiting
  }

  // Watchdog — 90s soft, 180s hard
  const idle = Date.now() - L.lastActivity;
  if (idle > 180000) { enginePause('Watchdog: no activity 3min'); return; }
  if (idle > 90000) { L.detail = '⚠ Watchdog: 90s idle'; render(); }

  // Round limit — soft checkpoint, not a hard stop. The cap exists to
  // catch runaway loops, so we PAUSE and ASK rather than strand the user
  // mid-task (e.g. a chat that legitimately runs to 24 with cap=20).
  if (L.round >= L.maxRounds) { engineLimit(); return; }

  // Still generating
  if (Adapter.isGenerating()) { L.lastActivity = Date.now(); return; }

  // Native continue button
  if (Adapter.clickContinue()) { L.lastActivity = Date.now(); return; }

  // Read output
  const text = Adapter.getLastText();
  if (!text) { L.staleTicks++; if (L.staleTicks >= 5) pauseWithProbe('No output detected'); return; }

  // Detect signal
  const result = detectSignal(text);
  L.lastSignal = result.signal;
  L.lastConfidence = result.confidence;
  if (result.progress) L.lastProgress = result.progress;

  if (result.signal === 'short') { L.staleTicks++; if (L.staleTicks >= 3) enginePause('Response too short — review output'); return; }

  if (result.signal === 'halt') {
    L.staleTicks = 0;
    // Workflow auto-advance
    if (GHOST.workflow.active && GHOST.workflow.autoAdvance) {
      const wf = allWorkflows()[GHOST.workflow.selected] || WORKFLOW_LIBRARY.none;
      const next = wf.stages[GHOST.workflow.stageIndex];
      if (next) {
        if (GHOST.workflow.pauseBetween) { enginePause(`Stage ${GHOST.workflow.stageIndex+1} complete — next queued`); return; }
        L.detail = `Advancing workflow stage ${GHOST.workflow.stageIndex+1}…`;
        engineSend(`Continue.\n\n[Ghost workflow — stage ${GHOST.workflow.stageIndex+1} of ${wf.stages.length}]\n${next}\n\nUse the same [[GITL::PROCEED]] / [[GITL::HALT]] protocol.`, false).then(ok => {
          if (ok) { GHOST.workflow.stageIndex++; _save('wfStage', GHOST.workflow.stageIndex); render(); }
          else { enginePause('Workflow advance failed'); }
        });
        return;
      }
      GHOST.workflow.active = false;
      GHOST.workflow.stageIndex = 0; _save('wfStage', 0);
    }
    if (L.payloadMode === 'roadmap' && GHOST.roadmap.captured) { engineHalt('✅ Roadmap complete'); resetRoadmap(); return; }
    engineHalt('✅ Task complete');
    return;
  }

  if (result.signal === 'proceed') {
    L.staleTicks = 0;
    if (L.payloadMode === 'roadmap') {
      const R = GHOST.roadmap;
      if (!R.captured) {
        if (parseRoadmap(text)) { L.detail = `🗺 Roadmap captured: ${R.steps.length} steps`; render(); sendRoadmapStep(); }
        else { enginePause('Roadmap mode: no [[GITL::ROADMAP]] list found — review output, then ▶ to retry'); }
        return;
      }
      if (R.index < R.steps.length) { sendRoadmapStep(); return; }
      if (!R.synthSent) { sendRoadmapSynthesis(); return; }
      engineHalt('✅ Roadmap complete'); resetRoadmap(); return;
    }
    engineSend('Continue', false);
    return;
  }

  // No signal
  L.staleTicks++;
  if (L.staleTicks >= 5) enginePause('No signal detected — review output');
}

// Watchdog heartbeat (supplements tick)
setInterval(() => {
  if (GHOST.loop.state !== 'RUNNING' || !GHOST.loop.lastActivity) return;
  if (Date.now() - GHOST.loop.lastActivity > 45000) {
    DIAG.push('Watchdog heartbeat: 45s stale');
  }
}, 10000);

/* Inserts a single prompt into the site's chat box (manual use of one
   workflow stage). Does NOT send — the user reviews and presses the
   site's own send, or Ghost's ▶. Gives brief tap feedback on mobile. */
function insertPrompt(text, btnEl) {
  const input = Adapter.getInput();
  if (!input) {
    if (btnEl) { const o = btnEl.textContent; btnEl.textContent = 'NO BOX'; setTimeout(()=>{ btnEl.textContent = o; }, 1400); }
    GHOST.loop.detail = '⚠ Couldn’t find the chat box on this page'; render();
    return false;
  }
  Adapter.injectText(input, text);
  try { input.focus(); } catch(_){}
  if (btnEl) { const o = btnEl.textContent; btnEl.classList.add('ins-ok'); btnEl.textContent = '✓ IN'; setTimeout(()=>{ btnEl.textContent = o; btnEl.classList.remove('ins-ok'); }, 1400); }
  Timeline.record('stage_inserted', { chars: text.length });
  return true;
}

/* ▶ Start workflow: turns the selected workflow on (active + auto-advance)
   so stages fire automatically, then starts the loop using whatever is in
   the chat box (or resumes an existing chat). One button, no tab-hopping. */
function startWorkflow() {
  const L = GHOST.loop;
  if (GHOST.workflow.selected === 'none') { L.detail = 'Pick a workflow first'; render(); return; }
  if (L.state === 'RUNNING') return;
  GHOST.workflow.active = true;
  GHOST.workflow.autoAdvance = true;
  _save('wfAuto', true);
  const input = Adapter.getInput();
  const typed = input ? (input.value || input.textContent || '').trim() : '';
  if (!typed && !Adapter.hasMessages()) {
    // Nothing to run yet — guide the user instead of silently doing nothing.
    try { input?.focus(); } catch(_){}
    L.detail = '⌨ Type your task in the chat box above, then press ▶ Start';
    render();
    return;
  }
  startLoop();
}

function startLoop() {
  const L = GHOST.loop;
  if (L.state === 'RUNNING') return;
  const input = Adapter.getInput();
  const typed = input ? (input.value || input.textContent || '').trim() : '';

  // Mark first run done
  if (GHOST.ui.firstRun) { GHOST.ui.firstRun = false; _save('firstRun', false); }

  // Case 1: resume from pause
  if (!L.needsPayload) {
    L.state = 'RUNNING'; L.lastActivity = Date.now(); L.detail = '';
    L.sendPending = false;
    GHOST.workflow.active = GHOST.workflow.selected !== 'none';
    L.timer = setInterval(engineTick, 2500);
    render(); engineTick();
    return;
  }

  // Case 2: new prompt
  if (typed) {
    L.needsPayload = false; L.round = 0; L.lastProgress = null; L.staleTicks = 0;
    L.originalTask = typed.slice(0, 2000); // remembered for the reground gate
    L.state = 'RUNNING'; L.lastActivity = Date.now();
    GHOST.workflow.active = GHOST.workflow.selected !== 'none';
    if (L.payloadMode === 'roadmap') { resetRoadmap(); GHOST.workflow.active = false; }
    const personaInject = resolvePersonaInject();
    const posture = POSTURES[L.posture] || POSTURES.standard;
    const postureClause = posture.clause + (L.posture === 'standard' ? '' : POSTURE_CEILING);
    const full = typed + (personaInject ? `\n\n[Active persona]\n${personaInject}` : '') + PAYLOADS[L.payloadMode].inject + postureClause;
    engineSend(full, true);
    L.timer = setInterval(engineTick, 2500);
    render();
    return;
  }

  // Case 3: empty input, existing conversation → resume
  if (Adapter.hasMessages()) {
    L.needsPayload = false; L.round = 0; L.lastProgress = null; L.staleTicks = 0;
    L.state = 'RUNNING'; L.lastActivity = Date.now(); L.detail = 'Resuming…';
    GHOST.workflow.active = GHOST.workflow.selected !== 'none';
    engineSend(RESUME_TEXT, true);
    L.timer = setInterval(engineTick, 2500);
    render();
    return;
  }

  L.detail = 'Type a prompt or open an existing chat';
  render();
}

function startQueue(rawLines) {
  const L = GHOST.loop;
  if (L.state === 'RUNNING') return;
  const steps = rawLines.split('\n').map(s => s.replace(/^\s*(?:\d+[.)]\s+|[-*]\s+)?/,'').trim()).filter(s => s.length > 2).slice(0, 30);
  if (!steps.length) { L.detail = 'Queue is empty'; render(); return; }
  L.payloadMode = 'roadmap'; _save('payloadMode','roadmap');
  GHOST.roadmap = { steps, index: 0, captured: true, synthSent: false };
  _save('rmSteps', JSON.stringify(steps)); _save('rmIndex', 0); _save('rmCaptured', true);
  L.needsPayload = false; L.round = 0; L.lastProgress = null; L.staleTicks = 0;
  L.state = 'RUNNING'; L.lastActivity = Date.now();
  GHOST.workflow.active = false;
  if (GHOST.ui.firstRun) { GHOST.ui.firstRun = false; _save('firstRun', false); }
  L.timer = setInterval(engineTick, 2500);
  sendRoadmapStep();
  render();
}

function pauseLoop() { enginePause('Paused'); }

/* The single ▶/⏸ button's behavior depends on state. Used by both the
   collapsed mini-bar and the Run-tab play button so they never diverge. */
function primaryAction() {
  const s = GHOST.loop.state;
  if (s === 'RUNNING') return pauseLoop();
  if (s === 'LIMIT')   return extendLimit();
  return startLoop();
}function stopLoop() {
  const L = GHOST.loop;
  L.state = 'IDLE'; L.round = 0; L.staleTicks = 0; L.lastProgress = null;
  L.originalTask = '';
  L.lastSignal = 'none'; L.lastConfidence = 0; L.needsPayload = true; L.detail = '';
  L.sendPending = false; L.sendRetries = 0;
  clearInterval(L.timer); L.timer = null;
  resetRoadmap();
  render();
}

/* ═══════════════════════════════════════════════════════════════
   SPA ROUTE DETECTION
   ═══════════════════════════════════════════════════════════════ */
(function patchHistory() {
  const orig = history.pushState;
  history.pushState = function(...a) { orig.apply(this, a); window.dispatchEvent(new Event('gitl:route')); };
  const origR = history.replaceState;
  history.replaceState = function(...a) { origR.apply(this, a); window.dispatchEvent(new Event('gitl:route')); };
})();
window.addEventListener('popstate', () => window.dispatchEvent(new Event('gitl:route')));
window.addEventListener('gitl:route', () => {
  if (location.href !== _lastHref) {
    _lastHref = location.href;
    _cache.clear();
    if (GHOST.loop.state === 'RUNNING') enginePause('Route changed — paused');
  }
});

/* Manual re-detect (v7.1): force a clean re-resolution of the page's
   chat input / send button. Fixes the case where you move browser → app →
   back into the same chat: the URL is unchanged so the route-watcher never
   fired, but the SPA rebuilt the DOM and Ghost is holding stale/detached
   element references (or the shadow-walk throttle is blocking a re-scan).
   This clears every cache, re-resolves the platform, and re-probes —
   no page reload, no hopping between chats. */
function reDetect() {
  _cache.clear();
  _deepLast.clear();
  // Re-resolve platform in case the host changed (e.g. app vs web shell).
  let matched = null;
  for (const [, p] of Object.entries(PROFILES)) { if (p.host.test(location.hostname)) { matched = p; break; } }
  if (matched) PLAT = matched;
  // Force fresh element lookups now.
  const input = Adapter.getInput();
  const send  = Adapter.getSendBtn();
  try { DIAG.runProbe(); } catch(_){}
  Timeline.record('redetect', { found_input: !!input, found_send: !!send, platform: PLAT?.label });
  const ok = !!input;
  GHOST.loop.detail = ok
    ? `🔄 Re-detected ✓ — found chat input on ${PLAT?.label || 'page'}`
    : '🔄 Re-detected — still no chat input. Try clicking inside the chat box once, then re-detect.';
  render();
  return ok;
}

/* ═══════════════════════════════════════════════════════════════
   CRASH RECOVERY
   ═══════════════════════════════════════════════════════════════ */
window.addEventListener('beforeunload', () => {
  if (GHOST.loop.state === 'RUNNING' || GHOST.loop.state === 'PAUSED') {
    _save('crashState', JSON.stringify({
      state: GHOST.loop.state, round: GHOST.loop.round, mode: GHOST.loop.payloadMode,
      url: location.href, ts: Date.now(), wasRunning: GHOST.loop.state === 'RUNNING'
    }));
  }
});

(function recoverCrash() {
  try {
    const raw = GM_getValue('crashState','');
    if (!raw) return;
    const cs = JSON.parse(raw);
    _save('crashState', '');
    if (Date.now() - cs.ts > 300000) return;
    if (cs.url !== location.href) return;
    // Only flag as crash if it was running (not manual refresh)
    if (cs.wasRunning) {
      const rm = GHOST.roadmap.captured && GHOST.roadmap.steps.length ? ` Roadmap at step ${GHOST.roadmap.index}/${GHOST.roadmap.steps.length}.` : '';
      GHOST.loop.detail = `Crash recovery: ${cs.round} rounds.${rm} Press ▶ to resume.`;
    }
  } catch(_){}
})();

/* ═══════════════════════════════════════════════════════════════
   EXPORT ENGINE
   ═══════════════════════════════════════════════════════════════ */
function buildFilename(mode) {
  const ts = new Date().toISOString().replace('T','_').replace(/:/g,'').slice(0,15);
  const proj = (GHOST.project.slug || GHOST.project.name || 'ghost').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'') || 'ghost';
  const slug = (GHOST.export.customSlug || document.title.replace(/\s*[-|].*$/,'').trim() || PLAT.label).toLowerCase().replace(/[^a-z0-9]+/g,'-').slice(0,30);
  const ext = GHOST.export.format === 'json' ? 'json' : 'md';
  return `${proj}_${mode}_${ts}_${slug}.${ext}`;
}

/* ── API-first exporters (lesson from the top GitHub exporters: the platform's own
      conversation API beats DOM scraping — complete history, exact roles, structured
      thinking, immune to virtualization and redesigns). DOM remains the fallback. ── */

// ChatGPT — technique from pionxzh/chatgpt-exporter: session token + backend-api, walk the node tree
async function apiExportChatGPT() {
  const id = location.pathname.match(/\/c\/([\w-]+)/)?.[1];
  if (!id) return null;
  const sess = await (await fetch(location.origin + '/api/auth/session')).json();
  if (!sess?.accessToken) return null;
  const r = await fetch(location.origin + '/backend-api/conversation/' + id, {
    headers: { 'Authorization': 'Bearer ' + sess.accessToken }
  });
  if (!r.ok) return null;
  const conv = await r.json();
  if (!conv?.mapping || !conv.current_node) return null;
  // Walk parent pointers from current_node → linear thread (correctly resolves branches/regenerations)
  const chain = [];
  let node = conv.mapping[conv.current_node];
  while (node) { chain.unshift(node); node = node.parent ? conv.mapping[node.parent] : null; }
  const out = [];
  for (const n of chain) {
    const m = n.message;
    if (!m || !m.author || m.author.role === 'system' || m.author.role === 'tool') continue;
    const c = m.content || {};
    let text = '';
    if (Array.isArray(c.parts)) text = c.parts.map(p => typeof p === 'string' ? p : (p?.text || '')).join('\n').trim();
    else if (typeof c.text === 'string') text = c.text.trim();
    if (c.content_type === 'code' && text) text = '```\n' + text + '\n```';
    let thinking = '';
    if (GHOST.export.thinking && Array.isArray(c.thoughts)) thinking = c.thoughts.map(t => t?.content || t?.summary || '').filter(Boolean).join('\n\n');
    if (text || thinking) out.push(thinking ? { role: m.author.role, index: out.length, text, thinking } : { role: m.author.role, index: out.length, text });
  }
  return out.length ? out : null;
}

// Claude — technique from socketteer/Claude-Conversation-Exporter, improved: orgId auto-fetched
// (their users had to paste it manually — their top setup complaint)
async function apiExportClaude() {
  const convId = location.pathname.match(/\/chat\/([\w-]+)/)?.[1];
  if (!convId) return null;
  const orgs = await (await fetch('/api/organizations', { credentials: 'include' })).json();
  const orgId = Array.isArray(orgs) ? orgs[0]?.uuid : null;
  if (!orgId) return null;
  const r = await fetch(`/api/organizations/${orgId}/chat_conversations/${convId}?tree=True&rendering_mode=messages&render_all_tools=true`, { credentials: 'include' });
  if (!r.ok) return null;
  const data = await r.json();
  const msgs = data?.chat_messages;
  if (!Array.isArray(msgs)) return null;
  const out = [];
  for (const m of msgs) {
    const role = m.sender === 'human' ? 'user' : 'assistant';
    let text = '', thinking = '';
    for (const b of (m.content || [])) {
      if (b.type === 'text' && b.text) text += (text ? '\n\n' : '') + b.text;
      else if (b.type === 'thinking' && GHOST.export.thinking) thinking += (thinking ? '\n\n' : '') + (b.thinking || b.text || '');
      else if (b.type === 'tool_use') text += (text ? '\n' : '') + `[tool: ${b.name || 'call'}]`;
    }
    if (!text && typeof m.text === 'string') text = m.text;
    text = text.trim();
    if (text || thinking) out.push(thinking ? { role, index: out.length, text, thinking } : { role, index: out.length, text });
  }
  return out.length ? out : null;
}

const API_EXPORTERS = { 'ChatGPT': apiExportChatGPT, 'Claude': apiExportClaude };

/* ── The Veil: export progress overlay ───────────────────────── */
const VEIL = {
  el: null, steps: [], idx: 0, cancelled: false, lastBeat: 0, _wd: null,
  _popover: false,        // true if using the Popover API top-layer path
  _richChecked: false,    // FPS probe runs once per session
  _visBound: false,
  ensure() {
    if (this.el) return;
    this.el = document.createElement('div');
    this.el.id = 'gitl-veil';
    // Popover API renders in the top layer — above ALL host stacking contexts,
    // immune to z-index wars and transform-based parents. Feature-detected.
    this._popover = typeof this.el.showPopover === 'function';
    if (this._popover) { try { this.el.setAttribute('popover', 'manual'); } catch(_) { this._popover = false; } }
    this.el.innerHTML = `
      <div class="gv-card">
        <div class="gv-ringwrap">
          <div class="gv-ghost-x gv-gx1">👻</div>
          <div class="gv-ghost-x gv-gx2">👻</div>
          <div class="gv-ghost-x gv-gx3">👻</div>
          <div class="gv-ring"></div>
          <div class="gv-ghost">👻</div>
        </div>
        <div class="gv-title" id="gv-title">Working…</div>
        <div class="gv-steps" id="gv-steps"></div>
        <div class="gv-barwrap"><div class="gv-bar" id="gv-bar"></div></div>
        <div class="gv-pct" id="gv-pct"></div>
        <div class="gv-note" id="gv-note">Please don't reload the page</div>
        <button class="gv-cancel" id="gv-cancel">Cancel</button>
      </div>`;
    document.body.appendChild(this.el);
    this.el.querySelector('#gv-cancel').addEventListener('click', () => { this.cancelled = true; this.el.querySelector('#gv-title').textContent = 'Stopping…'; });
    // Re-assert top-layer if the tab was backgrounded (mobile browsers can drop
    // the overlay behind native chrome when returning to the foreground).
    if (!this._visBound) {
      this._visBound = true;
      document.addEventListener('visibilitychange', () => {
        if (!document.hidden && this.el && this._isOpen()) this._reassert();
      });
    }
  },
  _isOpen() {
    if (!this.el) return false;
    return this._popover ? this.el.matches(':popover-open') : this.el.style.display === 'flex';
  },
  _reassert() {
    // Re-show in the top layer; harmless if already shown.
    if (this._popover) { try { this.el.hidePopover(); } catch(_){} try { this.el.showPopover(); } catch(_){ this.el.style.display = 'flex'; } }
    else { this.el.style.display = 'flex'; }
  },
  // One-time FPS probe: only enable the heavier multi-ghost parallax if the
  // device can sustain it AND the user hasn't asked for reduced motion.
  _maybeRich() {
    if (this._richChecked) return;
    this._richChecked = true;
    try {
      if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    } catch(_){}
    let frames = 0; const t0 = performance.now();
    const tick = () => {
      frames++;
      const dt = performance.now() - t0;
      if (dt < 500) { requestAnimationFrame(tick); return; }
      const fps = (frames / dt) * 1000;
      if (fps >= 50 && this.el) this.el.classList.add('gv-rich'); // smooth device only
    };
    requestAnimationFrame(tick);
  },
  show(steps) {
    this.ensure(); this.steps = steps; this.idx = 0; this.cancelled = false; this.lastBeat = Date.now();
    if (this._popover) { try { this.el.showPopover(); } catch(_) { this._popover = false; this.el.style.display = 'flex'; } }
    else { this.el.style.display = 'flex'; }
    this._maybeRich();
    this.renderSteps(); this.beat(null);
    this._wd = setInterval(() => {
      const quiet = Date.now() - this.lastBeat;
      const note = this.el.querySelector('#gv-note');
      if (quiet > 8000) note.textContent = '⏳ Still working — the page is slow. Don\'t reload.';
      if (quiet > 25000) note.textContent = '⚠️ This looks stuck. Cancel is safe — Ghost keeps what it collected.';
    }, 2000);
  },
  step(i, label) {
    this.idx = i; this.lastBeat = Date.now();
    this.el.querySelector('#gv-title').textContent = label || this.steps[i] || 'Working…';
    this.renderSteps(); this.beat(null);
  },
  renderSteps() {
    this.el.querySelector('#gv-steps').innerHTML = this.steps.map((s, i) =>
      `<div class="gv-step${i < this.idx ? ' done' : i === this.idx ? ' act' : ''}">${i < this.idx ? '✓' : i === this.idx ? '▶' : '·'} ${s}</div>`).join('');
  },
  beat(pct) {
    this.lastBeat = Date.now();
    const bar = this.el.querySelector('#gv-bar'), p = this.el.querySelector('#gv-pct');
    const note = this.el.querySelector('#gv-note'); if (note) note.textContent = "Please don't reload the page";
    if (pct == null) { bar.classList.add('indet'); bar.style.width = '40%'; p.textContent = ''; }
    else { bar.classList.remove('indet'); bar.style.width = Math.min(100, pct) + '%'; p.textContent = Math.round(Math.min(100, pct)) + '%'; }
  },
  hide() {
    if (this._wd) clearInterval(this._wd); this._wd = null;
    if (!this.el) return;
    if (this._popover) { try { this.el.hidePopover(); } catch(_){} this.el.style.display = 'none'; }
    else { this.el.style.display = 'none'; }
  }
};

/* ── Deep Export: capture thinking logs, not just chat ───────── */
const THINK_TOGGLE_RX = /\b(thinking|thought|thoughts|reasoning|chain of thought|thought for|show (?:steps|work|reasoning|thinking)|view (?:steps|reasoning))\b/i;

async function expandThinking() {
  // Auto-click collapsed "Thinking" toggles so reasoning text enters the DOM.
  let clicked = 0;
  for (let pass = 0; pass < 3; pass++) {
    let n = 0;
    document.querySelectorAll('details:not([open])').forEach(d => {
      if (!d.closest('#gitl')) { try { d.open = true; n++; } catch(_){} }
    });
    document.querySelectorAll('button,[role="button"],summary').forEach(b => {
      try {
        if (b.closest('#gitl') || b.dataset.gitlExpanded) return;
        const label = ((b.innerText || '') + ' ' + (b.getAttribute('aria-label') || '')).slice(0, 80);
        if (THINK_TOGGLE_RX.test(label) && b.getAttribute('aria-expanded') !== 'true') {
          b.click(); b.dataset.gitlExpanded = '1'; n++;
        }
      } catch(_){}
    });
    // Manus-style collapsed steps: clickable group/header divs driving grid-rows-[0fr] panels
    document.querySelectorAll('[class*="group/header"][class*="clickable"]').forEach(h => {
      try {
        if (h.closest('#gitl') || h.dataset.gitlExpanded) return;
        const wrap = h.parentElement?.parentElement || h.parentElement;
        if (!wrap || !wrap.querySelector('[class*="grid-rows-[0fr]"]')) return; // only genuinely collapsed sections
        h.click(); h.dataset.gitlExpanded = '1'; n++;
      } catch(_){}
    });
    clicked += n;
    if (!n) break;
    await sleep(450);
  }
  return clicked;
}

function tableToMd(t) {
  const rows = [...t.querySelectorAll('tr')].map(tr =>
    [...tr.children].map(c => (c.innerText || '').trim().replace(/\|/g, '/').replace(/\s*\n+\s*/g, ' ')));
  if (!rows.length || !rows[0].length) return '';
  const out = ['| ' + rows[0].join(' | ') + ' |', '| ' + rows[0].map(() => '---').join(' | ') + ' |'];
  rows.slice(1).forEach(r => out.push('| ' + r.join(' | ') + ' |'));
  return out.join('\n');
}

// innerText, but with <table> elements serialized as markdown tables so structure survives export
function textWithTables(el) {
  if (!el.querySelector || !el.querySelector('table')) return el.innerText || '';
  try {
    const clone = el.cloneNode(true);
    clone.querySelectorAll('table').forEach(t => {
      const pre = document.createElement('pre');
      pre.textContent = '\n' + tableToMd(t) + '\n';
      t.replaceWith(pre);
    });
    // clone must be in-document for innerText to compute layout; use a detached fallback
    return clone.innerText || clone.textContent || el.innerText;
  } catch(_) { return el.innerText || ''; }
}

const FILE_NAME_RX = /^[\w][\w\-. ()]{0,60}\.(md|py|js|ts|jsx|tsx|json|csv|txt|html|css|pdf|docx|xlsx|pptx|zip|yaml|yml|sh|sql)$/;

const MANUS_CHROME = new Set(['Lite','Accepted','View more','View all files in this task','Task completed','How was this result?','Suggested follow-ups','Knowledge recalled']);

function cleanManusText(raw) {
  return (raw || '').split('\n').filter(l => {
    const t = l.trim();
    if (!t) return false;
    if (MANUS_CHROME.has(t)) return false;
    if (/^Knowledge recalled/.test(t)) return false;
    if (/^\d+\/\d+$/.test(t)) return false;     // virtual-list counters like 5/16
    if (/^Code · [\d.]+ [KMG]B$/.test(t)) return false;
    return true;
  }).join('\n').trim();
}

// Manus virtualizes the chat — off-screen turns don't exist in the DOM.
// Harvest: scroll the list top→bottom, collecting top-level [data-event-id] turns by id.
async function harvestManus() {
  const first = document.querySelector('[data-event-id]');
  if (!first) return null;
  let sc = first.parentElement;
  while (sc && sc !== document.body && sc.scrollHeight <= sc.clientHeight * 1.5) sc = sc.parentElement;
  if (!sc || sc === document.body) sc = document.scrollingElement;
  const seen = new Map();
  const grab = () => {
    document.querySelectorAll('[data-event-id]').forEach(el => {
      if (el.parentElement?.closest('[data-event-id]')) return; // top-level turns only
      const id = el.getAttribute('data-event-id');
      const text = cleanManusText(textWithTables(el));
      if (!text) return;
      const role = /items-end/.test(el.className) ? 'user' : 'assistant';
      const pos = el.getBoundingClientRect().top + (sc.scrollTop || 0);
      const prev = seen.get(id);
      if (!prev || prev.text.length < text.length) seen.set(id, { role, text, pos });
    });
  };
  const orig = sc.scrollTop;
  const step = Math.max(300, (sc.clientHeight || 600) * 0.85);
  const maxIter = Math.min(800, Math.ceil(sc.scrollHeight / step) + 20); // sized to the chat, not a blind cap
  sc.scrollTop = 0; await sleep(420); grab();
  let guard = 0;
  while (sc.scrollTop + sc.clientHeight < sc.scrollHeight - 10 && guard++ < maxIter) {
    if (VEIL.cancelled) break; // user cancelled — keep what we have
    sc.scrollTop += step; await sleep(240); grab();
    if (guard % 3 === 0) VEIL.beat(100 * sc.scrollTop / sc.scrollHeight);
  }
  // Bottom settle: virtualizers render the tail late — force bottom twice
  if (!VEIL.cancelled) for (let i = 0; i < 2; i++) { sc.scrollTop = sc.scrollHeight; await sleep(550); grab(); VEIL.beat(100); }
  sc.scrollTop = orig;
  let arr = [...seen.values()].sort((a, b) => a.pos - b.pos)
    .map((m, i) => ({ role: m.role, index: i, text: m.text }));
  // Merge consecutive same-role fragments (Manus plan steps) into readable blocks
  const merged = [];
  for (const m of arr) {
    const last = merged[merged.length - 1];
    if (last && last.role === m.role && (m.text.length < 200 || last.text.length < 200)) {
      last.text += '\n' + m.text;
    } else merged.push({ ...m });
  }
  merged.forEach((m, i) => m.index = i);
  // Manus creates files during the task — surface them as a manifest (contents live in Manus's file panel)
  const files = new Set();
  for (const m of merged) for (const line of m.text.split('\n')) {
    const t = line.trim();
    if (FILE_NAME_RX.test(t)) files.add(t);
  }
  if (files.size) merged.push({
    role: 'assistant', index: merged.length,
    text: '## 📎 Files created in this task\n' + [...files].map(f => '- ' + f).join('\n') +
          '\n\n*(File contents are not in the chat DOM — download them from Manus via "View all files in this task" before the session expires.)*'
  });
  return merged.length ? merged : null;
}

const THINK_BLOCK_SELS = ['[class*="thinking" i]','[class*="thought" i]','[class*="reasoning" i]','[data-testid*="thought"]','[data-testid*="reasoning"]','details'];

function extractThinking(el) {
  const parts = [];
  for (const s of THINK_BLOCK_SELS) {
    try {
      el.querySelectorAll(s).forEach(t => {
        const txt = (t.innerText || '').trim();
        if (txt && txt.length > 40 && !parts.some(p => p.includes(txt.slice(0, 80)))) parts.push(txt);
      });
    } catch(_){}
  }
  return parts.join('\n\n');
}

function extractMessages(withThinking) {
  const allTurns = document.querySelectorAll('[data-message-author-role], .human-turn, .bot-turn, div[class*="user-message"], div[class*="assistant-message"]');
  const messages = [];
  const push = (el, role, i) => {
    let text = textWithTables(el).trim();
    let thinking = '';
    if (withThinking && role === 'assistant') {
      thinking = extractThinking(el);
      if (thinking) text = text.replace(thinking, '').trim(); // avoid double-capture
    }
    if (text || thinking) messages.push(thinking ? { role, index: i, text, thinking } : { role, index: i, text });
  };
  if (allTurns.length > 0) {
    allTurns.forEach((el, i) => {
      const role = el.dataset?.messageAuthorRole || (el.className.includes('user') || el.className.includes('human') ? 'user' : 'assistant');
      push(el, role, i);
    });
  } else {
    const els = [..._qAll(PLAT.assistant)];
    const leaves = els.filter(el => !els.some(o => o !== el && el.contains(o))); // drop ancestors of other matches
    const texts = new Set();
    leaves.forEach((el, i) => {
      const t = el.innerText.trim();
      if (t && !texts.has(t)) { texts.add(t); push(el, 'assistant', i); }
    });
  }
  return messages;
}

function applyFilter(msgs) {
  const f = GHOST.export.filter;
  if (f === 'user') return msgs.filter(m => m.role === 'user');
  if (f === 'assistant') return msgs.filter(m => m.role === 'assistant');
  if (f === 'code') return msgs.filter(m => /```/.test(m.text));
  return msgs;
}

const GM_KEYS = ['projectName','projectSlug','wfSelected','wfStage','wfAuto','wfPause','persona','payloadMode','posture','maxRounds','customProceed','customStop','sigWindow','expFormat','expFilter','expRoles','expThinking','expSlug','panelCollapsed','panelPosition','soundOn','notifyOn','cfgAdv','expAdv','firstRun','customSites','rmSteps','rmIndex','rmCaptured','qDraft','customPersonas','customWorkflows'];

function downloadText(content, filename, mime) {
  const blob = new Blob([content], { type: mime });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob); a.download = filename;
  a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
}

/* ── Workshop export (W2): write custom items to a .gitl.json bundle.
   Exports ONLY custom personas/workflows — never project text, chat
   content, settings, or credentials. */
function workshopExport() {
  const nP = Object.keys(Workshop.personas).length;
  const nW = Object.keys(Workshop.workflows).length;
  if (nP + nW === 0) { GHOST.loop.detail = 'No custom items to export yet'; render(); return; }
  const slug = (GHOST.project.slug || 'ghost') + '-workshop';
  downloadText(Workshop.exportBundle(), `${slug}.gitl.json`, 'application/json');
  Timeline.record('workshop_export', { personas: nP, workflows: nW });
  GHOST.loop.detail = `⬇ Exported ${nP} persona(s) + ${nW} workflow(s)`;
  render();
}

/* ── Workshop import (W3): pick a .gitl.json file, validate, merge.
   Untrusted input — all text is stored as plain strings and only ever
   rendered via textContent, never innerHTML, so a malicious label/inject
   cannot inject markup into Ghost's panel. */
function workshopImport() {
  const inp = document.createElement('input');
  inp.type = 'file';
  inp.accept = '.json,.gitl.json,application/json';
  inp.addEventListener('change', () => {
    const file = inp.files && inp.files[0];
    if (!file) return;
    if (file.size > WORKSHOP_LIMITS.fileBytes) { GHOST.loop.detail = `⚠ File too large (max ${Math.round(WORKSHOP_LIMITS.fileBytes/1024)} KB)`; render(); return; }
    const reader = new FileReader();
    reader.onload = () => {
      const res = Workshop.importBundle(String(reader.result || ''));
      if (!res.ok) { GHOST.loop.detail = `⚠ Import failed: ${res.error}`; render(); return; }
      Timeline.record('workshop_import', res);
      const bits = [];
      if (res.personas)  bits.push(`${res.personas} persona(s)`);
      if (res.workflows) bits.push(`${res.workflows} workflow(s)`);
      let msg = bits.length ? `✓ Imported ${bits.join(' + ')}` : '✓ Import complete';
      if (res.renamed) msg += ` · ${res.renamed} renamed`;
      if (res.skipped) msg += ` · ${res.skipped} skipped (invalid)`;
      GHOST.loop.detail = msg;
      render();
    };
    reader.onerror = () => { GHOST.loop.detail = '⚠ Could not read file'; render(); };
    reader.readAsText(file);
  });
  inp.click();
}

function exportRescue() {
  const all = extractMessages();
  const mission = (all.find(m => m.role === 'user')?.text || '').slice(0, 600);
  const msgs = all.slice(-10); // verbatim tail, both roles — the part a stuck chat can't summarize for you
  const R = GHOST.roadmap, W = GHOST.workflow;
  const wf = (allWorkflows()[W.selected]||WORKFLOW_LIBRARY.none).label;
  const steps = R.steps.length ? R.steps.map((s,i) =>
    `${i < R.index ? '✓' : i === R.index ? '▶' : '·'} ${i+1}. ${s}`).join('\n') : '(none)';
  const md = [
    '# 🛟 GITL Rescue File',
    '*Use this when a chat is stuck, full, or dead and cannot be prompted anymore. Paste it into a NEW chat to continue the work. (If the chat still responds, the 🤝 Handoff button produces a better briefing — the AI writes it itself.)*',
    '',
    '```yaml',
    `project: ${GHOST.project.name || 'Untitled'}`,
    `platform: ${PLAT.label}`,
    `exported: ${new Date().toISOString()}`,
    `mode: ${GHOST.loop.payloadMode}`,
    `persona: ${(PERSONA_LIBRARY[GHOST.persona.selected]||PERSONA_LIBRARY.none).label}`,
    `workflow: ${wf} (stage ${W.stageIndex})`,
    `rounds: ${GHOST.loop.round}`,
    `last_signal: ${GHOST.loop.lastSignal}`,
    '```',
    '',
    '## Mission (first prompt)',
    mission || '(not captured — describe the task to the next AI yourself)',
    '',
    '## Roadmap state',
    steps,
    '',
    '## Resumption instructions for the next AI',
    'The previous chat became unusable. You are continuing its work.',
    '1. Read the mission and the verbatim tail below — that is the freshest state available.',
    '2. Continue from the current roadmap position (▶) if one exists, not from the beginning.',
    '3. Deliverable-first output, no fluff.',
    '4. End every response with [[GITL::PROCEED]] (more work remains) or [[GITL::HALT]] (fully done).',
    '',
    '## Last 10 messages — verbatim (most recent last)',
    ...msgs.map((m) => `### ${m.role === 'user' ? '👤 User' : '🤖 Assistant'}\n${m.text}\n`),
    '---',
    `*Rescue file generated by Ghost in the Loop v${VER}*`
  ].join('\n');
  downloadText(md, buildFilename('rescue').replace(/\.\w+$/,'') + '.md', 'text/markdown');
}

const HANDOFF_IN_CHAT = `Stop all other work. Produce a COMPLETE HANDOFF REPORT for this entire conversation, in ONE markdown code block, structured exactly as:
# Handoff Report
## Mission — what we are building and why
## Everything tried — every approach/version, what worked, what failed and WHY
## Current state — exactly where things stand right now
## Key decisions & reasoning
## Open items — unresolved problems, risks, unknowns
## Next steps — concrete, ordered
## Instructions for a fresh AI — how to pick this up with zero prior knowledge
Be exhaustive — this report is the only memory the next AI will have. No fluff outside the code block. End with [[GITL::HALT]]`;

function handoffInChat() {
  if (GHOST.loop.state === 'RUNNING') { GHOST.loop.detail = 'Pause the loop first'; render(); return; }
  GHOST.loop.detail = '🤝 Handoff requested — copy the report (or Export) when it finishes';
  engineSend(HANDOFF_IN_CHAT, false);
  render();
}

function backupConfig() {
  const cfg = {};
  for (const k of GM_KEYS) cfg[k] = GM_getValue(k, undefined);
  downloadText(JSON.stringify({ gitl_version: VER, exported: new Date().toISOString(), config: cfg }, null, 2),
    'gitl-config-backup.json', 'application/json');
}

function restoreConfig(jsonText) {
  try {
    const data = JSON.parse(jsonText);
    const cfg = data.config || data;
    let n = 0;
    for (const k of GM_KEYS) { if (k in cfg && cfg[k] !== undefined) { GM_setValue(k, cfg[k]); n++; } }
    return `✓ Restored ${n} settings — reload the page to apply.`;
  } catch(e) { return '⚠ Invalid backup file.'; }
}

async function runExport() {
  const isManus = /Manus/i.test(PLAT.label);
  const apiFn = API_EXPORTERS[PLAT.label];
  let raw = null;
  // Path 1 — the platform's own archive: complete, exact, virtualization-proof
  if (apiFn) {
    VEIL.show(['Fetching from platform archive', 'Building your file']);
    try {
      VEIL.step(0, 'Fetching from platform archive…');
      raw = await apiFn().catch(e => { DIAG.push('API export failed: ' + e.message); return null; });
      VEIL.step(1, 'Building your file…');
      await sleep(150);
    } finally { if (raw) { VEIL.hide(); } }
  }
  // Path 2 — DOM (fallback, and the only path on platforms without a known API)
  if (!raw) {
    const steps = ['Reading chat', ...(GHOST.export.thinking ? ['Opening thinking blocks'] : []), ...(isManus ? ['Collecting every message'] : []), 'Building your file'];
    VEIL.show(steps);
    try {
      VEIL.step(0);
      await sleep(250);
      if (GHOST.export.thinking) {
        VEIL.step(1, 'Opening thinking blocks…');
        const n = await expandThinking();
        await sleep(n ? 600 : 0);
      }
      if (isManus && !VEIL.cancelled) {
        VEIL.step(steps.indexOf('Collecting every message'), 'Collecting every message…');
        raw = await harvestManus();
      }
      VEIL.step(steps.length - 1, 'Building your file…');
      await sleep(200);
    } finally { VEIL.hide(); GHOST.loop.detail = ''; render(); }
  } else { VEIL.hide(); GHOST.loop.detail = ''; render(); }
  const msgs = applyFilter(raw || extractMessages(GHOST.export.thinking));
  if (!msgs.length) { alert('Ghost: no messages found to export.'); return; }
  const proj = GHOST.project.name || 'Untitled';
  const ts = new Date().toLocaleString();
  let content, mime;
  if (GHOST.export.format === 'json') {
    content = JSON.stringify({ project: proj, platform: PLAT.label, exported: ts, rounds: GHOST.loop.round, workflow: (allWorkflows()[GHOST.workflow.selected]||WORKFLOW_LIBRARY.none).label, persona: (allPersonas()[GHOST.persona.selected]||PERSONA_LIBRARY.none).label, messages: msgs }, null, 2);
    mime = 'application/json';
  } else {
    const lines = [`# Ghost Export — ${proj}`, `**Platform:** ${PLAT.label} | **Exported:** ${ts} | **Rounds:** ${GHOST.loop.round}`, '', '---', ''];
    for (const m of msgs) {
      if (GHOST.export.includeRoles) lines.push(`## ${m.role === 'user' ? '👤 User' : '🤖 Assistant'}`, '');
      if (m.thinking) lines.push('> 💭 **Thinking**', ...m.thinking.split('\n').map(l => '> ' + l), '');
      lines.push(m.text, '', '---', '');
    }
    content = lines.join('\n');
    mime = 'text/markdown';
  }
  const blob = new Blob([content], { type: mime });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob); a.download = buildFilename('export');
  a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
}

/* ═══════════════════════════════════════════════════════════════
   S5 — ENHANCED EXPORT: SHA-256 DEDUP + CAPSULE V2
   Deduplicates messages from virtualized DOM re-renders.
   Produces resumable capsule with DAG links + resume token.
   Sources: Kimi capsule, ChatGPT Export 3 capsule builder
   ═══════════════════════════════════════════════════════════════ */
async function gitlSha256(text) {
  try {
    const data = new TextEncoder().encode(text || '');
    const hash = await crypto.subtle.digest('SHA-256', data);
    return [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, '0')).join('');
  } catch {
    // Deterministic fallback: djb2 hash (used when crypto.subtle unavailable)
    const s = String(text || '');
    let h = 5381;
    for (let i = 0; i < s.length; i++) h = ((h << 5) + h) ^ s.charCodeAt(i);
    return `djb2-${(h >>> 0).toString(16).padStart(8, '0')}`;
  }
}

async function buildCapsuleV2(rawMessages) {
  const seen = new Set();
  const graph = [];
  for (let i = 0; i < rawMessages.length; i++) {
    const m = rawMessages[i];
    const text = (m.text || '').trim();
    if (!text || text.length < 5) continue;
    const hash = await gitlSha256(`${m.role}:${text}`);
    if (seen.has(hash)) continue;
    seen.add(hash);
    graph.push({
      id: `m_${graph.length + 1}`,
      role: m.role || 'unknown',
      text,
      sha256: hash.slice(0, 16),
      parentId: graph.length > 0 ? graph[graph.length - 1].id : null
    });
  }
  const h = typeof platformHealth === 'function' ? platformHealth() : {};
  return {
    schema: 'gitl.capsule.v2',
    version: VER,
    exported_at: new Date().toISOString(),
    platform: PLAT.label,
    url: location.href,
    title: document.title || '',
    project: GHOST.project || {},
    workflow: { selected: GHOST.workflow.selected, stage: GHOST.workflow.stageIndex },
    health: { score: h.score, badge: h.badge },
    messages: graph,
    deduplicated: rawMessages.length - graph.length,
    resume: {
      last_id: graph.length ? graph[graph.length - 1].id : null,
      next_action: 'continue_from_capsule',
      instruction: 'Read this capsule. Preserve decisions. Continue from resume.next_action without restarting.'
    },
    timeline_summary: {
      total_events: Timeline.all().length,
      recent_failures: Timeline.failures().slice(-5).map(f => f.data)
    }
  };
}

async function exportCapsuleV2() {
  const raw = extractMessages(GHOST.export.thinking);
  const capsule = await buildCapsuleV2(raw);
  const json = JSON.stringify(capsule, null, 2);
  const fname = buildFilename('capsule').replace(/\.\w+$/, '') + '.gitl.json';
  downloadText(json, fname, 'application/json');
  Timeline.record('export_capsule', { messages: capsule.messages.length, deduped: capsule.deduplicated });
}

/* ═══════════════════════════════════════════════════════════════
   AUDIO
   ═══════════════════════════════════════════════════════════════ */
function playBeep() {
  try {
    const ctx = new (window.AudioContext || window.webkitAudioContext)();
    [520,680].forEach((f,i) => {
      const o = ctx.createOscillator(), g = ctx.createGain();
      o.type='sine'; o.frequency.value=f;
      g.gain.setValueAtTime(0.12, ctx.currentTime);
      g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime+0.5);
      o.connect(g).connect(ctx.destination);
      o.start(ctx.currentTime+i*0.18); o.stop(ctx.currentTime+0.5+i*0.18);
    });
  } catch(_){}
}

/* ═══════════════════════════════════════════════════════════════
   UI — STYLES
   Deferred: GM_addStyle / appendChild require document.head, which is
   null at document-start. Called inside safeBoot() once DOM exists.
   ═══════════════════════════════════════════════════════════════ */
let _stylesInjected = false;
function injectStyles() {
  if (_stylesInjected) return;
  _stylesInjected = true;
  const css = `
#gitl{position:fixed;z-index:2147483647;width:268px;max-width:calc(100vw - 16px);background:#111214;border:1px solid #27282e;
  border-radius:12px;padding:10px 12px;font:11.5px 'SF Mono','Cascadia Code','JetBrains Mono','Fira Mono',monospace;
  color:#c9cad0;box-shadow:0 10px 32px rgba(0,0,0,.65);user-select:none;transition:width .2s}
#gitl *{box-sizing:border-box}
#gitl.collapsed .g-body{display:none} #gitl.collapsed{width:auto;min-width:180px}
.g-body{max-height:min(52vh,380px);overflow-y:auto;overflow-x:hidden;scrollbar-width:thin;scrollbar-color:#2e2f35 transparent}
.g-body::-webkit-scrollbar{width:4px}.g-body::-webkit-scrollbar-thumb{background:#2e2f35;border-radius:2px}
.g-adv{width:100%;padding:4px 0;margin:4px 0;border:none;border-top:1px dashed #27282e;background:transparent;color:#555;font-size:9px;cursor:pointer;text-align:center;font-family:inherit;font-weight:600}
.g-adv:hover{color:#888}
.g-hdr{display:flex;justify-content:space-between;align-items:center;cursor:grab;padding:2px 0;margin-bottom:6px}
.g-hdr:active{cursor:grabbing}
.g-logo{font-weight:800;font-size:10.5px;text-transform:uppercase;color:#555;letter-spacing:.6px;display:flex;align-items:center;gap:5px}
.g-dot{display:inline-block;width:7px;height:7px;border-radius:50%;transition:all .3s}
.g-dot.run{background:#34d399;box-shadow:0 0 5px #34d399;animation:gpulse 1.4s infinite}
.g-dot.pause{background:#fbbf24}.g-dot.done{background:#818cf8}.g-dot.err{background:#f87171}.g-dot.idle{background:#555}
@keyframes gpulse{0%,100%{opacity:1}50%{opacity:.4}}
.g-plat{font-size:9.5px;background:#1c1d22;padding:2px 6px;border-radius:4px;color:#818cf8;font-weight:600;border:1px solid #2a2b33}
.g-minbtn{background:#18191c;border:1px solid #2e2f35;color:#888;font-size:10px;cursor:pointer;padding:1px 6px;border-radius:4px;font-weight:700;transition:all .15s}
.g-minbtn.spin{animation:gvspin .6s linear}
.g-minbtn:hover{background:#27282e;color:#fff}
.g-coll-row{display:none;align-items:center;gap:6px;margin-top:4px}
#gitl.collapsed .g-coll-row{display:flex}
.g-qbtn{width:34px;height:26px;border:1px solid #27282e;border-radius:6px;font-size:13px;cursor:pointer;transition:all .15s}
.g-qbtn.play{background:#052e1c;color:#34d399;border-color:#064e3b}.g-qbtn.pause{background:#2d1900;color:#fbbf24;border-color:#78350f}
.g-qstat{font-size:10px;font-weight:700}
.g-proj{display:flex;align-items:center;gap:5px;margin-bottom:7px;padding:5px 7px;background:#16171b;border:1px solid #27282e;border-radius:7px}
.g-proj-lbl{font-size:9px;color:#444;flex-shrink:0}
.g-proj-in{flex:1;background:transparent;border:none;color:#a5b4fc;font-size:10px;font-family:inherit;font-weight:600;outline:none;min-width:0}
.g-proj-in::placeholder{color:#333}
.g-tabs{display:flex;gap:3px;margin-bottom:8px}
.g-tab{flex:1;padding:4px 0;border:1px solid #27282e;border-radius:5px;background:#18191c;color:#555;font-size:8.5px;cursor:pointer;text-align:center;font-weight:600;transition:all .15s;font-family:inherit}
.g-tab:hover{background:#222329;color:#888}.g-tab.act{background:#1a1b2e;border-color:#3730a3;color:#a5b4fc}
#g-tc{position:relative}
.g-tabhelp{position:absolute;top:-2px;right:0;width:16px;height:16px;line-height:14px;text-align:center;border:1px solid #2e2f35;border-radius:50%;background:#16171b;color:#6b7280;font-size:10px;font-weight:700;cursor:pointer;font-family:inherit;padding:0;z-index:3}
.g-tabhelp:hover{background:#1a1b2e;border-color:#3730a3;color:#a5b4fc}
.g-modes{display:flex;gap:3px;margin-bottom:6px}
.g-md{flex:1;padding:5px 0;border:1px solid #27282e;border-radius:6px;background:#18191c;color:#666;font-size:9px;cursor:pointer;text-align:center;font-weight:600;transition:all .15s;font-family:inherit}
.g-md:hover{background:#222329}.g-md.act{background:#1a1b2e;border-color:#3730a3;color:#a5b4fc}
.g-hint{font-size:9px;color:#484a57;margin-bottom:7px;padding:4px 6px;background:#16171b;border-radius:4px;border-left:2px solid #27282e;line-height:1.4}
.g-posture-wrap{margin-bottom:7px}
.g-posture-lbl{font-size:8.5px;text-transform:uppercase;letter-spacing:.5px;color:#4a4d57;font-weight:600;margin-bottom:3px;display:flex;align-items:center;gap:5px}
.g-posture-q{width:14px;height:14px;line-height:12px;text-align:center;border:1px solid #2e2f35;border-radius:50%;background:#16171b;color:#6b7280;font-size:9px;font-weight:700;cursor:pointer;font-family:inherit;padding:0}
.g-posture-q:hover{background:#1a1b2e;border-color:#3730a3;color:#a5b4fc}
.g-postures{display:flex;gap:3px}
.g-pst{flex:1;padding:5px 0;border:1px solid #27282e;border-radius:6px;background:#18191c;color:#666;font-size:9px;cursor:pointer;text-align:center;font-weight:600;transition:all .15s;font-family:inherit}
.g-pst:hover{background:#222329}.g-pst.act{background:#1f1a2e;border-color:#6d28d9;color:#c4b5fd}
.g-posture-hint{font-size:8.5px;color:#5a5d68;line-height:1.4;margin-top:4px;padding:3px 6px;background:#141519;border-radius:4px;border-left:2px solid #3a2e5a}
.g-btns{display:flex;gap:3px;margin-bottom:7px}
.g-btn{flex:1;padding:7px 0;border:1px solid #27282e;border-radius:7px;background:#18191c;color:#999;font-size:14px;cursor:pointer;text-align:center;transition:all .15s;font-family:inherit}
.g-btn:hover{background:#222329}
.g-btn.go{background:#052e1c;border-color:#064e3b;color:#34d399}.g-btn.go:hover{background:#064e3b}
.g-btn.st{background:#2d0a0a;border-color:#7f1d1d;color:#f87171}.g-btn.st:hover{background:#7f1d1d}
.g-prog{margin:2px 0 6px}
.g-trk{height:5px;background:#1c1d22;border-radius:2px;overflow:hidden}
.g-fill{height:100%;background:linear-gradient(90deg,#34d399,#818cf8);border-radius:2px;transition:width .4s}
.g-plbl{display:flex;justify-content:space-between;align-items:baseline;margin-top:3px}
.g-step{font-size:11px;color:#c9cad0}.g-step b{font-size:13px;color:#e7e7ea;font-weight:700}
.g-step-pct{font-size:10px;color:#777}
.g-safety{margin-top:6px;padding:5px 7px;background:#141519;border:1px solid #20212a;border-radius:6px}
.g-safety-row{display:flex;align-items:center;gap:6px;font-size:8.5px}
.g-safety-lbl{color:#4a4d57;text-transform:uppercase;letter-spacing:.5px;font-weight:600}
.g-safety-num{margin-left:auto;color:#6b7280}.g-safety-num b{color:#9ca3af}
.g-safety-rst{flex:0 0 auto;width:16px;height:16px;line-height:14px;text-align:center;border:1px solid #2a2c35;border-radius:4px;background:#16171b;color:#6b7280;font-size:10px;cursor:pointer;font-family:inherit;padding:0}
.g-safety-rst:hover{background:#1c1d22;color:#a5b4fc;border-color:#3730a3}
.g-safety-trk{height:2px;background:#1c1d22;border-radius:1px;overflow:hidden;margin-top:4px}
.g-safety-fill{height:100%;background:#3a3d47;border-radius:1px;transition:width .4s}
.g-safety.warn{border-color:#5a4420;background:#1f1808}
.g-safety.warn .g-safety-num b,.g-safety.warn .g-safety-lbl{color:#fcd34d}
.g-safety.warn .g-safety-fill{background:#f59e0b}
.g-stat{text-align:center;font-weight:600;font-size:10.5px;padding:4px 0;border-top:1px solid #1c1d22;margin-top:2px}
.g-row{display:flex;align-items:center;justify-content:space-between;font-size:10px;color:#666;margin-bottom:5px}
.g-row label{color:#555}
.g-row input[type="number"],.g-row input[type="text"]{background:#18191c;border:1px solid #2e2f35;border-radius:4px;color:#c9cad0;font-size:10px;padding:2px 5px;font-family:inherit}
.g-row input[type="number"]{width:52px;text-align:center}.g-row input[type="text"]{width:110px}
.g-row input:focus{outline:none;border-color:#4338ca}
.g-row select{background:#18191c;border:1px solid #2e2f35;border-radius:4px;color:#c9cad0;font-size:10px;padding:2px 4px;font-family:inherit}
.g-tog{width:28px;height:14px;background:#2e2f35;border-radius:7px;position:relative;cursor:pointer;transition:background .2s;flex-shrink:0}
.g-tog.on{background:#064e3b}
.g-tog::after{content:'';width:10px;height:10px;background:#666;border-radius:50%;position:absolute;top:2px;left:2px;transition:left .2s,background .2s}
.g-tog.on::after{left:16px;background:#34d399}
.g-pos-row{display:flex;gap:3px}
.g-pos{background:#18191c;border:1px solid #2e2f35;color:#777;font-size:11px;width:22px;height:20px;cursor:pointer;border-radius:4px;display:flex;align-items:center;justify-content:center;transition:all .15s}
.g-pos:hover{background:#27282e;color:#fff}.g-pos.act{background:#1a1b2e;border-color:#3730a3;color:#a5b4fc}
.g-exp-btn{width:100%;padding:8px;background:#052e1c;border:1px solid #064e3b;border-radius:7px;color:#34d399;font-size:11px;font-weight:700;cursor:pointer;font-family:inherit;margin-top:2px;text-align:center;transition:all .15s}
.g-exp-btn:hover{background:#064e3b}
.g-div{height:1px;background:#1c1d22;margin:7px 0}
.g-diag{font-size:9px;color:#444;line-height:1.6;padding:5px 6px;background:#0c0d10;border:1px solid #27282e;border-radius:5px;max-height:200px;overflow-y:auto;white-space:pre-wrap;word-break:break-all}
.g-sites{width:100%;box-sizing:border-box;background:#0c0d10;border:1px solid #27282e;border-radius:5px;color:#9aa;font-size:9px;font-family:monospace;padding:5px 6px;margin-bottom:4px;resize:vertical}
.g-btn-sm{padding:3px 8px;margin-top:5px;border:1px solid #3730a3;border-radius:5px;background:#1a1b2e;color:#a5b4fc;font-size:9px;cursor:pointer;font-family:inherit;font-weight:600}
.g-btn-sm:hover{background:#222345}
.g-qrow{display:flex;align-items:center;gap:5px;margin-bottom:4px}
.g-qin{flex:1;min-width:0;background:#0c0d10;border:1px solid #27282e;border-radius:5px;color:#aab;font-size:9.5px;padding:4px 6px;font-family:inherit}
.g-qin:focus{border-color:#3730a3;outline:none}
.g-qdel{border:none;background:transparent;color:#444;cursor:pointer;font-size:10px;padding:2px}
.g-qdel:hover{color:#e66}
.g-qtext{flex:1;font-size:9.5px;color:#999;line-height:1.4;word-break:break-word}
.g-qtext.done{color:#4a5;text-decoration:line-through;text-decoration-color:#2a3}
.g-hpills{display:flex;flex-wrap:wrap;gap:3px;margin-bottom:7px}
.g-hpill{padding:3px 7px;border:1px solid #27282e;border-radius:10px;background:#18191c;color:#666;font-size:8.5px;cursor:pointer;font-family:inherit;font-weight:600}
.g-hpill.act{background:#1a1b2e;border-color:#3730a3;color:#a5b4fc}
.g-support{text-align:center;font-size:8px;color:#3a3b40;margin-top:8px;padding-top:6px;border-top:1px solid #1c1d22}
.g-support a{color:#4a4b55;text-decoration:none}
.g-support a:hover{color:#a5b4fc}
#gitl-veil{position:fixed;inset:0;z-index:2147483646;display:none;align-items:center;justify-content:center;background:rgba(8,9,12,.62);backdrop-filter:blur(2px);font-family:-apple-system,'Segoe UI',Roboto,sans-serif;border:none;padding:0;margin:0;width:100vw;height:100vh;max-width:none;max-height:none;overflow:hidden}
/* Popover top-layer mode: renders above ALL host stacking contexts */
#gitl-veil:popover-open{display:flex}
#gitl-veil::backdrop{background:rgba(8,9,12,.62);backdrop-filter:blur(2px)}
.gv-card{position:relative;background:#111214;border:1px solid #27282e;border-radius:14px;padding:22px 26px;width:240px;text-align:center;box-shadow:0 12px 48px rgba(0,0,0,.6);z-index:1}
/* ── Ghost field (3D-ish parallax). Compositor-only transforms — no JS loop. */
.gv-ringwrap{position:relative;width:96px;height:72px;margin:0 auto 12px;perspective:340px;transform-style:preserve-3d}
.gv-ring{position:absolute;top:4px;left:50%;width:60px;height:60px;margin-left:-30px;border:3px solid #25262c;border-top-color:#a5b4fc;border-radius:50%;animation:gvspin 1s linear infinite;opacity:.9}
.gv-ghost{position:absolute;top:50%;left:50%;font-size:30px;line-height:1;transform:translate(-50%,-50%);animation:gvbob 2s ease-in-out infinite;filter:drop-shadow(0 4px 6px rgba(0,0,0,.5));z-index:2}
/* extra ghosts only appear in full-motion mode */
.gv-ghost-x{position:absolute;top:50%;left:50%;font-size:18px;line-height:1;opacity:0;pointer-events:none;will-change:transform,opacity}
#gitl-veil.gv-rich .gv-ghost-x{opacity:1}
#gitl-veil.gv-rich .gv-gx1{animation:gvz1 3.2s ease-in-out infinite}
#gitl-veil.gv-rich .gv-gx2{animation:gvz2 3.8s ease-in-out infinite .5s}
#gitl-veil.gv-rich .gv-gx3{animation:gvz3 4.4s ease-in-out infinite 1s}
#gitl-veil.gv-rich .gv-ring{animation-duration:1.4s}
#gitl-veil.gv-rich .gv-ghost{animation:gvbob3d 2.6s ease-in-out infinite}
@keyframes gvspin{to{transform:rotate(360deg)}}
@keyframes gvbob{0%,100%{transform:translate(-50%,-50%)}50%{transform:translate(-50%,calc(-50% - 4px))}}
@keyframes gvbob3d{0%,100%{transform:translate(-50%,-50%) scale(1) rotateY(0deg)}50%{transform:translate(-50%,calc(-50% - 5px)) scale(1.06) rotateY(18deg)}}
/* zoom-in/out depth passes — translateZ + scale read as 3D */
@keyframes gvz1{0%,100%{transform:translate(-150%,-90%) translateZ(-120px) scale(.5);opacity:0}40%{opacity:.55}60%{opacity:.55}50%{transform:translate(-140%,-60%) translateZ(60px) scale(1.1)}}
@keyframes gvz2{0%,100%{transform:translate(60%,-120%) translateZ(-140px) scale(.45);opacity:0}45%{opacity:.5}55%{opacity:.5}50%{transform:translate(70%,-70%) translateZ(50px) scale(1.05)}}
@keyframes gvz3{0%,100%{transform:translate(20%,30%) translateZ(-100px) scale(.55);opacity:0}40%{opacity:.45}60%{opacity:.45}50%{transform:translate(30%,40%) translateZ(70px) scale(1.15)}}
@media (prefers-reduced-motion: reduce){
  #gitl-veil.gv-rich .gv-ghost-x{animation:none;opacity:0}
  #gitl-veil.gv-rich .gv-ghost{animation:gvbob 2s ease-in-out infinite}
  .gv-ring{animation:gvspin 1.4s linear infinite} }
.gv-title{color:#e7e7ea;font-size:12px;font-weight:700;margin-bottom:8px}
.gv-steps{text-align:left;margin:0 auto 10px;display:inline-block}
.gv-step{font-size:9.5px;color:#555;line-height:1.8}
.gv-step.act{color:#a5b4fc}.gv-step.done{color:#4a5}
.gv-barwrap{height:5px;background:#1c1d22;border-radius:3px;overflow:hidden;margin-bottom:5px}
.gv-bar{height:100%;background:linear-gradient(90deg,#6366f1,#a5b4fc);border-radius:3px;width:0;transition:width .25s}
.gv-bar.indet{animation:gvslide 1.2s ease-in-out infinite}
@keyframes gvslide{0%{margin-left:-40%}100%{margin-left:100%}}
.gv-pct{font-size:9px;color:#777;height:12px;margin-bottom:6px}
.gv-note{font-size:8.5px;color:#666;margin-bottom:10px}
.gv-cancel{padding:4px 14px;border:1px solid #3a2a2a;border-radius:6px;background:#1c1416;color:#c88;font-size:9px;cursor:pointer;font-family:inherit}
.gv-cancel:hover{background:#241719}
#gitl.pos-dock{border-radius:10px 0 0 10px;border-right:none;width:268px}
#gitl.pos-dock.collapsed{width:32px!important;min-width:0}
#gitl.pos-dock.collapsed .g-hdr{flex-direction:column;padding:10px 4px;gap:6px}
#gitl.pos-dock.collapsed .g-hdr > span:last-child{flex-direction:column}
#gitl.pos-dock.collapsed .g-plat{display:none}
#gitl.pos-dock.collapsed .g-logo{writing-mode:vertical-rl;font-size:11px}
#gitl.pos-dock.collapsed .g-coll-row{flex-direction:column;padding:4px 2px}
#gitl.pos-dock.collapsed .g-qstat{display:none}
/* Gold left-dock: mirror geometry to the left edge + gold accents. Our own
   element in the top stacking context — never injected into the host's menu. */
#gitl.pos-dock-left{left:0;right:auto;border-radius:0 10px 10px 0;border-left:none;border-right:1px solid #5a4a1e}
#gitl.pos-dock-left.collapsed .g-hdr{flex-direction:column;padding:10px 4px;gap:6px}
#gitl.pos-dock-left{border-color:#5a4a1e;box-shadow:0 10px 32px rgba(120,90,10,.28)}
#gitl.pos-dock-left .g-logo{color:#e8c66a}
#gitl.pos-dock-left.collapsed .g-logo{writing-mode:vertical-rl;font-size:13px;color:#f0cd6e;letter-spacing:1px}
#gitl.pos-dock-left .g-dot{box-shadow:0 0 6px rgba(232,198,106,.6)}
.g-pos-gold{color:#e8c66a!important}
.g-pos-gold.act{background:#2a2410!important;border-color:#5a4a1e!important}
.g-diag .ok{color:#34d399}.g-diag .warn{color:#f87171}
.g-persona-btn{width:100%;text-align:left;padding:5px 7px;margin-bottom:3px;border:1px solid #27282e;border-radius:6px;background:#18191c;color:#c9cad0;font-family:inherit;font-size:10px;cursor:pointer;transition:all .15s}
.g-persona-btn.act{background:#1a1b2e;border-color:#3730a3;color:#c7d2fe}
.g-persona-btn .plbl{font-weight:700;color:#9ca3af}.g-persona-btn.act .plbl{color:#a5b4fc}
.g-persona-btn .pdesc{font-size:9px;color:#6b7280;line-height:1.4;margin-top:1px}
.g-cust-badge{color:#e8c66a;font-size:9px}
.g-del{float:right;color:#7a5050;font-size:10px;padding:0 3px;border-radius:3px;cursor:pointer}
.g-del:hover{background:#3a1f1f;color:#e0a0a0}
.g-ws-bar{display:flex;gap:5px;margin-top:8px;padding-top:7px;border-top:1px solid #1c1d22}
.g-ws-btn{flex:1;padding:5px 0;border:1px solid #2a2c35;border-radius:5px;background:#16171b;color:#8b8ea3;font-size:9px;font-weight:600;cursor:pointer;font-family:inherit}
.g-ws-btn:hover{background:#1a1b2e;border-color:#3730a3;color:#a5b4fc}
.g-ws-form{margin-top:6px;padding:7px;background:#141519;border:1px solid #2a2c35;border-radius:6px}
.g-ws-in,.g-ws-ta{width:100%;margin-bottom:5px;padding:5px 6px;background:#0e0f12;border:1px solid #2a2c35;border-radius:4px;color:#c9cad0;font-family:inherit;font-size:9.5px;box-sizing:border-box}
.g-ws-ta{resize:vertical;line-height:1.4}
.g-ws-form-btns{display:flex;gap:5px}.g-ws-form-btns .g-btn-sm{flex:1;margin-top:0}
.g-wf-desc{font-size:9px;color:#7a7d88;line-height:1.45;background:#16171b;border:1px solid #27282e;border-radius:5px;padding:6px;margin-bottom:7px}
.g-wf-how{font-size:9px;color:#9ca3af;line-height:1.5;background:#16171f;border:1px solid #2a2c3a;border-radius:6px;padding:7px 8px;margin-bottom:8px}
.g-wf-how b{color:#c7d2fe}
.g-wf-start{width:100%;font-size:12px;padding:8px 0;margin-bottom:2px}
.g-wf-start.g-dim{opacity:.4;cursor:default}
.g-wf-progress{font-size:9px;color:#7a7d88;text-align:center;margin:6px 0 2px}.g-wf-progress b{color:#a5b4fc}
.g-wf-stage{display:flex;align-items:stretch;gap:6px;padding:5px 6px;margin-bottom:3px;background:#16171b;border:1px solid #27282e;border-radius:5px}
.g-wf-stage-txt{flex:1;font-size:9px;line-height:1.45;color:#7a7d88}
.g-wf-stage b{color:#8b8ea3}.g-wf-stage.act{background:#1a1b2e;border-color:#3730a3}.g-wf-stage.act .g-wf-stage-txt,.g-wf-stage.act b{color:#c7d2fe}
.g-wf-ins{flex:0 0 auto;width:20px;border:1px solid #3730a3;border-radius:4px;background:#1a1b2e;color:#a5b4fc;font-size:8px;font-weight:700;letter-spacing:.5px;cursor:pointer;font-family:inherit;writing-mode:vertical-rl;text-orientation:mixed;padding:4px 0;transition:all .15s}
.g-wf-ins:hover{background:#26284a}
.g-wf-ins.ins-ok{background:#14532d;border-color:#16a34a;color:#86efac}
.g-peek-btn{font-size:9px;color:#3a3b44;cursor:pointer;text-align:center;margin-top:5px;padding-top:4px;border-top:1px solid #1c1d22}
.g-peek-btn:hover{color:#777}
.g-peek{display:none;margin-top:4px;padding:5px;background:#0c0d10;border:1px solid #27282e;border-radius:5px;font-size:9px;line-height:1.5;color:#48505e;white-space:pre-wrap;max-height:140px;overflow-y:auto}
.g-peek.open{display:block}
.g-shortcuts{font-size:8.5px;color:#333;text-align:center;margin-top:4px}
.g-firstrun{padding:6px 8px;background:#1a1b2e;border:1px solid #3730a3;border-radius:6px;font-size:9.5px;color:#a5b4fc;line-height:1.4;margin-bottom:7px;text-align:center}
.g-report{margin-top:6px;padding:7px 9px;background:#241719;border:1px solid #5a2e2e;border-radius:7px}
.g-report-h{font-size:10px;font-weight:700;color:#f1b4b4;display:flex;align-items:center;gap:6px}
.g-report-k{font-size:8px;font-weight:600;color:#c88;background:#1c1416;border:1px solid #3a2a2a;border-radius:4px;padding:1px 5px}
.g-report-b{font-size:9px;color:#caa;line-height:1.4;margin:4px 0 6px}
.g-report-btns{display:flex;gap:5px}
.g-report-btns .g-btn-sm{margin-top:0;border-color:#5a2e2e;background:#1c1416;color:#e0a0a0;flex:1}
.g-limit{margin:6px 0;padding:8px 9px;background:#231a0c;border:1px solid #5a4420;border-radius:7px;text-align:center}
.g-limit-h{font-size:10px;font-weight:700;color:#fcd34d}
.g-limit-b{font-size:9px;color:#caa968;line-height:1.4;margin:3px 0 7px}
.g-limit .g-btn.go{width:100%;font-size:11px;padding:7px 0}
.g-limit-btns{display:flex;flex-direction:column;gap:5px}
.g-limit-btns .g-btn{width:100%;font-size:10.5px;padding:6px 0}
.g-limit-btns .g-btn.rg{background:#152a22;border-color:#1e5a44;color:#6ee7b7}
.g-limit-btns .g-btn.rg:hover{background:#1a3a2e}
.g-limit-btns .g-btn.st{background:#241719;border-color:#5a2e2e;color:#e0a0a0}
@keyframes gpulse{0%,100%{box-shadow:0 0 0 0 rgba(245,158,11,.5)}50%{box-shadow:0 0 0 5px rgba(245,158,11,0)}}
.pulse{animation:gpulse 1.4s ease-in-out infinite}
.g-qbtn.limit{background:#f59e0b;color:#1a1205;animation:gpulse 1.4s ease-in-out infinite}
#gitl.pos-bb{bottom:0!important;left:0!important;right:0!important;width:100%!important;border-radius:10px 10px 0 0!important;top:auto!important}
`;
  try {
    GM_addStyle(css);
  } catch (e) {
    /* GM_addStyle itself can throw if head is null — inject manually with fallback */
    try {
      const style = document.createElement('style');
      style.textContent = css;
      (document.head || document.documentElement).appendChild(style);
    } catch (e2) {
      console.error('[GITL] style injection failed:', e2);
    }
  }
}

/* ═══════════════════════════════════════════════════════════════
   UI — RENDER + TABS
   panel element is created at top level (safe — no DOM tree needed),
   but attached to document.body inside safeBoot() (body may be null
   at document-start).
   ═══════════════════════════════════════════════════════════════ */
const panel = document.createElement('div');
panel.id = 'gitl';
let _panelMounted = false;
function mountPanel() {
  if (_panelMounted || !document.body) return;
  // Defense-in-depth: if a stray #gitl exists (e.g. script eval'd twice in a
  // test harness that bypasses the __GITL_V8__ guard), remove it first.
  const existing = document.getElementById('gitl');
  if (existing && existing !== panel) existing.remove();
  _panelMounted = true;
  document.body.appendChild(panel);
}

/* Escape untrusted text before interpolating into innerHTML templates.
   Custom/imported persona & workflow text flows through here so a crafted
   label/inject/stage can never inject markup into Ghost's own panel. */
function _esc(s) {
  return String(s == null ? '' : s)
    .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
    .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}

function dotClass() {
  const s = GHOST.loop.state;
  return s==='RUNNING'?'run':s==='PAUSED'?'pause':s==='COMPLETE'?'done':s==='ERROR'?'err':'idle';
}
function statColor() {
  const s = GHOST.loop.state;
  return s==='RUNNING'?'#34d399':s==='PAUSED'?'#fbbf24':s==='LIMIT'?'#f59e0b':s==='COMPLETE'?'#818cf8':s==='ERROR'?'#f87171':'#555';
}
function statLabel() {
  const L = GHOST.loop;
  if (L.state==='IDLE') return L.detail || 'Ready — type a prompt and press ▶';
  if (L.state==='RUNNING') return L.detail || `Round ${L.round} / ${L.maxRounds}`;
  if (L.state==='PAUSED') return L.detail || 'Paused';
  if (L.state==='LIMIT') return L.detail || `Hit ${L.maxRounds} auto-continues — ▶ for ${L.limitStep} more`;
  if (L.state==='COMPLETE') return L.detail || 'Complete';
  return L.detail || L.state;
}

function renderRunTab() {
  const L = GHOST.loop, p = L.lastProgress, pct = p ? Math.round((p.step/p.total)*100) : 0;
  const pm = L.payloadMode;
  const peekOpen = panel.querySelector('.g-peek')?.classList.contains('open');
  const firstRun = GHOST.ui.firstRun;
  return `
    ${firstRun ? `<div class="g-firstrun"><b>👻 Quick start</b><br>1. Type your big task in the chat box<br>2. Press ▶ — Ghost wraps it in the loop protocol<br>3. Walk away. Ghost auto-continues, stops on [[GITL::HALT]]<br><button class="g-btn-sm" id="g-onb-done">Got it</button></div>` : ''}
    <div class="g-modes">
      <button class="g-md${pm==='loop'?' act':''}" data-m="loop">${PAYLOADS.loop.label}</button>
      <button class="g-md${pm==='think'?' act':''}" data-m="think">${PAYLOADS.think.label}</button>
      <button class="g-md${pm==='roadmap'?' act':''}" data-m="roadmap">${PAYLOADS.roadmap.label}</button>
    </div>
    <div class="g-hint">${PAYLOADS[pm].hint}</div>
    <div class="g-posture-wrap">
      <div class="g-posture-lbl">Thinking posture <button class="g-posture-q" id="g-posture-help" title="What do these mean?">?</button></div>
      <div class="g-postures">
        <button class="g-pst${L.posture==='standard'?' act':''}" data-pst="standard">${POSTURES.standard.label}</button>
        <button class="g-pst${L.posture==='evolving'?' act':''}" data-pst="evolving">${POSTURES.evolving.label}</button>
        <button class="g-pst${L.posture==='extended'?' act':''}" data-pst="extended">${POSTURES.extended.label}</button>
      </div>
      <div class="g-posture-hint">${_esc((POSTURES[L.posture]||POSTURES.standard).desc)}</div>
    </div>
    ${L.state==='LIMIT' ? `<div class="g-limit"><div class="g-limit-h">⏸ Drift checkpoint — ${L.maxRounds} auto-continues reached</div><div class="g-limit-b">A grounding pause so the run can't wander off-task unattended. What next?</div><div class="g-limit-btns"><button class="g-btn go pulse" id="g-limit-go">▶ Continue ${L.limitStep} more</button><button class="g-btn rg" id="g-limit-reground" title="Re-anchor the AI to the task it started on, then continue">⊕ Reground</button><button class="g-btn st" id="g-limit-wait" title="Pause and wait for your instructions">✋ Stop &amp; wait</button></div></div>` : ''}
    <div class="g-btns">
      <button class="g-btn go${L.state==='LIMIT'?' pulse':''}" id="g-play" title="${L.state==='LIMIT'?`Continue ${L.limitStep} more`:'Start / Resume (Alt+P)'}">▶</button>
      <button class="g-btn" id="g-pause" title="Pause (Alt+P)">⏸</button>
      <button class="g-btn st" id="g-stop" title="Stop & Reset (Alt+S)">■</button>
    </div>
    <div class="g-prog">
      <div class="g-trk"><div class="g-fill" style="width:${pct}%"></div></div>
      <div class="g-plbl">
        <span class="g-step">${p?`${pm==='think'?'Batch':'Step'} <b>${p.step}</b> / ${p.total}${p.desc?' — '+p.desc.slice(0,22):''}` : (L.state==='RUNNING'||L.state==='LIMIT'?`Round <b>${L.round}</b>`:'Waiting…')}</span>
        <span class="g-step-pct">${p?pct+'%':''}</span>
      </div>
      ${(L.state==='RUNNING'||L.state==='PAUSED'||L.state==='LIMIT') ? (()=>{
        const left = Math.max(0, L.maxRounds - L.round);
        const lowpct = L.maxRounds ? (left / L.maxRounds) * 100 : 100;
        const warn = left <= 5;
        return `<div class="g-safety${warn?' warn':''}" title="Safety check: Ghost pauses after ${L.maxRounds} auto-continues so it can't drift or invent steps unattended. Not your task length — just a drift guard.">
          <div class="g-safety-row">
            <span class="g-safety-lbl">drift guard</span>
            <span class="g-safety-num"><b>${left}</b> left of ${L.maxRounds}</span>
            <button class="g-safety-rst" id="g-cnt-reset" title="Reset this counter back to ${L.maxRounds} (does not touch your chat or the page)">↻</button>
          </div>
          <div class="g-safety-trk"><div class="g-safety-fill" style="width:${lowpct}%"></div></div>
        </div>`;
      })() : ''}
    </div>
    <div class="g-stat" style="color:${statColor()}">${statLabel()}</div>
    ${GHOST.report ? `<div class="g-report"><div class="g-report-h">⚠ Trouble report ready <span class="g-report-k">${GHOST.report.kind}</span></div><div class="g-report-b">${(GHOST.report.detail||'').slice(0,120)}</div><div class="g-report-btns"><button class="g-btn-sm" id="g-rep-copy">📋 Copy</button><button class="g-btn-sm" id="g-rep-issue">↗ Open issue</button><button class="g-btn-sm" id="g-rep-x" style="background:#18191c">✕</button></div></div>` : ''}
    <div class="g-peek-btn" id="g-peek-btn">${peekOpen?'▾ Hide prompt':'▸ What gets injected'}</div>
    <div class="g-peek${peekOpen?' open':''}" id="g-peek">${PAYLOADS[pm].preview}</div>
    <div class="g-shortcuts">v${VER} · Alt+P toggle · Alt+S stop</div>`;
}

function renderFlowTab() {
  const wf = allWorkflows()[GHOST.workflow.selected] || WORKFLOW_LIBRARY.none;
  const opts = Object.entries(allWorkflows()).map(([k,v]) => `<option value="${_esc(k)}"${GHOST.workflow.selected===k?' selected':''}>${v.custom?'★ ':''}${_esc(v.label)}</option>`).join('');
  const isManual = GHOST.workflow.selected === 'none' || !wf.stages.length;
  const running = GHOST.loop.state === 'RUNNING';
  const stages = wf.stages.length
    ? wf.stages.map((s,i) => `
        <div class="g-wf-stage${i===GHOST.workflow.stageIndex&&GHOST.workflow.active?' act':''}">
          <div class="g-wf-stage-txt"><b>Stage ${i+1}</b><br>${_esc(s.slice(0,120))}${s.length>120?'…':''}</div>
          <button class="g-wf-ins" data-ins="${i}" title="Insert just this stage's prompt into the chat box">INSERT</button>
        </div>`).join('')
    : '<div style="font-size:9px;color:#555;padding:4px 0">Manual mode — no preset stages. Use the Run tab instead.</div>';
  const creating = GHOST.ui.wsNewWorkflow;
  const form = creating ? `
    <div class="g-ws-form">
      <input class="g-ws-in" id="ws-w-label" placeholder="Workflow name (e.g. Spec Review)" maxlength="40">
      <input class="g-ws-in" id="ws-w-desc" placeholder="One-line description (optional)" maxlength="200">
      <textarea class="g-ws-ta" id="ws-w-stages" placeholder="One stage per line. Each line becomes a stage prompt, run in order." rows="5" maxlength="8000"></textarea>
      <div class="g-ws-form-btns"><button class="g-btn-sm" id="ws-w-save">✓ Save workflow</button><button class="g-btn-sm" id="ws-w-cancel" style="background:#18191c">Cancel</button></div>
    </div>` : `<button class="g-exp-btn" id="ws-w-new" style="margin-top:5px">+ Create custom workflow</button>`;
  const delBtn = wf.custom ? `<button class="g-exp-btn" id="ws-w-del" data-confirm="0" style="margin-top:5px;background:#241719;border-color:#5a2e2e;color:#e0a0a0;font-size:9px">✕ Delete this custom workflow</button>` : '';
  const wsBar = `
    <div class="g-ws-bar">
      <button class="g-ws-btn" id="ws-import" title="Import a .gitl.json pack of personas & workflows">⬆ Import</button>
      <button class="g-ws-btn" id="ws-export" title="Export your custom personas & workflows to a shareable file">⬇ Export</button>
      <button class="g-ws-btn" id="ws-submit" title="Share your pack with the community">🌐 Share</button>
    </div>`;
  return `
    <div class="g-row"><label>Workflow</label><select id="wf-sel" style="width:118px">${opts}</select></div>
    <div class="g-wf-desc">${_esc(wf.desc)}</div>
    ${!isManual ? `
      <div class="g-wf-how">
        <b>How this works:</b> press <b>▶ Start</b> below and Ghost runs all ${wf.stages.length} stages in order, moving to the next each time the AI says it's done. Or tap a single stage's <b>INSERT</b> to drop just that prompt into the chat yourself.
        ${GHOST.workflow.pauseBetween ? '<br><br>⏸ <b>Pause between is ON</b> — Ghost stops after each stage so you can review or switch models, then press ▶ to continue.' : ''}
      </div>
      <button class="g-btn go g-wf-start${running?' g-dim':''}" id="wf-start"${running?' disabled':''}>▶ Start workflow</button>
      <div class="g-row" style="margin-top:8px"><label>Pause between stages</label><div class="g-tog${GHOST.workflow.pauseBetween?' on':''}" id="wf-pause"></div></div>
      <div class="g-wf-progress">Stage <b>${wf.stages.length?(GHOST.workflow.stageIndex+1):'—'}</b> of ${wf.stages.length} ${GHOST.workflow.active?'· running':''}</div>
      <div class="g-div"></div>${stages}
      <button class="g-exp-btn" id="wf-reset" style="background:#18191c;border-color:#2e2f35;color:#999;margin-top:6px;font-size:9px">↺ Reset to stage 1</button>
      ${delBtn}
    ` : stages}
    ${form}${wsBar}`;
}

/* Maps each tab to its help section so a per-tab ? deep-links correctly. */
const TAB_HELP = { run:'run', auto:'auto', flow:'flow', personas:'roles', export:'export', settings:'setup' };

const HELP_SECTIONS = {
  start: { label: 'Start', html: `
    <b>What is Ghost?</b><br>You give the AI a big task. Ghost keeps pressing "continue" for you — through every step — until the AI says it's truly done.<br><br>
    <b>The 30-second version:</b><br>1. Type your task in the chat box<br>2. Press the big ▶<br>3. Walk away ☕<br><br>
    <b>How does it know when to stop?</b><br>Ghost teaches the AI two signals: <code>[[GITL::PROCEED]]</code> = "more to do", <code>[[GITL::HALT]]</code> = "finished". Ghost reads them and acts.` },
  run: { label: 'Run', html: `
    <b>The Run tab</b> is the classic loop.<br><br>
    <b>Three modes:</b><br>· <b>Loop</b> — AI works in batches, Ghost continues each one<br>· <b>Think First</b> — AI plans before working, then batches<br>· <b>Roadmap</b> — AI researches, writes its own plan, Ghost runs every step (see Auto)<br><br>
    <b>Buttons:</b> ▶ start/resume · ⏸ pause · ⏹ stop &amp; reset<br><br>
    <b>Q: It stopped and shows "drift checkpoint"?</b><br>That's the drift guard (default 20 auto-continues) catching a long run — <i>not</i> an error. It's a grounding pause so an unattended run can't wander off-task. Three choices:<br>· <b>▶ Continue</b> — run 20 more (asks again each 20)<br>· <b>⊕ Reground</b> — re-anchor the AI to the task it started on, then continue<br>· <b>✋ Stop &amp; wait</b> — pause for your instructions<br>Raise the default in Setup → Max rounds, or tap ↻ on the drift-guard bar to reset the count anytime.` },
  auto: { label: 'Auto', html: `
    <b>The Auto tab</b> = fire &amp; forget.<br><br>
    <b>Roadmap</b> (AI plans): pick Roadmap on Run, press ▶. The AI studies your task, writes a numbered plan, and Ghost executes every step + a final synthesis. Watch steps get ✓ here.<br><br>
    <b>Queue</b> (you plan): write your own steps — one box each, + to add more — and hit ▶ Run queue.<br><br>
    <b>Q: Roadmap vs Workflow?</b><br><i>Workflow</i> = you know the recipe, same stages every time.<br><i>Roadmap</i> = the AI invents the plan for THIS task.<br>Example, "build a landing page": a workflow always runs draft→critique→refine; a roadmap might plan research→copy→HTML→styling→review, because that's what this task needed.` },
  flow: { label: 'Flow', html: `
    <b>The Flow tab</b> runs fixed multi-stage recipes (e.g. Draft → Critique → Polish).<br><br>
    <b>To run one:</b><br>1. Pick a workflow from the dropdown<br>2. Type your task in the chat box<br>3. Press <b>▶ Start workflow</b><br>Ghost runs every stage in order, advancing each time the AI HALTs.<br><br>
    <b>INSERT button</b> (the small vertical tab on each stage): drops just that one stage's prompt into the chat box, so you can run a single stage by hand instead of the whole sequence.<br><br>
    <b>Pause between stages:</b> OFF = Ghost runs start-to-finish. ON = Ghost stops after each stage so you can review — or switch the model (that's how <b>Lens Relay</b> works: swap model at each pause, press ▶ to continue).` },
  roles: { label: 'Roles', html: `
    <b>The Roles tab</b> injects a persona into your first prompt — Red Team attacks the work, Round Table simulates a committee, and so on.<br><br>
    <b>On Perplexity</b>, Round Table automatically becomes a REAL round table: it expects you to switch models between turns, and each model must give its own independent assessment, in a code block, naming who goes next.` },
  export: { label: 'Export', html: `
    <b>Three buttons, three jobs:</b><br><br>
    <b>⬇ Export</b> — the full record. The whole conversation as a file (with 💭 thinking logs). For archiving and reading.<br><br>
    <b>🤝 Handoff</b> — moving to another model? Ghost asks THIS AI to write a structured briefing in-chat (mission, decisions, failures, next steps). Paste it into the new model. The AI's own summary beats a raw transcript — decisions don't get buried.<br><br>
    <b>🛟 Rescue</b> — the chat is full, stuck, or won't respond, so you can't ask it anything. Ghost scrapes the state + last 10 messages verbatim + resumption instructions into a file. Paste into a fresh chat and keep going.<br><br>
    <i>Working chat → Handoff. Dead chat → Rescue. Records → Export.</i>` },
  setup: { label: 'Setup', html: `
    <b>The Setup tab:</b><br>· <b>Max rounds</b> — drift-guard cap on auto-continues<br>· <b>Notify</b> — desktop alert when done (great with ☕)<br>· <b>Position</b> — corners, bottom bar, ▐ <b>Dock</b> (slim right-edge tab that never covers the chat), or ☰ <b>Gold menu</b> (the same slim tab on the left edge, opposite most sites' own menu, styled gold)<br><br>
    <b>🔄 Re-detect (top of panel):</b> if Ghost says it can't find the chat box — common after switching between the browser and the app, or between tabs — tap 🔄. It re-finds the input without reloading the page, so you don't have to hop between chats to wake it up.<br><br>
    <b>Advanced ▾</b> hides the power tools: custom signal words, per-site selector overrides (Custom sites), and <b>Diagnostics → Probe</b>, which live-tests Ghost's connection to the page — your first stop when a platform misbehaves.` },
  posture: { label: 'Posture', html: `
    <b>Thinking posture = how much room the AI has to grow its own plan.</b> You pick it up front, like a reasoning dial — Ghost never guesses. It works with any mode (Loop / Think / Roadmap).<br><br>
    <b>Standard</b> — locked. The AI does exactly the steps it declared, nothing more. Most predictable; best when you know the scope.<br><br>
    <b>Evolving</b> (a.k.a. <i>adaptive</i>) — the AI may add steps <i>while working</i>, but only when it hits a real blocker or a gap that would otherwise make it fail the goal — and it must justify each addition in one line. It can't wander into unrelated topics, and it stays under the drift-guard ceiling.<br><br>
    <b>Extended</b> (a.k.a. <i>review</i>) — the AI runs the plan locked, then does <i>one</i> gap-check at the end: what's missing or unanswered against the original goal. It fills only genuinely valuable holes, then stops. If nothing's missing, it says so and halts.<br><br>
    All three keep the drift guard as the hard ceiling — if the AI hits it, it stops and reports the biggest unresolved gap instead of padding. <span style="color:#5a5d68">(Wording based on current best-practice research: OpenAI/Anthropic planning guidance, ReAct/Reflexion, Self-Refine, and agent guardrail patterns.)</span>` },
  workshop: { label: 'Workshop', html: `
    <b>Make Ghost yours — and share it.</b><br><br>
    <b>Custom personas</b> (Roles tab) and <b>custom workflows</b> (Flow tab) are yours to create. Tap <b>+ Create</b>, give it a name and either a persona framing or one stage per line. Custom items show a ★ and sit right beside the built-ins.<br><br>
    <b>⬇ Export</b> bundles all your custom personas + workflows into one <code>.gitl.json</code> file. <b>⬆ Import</b> loads someone else's bundle — it only ever ADDS (your existing items and the built-ins are never overwritten; name clashes auto-rename).<br><br>
    <b>🌐 Share with the community:</b><br>· Post your <code>.gitl.json</code> in <b>GitHub Discussions</b>: <a href="https://github.com/MShneur/ghost-in-the-loop/discussions" target="_blank" rel="noopener" style="color:#a5b4fc">ghost-in-the-loop/discussions</a><br>· Or open an issue tagged <code>workshop</code> to suggest it for the built-in library<br><br>
    Good packs get folded into future releases so everyone benefits.` },
  feedback: { label: 'Feedback', html: `
    <b>Found a bug? Have an idea?</b><br><br>
    Open an issue: <a href="https://github.com/MShneur/ghost-in-the-loop/issues" target="_blank" rel="noopener" style="color:#a5b4fc">github.com/MShneur/ghost-in-the-loop</a><br><br>
    <b>Please include:</b><br>· Ghost version (v${VER}) and the platform<br>· What you did, what you expected, what happened<br>· Setup → Advanced → Diagnostics → <b>Probe</b> output — it tells us exactly what Ghost can and can't see<br><br>
    ⭐ A star on GitHub helps more people find Ghost.<br>
    ♡ And if Ghost saved you real time: <a href="${SUPPORT_URL}" target="_blank" rel="noopener" style="color:#a5b4fc">support its development</a> — entirely optional, it stays free either way.` }
};

function renderInfoTab() {
  const sec = GHOST.ui.helpSec || 'start';
  const pills = Object.entries(HELP_SECTIONS).map(([k, s]) =>
    `<button class="g-hpill${k===sec?' act':''}" data-h="${k}">${s.label}</button>`).join('');
  return `
    <div class="g-hpills">${pills}</div>
    <div class="g-hint" style="line-height:1.75;font-size:9.5px">${HELP_SECTIONS[sec].html}</div>
    <button class="g-btn-sm" id="g-info-back">← Back to Ghost</button>`;
}

function renderAutoTab() {
  const R = GHOST.roadmap;
  // Active roadmap → live progress rows with ✓ / ▶ / ·
  if (R.steps.length) {
    const rows = R.steps.map((s,i) => {
      const mark = i < R.index ? '<span class="ok" style="width:14px">✓</span>' : i === R.index ? '<span style="color:#a5b4fc;width:14px">▶</span>' : '<span style="color:#3a3b40;width:14px">·</span>';
      return `<div class="g-qrow">${mark}<span class="g-qtext${i<R.index?' done':''}">${i+1}. ${s.replace(/</g,'&lt;')}</span></div>`;
    }).join('');
    return `
      <div style="font-size:9px;color:#777;font-weight:700;margin-bottom:4px">🗺 ROADMAP — step ${Math.min(R.index+1,R.steps.length)} of ${R.steps.length}</div>
      <div style="max-height:170px;overflow-y:auto">${rows}</div>
      <button class="g-btn-sm" id="rm-clear">Clear roadmap</button>`;
  }
  // No roadmap → step editor: one input per step, + to add
  const d = GHOST.ui.qDraft;
  const rows = d.map((s,i) => `
    <div class="g-qrow">
      <span style="color:#555;width:14px;font-size:9px">${i+1}.</span>
      <input type="text" class="g-qin" data-qi="${i}" value="${(s||'').replace(/"/g,'&quot;')}" placeholder="Step ${i+1}…">
      <button class="g-qdel" data-qd="${i}">✕</button>
    </div>`).join('');
  return `
    <div class="g-hint">🗺 <b>Autopilot.</b> Pick <b>Roadmap</b> on the Run tab and press ▶ — the AI plans this task itself. Or write your own steps below; each gets a ✓ as it completes.</div>
    <div style="font-size:9px;color:#777;font-weight:700;margin:6px 0 4px">PROMPT QUEUE</div>
    ${rows}
    <div style="display:flex;gap:5px">
      <button class="g-btn-sm" id="q-add" style="flex:1;margin-top:4px">+ Add step</button>
      <button class="g-btn-sm" id="q-start" style="flex:1;margin-top:4px">▶ Run queue</button>
    </div>`;
}

function renderPersonasTab() {
  const items = Object.entries(allPersonas()).map(([k,v]) =>
    `<button class="g-persona-btn${GHOST.persona.selected===k?' act':''}" data-p="${_esc(k)}">
       <span class="plbl">${v.custom?'<span class="g-cust-badge">★</span> ':''}${_esc(v.label)}${v.custom?`<span class="g-del" data-del-p="${_esc(k)}" title="Delete this custom persona">✕</span>`:''}</span>
       <div class="pdesc">${_esc(v.inject||'No persona framing.')}</div>
     </button>`
  ).join('');
  const creating = GHOST.ui.wsNewPersona;
  const form = creating ? `
    <div class="g-ws-form">
      <input class="g-ws-in" id="ws-p-label" placeholder="Persona name (e.g. Legal Reviewer)" maxlength="40">
      <textarea class="g-ws-ta" id="ws-p-inject" placeholder="Persona framing — 'Adopt the persona of…'" rows="3" maxlength="4000"></textarea>
      <div class="g-ws-form-btns"><button class="g-btn-sm" id="ws-p-save">✓ Save persona</button><button class="g-btn-sm" id="ws-p-cancel" style="background:#18191c">Cancel</button></div>
    </div>` : `<button class="g-exp-btn" id="ws-p-new" style="margin-top:5px">+ Create custom persona</button>`;
  return items + form + `
    <div class="g-ws-bar">
      <button class="g-ws-btn" id="ws-import" title="Import a .gitl.json pack of personas & workflows">⬆ Import</button>
      <button class="g-ws-btn" id="ws-export" title="Export your custom personas & workflows to a shareable file">⬇ Export</button>
      <button class="g-ws-btn" id="ws-submit" title="Share your pack with the community">🌐 Share</button>
    </div>`;
}

function renderExportTab() {
  const fn = buildFilename('export');
  const adv = GHOST.ui.expAdv;
  return `
    <div class="g-row"><label>Format</label><select id="exp-fmt"><option value="markdown"${GHOST.export.format==='markdown'?' selected':''}>Markdown</option><option value="json"${GHOST.export.format==='json'?' selected':''}>JSON</option></select></div>
    <div class="g-row"><label>💭 Thinking logs</label><div class="g-tog${GHOST.export.thinking?' on':''}" id="exp-think"></div></div>
    <button class="g-exp-btn" id="g-export">⬇ Export conversation</button>
    <button class="g-exp-btn" id="g-capsule" style="margin-top:5px">💊 Capsule v2 — resumable JSON</button>
    <button class="g-exp-btn" id="g-handoff" style="margin-top:5px">🤝 Handoff — AI writes the baton</button>
    <button class="g-exp-btn" id="g-rescue" style="margin-top:5px;background:#18191c;border-color:#2e2f35;color:#ccc">🛟 Rescue file (chat stuck/full)</button>
    <div class="g-hint" style="margin-top:4px"><b>Export</b> = full record. <b>Handoff</b> = the AI writes a briefing in-chat for the next model. <b>Rescue</b> = chat won't respond anymore — scrape the tail + instructions into a file for a fresh chat.</div>
    <button class="g-adv" id="exp-adv">${adv?'Advanced ▴':'Advanced ▾'}</button>
    ${adv ? `
    <div class="g-row"><label>Filter</label><select id="exp-flt"><option value="all"${GHOST.export.filter==='all'?' selected':''}>All</option><option value="user"${GHOST.export.filter==='user'?' selected':''}>User</option><option value="assistant"${GHOST.export.filter==='assistant'?' selected':''}>Assistant</option><option value="code"${GHOST.export.filter==='code'?' selected':''}>Code blocks</option></select></div>
    <div class="g-row"><label>Roles</label><div class="g-tog${GHOST.export.includeRoles?' on':''}" id="exp-roles"></div></div>
    <div class="g-row"><label>Slug</label><input type="text" id="exp-slug" placeholder="auto" value="${GHOST.export.customSlug}" style="width:100px"></div>
    <div style="font-size:8.5px;color:#383940;margin-bottom:5px;word-break:break-all">${fn}</div>
    <div style="display:flex;gap:5px">
      <button class="g-btn-sm" id="g-backup" style="flex:1;margin-top:0">⚙ Backup config</button>
      <button class="g-btn-sm" id="g-restore" style="flex:1;margin-top:0">↩ Restore</button>
    </div>
    <input type="file" id="g-restore-file" accept=".json" style="display:none">
    <div class="g-hint" id="g-restore-status" style="margin-top:4px;display:none"></div>` : ''}`;
}

function renderSettingsTab() {
  const adv = GHOST.ui.cfgAdv;
  return `
    <div class="g-row"><label>Max rounds</label><input type="number" id="cfg-max" min="1" max="999" value="${GHOST.loop.maxRounds}"></div>
    <div class="g-row"><label>🔔 Sound</label><div class="g-tog${GHOST.ui.soundOn?' on':''}" id="cfg-snd"></div></div>
    <div class="g-row"><label>💬 Notify when done</label><div class="g-tog${GHOST.ui.notifyOn?' on':''}" id="cfg-ntf"></div></div>
    <div class="g-row"><label>📍 Position</label>
      <div class="g-pos-row">${['top-left','top-right','bot-left','bot-right','bottom-bar','dock','dock-left'].map(p=>
        `<button class="g-pos${GHOST.ui.position===p?' act':''}${p==='dock-left'?' g-pos-gold':''}" data-pos="${p}" title="${p==='dock'?'Dock — slim edge tab, right side':p==='dock-left'?'Gold menu — slim hamburger tab, left side (opposite the site menu)':p}">${p==='top-left'?'↖':p==='top-right'?'↗':p==='bot-left'?'↙':p==='bot-right'?'↘':p==='bottom-bar'?'━':p==='dock-left'?'☰':'▐'}</button>`
      ).join('')}</div>
    </div>
    <div class="g-row"><label>❓ Quick start</label><button class="g-btn-sm" id="cfg-qs" style="margin-top:0">Show</button></div>
    <button class="g-adv" id="cfg-adv">${adv?'Advanced ▴':'Advanced ▾'}</button>
    ${adv ? `
    <div class="g-row"><label>Signal window</label><input type="number" id="cfg-win" min="200" max="1200" step="100" value="${GHOST.signals.windowSize}"></div>
    <div class="g-row"><label>Extra proceed</label><input type="text" id="cfg-cp" placeholder="e.g. go on, next" value="${GHOST.signals.customProceed}"></div>
    <div class="g-row"><label>Extra stop</label><input type="text" id="cfg-cs" placeholder="e.g. all done" value="${GHOST.signals.customStop}"></div>
    <div class="g-row"><label>🌐 Custom sites</label><div class="g-tog${GHOST.ui.showSites?' on':''}" id="cfg-sites-tog"></div></div>
    ${GHOST.ui.showSites ? `
      <textarea id="cfg-sites" class="g-sites" rows="5" spellcheck="false" placeholder='{"example.com":{"label":"MyAI","input":["textarea"],"send":["button[type=submit]"],"assistant":["div.msg"]}}'>${GM_getValue('customSites','').replace(/</g,'&lt;')}</textarea>
      <div class="g-hint" id="cfg-sites-status">Per-host selector overrides (JSON). Also add the site under Tampermonkey → script settings → User matches.</div>` : ''}
    <div class="g-row"><label>🔧 Diagnostics</label><div class="g-tog${GHOST.ui.showDiag?' on':''}" id="cfg-diag"></div></div>
    ${GHOST.ui.showDiag ? renderDiag() : ''}` : ''}
    <div class="g-support"><a href="${SUPPORT_URL}" target="_blank" rel="noopener">♡ Support Ghost</a> · free forever</div>`;
}

function renderDiag() {
  const L = GHOST.loop;
  const h = typeof platformHealth === 'function' ? platformHealth() : null;
  const lines = [
    h ? `<span class="ok">Health:</span> ${h.badge} ${h.score}/100 (in:${h.input?'✓':'✗'} send:${h.send?'✓':'✗'} read:${h.assistantCount} net:${h.netActive?'✓':'✗'})` : '',
    `<span class="ok">Adapter:</span> ${DIAG.adapter}`,
    `<span class="ok">Platform:</span> ${PLAT.label}`,
    `<span>Selector:</span> ${DIAG.selector || '—'}`,
    `<span>Send path:</span> ${DIAG.sendPath || '—'}`,
    `<span>Signal:</span> ${L.lastSignal} (${L.lastConfidence}) ${DIAG.lastSignal}`,
    `<span>Tail:</span> ${DIAG.lastTail ? DIAG.lastTail.slice(-50) : '—'}`,
    `<span>Round:</span> ${L.round} / ${L.maxRounds}`,
    `<span>State:</span> ${L.state}`,
    `<span>Stale:</span> ${L.staleTicks}`,
    `<span>Tick:</span> ${L.lastActivity ? Math.round((Date.now()-L.lastActivity)/1000)+'s ago' : '—'}`,
    `<span>Tab:</span> ${GITL_TAB_ID.slice(0,8)}`,
    DIAG.probe ? `<span class="ok">Probe:</span>\n${DIAG.probe}` : '',
    DIAG.errors.length ? `<span class="warn">Errors:</span>\n${DIAG.errors.slice(0,5).join('\n')}` : ''
  ].filter(Boolean).join('\n');
  return `<div class="g-diag">${lines}</div><button class="g-btn-sm" id="g-probe">🔍 Probe selectors</button> <button class="g-btn-sm" id="g-report-now">⚠ Report a problem</button>`;
}

function applyPosition(pos) {
  const G = '14px';
  panel.style.top = panel.style.bottom = panel.style.left = panel.style.right = 'auto';
  panel.style.width = '268px';
  panel.classList.remove('pos-bb');
  if (pos==='top-right'){panel.style.top=G;panel.style.right=G}
  else if(pos==='top-left'){panel.style.top=G;panel.style.left=G}
  else if(pos==='bot-right'){panel.style.bottom=G;panel.style.right=G}
  else if(pos==='bot-left'){panel.style.bottom=G;panel.style.left=G}
  else if(pos==='bottom-bar'){panel.classList.add('pos-bb')}
  else if(pos==='dock'){panel.style.top='30%';panel.style.right='0';panel.style.width=''}
  else if(pos==='dock-left'){panel.style.top='30%';panel.style.left='0';panel.style.width=''}
}

function renderReportBadge() {
  // v7.1: a report just landed — surface it. Switch to Run tab so the
  // banner is visible, then re-render.
  try {
    if (typeof GHOST === 'undefined' || !GHOST.ui) return;
    if (GHOST.report) GHOST.ui.tab = 'run';
    if (typeof panel !== 'undefined' && panel) render();
  } catch(_){}
}

function render() {
  const L = GHOST.loop, tab = GHOST.ui.tab, col = GHOST.ui.collapsed;
  const isDock = GHOST.ui.position==='dock' || GHOST.ui.position==='dock-left';
  panel.className = [col?'collapsed':'', GHOST.ui.position==='bottom-bar'?'pos-bb':'', GHOST.ui.position==='dock'?'pos-dock':'', GHOST.ui.position==='dock-left'?'pos-dock pos-dock-left':''].filter(Boolean).join(' ');
  const qc = statColor();
  const ql = L.state==='RUNNING'?'Running…':L.state==='LIMIT'?`▶ ${L.maxRounds} reached — tap for ${L.limitStep} more`:L.state==='PAUSED'?'Paused':L.state==='COMPLETE'?'Done':'Idle';
  const qIcon = L.state==='RUNNING'?'⏸':'▶';
  const qCls  = L.state==='RUNNING'?'pause':L.state==='LIMIT'?'play limit':'play';
  panel.innerHTML = `
    <div class="g-hdr" id="g-drag">
      <span class="g-logo">${col && GHOST.ui.position==='dock-left' ? '☰ Ghost' : '👻 Ghost'}<span class="g-dot ${dotClass()}"></span></span>
      <span style="display:flex;align-items:center;gap:5px">
        <span class="g-plat">${(typeof platformHealth==='function'?platformHealth().badge:'') + ' ' + PLAT.label}</span>
        <button class="g-minbtn" id="g-redetect" title="Re-detect the chat box — fixes 'can't find input' after switching browser/app or tabs (no page reload)">🔄</button>
        <button class="g-minbtn" id="g-info" title="Help & FAQ">?</button>
        <button class="g-minbtn" id="g-col" title="${col?'Expand':'Minimize'}">${GHOST.ui.position==='dock' ? (col?'◀':'▶') : GHOST.ui.position==='dock-left' ? (col?'▶':'◀') : (col?'+':'-')}</button>
      </span>
    </div>
    <div class="g-coll-row">
      <button class="g-qbtn ${qCls}" id="g-quick">${qIcon}</button>
      <span class="g-qstat" style="color:${qc}">${ql}</span>
    </div>
    <div class="g-body">
      <div class="g-proj">
        <span class="g-proj-lbl">📁</span>
        <input class="g-proj-in" id="g-projname" type="text" placeholder="Project name…" value="${GHOST.project.name}">
      </div>
      <div class="g-tabs">
        <button class="g-tab${tab==='run'?' act':''}" data-t="run" title="Standard continue loop">Run</button>
        <button class="g-tab${tab==='auto'?' act':''}" data-t="auto" title="Roadmap autopilot & prompt queue">Auto</button>
        <button class="g-tab${tab==='flow'?' act':''}" data-t="flow" title="Multi-stage workflows">Flow</button>
        <button class="g-tab${tab==='personas'?' act':''}" data-t="personas" title="Personas">Roles</button>
        <button class="g-tab${tab==='export'?' act':''}" data-t="export" title="Export & handoff">Export</button>
        <button class="g-tab${tab==='settings'?' act':''}" data-t="settings" title="Settings">Setup</button>
      </div>
      <div id="g-tc">
        ${TAB_HELP[tab] && tab!=='info' ? `<button class="g-tabhelp" id="g-tabhelp" data-h="${TAB_HELP[tab]}" title="Help for this tab">?</button>` : ''}
        ${tab==='run'?renderRunTab():''}${tab==='auto'?renderAutoTab():''}${tab==='info'?renderInfoTab():''}${tab==='flow'?renderFlowTab():''}
        ${tab==='personas'?renderPersonasTab():''}${tab==='export'?renderExportTab():''}
        ${tab==='settings'?renderSettingsTab():''}
      </div>
    </div>`;
  bindEvents();
  applyPosition(GHOST.ui.position);
}

/* ═══════════════════════════════════════════════════════════════
   EVENT BINDING
   ═══════════════════════════════════════════════════════════════ */
function bindEvents() {
  const $ = s => panel.querySelector(s);
  const $$ = s => panel.querySelectorAll(s);

  $('#g-col')?.addEventListener('click', () => { GHOST.ui.collapsed=!GHOST.ui.collapsed; _save('panelCollapsed',GHOST.ui.collapsed); render(); });
  // Docked + collapsed: the whole strip is the expand target (the play button stays play)
  if ((GHOST.ui.position==='dock' || GHOST.ui.position==='dock-left') && GHOST.ui.collapsed) {
    panel.addEventListener('click', e => {
      if (e.target.closest('#g-quick') || e.target.closest('#g-col')) return;
      GHOST.ui.collapsed = false; _save('panelCollapsed', false); render();
    }, { once: true });
  }
  $('#g-quick')?.addEventListener('click', primaryAction);
  $('#g-projname')?.addEventListener('change', e => {
    GHOST.project.name = e.target.value.trim();
    GHOST.project.slug = GHOST.project.name.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
    _save('projectName',GHOST.project.name); _save('projectSlug',GHOST.project.slug);
    if (GHOST.ui.tab==='export') render();
  });
  $$('.g-tab').forEach(b => b.addEventListener('click', () => { GHOST.ui.tab=b.dataset.t; render(); }));
  $('#g-tabhelp')?.addEventListener('click', function(){ GHOST.ui.prevTab = GHOST.ui.tab; GHOST.ui.helpSec = this.dataset.h; GHOST.ui.tab = 'info'; render(); });

  // Run tab
  $$('.g-md').forEach(b => b.addEventListener('click', () => {
    if (GHOST.loop.state==='RUNNING') return;
    GHOST.loop.payloadMode=b.dataset.m; GHOST.loop.needsPayload=true; _save('payloadMode',GHOST.loop.payloadMode); render();
  }));
  $$('.g-pst').forEach(b => b.addEventListener('click', () => {
    if (GHOST.loop.state==='RUNNING') return;
    GHOST.loop.posture=b.dataset.pst; _save('posture',GHOST.loop.posture); render();
  }));
  $('#g-posture-help')?.addEventListener('click', () => { GHOST.ui.prevTab=GHOST.ui.tab; GHOST.ui.helpSec='posture'; GHOST.ui.tab='info'; render(); });
  $('#g-play')?.addEventListener('click', primaryAction);
  $('#g-limit-go')?.addEventListener('click', extendLimit);
  $('#g-limit-reground')?.addEventListener('click', regroundLoop);
  $('#g-limit-wait')?.addEventListener('click', () => enginePause('✋ Stopped at drift checkpoint — ▶ to resume'));
  $('#g-cnt-reset')?.addEventListener('click', () => {
    GHOST.loop.round = 0;
    Timeline.record('drift_guard_reset', { cap: GHOST.loop.maxRounds });
    GHOST.loop.detail = '↻ Drift guard reset';
    render();
  });
  $('#g-pause')?.addEventListener('click', pauseLoop);
  $('#g-stop')?.addEventListener('click', stopLoop);
  $('#g-rep-copy')?.addEventListener('click', function(){ Reporter.copy().then(ok => { this.textContent = ok ? '✓ Copied' : '✕ Failed'; setTimeout(()=>{ this.textContent='📋 Copy'; }, 1500); }); });
  $('#g-rep-issue')?.addEventListener('click', () => Reporter.openIssue());
  $('#g-rep-x')?.addEventListener('click', () => { GHOST.report = null; Reporter.last = null; render(); });
  $('#g-peek-btn')?.addEventListener('click', () => {
    const p=$('#g-peek'),b=$('#g-peek-btn');
    if(p&&b){p.classList.toggle('open'); b.textContent=p.classList.contains('open')?'▾ Hide prompt':'▸ What gets injected';}
  });

  // Flow tab
  $('#wf-sel')?.addEventListener('change', e => {
    GHOST.workflow.selected=e.target.value; GHOST.workflow.stageIndex=0; GHOST.workflow.active=e.target.value!=='none';
    _save('wfSelected',GHOST.workflow.selected); _save('wfStage',0); render();
  });
  $('#wf-pause')?.addEventListener('click', function(){ this.classList.toggle('on'); GHOST.workflow.pauseBetween=this.classList.contains('on'); _save('wfPause',GHOST.workflow.pauseBetween); render(); });
  $('#wf-reset')?.addEventListener('click', () => { GHOST.workflow.stageIndex=0; GHOST.workflow.active=GHOST.workflow.selected!=='none'; _save('wfStage',0); render(); });
  $('#wf-start')?.addEventListener('click', startWorkflow);
  $$('.g-wf-ins').forEach(b => b.addEventListener('click', () => {
    const wf = allWorkflows()[GHOST.workflow.selected] || WORKFLOW_LIBRARY.none;
    const stage = wf.stages[+b.dataset.ins];
    if (stage) insertPrompt(stage, b);
  }));
  $('#ws-w-new')?.addEventListener('click', () => { GHOST.ui.wsNewWorkflow = true; render(); });
  $('#ws-w-cancel')?.addEventListener('click', () => { GHOST.ui.wsNewWorkflow = false; render(); });
  $('#ws-w-save')?.addEventListener('click', () => {
    const label = ($('#ws-w-label')?.value || '').trim();
    const desc  = ($('#ws-w-desc')?.value || '').trim();
    const stages = ($('#ws-w-stages')?.value || '').split('\n').map(s => s.trim()).filter(s => s.length > 1);
    if (!label || !stages.length) { GHOST.loop.detail = '⚠ Name and at least one stage line are required'; render(); return; }
    const id = Workshop.addWorkflow(label, desc, stages);
    GHOST.ui.wsNewWorkflow = false; GHOST.workflow.selected = id; GHOST.workflow.stageIndex = 0;
    _save('wfSelected', id); _save('wfStage', 0);
    GHOST.loop.detail = `✓ Created workflow "${label}" (${stages.length} stages)`; render();
  });
  $('#ws-w-del')?.addEventListener('click', function(){
    const id = GHOST.workflow.selected;
    if (this.dataset.confirm === '1') {
      Workshop.removeWorkflow(id);
      GHOST.workflow.selected = 'none'; GHOST.workflow.stageIndex = 0; GHOST.workflow.active = false;
      _save('wfSelected','none'); _save('wfStage',0); render();
    } else { this.dataset.confirm = '1'; this.textContent = '✕ Tap again to confirm delete'; }
  });

  // Personas tab
  $$('.g-persona-btn').forEach(b => b.addEventListener('click', (e) => {
    if (e.target.closest('[data-del-p]')) return; // delete handled separately
    GHOST.persona.selected=b.dataset.p; _save('persona',GHOST.persona.selected); render();
  }));
  $$('[data-del-p]').forEach(x => x.addEventListener('click', (e) => {
    e.stopPropagation();
    const id = x.dataset.delP;
    if (x.dataset.confirm === '1') {
      if (GHOST.persona.selected === id) { GHOST.persona.selected = 'none'; _save('persona','none'); }
      Workshop.removePersona(id); render();
    } else { x.dataset.confirm = '1'; x.textContent = '✓?'; x.title = 'Tap again to confirm delete'; }
  }));
  $('#ws-p-new')?.addEventListener('click', () => { GHOST.ui.wsNewPersona = true; render(); });
  $('#ws-p-cancel')?.addEventListener('click', () => { GHOST.ui.wsNewPersona = false; render(); });
  $('#ws-p-save')?.addEventListener('click', () => {
    const label = ($('#ws-p-label')?.value || '').trim();
    const inject = ($('#ws-p-inject')?.value || '').trim();
    if (!label || !inject) { GHOST.loop.detail = '⚠ Name and framing are both required'; render(); return; }
    const id = Workshop.addPersona(label, inject);
    GHOST.ui.wsNewPersona = false; GHOST.persona.selected = id; _save('persona', id);
    GHOST.loop.detail = `✓ Created persona "${label}"`; render();
  });
  $('#ws-import')?.addEventListener('click', workshopImport);
  $('#ws-export')?.addEventListener('click', workshopExport);
  $('#ws-submit')?.addEventListener('click', () => { GHOST.ui.prevTab = GHOST.ui.tab; GHOST.ui.helpSec = 'workshop'; GHOST.ui.tab = 'info'; render(); });

  // Export tab
  $('#exp-fmt')?.addEventListener('change', e => { GHOST.export.format=e.target.value; _save('expFormat',e.target.value); render(); });
  $('#exp-flt')?.addEventListener('change', e => { GHOST.export.filter=e.target.value; _save('expFilter',e.target.value); });
  $('#exp-roles')?.addEventListener('click', function(){ this.classList.toggle('on'); GHOST.export.includeRoles=this.classList.contains('on'); _save('expRoles',GHOST.export.includeRoles); });
  $('#exp-slug')?.addEventListener('change', e => { GHOST.export.customSlug=e.target.value.trim(); _save('expSlug',GHOST.export.customSlug); render(); });
  $('#g-export')?.addEventListener('click', runExport);
  $('#g-capsule')?.addEventListener('click', () => { exportCapsuleV2(); });
  $('#exp-think')?.addEventListener('click', function(){ this.classList.toggle('on'); GHOST.export.thinking=this.classList.contains('on'); _save('expThinking',GHOST.export.thinking); });
  $('#g-handoff')?.addEventListener('click', handoffInChat);
  $('#g-rescue')?.addEventListener('click', exportRescue);
  $('#g-backup')?.addEventListener('click', backupConfig);
  $('#g-restore')?.addEventListener('click', () => $('#g-restore-file')?.click());
  $('#g-restore-file')?.addEventListener('change', e => {
    const f = e.target.files?.[0]; if (!f) return;
    const r = new FileReader();
    r.onload = () => { const st = $('#g-restore-status'); if (st) { st.style.display='block'; st.textContent = restoreConfig(String(r.result)); } };
    r.readAsText(f);
  });

  // Auto tab — roadmap / queue
  $$('.g-qin').forEach(inp => inp.addEventListener('change', e => {
    const i = +e.target.dataset.qi; GHOST.ui.qDraft[i] = e.target.value;
    _save('qDraft', JSON.stringify(GHOST.ui.qDraft));
  }));
  $$('.g-qdel').forEach(b => b.addEventListener('click', e => {
    const i = +e.target.dataset.qd; GHOST.ui.qDraft.splice(i,1);
    if (!GHOST.ui.qDraft.length) GHOST.ui.qDraft = [''];
    _save('qDraft', JSON.stringify(GHOST.ui.qDraft)); render();
  }));
  $('#q-add')?.addEventListener('click', () => { GHOST.ui.qDraft.push(''); render(); setTimeout(()=>{ const ins=$$('.g-qin'); ins[ins.length-1]?.focus(); },50); });
  $('#q-start')?.addEventListener('click', () => {
    const steps = GHOST.ui.qDraft.map(s=>s.trim()).filter(Boolean);
    if (steps.length) startQueue(steps.join('\n'));
  });
  $('#rm-clear')?.addEventListener('click', () => { resetRoadmap(); render(); });

  // Settings tab
  $('#cfg-max')?.addEventListener('change', e => { const v=parseInt(e.target.value,10); if(v>0&&v<=999){GHOST.loop.maxRounds=v; _save('maxRounds',v);} });
  $('#cfg-win')?.addEventListener('change', e => { const v=parseInt(e.target.value,10); if(v>=200&&v<=1200){GHOST.signals.windowSize=v; _save('sigWindow',v);} });
  $('#cfg-cp')?.addEventListener('change', e => { GHOST.signals.customProceed=e.target.value; _save('customProceed',e.target.value); });
  $('#cfg-cs')?.addEventListener('change', e => { GHOST.signals.customStop=e.target.value; _save('customStop',e.target.value); });
  $('#cfg-snd')?.addEventListener('click', function(){ this.classList.toggle('on'); GHOST.ui.soundOn=this.classList.contains('on'); _save('soundOn',GHOST.ui.soundOn); });
  $('#cfg-ntf')?.addEventListener('click', function(){
    this.classList.toggle('on'); GHOST.ui.notifyOn=this.classList.contains('on'); _save('notifyOn',GHOST.ui.notifyOn);
    if (GHOST.ui.notifyOn) { try { if (typeof Notification !== 'undefined' && Notification.permission === 'default') Notification.requestPermission(); } catch(_){} }
  });
  $$('.g-pos').forEach(b => b.addEventListener('click', () => { GHOST.ui.position=b.dataset.pos; _save('panelPosition',GHOST.ui.position); applyPosition(GHOST.ui.position); render(); }));
  $('#cfg-diag')?.addEventListener('click', function(){ this.classList.toggle('on'); GHOST.ui.showDiag=this.classList.contains('on'); render(); });
  $('#g-probe')?.addEventListener('click', () => { DIAG.runProbe(); render(); });
  $('#g-report-now')?.addEventListener('click', () => { DIAG.runProbe(); Reporter.capture('manual', 'User-triggered problem report'); });
  $('#cfg-sites-tog')?.addEventListener('click', function(){ this.classList.toggle('on'); GHOST.ui.showSites=this.classList.contains('on'); render(); });
  $('#cfg-sites')?.addEventListener('change', e => {
    const raw = e.target.value.trim(), st = $('#cfg-sites-status');
    if (!raw) { _save('customSites',''); if(st) st.textContent='Cleared. Reload the page to apply.'; return; }
    try { JSON.parse(raw); _save('customSites', raw); if(st) st.textContent='✓ Saved. Reload the page to apply.'; }
    catch(err) { if(st) st.textContent='⚠ Invalid JSON — not saved.'; }
  });
  $('#cfg-qs')?.addEventListener('click', () => { GHOST.ui.firstRun=true; _save('firstRun',true); GHOST.ui.tab='run'; render(); });
  $('#g-redetect')?.addEventListener('click', function(){
    this.classList.add('spin');
    const ok = reDetect();
    setTimeout(() => this.classList.remove('spin'), 600);
  });
  $('#g-info')?.addEventListener('click', () => { GHOST.ui.tab = GHOST.ui.tab==='info' ? 'run' : 'info'; render(); });
  $('#g-info-back')?.addEventListener('click', () => { GHOST.ui.tab = GHOST.ui.prevTab || 'run'; GHOST.ui.prevTab = null; render(); });
  $$('.g-hpill').forEach(b => b.addEventListener('click', e => { GHOST.ui.helpSec = e.target.dataset.h; render(); }));
  $('#cfg-adv')?.addEventListener('click', () => { GHOST.ui.cfgAdv=!GHOST.ui.cfgAdv; _save('cfgAdv',GHOST.ui.cfgAdv); render(); });
  $('#exp-adv')?.addEventListener('click', () => { GHOST.ui.expAdv=!GHOST.ui.expAdv; _save('expAdv',GHOST.ui.expAdv); render(); });
  $('#g-onb-done')?.addEventListener('click', () => { GHOST.ui.firstRun=false; _save('firstRun',false); render(); });

  bindDrag();
}

function bindDrag() {
  const hdr = panel.querySelector('#g-drag');
  if (!hdr) return;
  let dragging=false, ox=0, oy=0;
  hdr.addEventListener('mousedown', e => { if(e.button!==0)return; dragging=true; ox=e.clientX-panel.getBoundingClientRect().left; oy=e.clientY-panel.getBoundingClientRect().top; e.preventDefault(); });
  document.addEventListener('mousemove', e => { if(!dragging)return; panel.style.left=`${e.clientX-ox}px`; panel.style.top=`${e.clientY-oy}px`; panel.style.right='auto'; panel.style.bottom='auto'; });
  document.addEventListener('mouseup', () => { dragging=false; });
}

/* ═══════════════════════════════════════════════════════════════
   KEYBOARD SHORTCUTS
   ═══════════════════════════════════════════════════════════════ */
document.addEventListener('keydown', e => {
  if(e.altKey&&e.key.toLowerCase()==='p'){e.preventDefault(); primaryAction();}
  if(e.altKey&&e.key.toLowerCase()==='s'){e.preventDefault(); stopLoop();}
});

/* ═══════════════════════════════════════════════════════════════
   MUTATION OBSERVER (gated by sendInProgress to prevent double-fire)
   ═══════════════════════════════════════════════════════════════ */
let _mutDebounce;

/* ═══════════════════════════════════════════════════════════════
   BOOT — wrapped in safeBoot to prevent v7.0-alpha loading failures
   ═══════════════════════════════════════════════════════════════ */
safeBoot(() => {
  // Observer watches childList (new nodes) AND a narrow set of attributes
  // (style/class/hidden), so a Continue button revealed via CSS — not just
  // one freshly inserted — also triggers the auto-click fast-path.
  // Loop tick (setInterval) remains the primary driver; this is a fast-path.
  new MutationObserver(() => {
    if (GHOST.loop.state !== 'RUNNING' || GHOST.loop.isSending) return;
    clearTimeout(_mutDebounce);
    _mutDebounce = setTimeout(() => { GHOST.loop.lastActivity = Date.now(); Adapter.clickContinue(); }, 300);
  }).observe(document.body, {
    childList: true,
    subtree: true,
    attributes: true,
    attributeFilter: ['style', 'class', 'hidden', 'disabled', 'aria-hidden']
  });

  startTabHeartbeat();
  claimTabLock();
  GhostBus.init();
  Workshop.load();
  injectStyles();
  mountPanel();
  render();
  Timeline.record('boot', { version: VER, platform: PLAT.label, tab: GITL_TAB_ID.slice(0,8) });
  console.log(`[Ghost in the Loop v${VER}] ${PLAT.label} | ${DIAG.adapter} | tab:${GITL_TAB_ID.slice(0,8)}`);
});
})();