// ==UserScript== // @name Ghost in the Loop // @namespace https://github.com/MShneur/ghost-in-the-loop // @version 8.7.1 // @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) β€” v8.3.0 main editor: Agent CG (ChatGPT); prior 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 // @grant unsafeWindow // @updateURL https://raw.githubusercontent.com/MShneur/ghost-in-the-loop/main/ghost-in-the-loop.user.js // @downloadURL https://raw.githubusercontent.com/MShneur/ghost-in-the-loop/main/ghost-in-the-loop.user.js // @run-at document-idle // @noframes // @license AGPL-3.0 // ==/UserScript== (() => { 'use strict'; /* v8.2.0 transactional boot: do NOT commit the singleton here. Committing it before boot succeeded meant a partial/failed boot (e.g. the Trusted Types throw) permanently blocked any same-page retry. `window.__GITL_V8__` is now set to `true` only after the CRITICAL UI phases (styles β†’ panel β†’ render) succeed. A short in-flight marker prevents concurrent double-execution without poisoning a retry after a failed attempt. */ if (window.__GITL_V8__ === true) return; // already fully booted if (window.__GITL_BOOTING__ && Date.now() - window.__GITL_BOOTING__ < 15000) return; // an attempt is in flight window.__GITL_BOOTING__ = Date.now(); /* ═══════════════════════════════════════════════════════════════ BOOT BEACON + FAIL-LOUD (v8.1.4) The Gemini "panel never appears" reports were undiagnosable because a silent top-level throw kills the whole script before the panel (which is where all diagnostics live) can mount β€” so lastBootError was invisible. Two dependency-free instruments that work even when nothing else does: β€’ a beacon written to at each phase, so it shows up in a plain SingleFile/"save page" capture: `started` β†’ `ok:` on success, or `error:` if boot throws. This turns a static page save into a real diagnosis of whether the script even ran. β€’ _gitlFatal(): on any fatal boot throw, surface it via GM_notification AND a fixed banner injected at documentElement level (not body β€” body may be the very thing that's missing/hostile), so the user can SEE and screenshot the actual error instead of a blank page. _gitlFatal is declared at IIFE scope (outside the try below) so it is reachable from both the top-level catch and safeBoot's catch. */ const _beacon = (s) => { try { document.documentElement.setAttribute('data-gitl-boot', s); } catch(_) {} }; _beacon('started'); function _gitlFatal(stage, err) { const msg = String((err && (err.message || err)) || 'unknown'); _beacon('error:' + stage); /* Persist metadata only. The live banner can show the browser's error, but recovery/reporting must never retain page content, URLs, or stack data. */ try { GM_setValue('lastBootError', JSON.stringify({ code: 'BOOT-001', stage, at: new Date().toISOString() })); } catch(_) {} try { console.error('[GITL] FATAL @' + stage + ':', err); } catch(_) {} try { if (typeof GM_notification === 'function') GM_notification({ title: 'πŸ‘» Ghost failed to load (' + stage + ')', text: msg, timeout: 15000 }); } catch(_) {} try { if (document.getElementById('gitl-fatal')) return; const b = document.createElement('div'); b.id = 'gitl-fatal'; b.setAttribute('style', 'position:fixed;top:0;left:0;right:0;z-index:2147483647;background:#3a0d12;color:#ffd7dd;font:600 12px/1.4 system-ui,sans-serif;padding:10px 34px 10px 12px;border-bottom:2px solid #ff5570;box-shadow:0 4px 18px rgba(0,0,0,.5);white-space:pre-wrap;word-break:break-word'); b.textContent = 'πŸ‘» Ghost in the Loop couldn’t start on this page (' + stage + ').\n' + msg + '\nScreenshot this and send it β€” it says exactly what broke.'; const x = document.createElement('span'); x.textContent = 'Γ—'; x.setAttribute('style', 'position:absolute;top:6px;right:10px;cursor:pointer;font-size:18px;line-height:1'); x.addEventListener('click', () => b.remove()); b.appendChild(x); (document.body || document.documentElement).appendChild(b); } catch(_) {} } try { /* ═══════════════════════════════════════════════════════════════ LAYER 0 β€” CONSTANTS ═══════════════════════════════════════════════════════════════ */ const VER = '8.7.1'; const SUPPORT_URL = 'https://github.com/sponsors/MShneur'; const REPORT_REPO = 'MShneur/ghost-in-the-loop'; /* ═══════════════════════════════════════════════════════════════ TRUSTED TYPES (v8.1.5) β€” the actual Gemini root cause Gemini (a Google property) enforces `require-trusted-types-for 'script'`. Under that CSP, assigning a plain string to `.innerHTML` THROWS ("Sink type mismatch violation blocked by CSP" in Firefox) β€” which killed boot on the very first render and is why the panel never appeared on Gemini specifically (no other supported platform enforces Trusted Types). Confirmed from the v8.1.4 fail-loud banner on the reporter's device. Fix: register one policy at boot and route GITL's 4 innerHTML sinks through it. On every site that does NOT enforce Trusted Types, `_ttPolicy` stays null and `_TT()` returns the raw string β€” byte-identical behaviour, zero regression risk off Gemini. GITL only ever passes its OWN static templates here (persona/workflow text is already escaped via _esc upstream), so the pass-through policy introduces no new injection surface. */ let _ttPolicy = null; try { if (typeof window !== 'undefined' && window.trustedTypes && window.trustedTypes.createPolicy) { // A per-script named policy β€” page-scoped, does NOT touch the page's own // default policy or other code. Name kept unique to avoid collisions. _ttPolicy = window.trustedTypes.createPolicy('gitl-ui', { createHTML: (s) => s }); } } catch (e) { // A restrictive `trusted-types` allow-list can forbid creating our policy. // Record it (surfaces via the beacon/banner) β€” the panel would then need a // DOM-built fallback, tracked as follow-up. Never fatal here. _ttPolicy = null; try { _beacon('tt-policy-blocked'); } catch(_) {} } /* Wrap any HTML string destined for an innerHTML sink. */ function _TT(s) { return _ttPolicy ? _ttPolicy.createHTML(s) : s; } 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) /* ═══════════════════════════════════════════════════════════════ 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) { // v8.1.4: was silent (stored to GM only, invisible since the panel that // shows it never mounted). Now fails loud via the same beacon+banner. _gitlFatal('boot', err); } }; 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; } /* A read/write lease is not atomic across tabs. Before any actuator runs, claim, yield briefly, then re-read. If two tabs raced from an empty lock, only the deterministic last owner survives this verification step. */ async function verifyTabLease() { if (!claimTabLock()) return false; await new Promise(resolve => setTimeout(resolve, 35 + Math.floor(Math.random() * 45))); try { const raw = GM_getValue(_tabLockKey(), null); const lock = raw ? JSON.parse(raw) : null; return !!lock && lock.tabId === GITL_TAB_ID && Date.now() - lock.ts < 8000; } catch(_) { return false; } } 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); } /* ── Ticker (d12) ─────────────────────────────────────────────── Hidden tabs throttle setInterval to roughly once a minute, which stalls the engine loop when you walk away. A Web Worker's timer is not throttled the same way, so unattended runs tick from a Worker. Strict page CSP can refuse blob: workers β€” in that case we transparently fall back to setInterval and report which path is live (visible in Diagnostics). */ const Ticker = { _worker: null, _iv: null, mode: 'none', start(fn, ms) { this.stop(); if (unattendedOn() && typeof Worker !== 'undefined' && typeof Blob !== 'undefined') { try { const code = 'let i=null;onmessage=e=>{if(e.data&&e.data.cmd==="start"){if(i)clearInterval(i);i=setInterval(()=>postMessage("t"),e.data.ms);}else{clearInterval(i);i=null;}};'; const url = URL.createObjectURL(new Blob([code], { type: 'text/javascript' })); this._worker = new Worker(url); URL.revokeObjectURL(url); this._worker.onmessage = () => { try { fn(); } catch(e) { DIAG.push('tick: ' + e.message); } }; this._worker.postMessage({ cmd: 'start', ms }); this.mode = 'worker'; DIAG.push('Unattended: Worker ticker active (background-throttle immune)'); return 'worker'; } catch (e) { DIAG.push('Worker ticker blocked (page CSP) β€” using throttled timer: ' + e.message); this._worker = null; } } this._iv = setInterval(fn, ms); this.mode = 'interval'; return 'interval'; }, stop() { if (this._worker) { try { this._worker.postMessage({ cmd: 'stop' }); this._worker.terminate(); } catch(_) {} this._worker = null; } if (this._iv) { clearInterval(this._iv); this._iv = null; } this.mode = 'none'; } }; /* Focus guard: prevents background tabs from burning tokens by auto-sending prompts while user isn't looking. */ function unattendedOn() { try { return !!(GHOST && GHOST.ui && GHOST.ui.unattended); } catch(_) { return false; } } function isTabSafeToAct() { if (!unattendedOn()) { if (!document.hasFocus()) return false; if (document.hidden) return false; } return claimTabLock(); // multi-tab collision guard is NEVER relaxed } /* Pre-send safety gate: called before every engineSend. Returns { ok, reason } */ function assertInteractionSafe() { if (!unattendedOn() && !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 ═══════════════════════════════════════════════════════════════ */ /* Page-world handle: with GM grants the script runs sandboxed, so patching the sandbox's window.fetch never sees the site's own requests. unsafeWindow is the page's real window (Firefox MV3 port: inject in world:"MAIN"). */ const UW = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; const GITL_NET = { bus: new EventTarget(), capturedAt: 0, lastEventBytes: 0, bytesSeen: 0, active: false, // interceptor installed (kept for health snapshot compat) lastPulseT: 0, // last traffic on a KNOWN chat endpoint (trusted) lastPulseH: 0, // last traffic on a heuristic same-origin stream lastWsPulseT: 0, // meaningful WS payload only; heartbeat/control frames excluded _open: 0, // streams currently open expectUntil: 0, // set by a dispatch attempt; bounds heuristic pulses 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 (legacy) 'batchexecute', // Gemini (current streaming transport) '/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)); }, _pulse(trusted) { const t = Date.now(); if (trusted) this.lastPulseT = t; else this.lastPulseH = t; }, /* Same-origin streams that LOOK like chat traffic even when the endpoint isn't in AI_ENDPOINTS β€” the platform-proof fallback when sites reshuffle their APIs. Analytics-ish URLs are excluded. */ _maybeChat(url, method) { try { const s = typeof url === 'string' ? url : (url && url.url) || String(url || ''); if (!s) return false; const sameOrigin = s.startsWith('/') || s.includes(location.hostname); if (!sameOrigin) return false; if (/log|telemetry|beacon|analytics|sentry|metric|track|collect|report/i.test(s)) return false; return String(method || 'GET').toUpperCase() === 'POST'; } catch(_) { return false; } }, _wsFrameIsMeaningful(data) { try { if (typeof data === 'string') { const s = data.trim(); if (!s || /^(?:(?:2|3)(?:probe)?|(?:0|40|41)(?:\/[^,]+,?)?|"?(?:ping|pong|heartbeat|keepalive)"?)$/i.test(s)) return false; if (/^\{[^}]*"(?:type|event)"\s*:\s*"(?:ping|pong|heartbeat|keepalive)"[^}]*\}$/i.test(s)) return false; return s.length >= 8 || /(?:message|answer|query|text|content|token|delta|event)/i.test(s); } if (data && typeof data.size === 'number') return data.size >= 8; if (data && typeof data.byteLength === 'number') return data.byteLength >= 8; return !!data; } catch(_) { return false; } }, _pulseWs(data) { if (!this._wsFrameIsMeaningful(data)) return false; this.lastWsPulseT = Date.now(); this._pulse(true); return true; }, /* True while generation traffic is plausibly flowing. Trusted (known-endpoint) pulses always count; heuristic pulses only count inside the post-send expectation window, so a random background stream can't convince the engine that a reply is being written. */ streaming() { const now = Date.now(); if (now - this.lastPulseT < 1500) return true; if (now < this.expectUntil && (this._open > 0 || now - this.lastPulseH < 1500)) return true; return false; }, _emit(byteCount, isDone) { const bytes = Math.max(0, Number(byteCount) || 0); this.lastEventBytes = bytes; this.bytesSeen += bytes; this.capturedAt = Date.now(); this._pulse(true); this.bus.dispatchEvent(new CustomEvent('gitl:net', { detail: { bytes, isDone: !!isDone, ts: Date.now() } })); }, install() { if (this.active) return; this.active = true; /* v8.1.3 field report (Gemini "doesn't load"): install() used to run fully unguarded at module top-level, OUTSIDE safeBoot()'s try/catch (which only wraps panel creation much further down). In strict mode, reassigning a property a host page has hardened (Object.defineProperty with writable:false β€” a real pattern on security-conscious Google properties) throws a TypeError right here, which aborts the ENTIRE script before a single line of panel code runs: no #gitl, no console message a normal user would ever see, nothing. Every patch below is now individually fault-tolerant, and the whole method is wrapped too, so one hardened site can only cost that site's network telemetry β€” never the panel. */ try { /* Fetch proxy on the PAGE window β€” captures SSE / JSON streams */ const self = this; let origFetch; try { origFetch = UW.fetch; } catch(_) { origFetch = null; } try { if (typeof origFetch === 'function') UW.fetch = async function(...args) { const response = await origFetch.apply(this, args); const listed = self._isChat(args[0]); let heur = false; if (!listed) { try { const ct = response.headers && response.headers.get && (response.headers.get('content-type') || ''); heur = ct.includes('event-stream') || self._maybeChat(args[0], args[1] && args[1].method); } catch(_) {} } if (heur) { /* Heuristic path: timestamps only β€” content is never read or stored. */ try { const cloned = response.clone(); if (cloned.body) { const reader = cloned.body.getReader(); self._open++; self._pulse(false); (async () => { try { while (true) { const { done } = await reader.read(); self._pulse(false); if (done) break; } } catch(_) {} finally { self._open = Math.max(0, self._open - 1); } })(); } } catch(_) {} } if (listed) { try { const cloned = response.clone(); if (cloned.body) { const reader = cloned.body.getReader(); (async () => { try { while (true) { const { done, value } = await reader.read(); if (done) { self._emit(0, true); break; } self._emit(value?.byteLength || value?.length || 0, false); } } catch(_) { /* stream aborted β€” normal on navigation */ } })(); } } catch(err) { console.warn('[GITL] fetch intercept error:', err); } } return response; }; } catch(err) { console.warn('[GITL] fetch patch skipped:', err); } /* XHR proxy on the PAGE window β€” Gemini streams via batchexecute XHRs */ try { const XP = (UW.XMLHttpRequest && UW.XMLHttpRequest.prototype) || null; if (XP && XP.open && XP.send) { const origOpen = XP.open; XP.open = function(method, url, ...rest) { this._gitlUrl = url; this._gitlMethod = method; return origOpen.call(this, method, url, ...rest); }; const origSend = XP.send; XP.send = function(...args) { const listed = self._isChat(this._gitlUrl); const heur = !listed && self._maybeChat(this._gitlUrl, this._gitlMethod); if (listed || heur) { try { this.addEventListener('loadstart', () => { self._open++; self._pulse(listed); }); this.addEventListener('progress', () => self._pulse(listed)); this.addEventListener('loadend', () => { self._open = Math.max(0, self._open - 1); self._pulse(listed); }); if (listed) this.addEventListener('load', function() { if (this.status >= 200 && this.status < 300) self._emit(this.responseText?.length || 0, true); }); } catch(_) {} } return origSend.apply(this, args); }; } } catch(err) { console.warn('[GITL] XHR patch skipped:', err); } /* WebSocket pulse β€” Perplexity's socket.io traffic (timestamps only) */ try { if (typeof UW.WebSocket === 'function') { UW.WebSocket = new Proxy(UW.WebSocket, { construct(T, a) { const ws = new T(...a); try { ws.addEventListener('message', (ev) => { if (self._isChat(a[0])) self._pulseWs(ev && ev.data); else self._pulse(false); }); } catch(_) {} return ws; } }); } } catch(err) { console.warn('[GITL] WebSocket patch skipped:', err); } console.log('[GITL] Network interceptor active'); } catch(err) { console.error('[GITL] Network interceptor failed to install β€” panel will still boot:', err); try { GM_setValue('lastNetInstallError', JSON.stringify({ code: 'BOOT-002', at: new Date().toISOString() })); } catch(_) {} } } }; /* Install immediately β€” safe even before DOM. Also guarded at the call site: this runs before safeBoot() and used to be able to take the whole script down with it (see the v8.1.3 note inside install()). */ try { GITL_NET.install(); } catch(err) { console.error('[GITL] GITL_NET.install() threw at top level β€” continuing boot anyway:', err); } /* ═══════════════════════════════════════════════════════════════ LAYER 1 β€” PLATFORM ADAPTERS (all DOM access lives here) The loop engine NEVER touches the DOM directly. ═══════════════════════════════════════════════════════════════ */ const PROFILES = { chatgpt: { key: 'chatgpt', reviewed: true, host: /chatgpt\.com|chat\.openai\.com/, label: 'ChatGPT', input: ['#prompt-textarea','div[contenteditable="true"][id="prompt-textarea"]','div[contenteditable="true"][data-placeholder]','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[data-testid*="send"]','button[data-testid*="submit"]','button[class*="send"]'], stop: ['button[aria-label="Stop generating"]','button[data-testid="stop-button"]','button[aria-label*="Stop"]','button[data-testid*="stop"]'], assistant: ['div[data-message-author-role="assistant"]','article [data-message-author-role="assistant"]','div[data-testid^="conversation-turn"] div[data-message-author-role="assistant"]'], continueLabels: ['Continue generating','Continue'], dispatchFallback: 'enter', // mobile web hides the send button until a native keystroke; Enter submits the ProseMirror composer. Selected before transaction start, only when no unique reviewed button resolves. useCE: false, useNS: true }, perplexity: { key: 'perplexity', reviewed: true, host: /perplexity\.ai/, label: 'Perplexity', input: ['textarea[placeholder*="Ask"]','textarea[placeholder*="Follow"]','div[contenteditable="true"][role="textbox"]','div[class*="ProseMirror"]','[data-testid="composer"]','textarea:not([disabled])'], send: ['button[aria-label="Submit"]','button[aria-label="Send"]','button[type="submit"]'], stop: ['button[aria-label="Stop"]','button[aria-label*="Stop"]','[data-testid="stop-button"]','button[data-testid*="stop"]'], staleTicks: 24, // Deep Research thinks for minutes with no DOM growth and no stop button assistant: ['div[class*="prose"]','div[dir="auto"][class*="break-words"]'], assistantFallback: ['.pb-md > div'], dispatchFallback: 'enter', // reviewed mobile fallback; selected before transaction start continueLabels: [], useCE: true, useNS: false }, gemini: { key: 'gemini', reviewed: true, host: /gemini\.google\.com/, label: 'Gemini', input: ['rich-textarea .ql-editor[contenteditable="true"]','div.ql-editor[contenteditable="true"]','rich-textarea div[contenteditable="true"]','div[role="textbox"][contenteditable="true"]','div[contenteditable="true"]','textarea'], send: ['button[aria-label="Send message"]','button[aria-label*="Send"]','button.send-button','button[data-test-id="send-button"]'], stop: ['button[aria-label*="Stop"]','button[aria-label*="stop"]'], assistant: ['model-response message-content','model-response .message-content','model-response','div[class*="model-response"]','message-content'], continueLabels: [], useCE: true, useNS: false }, deepseek: { key: 'deepseek', reviewed: true, 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: { key: 'copilot', reviewed: true, 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: { key: 'grok', reviewed: true, host: /grok\.com/, label: 'Grok', input: ['textarea[aria-label="Ask Grok anything"]','textarea[placeholder*="Grok"]','textarea[placeholder*="Ask"]','textarea[data-testid="grok-compose-input"]','div[contenteditable="true"][data-lexical-editor="true"]','div[contenteditable="true"]','textarea'], send: ['button[aria-label="Submit"]','button[aria-label="Send message"]','button[aria-label*="Send"]','button[data-testid="send-button"]','button[data-testid*="submit"]','button[type="submit"]','button.send-button'], stop: ['button[aria-label="Stop"]','button[aria-label*="stop"]'], assistant: ['div[class*="message"][class*="bot"]','div[data-role="assistant"]','div[class*="response"]'], continueLabels: [], useCE: false, useNS: false }, claude: { key: 'claude', reviewed: true, 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: { key: 'manus', reviewed: true, host: /manus\.im/, label: 'Manus', // Verified against real Manus DOM: Tiptap ProseMirror input; Monaco code viewer has a decoy
` : ''} ${runAdv ? `
🧭Strategy${PAYLOADS[pm].label}
${PAYLOADS[pm].hint}
🧠Thinking${(POSTURES[L.posture]||POSTURES.standard).label}
Thinking
${peekOpen?'β–Ύ Hide prompt':'β–Έ What gets injected'}
${PAYLOADS[pm].preview}
` : ''}
v${VER} Β· Alt+P toggle Β· Alt+S stop
`; } function renderFlowTab() { const wf = allWorkflows()[GHOST.workflow.selected] || WORKFLOW_LIBRARY.none; const opts = Object.entries(allWorkflows()).map(([k,v]) => ``).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) => `
Stage ${i+1}
${_esc(s.slice(0,120))}${s.length>120?'…':''}
`).join('') : '
Manual mode β€” no preset stages. Use the Run tab instead.
'; const creating = GHOST.ui.wsNewWorkflow; const form = creating ? `
` : ``; const delBtn = wf.custom ? `` : ''; const wsBar = `
`; return `
${_esc(wf.desc)}
${!isManual ? `
How this works: press β–Ά Start 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 INSERT to drop just that prompt into the chat yourself. ${GHOST.workflow.pauseBetween ? '

⏸ Pause between is ON β€” Ghost stops after each stage so you can review or switch models, then press β–Ά to continue.' : ''}
${running||GHOST.workflow.active ? `
` : ''}
Stage ${wf.stages.length?(GHOST.workflow.stageIndex+1):'β€”'} of ${wf.stages.length} ${GHOST.workflow.active?'Β· running':''}
${stages} ${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: ` What is Ghost?
You give the AI a big task. Ghost keeps pressing "continue" for you β€” through every step β€” until the AI says it's truly done.

The 30-second version:
1. Type your task in the chat box
2. Press the big β–Ά
3. Walk away β˜•

How does it know when to stop?
Ghost teaches the AI two signals: [[GITL::PROCEED]] = "more to do", [[GITL::HALT]] = "finished". Ghost reads them and acts.` }, run: { label: 'Run', html: ` The Run tab is command center.

Strategy dropdown:
Β· Step by step β€” AI works in batches, Ghost continues each one
Β· Plan first β€” AI plans before working, then batches
Β· Autopilot β€” AI researches, writes its own plan, Ghost runs every step

Buttons: β–Ά Start/Resume Β· ⏸ Pause Β· β–  Stop (preserves progress). Reground and Reset are separate under Advanced β–Ύ.

Personas line: shows your active persona or committee. Tap "edit" to jump to the Personas tab.

Q: It stopped and shows "drift checkpoint"?
That's the drift guard catching a long run. It's a grounding pause so an unattended run cannot wander off-task. Three choices:
Β· β–Ά Continue β€” run more
Β· βŠ• Reground β€” re-anchor the AI to the task it started on
Β· βœ‹ Stop & wait β€” pause for your instructions
You can edit the cap inline, toggle the guard off, or tap ↻ to reset.` }, auto: { label: 'Auto', html: ` The Auto tab = fire & forget.

Roadmap (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.

Queue (you plan): write your own steps β€” one box each, οΌ‹ to add more β€” and hit β–Ά Run queue.

Q: Roadmap vs Workflow?
Workflow = you know the recipe, same stages every time.
Roadmap = the AI invents the plan for THIS task.
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: ` The Flow tab runs fixed multi-stage recipes (e.g. Draft → Critique → Polish).

To run one:
1. Pick a workflow from the dropdown
2. Type your task in the chat box
3. Press β–Ά Start workflow
Ghost runs every stage in order, advancing each time the AI HALTs.

INSERT button (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.

Pause between stages: OFF = Ghost runs start-to-finish. ON = Ghost stops after each stage so you can review β€” or switch the model (that's how Lens Relay works: swap model at each pause, press β–Ά to continue).` }, roles: { label: 'Personas', html: ` The Personas tab shapes how the AI approaches your task.

Basic: pick a persona from the dropdown β€” Red Team attacks the work, Researcher digs deep, Devil's Advocate challenges every claim. The persona framing is injected into your first prompt.

Committee mode (toggle at top): select multiple personas. The AI simulates all perspectives on every response, then synthesizes a consensus with disagreements preserved.

Per-task toggle: re-inject the committee framing on every step, not just the first.
Final review toggle: after all work completes, the committee does one final review pass before halting.

On Perplexity, Round Table becomes a REAL round table: switch models between turns, each model gives independent assessment naming who goes next.` }, export: { label: 'Export', html: ` Three buttons, three jobs:

⬇ Export β€” a validated transcript. The file says complete, partial, or failed and explains fallbacks or omissions instead of silently claiming completeness.

🀝 Handoff β€” 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.

🧷 Backup Handoff β€” the chat is full, stuck, or won't respond, so it can't write its own briefing (that's what Handoff normally does). Ghost writes a smaller one instead: state + last 10 messages verbatim + resumption instructions. Deliberately lighter than a full export β€” just enough to resume elsewhere.

Working chat β†’ Handoff. Dead chat β†’ Backup Handoff. Archive β†’ validated Export. Experimental Capsule v2 is under Advanced for external tools; Ghost does not import it yet.` }, setup: { label: 'Setup', html: ` The Setup tab:
Β· Max rounds β€” drift-guard cap on auto-continues
Β· Notify β€” desktop alert when done (great with β˜•)
Β· Position β€” corners, bottom bar, ▐ Dock (slim right-edge tab that never covers the chat), or ☰ Gold menu (the same slim tab on the left edge, opposite most sites' own menu, styled gold)
Β· Unattended β€” by default Ghost stops sending the moment the tab loses focus (a guard against burning tokens unwatched). Turn it on to keep a run going in a background tab; it also moves the loop onto a Web Worker timer, because browsers throttle background setInterval to about once a minute. The tab must remain open β€” closing the browser still ends the run. Drift guard and round limits still apply.
Β· Skin β€” 13 presets (Classic, Aurora, Glass, Metal, Neon, Clay, Liquid, OLED, Paper, HUD, Nova, Ion, Flow) or Custom. Swatches or the slider tint the accent family on any of them. (import a .gitl.json skin file). Skins are pure style tokens: they can never add, remove, or change buttons and features, and old skins keep working on new GITL versions
Β· Accent β€” hue slider to tint the interface any color you want

πŸ”„ Re-detect (top of panel): 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.

Advanced β–Ύ hides the power tools: custom signal words, per-site selector overrides (Custom sites), and Diagnostics β†’ Probe, which live-tests Ghost's connection to the page β€” your first stop when a platform misbehaves.` }, posture: { label: 'Posture', html: ` Thinking posture = how much room the AI has to grow its own plan. You pick it up front, like a reasoning dial β€” Ghost never guesses. It works with any mode (Loop / Think / Roadmap).

Locked (formerly Standard) β€” The AI does exactly the steps it declared, nothing more. Most predictable; best when you know the scope.

Adaptive (formerly Evolving) β€” the plan can grow: the AI may add steps while working, 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.

Audit (formerly Extended) (a.k.a. review) β€” the AI runs the plan locked, then does one 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.

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. (Wording based on current best-practice research: OpenAI/Anthropic planning guidance, ReAct/Reflexion, Self-Refine, and agent guardrail patterns.)` }, workshop: { label: 'Workshop', html: ` Make Ghost yours β€” and share it.

Custom personas (Roles tab) and custom workflows (Flow tab) are yours to create. Tap οΌ‹ Create, 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.

⬇ Export bundles all your custom personas + workflows into one .gitl.json file. ⬆ Import loads someone else's bundle β€” it only ever ADDS (your existing items and the built-ins are never overwritten; name clashes auto-rename).

🌐 Share with the community:
Β· Post your .gitl.json in GitHub Discussions: ghost-in-the-loop/discussions
Β· Or open an issue tagged workshop to suggest it for the built-in library

Good packs get folded into future releases so everyone benefits.` }, feedback: { label: 'Feedback', html: ` Found a bug? Have an idea?

Open an issue: github.com/MShneur/ghost-in-the-loop

Please include:
Β· Ghost version (v${VER}) and the platform
Β· What you did, what you expected, what happened
Β· Setup β†’ Advanced β†’ Diagnostics β†’ Probe output β€” it tells us exactly what Ghost can and can't see

⭐ A star on GitHub helps more people find Ghost.
β™‘ And if Ghost saved you real time: support its development β€” entirely optional, it stays free either way.` } }; function renderInfoTab() { const sec = GHOST.ui.helpSec || 'start'; const pills = Object.entries(HELP_SECTIONS).map(([k, s]) => ``).join(''); return `
${pills}
${HELP_SECTIONS[sec].html}
`; } 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 ? 'βœ“' : i === R.index ? 'β–Ά' : 'Β·'; return `
${mark}${i+1}. ${s.replace(/
`; }).join(''); return `
πŸ—Ί ROADMAP β€” step ${Math.min(R.index+1,R.steps.length)} of ${R.steps.length}
${rows}
`; } // No roadmap β†’ step editor: one input per step, + to add const d = GHOST.ui.qDraft; const rows = d.map((s,i) => `
${i+1}.
`).join(''); return `
πŸ—Ί Autopilot. Pick Roadmap on the Run tab and press β–Ά β€” the AI plans this task itself. Or write your own steps below; each gets a βœ“ as it completes.
PROMPT QUEUE
${rows}
`; } function renderPersonasTab() { const sel = GHOST.persona.selected || ['none']; const comm = GHOST.persona.committee; const creating = GHOST.ui.wsNewPersona; const allP = allPersonas(); const activeP = sel.filter(s=>s&&s!=='none'); // Basic: single persona selector with preview const opts = Object.entries(allP).map(([k,v]) => ``).join(''); const curKey = !comm && activeP.length===1 ? activeP[0] : null; const curP = curKey ? allP[curKey] : null; // Committee: multi-select rows const committeeRows = comm ? activeP.map((k,i) => { const p = allP[k]; const rowOpts = Object.entries(allP).filter(([id])=>id!=='none').map(([id,v]) => ``).join(''); return `
${p?_esc(p.inject.slice(0,80))+(p.inject.length>80?'…':''):'Unknown persona'}
`; }).join('') : ''; return `
${!comm ? `
${curP ? `
${_esc(curP.label)}${curP.custom?' β˜… custom':''}
${_esc(curP.inject.slice(0,200))}${curP.inject.length>200?'…':''}
` : '
No persona active β€” the AI uses its default behavior.
'} ` : `
COMMITTEE MEMBERS (${activeP.length})
${committeeRows}
Per-task = each step runs with the committee framing. Final review = after the last step, the committee reviews and synthesizes.
`}
${creating ? `
` : ``}
`; } function renderExportTab() { const fn = buildFilename('export'); const adv = GHOST.ui.expAdv; const last = GHOST.export.lastResult; return `
⬇
ExportValidated transcript with a truthful complete, partial, or failed result.
🀝
HandoffChat still responds: asks the AI to write its own briefing for the next chat.
🧷
Backup HandoffChat is dead: Ghost writes a lighter one itself β€” state + last 10 messages, enough to resume elsewhere.
${last ? `
${_esc(last.status.toUpperCase())} Β· ${Number(last.captured)||0} captured via ${_esc(last.source || 'unknown')}${last.warnings?.length?`
${_esc(last.warnings[0])}`:''}
` : ''} ${adv ? `
πŸ’Š
Experimental Capsule v2Machine JSON for external tools. Ghost does not import it yet; repeated and short turns are preserved.
${fn}
` : ''}`; } function renderSettingsTab() { const adv = GHOST.ui.cfgAdv; return `
${['top-left','top-right','bot-left','bot-right','bottom-bar','dock','dock-left','orb','rail'].map(p=> `` ).join('')}
Keeps running when the tab is in the background. The tab must stay open β€” this does not move the run to a server. Off by default: it sends prompts while you're not looking.
${[350,265,220,185,145,40].map(h=>``).join('')}
${adv ? `
${GHOST.ui.showSites ? `
Per-host selector overrides (JSON). Also add the site under Tampermonkey β†’ script settings β†’ User matches.
` : ''}
${GHOST.ui.showDiag ? renderDiag() : ''}` : ''}
β™‘ Support Ghost Β· free forever
`; } function renderDiag() { const L = GHOST.loop; const h = typeof platformHealth === 'function' ? platformHealth() : null; const lines = [ h ? `Health: ${h.badge} ${h.score}/100 (in:${h.input?'βœ“':'βœ—'} send:${h.send?'βœ“':'βœ—'} read:${h.assistantCount} net:${h.netActive?'βœ“':'βœ—'})` : '', `Adapter: ${_esc(DIAG.adapter)}`, `Platform: ${_esc(PLAT.label)}`, `Selector: ${_esc(DIAG.selector || 'β€”')}`, `Send path: ${_esc(DIAG.sendPath || 'β€”')}`, `Signal: ${_esc(L.lastSignal)} (${Number(L.lastConfidence)||0}) ${_esc(DIAG.lastSignal)}`, `Tail: ${_esc(DIAG.lastTail ? DIAG.lastTail.slice(-50) : 'β€”')}`, `Round: ${L.round} / ${L.maxRounds}`, `State: ${_esc(L.state)}`, `Stale: ${L.staleTicks}`, `Tick: ${L.lastActivity ? Math.round((Date.now()-L.lastActivity)/1000)+'s ago' : 'β€”'}`, `Tab: ${GITL_TAB_ID.slice(0,8)}`, DIAG.probe ? `Probe:\n${_esc(DIAG.probe)}` : '', DIAG.errors.length ? `Errors:\n${_esc(DIAG.errors.slice(0,5).join('\n'))}` : '' ].filter(Boolean).join('\n'); return `
${lines}
`; } 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=''} else if(pos==='orb'){_applyOrb()} else if(pos==='rail'){_applyRail()} if (pos==='rail') startRailTracker(); else stopRailTracker(); } /* Pure geometry for the composer rail (v8.5.0). Given the composer's rect and the viewport, return where the slim rail sits: hugging the composer's TOP edge (so it never covers the input or the messages), clamped on-screen, flipping below only if there's no room above. No composer β†’ a bottom-pinned fallback strip. Kept pure so the placement math is unit-testable without a DOM. */ function _railBox(rect, vw, vh, opts) { const gap = (opts && opts.gap) || 8; const h = (opts && opts.h) || 40; const m = 6; if (!rect || !(rect.width > 0) || !(rect.height >= 0)) { return { docked: false, left: m, right: m }; } const width = Math.max(180, Math.min(rect.width, vw - m * 2)); const left = Math.min(Math.max(m, rect.left), Math.max(m, vw - width - m)); let top = rect.top - gap - h; if (top < m) top = Math.min(rect.bottom + gap, vh - h - m); // no room above β†’ below return { docked: true, left, top, width }; } /* Dock the panel to the site's composer. Collapsed: a slim bar hugging the composer's top edge. Expanded: the full panel pinned so its BOTTOM sits just above the composer, growing upward β€” it can never cover the input. Uses the composer position Ghost already finds (Adapter.peekInput, non-mutating); if none is found it falls back to a bottom-pinned strip above the safe area. Own-UI (inside #gitl) and injects nothing into the page. */ function _applyRail() { const col = GHOST.ui.collapsed; const input = (typeof Adapter !== 'undefined' && Adapter.peekInput) ? Adapter.peekInput() : null; let rect = null; try { rect = input && input.getBoundingClientRect ? input.getBoundingClientRect() : null; } catch(_) {} const vw = Math.max(1, (typeof innerWidth === 'number' ? innerWidth : 1200)); const vh = Math.max(1, (typeof innerHeight === 'number' ? innerHeight : 800)); // Only dock to a composer that is actually in the lower portion of the screen // (a real chat box). Guards against latching onto a stray input near the top // and flying the panel around on scroll (field-reported jumping on Perplexity). const dockable = rect && rect.width > 0 && rect.top > vh * 0.45; const box = _railBox(dockable ? rect : null, vw, vh, { gap: 8, h: col ? 40 : 44 }); panel.style.top = panel.style.bottom = panel.style.left = panel.style.right = 'auto'; if (!box.docked) { // Fallback: a bottom strip above device UI / keyboard (visualViewport-safe). panel.style.left = box.left + 'px'; panel.style.right = box.right + 'px'; panel.style.bottom = 'calc(10px + env(safe-area-inset-bottom, 0px))'; panel.style.width = ''; return; } if (col) { panel.style.left = box.left + 'px'; panel.style.top = box.top + 'px'; panel.style.width = box.width + 'px'; } else { // Expanded: pin the panel's bottom just above the composer so it grows up, // never covering the input. Height is still capped by the .g-body max-height. const w = Math.min(300, vw - 12); panel.style.width = w + 'px'; panel.style.left = Math.min(Math.max(6, rect.left), Math.max(6, vw - w - 6)) + 'px'; panel.style.bottom = Math.max(6, vh - rect.top + 8) + 'px'; } } /* Reposition the rail as the page scrolls or the viewport changes (mobile keyboard, orientation). rAF-coalesced, and only while rail mode is active β€” no idle loop, no permanent listeners. */ let _railTracking=false,_railInput=null,_railRO=null,_railMO=null,_railPoll=null,_railRectKey=''; function _railBindInput(){const n=Adapter.peekInput();if(n===_railInput&&n?.isConnected)return;try{_railRO?.disconnect();}catch(_){}_railInput=n||null;_railRO=null;if(_railInput&&typeof ResizeObserver==='function'){try{_railRO=new ResizeObserver(_railReposition);_railRO.observe(_railInput);}catch(_){}}_railReposition();} function _railReposition() { if (_railReposition.pending) return; _railReposition.pending = true; const raf = (typeof requestAnimationFrame === 'function') ? requestAnimationFrame : (fn) => setTimeout(fn, 16); raf(() => { _railReposition.pending = false; if (GHOST.ui.position === 'rail' && panel && panel.isConnected) { try { _applyRail(); } catch(_) {} } }); } function startRailTracker(){if(_railTracking)return;_railTracking=true;try{window.addEventListener('resize',_railReposition,{passive:true});if(window.visualViewport){window.visualViewport.addEventListener('resize',_railReposition,{passive:true});}_railBindInput();_railMO=new MutationObserver(()=>{if(!_railInput?.isConnected)_railBindInput();});_railMO.observe(document.body,{childList:true,subtree:true});_railPoll=setInterval(()=>{_railBindInput();if(!_railInput?.getBoundingClientRect)return;const r=_railInput.getBoundingClientRect(),k=[Math.round(r.left),Math.round(r.top),Math.round(r.width),Math.round(r.height)].join(':');if(k!==_railRectKey){_railRectKey=k;_railReposition();}},1200);}catch(_){} } function stopRailTracker(){if(!_railTracking)return;_railTracking=false;try{window.removeEventListener('resize',_railReposition);if(window.visualViewport){window.visualViewport.removeEventListener('resize',_railReposition);}_railRO?.disconnect();_railMO?.disconnect();if(_railPoll)clearInterval(_railPoll);}catch(_){}_railInput=null;_railRO=null;_railMO=null;_railPoll=null;_railRectKey='';} /* Position the orb. Collapsed: a tucked circle clinging to the saved edge (~12px off-screen) at the saved vertical ratio. Expanded: a normal-width drawer hugging that same edge, so opening it never jumps across the screen. Height is bounded by the existing .g-body max-height, so it can't swallow the composer on mobile. */ function _applyOrb() { const col = GHOST.ui.collapsed; panel.style.top = panel.style.bottom = panel.style.left = panel.style.right = 'auto'; const yPct = (_orbClampY(GHOST.ui.orbY) * 100).toFixed(2) + '%'; if (col) { panel.style.width = ''; panel.style.top = yPct; if (GHOST.ui.orbEdge === 'left') panel.style.left = '-12px'; else panel.style.right = '-12px'; } else { panel.style.width = '268px'; panel.style.top = '14px'; if (GHOST.ui.orbEdge === 'left') panel.style.left = '8px'; else panel.style.right = '8px'; } } 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() { try { panel.dataset.run = (GHOST.loop.state === 'RUNNING') ? '1' : '0'; panel.dataset.explain = GHOST.ui.explain ? '1' : '0'; } catch(_) {} 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':'', GHOST.ui.position==='orb'?('pos-orb'+(GHOST.ui.orbEdge==='left'?' orb-left':'')):'', GHOST.ui.position==='rail'?'pos-rail':''].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'; // Compact dock status: step/round + drift guard remaining (editable) const dockStat = (()=>{ if (L.state==='IDLE'||L.state==='COMPLETE') return ''; const p = L.lastProgress; const pctv = (p && p.total) ? Math.round((p.step / p.total) * 100) : null; const bar = pctv !== null ? `` : ''; const line1 = p ? `${p.step}/${p.total}` : (L.round ? `round ${L.round}` : ''); const left = L.driftEnabled ? Math.max(0, L.maxRounds - L.round) : null; const line2 = left !== null ? `${left} left` : ''; return [bar, line1, line2].filter(Boolean).join(''); })(); panel.innerHTML = _TT(`
${_esc((typeof platformHealth==='function'?platformHealth().badge:'') + ' ' + PLAT.label)}
${ql} ${dockStat?`${dockStat}`:''}
πŸ“
${TAB_HELP[tab] && tab!=='info' ? `` : ''} ${tab==='run'?renderRunTab():''}${tab==='auto'?renderAutoTab():''}${tab==='info'?renderInfoTab():''}${tab==='flow'?renderFlowTab():''} ${tab==='personas'?renderPersonasTab():''}${tab==='export'?renderExportTab():''} ${tab==='settings'?renderSettingsTab():''}
`); bindEvents(); applyPosition(GHOST.ui.position); } /* ═══════════════════════════════════════════════════════════════ EVENT BINDING ═══════════════════════════════════════════════════════════════ */ function bindEvents() { const $ = s => panel.querySelector(s); const $$ = s => panel.querySelectorAll(s); $('#g-redetect')?.addEventListener('click', rebootGhost); $('#g-redetect-only')?.addEventListener('click', reDetect); $('#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 }); } // Orb + collapsed: drag to reposition (vertical + snap to nearer edge), tap to open. if (GHOST.ui.position==='orb' && GHOST.ui.collapsed) bindOrbDrag(); // Rail + collapsed: the whole slim bar is the expand target (play button stays play). if (GHOST.ui.position==='rail' && 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 β€” strategy dropdown $$('.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-strategy')?.addEventListener('change', e => { if (GHOST.loop.state==='RUNNING') return; GHOST.loop.payloadMode=e.target.value; GHOST.loop.needsPayload=true; _save('payloadMode',GHOST.loop.payloadMode); render(); }); $('#run-adv')?.addEventListener('click', () => { GHOST.ui.runAdv=!GHOST.ui.runAdv; render(); }); $('#g-goto-personas')?.addEventListener('click', e => { e.preventDefault(); GHOST.ui.tab='personas'; render(); }); $('#g-reground')?.addEventListener('click', () => { if (GHOST.loop.state==='RUNNING'||GHOST.loop.state==='PAUSED') regroundLoop(); }); $$('.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-drift-tog')?.addEventListener('click', function(){ this.classList.toggle('on'); GHOST.loop.driftEnabled=this.classList.contains('on'); _save('driftEnabled',GHOST.loop.driftEnabled); render(); }); $('#g-drift-max')?.addEventListener('change', e => { const v=parseInt(e.target.value,10); if(v>0&&v<=999){GHOST.loop.maxRounds=v; _save('maxRounds',v); render();} }); $('#g-drift-max')?.addEventListener('click', e => e.stopPropagation()); $('#g-dk-max')?.addEventListener('change', e => { const v=parseInt(e.target.value,10); if(v>0&&v<=999){GHOST.loop.maxRounds=v; _save('maxRounds',v); render();} }); $('#g-dk-max')?.addEventListener('click', e => e.stopPropagation()); $('#g-dk-reset')?.addEventListener('click', e => { e.stopPropagation(); GHOST.loop.round=0; GHOST.loop.detail='↻ Drift guard reset'; render(); }); $('#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-stop')?.addEventListener('click', stopLoop); $('#g-teach-send')?.addEventListener('click', () => Teach.arm('send')); $('#g-teach-input')?.addEventListener('click', () => Teach.arm('input')); $('#g-teach-cancel')?.addEventListener('click', () => { GHOST.ui.teachMsg=''; Teach.disarm(true); }); $('#g-teach-forget')?.addEventListener('click', () => { TeachStore.forgetHost(); GHOST.ui.teachMsg='Taught controls cleared for this site.'; try { reDetect(); } catch(_){} render(); }); $('#g-reset')?.addEventListener('click', resetLoop); $('#g-send-seen')?.addEventListener('click', () => reconcileUncertainSend(true)); $('#g-send-manual')?.addEventListener('click', () => reconcileUncertainSend(false)); $('#g-rep-copy')?.addEventListener('click', function(){ Reporter.copy().then(ok => { this.textContent = ok ? 'βœ“ Copied' : 'βœ• Failed'; setTimeout(()=>{ this.textContent='πŸ“‹ Copy'; }, 1500); }); }); $('#g-rep-dl')?.addEventListener('click', function(){ const ok = Reporter.download(); this.textContent = ok ? 'βœ“ Downloaded' : 'βœ• Failed'; }); $('#g-rep-issue')?.addEventListener('click', () => Reporter.openIssue()); $('#g-rep-x')?.addEventListener('click', () => { if (GHOST.loop.sendTxn?.state === 'uncertain') { GHOST.loop.detail = 'Reconcile the uncertain Send before dismissing this report.'; render(); return; } 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); $('#wf-do-pause')?.addEventListener('click', () => { if(GHOST.loop.state==='RUNNING') pauseLoop(); }); $('#wf-do-stop')?.addEventListener('click', () => { GHOST.workflow.active=false; GHOST.workflow.stageIndex=0; _save('wfStage',0); stopLoop(); }); $$('.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 const _saveSel = () => { GHOST.persona._delivered = false; _save('persona', JSON.stringify(GHOST.persona.selected)); }; $('#p-comm-tog')?.addEventListener('click', function(){ this.classList.toggle('on'); GHOST.persona.committee=this.classList.contains('on'); _save('personaCommittee',GHOST.persona.committee); if(GHOST.persona.committee&&GHOST.persona.selected.filter(s=>s&&s!=='none').length<2){ GHOST.persona.selected=GHOST.persona.selected.filter(s=>s&&s!=='none'); if(!GHOST.persona.selected.length) GHOST.persona.selected=['researcher','redteam']; _saveSel(); } render(); }); $('#p-single')?.addEventListener('change', e => { GHOST.persona.selected=[e.target.value]; _saveSel(); render(); }); $('#p-run')?.addEventListener('click', () => { GHOST.ui.tab='run'; startLoop(); }); $('#p-pertask')?.addEventListener('click', function(){ this.classList.toggle('on'); GHOST.persona.perTask=this.classList.contains('on'); _save('personaPerTask',GHOST.persona.perTask); }); $('#p-review')?.addEventListener('click', function(){ this.classList.toggle('on'); GHOST.persona.finalReview=this.classList.contains('on'); _save('personaFinalReview',GHOST.persona.finalReview); }); // Committee multi-select rows $$('.g-cm-sel').forEach(sel => sel.addEventListener('change', e => { const i=+sel.dataset.ci; const active=GHOST.persona.selected.filter(s=>s&&s!=='none'); if(i b.addEventListener('click', () => { const i=+b.dataset.ci; const active=GHOST.persona.selected.filter(s=>s&&s!=='none'); active.splice(i,1); GHOST.persona.selected=active.length?active:['none']; _saveSel(); render(); })); $('#p-cm-add')?.addEventListener('click', () => { const active=GHOST.persona.selected.filter(s=>s&&s!=='none'); const all=Object.keys(allPersonas()).filter(k=>k!=='none'&&!active.includes(k)); if(all.length) active.push(all[0]); GHOST.persona.selected=active; _saveSel(); render(); }); // Workshop: create/import/export (same as before, updated for array) $('#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; if(GHOST.persona.committee){ GHOST.persona.selected.push(id); } else { GHOST.persona.selected=[id]; } _saveSel(); GHOST.loop.detail = `βœ“ Created persona "${label}"`; render(); }); $('#ws-import')?.addEventListener('click', workshopImport); $('#ws-export')?.addEventListener('click', workshopExport); $('#ws-submit')?.addEventListener('click', () => { // v8.1: Share now does the work β€” a paste-ready Discussions post (item // list + JSON bundle) lands on the clipboard, then the how-to opens. try { const t = Workshop.shareText(); if (typeof GM_setClipboard === 'function') GM_setClipboard(t, { type:'text', mimetype:'text/plain' }); else navigator.clipboard?.writeText(t); GHOST.loop.detail = '🌐 Share post copied β€” paste it into GitHub Discussions'; } catch(_) {} 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', exportBackupHandoff); $('#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); // Choosing the orb collapses to it immediately so the tuck is visible and // the user learns tap-to-open (their last tab is restored on reopen). if (GHOST.ui.position==='orb') { GHOST.ui.collapsed=true; _save('panelCollapsed',true); } 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(); }); $('#cfg-unattended')?.addEventListener('click', function(){ this.classList.toggle('on'); GHOST.ui.unattended = this.classList.contains('on'); _save('unattended', GHOST.ui.unattended); // Re-arm the ticker on the new mode if a run is live. if (GHOST.loop.state === 'RUNNING') GHOST.loop.timer = Ticker.start(engineTick, 2500); GHOST.loop.detail = GHOST.ui.unattended ? 'πŸŒ™ Unattended ON β€” keeps running in a background tab' : 'Unattended OFF β€” pauses when you switch away'; render(); }); $('#cfg-skin')?.addEventListener('change', e => { const v = e.target.value; if (v === 'custom' && !GHOST.ui.customSkin) { SKIN.importFile(); render(); return; } GHOST.ui.skinTheme = v; _save('skinTheme', v); SKIN.apply(); render(); }); $('#cfg-skin-imp')?.addEventListener('click', () => SKIN.importFile()); $('#cfg-skin-exp')?.addEventListener('click', () => SKIN.exportCurrent()); $$('.g-swatch').forEach(b => b.addEventListener('click', () => { const h = parseInt(b.dataset.hue, 10); GHOST.ui.accentHue = h; _save('accentHue', h); SKIN.apply(); render(); })); $('#cfg-hue')?.addEventListener('dblclick', () => { GHOST.ui.accentHue = NaN; _save('accentHue',''); SKIN.apply(); render(); }); $('#cfg-hue')?.addEventListener('input', e => { GHOST.ui.accentHue=parseInt(e.target.value,10); _save('accentHue',GHOST.ui.accentHue); SKIN.apply(); render(); }); $('#g-redetect')?.addEventListener('click', function(){ this.classList.add('spin'); const ok = reDetect(); // Found immediately β†’ brief spin. Otherwise the async watcher keeps the // spin going and clears it itself on success or 12s timeout. if (ok) 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(); } let _dragBound = false; let _dragging = false; let _dragOffsetX = 0; let _dragOffsetY = 0; /* Orb drag: vertical reposition + edge snap, tap-to-open. One pointer handler distinguishes a tap (open the panel) from a drag (move + persist) via a small movement threshold, so the two never fight. Falls back to click-to-open where Pointer Events are unavailable. */ function bindOrbDrag() { if (typeof PointerEvent === 'undefined') { panel.addEventListener('click', () => { GHOST.ui.collapsed = false; _save('panelCollapsed', false); render(); }, { once: true }); return; } let active = false, moved = false, startX = 0, startY = 0, startRatio = GHOST.ui.orbY; const vh = () => Math.max(1, (typeof innerHeight === 'number' ? innerHeight : 800)); const vw = () => Math.max(1, (typeof innerWidth === 'number' ? innerWidth : 1200)); panel.addEventListener('pointerdown', e => { if (e.button != null && e.button !== 0) return; active = true; moved = false; startX = e.clientX; startY = e.clientY; startRatio = GHOST.ui.orbY; try { panel.setPointerCapture(e.pointerId); } catch(_){} }); panel.addEventListener('pointermove', e => { if (!active) return; if (!moved && Math.hypot(e.clientX - startX, e.clientY - startY) < 6) return; moved = true; GHOST.ui.orbY = _orbClampY(startRatio + (e.clientY - startY) / vh()); GHOST.ui.orbEdge = _orbEdgeFromX(e.clientX, vw()); panel.classList.toggle('orb-left', GHOST.ui.orbEdge === 'left'); _applyOrb(); }); const end = e => { if (!active) return; active = false; try { panel.releasePointerCapture(e.pointerId); } catch(_){} if (moved) { _save('orbY', GHOST.ui.orbY); _save('orbEdge', GHOST.ui.orbEdge); } else { GHOST.ui.collapsed = false; _save('panelCollapsed', false); render(); } }; panel.addEventListener('pointerup', end); panel.addEventListener('pointercancel', end); } function bindDrag() { /* panel is a stable shell; its contents are re-rendered. Delegate the pointer-down once and install one document move/up pair for the lifetime of the script. This prevents two global listeners being added per render and gives touch/pen the same path as a mouse. */ if (_dragBound) return; _dragBound = true; panel.addEventListener('pointerdown', e => { if (e.button !== 0 || !e.target?.closest?.('#g-drag')) return; if (e.target.closest('button,input,select,a')) return; const rect = panel.getBoundingClientRect(); _dragging = true; _dragOffsetX = e.clientX - rect.left; _dragOffsetY = e.clientY - rect.top; try { panel.setPointerCapture(e.pointerId); } catch(_) {} e.preventDefault(); }); document.addEventListener('pointermove', e => { if (!_dragging) return; panel.style.left = `${e.clientX - _dragOffsetX}px`; panel.style.top = `${e.clientY - _dragOffsetY}px`; panel.style.right = 'auto'; panel.style.bottom = 'auto'; }); document.addEventListener('pointerup', e => { if (!_dragging) return; _dragging = false; try { panel.releasePointerCapture(e.pointerId); } catch(_) {} }); } /* ═══════════════════════════════════════════════════════════════ 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(() => { /* v8.2.0 TRANSACTIONAL BOOT. Previously boot was one straight-line block: any throw before mountPanel() β€” including in an OPTIONAL subsystem like the tab bus or heartbeat β€” aborted the rest and the panel never appeared. Now boot runs as isolated phases: β€’ CRITICAL phases (styles β†’ panel β†’ render) must succeed; a failure is fatal AND loud (_gitlFatal via safeBoot's catch). β€’ OPTIONAL phases are each caught: one failing subsystem degrades health and is logged, but can never suppress the panel or the phases after it. The singleton `window.__GITL_V8__` is committed only after the critical phases succeed, so a failed attempt no longer blocks a retry. */ GHOST._degraded = []; const _phase = (name, critical, fn) => { const t = Date.now(); try { fn(); Timeline.record('boot_phase', { name, ok: true, ms: Date.now() - t }); } catch (e) { Timeline.record('boot_phase', { name, ok: false, error: String(e && e.message || e) }); if (critical) throw new Error('critical boot phase "' + name + '" failed: ' + (e && e.message || e)); GHOST._degraded.push(name); try { DIAG.push('Boot phase "' + name + '" failed (non-critical, panel unaffected): ' + (e && e.message || e)); } catch(_) {} } }; // ── CRITICAL: get the panel on screen. Nothing optional runs before this. ── _phase('workshop', false, () => Workshop.load()); // custom items for first render; non-fatal if it throws _phase('styles', true, () => injectStyles()); _phase('panel', true, () => mountPanel()); _phase('skin', true, () => SKIN.apply()); _phase('render', true, () => render()); // Panel is up and rendered β€” commit the singleton NOW (never before boot). window.__GITL_V8__ = true; window.__GITL_BOOTING__ = 0; _beacon(document.getElementById('gitl') ? 'ok:' + VER : 'no-panel:' + VER); // ── OPTIONAL: isolated. None of these can remove the panel if they throw. ── _phase('continue-observer', false, () => { // Fast-path: a Continue button revealed via CSS (not just freshly inserted) // also triggers the auto-click. The loop tick remains the primary driver. 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'] }); }); _phase('heartbeat', false, () => startTabHeartbeat()); _phase('tab-lock', false, () => claimTabLock()); _phase('bus', false, () => GhostBus.init()); _phase('panel-sentinel', false, () => startPanelSentinel()); _phase('boot-retry', false, () => { // SPA boot retry: ChatGPT/Gemini/Angular render chat elements late; keep // trying to find input+send for 30s (every 2s), then stop. let _bootRetry = 0; const _bootInterval = setInterval(() => { _bootRetry++; const inp = _q('input', PLAT.input); if (inp) { clearInterval(_bootInterval); GHOST.loop.detail = `βœ“ Connected to ${PLAT.label}`; render(); DIAG.push(`Boot: elements found after ${_bootRetry * 2}s`); } else if (_bootRetry >= 15) { clearInterval(_bootInterval); DIAG.push('Boot: gave up waiting for elements after 30s'); } else { _cache.clear(); // re-attempt detect during SPA hydration } }, 2000); }); _phase('prior-error-surface', false, () => { /* Surface a PRIOR boot failure once (from GM storage), then clear it, so a failure on an earlier load becomes a reviewable local diagnostic now that the panel is up. Persisted records contain metadata only. */ const lastBoot = GM_getValue('lastBootError', ''); const lastNet = GM_getValue('lastNetInstallError', ''); if (lastBoot) { DIAG.push('Previous page load failed during critical boot.'); Reporter.capture('BOOT-001'); _save('lastBootError', ''); } if (lastNet) { DIAG.push('Previous page load could not install optional network observation.'); Reporter.capture('BOOT-002'); _save('lastNetInstallError', ''); } }); Timeline.record('boot', { version: VER, platform: PLAT.key, degraded: GHOST._degraded }); console.log(`[Ghost in the Loop v${VER}] ${PLAT.label} | ${DIAG.adapter} | tab:${GITL_TAB_ID.slice(0,8)}` + (GHOST._degraded.length ? ` | degraded:${GHOST._degraded.join(',')}` : '')); }); /* PANEL SENTINEL (v8.2.0) β€” bounded, visibility-aware panel liveness. Replaces the v8.1.4 watchdog, which only checked ABSENCE and had no cap, so a page that re-hid the panel each time could drive an unbounded append/remove storm. This version: β€’ treats the panel as "down" when it is disconnected, in a display:none / visibility:hidden subtree, or has zero size (host may move #gitl into a hidden container rather than remove it) β€” re-appending to document.body rescues all of those. NOTE: safe because GITL never hides its OWN root (collapsed state only hides the inner .g-body; the root keeps β‰₯44px); β€’ debounces, and CAPS remounts within a rolling window; β€’ on exceeding the cap, OPENS A CIRCUIT BREAKER: stops observing and shows a visible, dismissible note instead of thrashing forever; β€’ disconnects observers/timers on teardown. */ function startPanelSentinel() { const MAX = 5, WINDOW_MS = 30000, DEBOUNCE_MS = 120; let mo = null, poll = null, scheduled = null, opened = false; const times = []; const isDown = () => { const n = document.getElementById('gitl'); if (!n || !n.isConnected || !document.body) return true; try { const st = getComputedStyle(n); if (st.display === 'none' || st.visibility === 'hidden') return true; const r = n.getBoundingClientRect(); if (r.width <= 2 && r.height <= 2) return true; } catch(_) {} return false; }; const teardown = () => { try { mo && mo.disconnect(); } catch(_) {} if (poll) clearInterval(poll); if (scheduled) clearTimeout(scheduled); mo = poll = scheduled = null; }; const openBreaker = () => { opened = true; teardown(); _beacon('sentinel-open'); Timeline.record('panel_circuit_open', { remounts: times.length, windowMs: WINDOW_MS }); try { DIAG.push('Panel sentinel opened: the page kept removing/hiding the panel β€” stopped re-mounting to avoid a loop.'); } catch(_) {} // Visible, dismissible note (reuses the fatal-banner style, distinct id). try { if (document.getElementById('gitl-sentinel')) return; const b = document.createElement('div'); b.id = 'gitl-sentinel'; b.setAttribute('style', 'position:fixed;top:0;left:0;right:0;z-index:2147483646;background:#2a230a;color:#ffe6a6;font:600 12px/1.4 system-ui,sans-serif;padding:9px 32px 9px 12px;border-bottom:2px solid #d9a441;white-space:pre-wrap'); b.textContent = "πŸ‘» Ghost's panel keeps being removed by this page, so it stopped re-adding it. Tap πŸ”„ re-detect or reload to try again."; const x = document.createElement('span'); x.textContent = 'Γ—'; x.setAttribute('style', 'position:absolute;top:5px;right:10px;cursor:pointer;font-size:18px;line-height:1'); x.addEventListener('click', () => b.remove()); b.appendChild(x); (document.body || document.documentElement).appendChild(b); } catch(_) {} }; const ensure = () => { if (opened || !isDown()) return; const now = Date.now(); while (times.length && now - times[0] > WINDOW_MS) times.shift(); if (times.length >= MAX) { openBreaker(); return; } times.push(now); // Re-append the SAME node (state + event handlers intact). _panelMounted = false; mountPanel(); _beacon('remounted:' + times.length); Timeline.record('panel_remount', { count: times.length }); try { DIAG.push('Panel was removed/hidden by the page β€” re-mounted (#' + times.length + ')'); } catch(_) {} render(); }; const schedule = () => { if (opened || scheduled) return; scheduled = setTimeout(() => { scheduled = null; ensure(); }, DEBOUNCE_MS); }; mo = new MutationObserver(schedule); mo.observe(document.documentElement, { childList: true, subtree: true }); // Belt-and-suspenders: catch body swaps / CSS-only hides the observer may miss. poll = setInterval(ensure, 3000); } } catch(__gitlBootErr) { _gitlFatal('top-level', __gitlBootErr); } })();