diff --git a/src/ai-repo-commander.user.js b/src/ai-repo-commander.user.js index af99a04..a177b59 100644 --- a/src/ai-repo-commander.user.js +++ b/src/ai-repo-commander.user.js @@ -19,304 +19,305 @@ /* global GM_xmlhttpRequest */ (function () { - 'use strict'; + 'use strict'; - // ---------------------- Storage keys ---------------------- - const STORAGE_KEYS = { - history: 'ai_repo_commander_executed', - cfg: 'ai_repo_commander_cfg', - panel: 'ai_repo_commander_panel_state' - }; + // ---------------------- Storage keys ---------------------- + const STORAGE_KEYS = { + history: 'ai_repo_commander_executed', + cfg: 'ai_repo_commander_cfg', + panel: 'ai_repo_commander_panel_state' + }; - // ---------------------- Config (with persistence) ---------------------- - const DEFAULT_CONFIG = { - ENABLE_API: true, - DEBUG_MODE: true, - DEBUG_LEVEL: 2, // 0=off, 1=errors, 2=info, 3=verbose, 4=trace - DEBUG_WATCH_MS: 120000, - DEBUG_MAX_LINES: 400, - DEBUG_SHOW_PANEL: true, + // ---------------------- Config (with persistence) ---------------------- + const DEFAULT_CONFIG = { + ENABLE_API: true, + DEBUG_MODE: true, + DEBUG_LEVEL: 2, // 0=off, 1=errors, 2=info, 3=verbose, 4=trace + DEBUG_WATCH_MS: 120000, + DEBUG_MAX_LINES: 400, + DEBUG_SHOW_PANEL: true, - // Timing & API - // If you see "debouncing → error" in logs (assistant streams very slowly), - // try bumping DEBOUNCE_DELAY by +1000–2000 and/or SETTLE_CHECK_MS by +400–800. + // Timing & API + // If you see "debouncing → error" in logs (assistant streams very slowly), + // try bumping DEBOUNCE_DELAY by +1000–2000 and/or SETTLE_CHECK_MS by +400–800. - DEBOUNCE_DELAY: 6500, - MAX_RETRIES: 2, - VERSION: '1.6.2', - API_TIMEOUT_MS: 60000, + DEBOUNCE_DELAY: 6500, + MAX_RETRIES: 2, + VERSION: '1.6.2', + API_TIMEOUT_MS: 60000, - PROCESS_EXISTING: false, - ASSISTANT_ONLY: true, - BRIDGE_KEY: '', + PROCESS_EXISTING: false, + ASSISTANT_ONLY: true, + BRIDGE_KEY: '', - // Persistent dedupe window - DEDUPE_TTL_MS: 30 * 24 * 60 * 60 * 1000, // 30 days + // Persistent dedupe window + DEDUPE_TTL_MS: 30 * 24 * 60 * 60 * 1000, // 30 days - COLD_START_MS: 2000, - SHOW_EXECUTED_MARKER: true, + COLD_START_MS: 2000, + SHOW_EXECUTED_MARKER: true, - // Housekeeping - CLEANUP_AFTER_MS: 30000, - CLEANUP_INTERVAL_MS: 60000, + // Housekeeping + CLEANUP_AFTER_MS: 30000, + CLEANUP_INTERVAL_MS: 60000, - // Paste + submit behavior - APPEND_TRAILING_NEWLINE: true, - AUTO_SUBMIT: true, - POST_PASTE_DELAY_MS: 600, - SUBMIT_MODE: 'button_first', - MAX_COMPOSER_WAIT_MS: 15 * 60 * 1000, // 15 minutes - SUBMIT_MAX_RETRIES: 12, + // Paste + submit behavior + APPEND_TRAILING_NEWLINE: true, + AUTO_SUBMIT: true, + POST_PASTE_DELAY_MS: 600, + SUBMIT_MODE: 'button_first', + MAX_COMPOSER_WAIT_MS: 15 * 60 * 1000, // 15 minutes + SUBMIT_MAX_RETRIES: 12, - // Streaming-complete hardening - // SETTLE_CHECK_MS is the "stable window" after last text change; - // SETTLE_POLL_MS is how often we re-check the code block. - REQUIRE_TERMINATOR: true, - SETTLE_CHECK_MS: 1300, - SETTLE_POLL_MS: 250, + // Streaming-complete hardening + // SETTLE_CHECK_MS is the "stable window" after last text change; + // SETTLE_POLL_MS is how often we re-check the code block. + REQUIRE_TERMINATOR: true, + SETTLE_CHECK_MS: 1300, + SETTLE_POLL_MS: 250, - // Runtime toggles - RUNTIME: { PAUSED: false }, + // Runtime toggles + RUNTIME: { PAUSED: false }, - // New additions for hardening - STUCK_AFTER_MS: 10 * 60 * 1000, - SCAN_DEBOUNCE_MS: 400, - FAST_WARN_MS: 50, - SLOW_WARN_MS: 60_000, - CLUSTER_RESCAN_MS: 1000, // time window to rescan adjacent messages - CLUSTER_MAX_LOOKAHEAD: 3, // how many adjacent assistant messages to check + // New additions for hardening + STUCK_AFTER_MS: 10 * 60 * 1000, + SCAN_DEBOUNCE_MS: 400, + FAST_WARN_MS: 50, + SLOW_WARN_MS: 60_000, + CLUSTER_RESCAN_MS: 1000, // time window to rescan adjacent messages + CLUSTER_MAX_LOOKAHEAD: 3, // how many adjacent assistant messages to check - // Queue management - QUEUE_MIN_DELAY_MS: 1500, - QUEUE_MAX_PER_MINUTE: 15, - QUEUE_MAX_PER_MESSAGE: 5, - QUEUE_WAIT_FOR_COMPOSER_MS: 12000, + // Queue management + QUEUE_MIN_DELAY_MS: 1500, + QUEUE_MAX_PER_MINUTE: 15, + QUEUE_MAX_PER_MESSAGE: 5, + QUEUE_WAIT_FOR_COMPOSER_MS: 12000, - RESPONSE_BUFFER_FLUSH_DELAY_MS: 500, // wait for siblings to finish - RESPONSE_BUFFER_SECTION_HEADINGS: true, + RESPONSE_BUFFER_FLUSH_DELAY_MS: 500, // wait for siblings to finish + RESPONSE_BUFFER_SECTION_HEADINGS: true, - MAX_PASTE_CHARS: 250_000, // hard cap per message - SPLIT_LONG_RESPONSES: true, // enable multi-message split + MAX_PASTE_CHARS: 250_000, // hard cap per message + SPLIT_LONG_RESPONSES: true, // enable multi-message split - }; + }; - function loadSavedConfig() { - try { - const raw = localStorage.getItem(STORAGE_KEYS.cfg); - if (!raw) return structuredClone(DEFAULT_CONFIG); - const saved = JSON.parse(raw); - return { ...DEFAULT_CONFIG, ...saved, RUNTIME: { ...DEFAULT_CONFIG.RUNTIME, ...(saved.RUNTIME || {}) } }; - } catch { - return structuredClone(DEFAULT_CONFIG); - } - } - function saveConfig(cfg) { - try { localStorage.setItem(STORAGE_KEYS.cfg, JSON.stringify(cfg)); } catch {} - } - - const CONFIG = loadSavedConfig(); - - // Ensure response buffer singleton exists before command execution - if (!window.AI_REPO_RESPONSES) { - window.AI_REPO_RESPONSES = new ResponseBuffer(); - } - - // ---------------------- Debug Console ---------------------- - let RC_DEBUG = null; - - class DebugConsole { - constructor(cfg) { - this.cfg = cfg; - this.buf = []; - this.loopCounts = new Map(); - this.startedAt = Date.now(); - this.panel = null; - this.bodyLogs = null; - this.bodyTools = null; - this.collapsed = false; - this.drag = { active: false, dx: 0, dy: 0 }; - this.panelState = this._loadPanelState(); - - this.loopCleanupInterval = setInterval(() => { - if (Date.now() - this.startedAt > this.cfg.DEBUG_WATCH_MS * 2) { - this.loopCounts.clear(); - this.startedAt = Date.now(); - this.verbose('Cleaned loop counters'); + function loadSavedConfig() { + try { + const raw = localStorage.getItem(STORAGE_KEYS.cfg); + if (!raw) return structuredClone(DEFAULT_CONFIG); + const saved = JSON.parse(raw); + // Always use current script's VERSION and RUNTIME, not stale values from storage + delete saved.VERSION; + delete saved.RUNTIME; + return { ...DEFAULT_CONFIG, ...saved, RUNTIME: { ...DEFAULT_CONFIG.RUNTIME, ...(saved.RUNTIME || {}) } }; + } catch { + return structuredClone(DEFAULT_CONFIG); } - }, this.cfg.DEBUG_WATCH_MS); - - if (cfg.DEBUG_SHOW_PANEL) this.mount(); - this.info(`Debug console ready (level=${cfg.DEBUG_LEVEL})`); + } + function saveConfig(cfg) { + try { + const { VERSION, RUNTIME, ...persistable } = cfg; + localStorage.setItem(STORAGE_KEYS.cfg, JSON.stringify(persistable)); + } catch {} } - _loadPanelState() { - try { - return JSON.parse(localStorage.getItem(STORAGE_KEYS.panel) || '{}'); - } catch { return {}; } - } - _savePanelState(partial) { - try { - const merged = { ...(this.panelState || {}), ...(partial || {}) }; - this.panelState = merged; - localStorage.setItem(STORAGE_KEYS.panel, JSON.stringify(merged)); - } catch {} - } + const CONFIG = loadSavedConfig(); - // Levels - error(msg, data) { this._log(1, 'ERROR', msg, data); } - warn(msg, data) { this._log(2, 'WARN', msg, data); } - info(msg, data) { this._log(3, 'INFO', msg, data); } - verbose(msg, data){ this._log(4, 'VERB', msg, data); } - trace(msg, data) { this._log(5, 'TRACE', msg, data); } + // ---------------------- Debug Console ---------------------- + let RC_DEBUG = null; - command(action, status, extra={}) { - const icon = { detected:'👁️', parsing:'📝', validating:'✓', debouncing:'⏳', executing:'⚙️', complete:'✅', error:'❌' }[status] || '•'; - this.info(`${icon} ${action} [${status}]`, extra); - } + class DebugConsole { + constructor(cfg) { + this.cfg = cfg; + this.buf = []; + this.loopCounts = new Map(); + this.startedAt = Date.now(); + this.panel = null; + this.bodyLogs = null; + this.bodyTools = null; + this.collapsed = false; + this.drag = { active: false, dx: 0, dy: 0 }; + this.panelState = this._loadPanelState(); - nowIso() { return new Date().toISOString(); } - withinWatch() { return Date.now() - this.startedAt <= this.cfg.DEBUG_WATCH_MS; } + this.loopCleanupInterval = setInterval(() => { + if (Date.now() - this.startedAt > this.cfg.DEBUG_WATCH_MS * 2) { + this.loopCounts.clear(); + this.startedAt = Date.now(); + this.verbose('Cleaned loop counters'); + } + }, this.cfg.DEBUG_WATCH_MS); - logLoop(kind, msg) { - const k = `${kind}:${msg}`; - const cur = this.loopCounts.get(k) || 0; - if (!this.withinWatch() && kind !== 'WARN') return; - if (cur >= 10) return; - this.loopCounts.set(k, cur + 1); - const suffix = (cur + 1) > 1 ? ` (${cur + 1}x)` : ''; - if (kind === 'ERROR') this.error(`${msg}${suffix}`); - else if (kind === 'WARN') this.warn(`${msg}${suffix}`); - else this.info(`${msg}${suffix}`); - } - - copyLast(n=50) { - const lines = this.buf.slice(-n).map(e => `${e.ts} ${e.level.padEnd(5)} ${e.msg}${e.data? ' ' + JSON.stringify(e.data): ''}`).join('\n'); - - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(lines).then(() => { - this.info(`Copied last ${Math.min(n, this.buf.length)} lines to clipboard`); - }).catch(e => this._fallbackCopy(lines, e)); - } else { - this._fallbackCopy(lines); - } - } - - _fallbackCopy(text, originalError = null) { - try { - // Show a minimal manual copy UI (no deprecated execCommand) - const overlay = document.createElement('div'); - overlay.style.cssText = 'position:fixed;inset:0;z-index:999999;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;'; - const panel = document.createElement('div'); - panel.style.cssText = 'background:#1f2937;color:#e5e7eb;padding:12px 12px 8px;border-radius:8px;width:min(760px,90vw);max-height:70vh;display:flex;flex-direction:column;gap:8px;box-shadow:0 10px 30px rgba(0,0,0,0.5)'; - const title = document.createElement('div'); - title.textContent = 'Copy to clipboard'; - title.style.cssText = 'font:600 14px system-ui,sans-serif;'; - const hint = document.createElement('div'); - hint.textContent = 'Press Ctrl+C (Windows/Linux) or ⌘+C (macOS) to copy the selected text.'; - hint.style.cssText = 'font:12px system-ui,sans-serif;opacity:0.85;'; - const ta = document.createElement('textarea'); - ta.value = text; - ta.readOnly = true; - ta.style.cssText = 'width:100%;height:40vh;font:12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;'; - const row = document.createElement('div'); - row.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;'; - const close = document.createElement('button'); - close.textContent = 'Close'; - close.style.cssText = 'padding:6px 10px;background:#374151;color:#e5e7eb;border:1px solid #4b5563;border-radius:6px;cursor:pointer;'; - close.onclick = () => overlay.remove(); - panel.append(title, hint, ta, row); - row.append(close); - overlay.append(panel); - document.body.appendChild(overlay); - // Focus and select - ta.focus(); - ta.select(); - this.warn('Clipboard API unavailable; showing manual copy UI', { error: originalError?.message }); - } catch (e) { - this.warn('Clipboard copy failed', { error: originalError?.message || e.message }); - } - } - - setLevel(n) { - const lv = Math.max(0, Math.min(4, n)); - this.cfg.DEBUG_LEVEL = lv; - saveConfig(this.cfg); - this.info(`Log level => ${lv}`); - } - - _sanitize(data) { - if (!data) return null; - try { - if (data instanceof HTMLElement) return '[HTMLElement]'; - if (typeof data === 'string' && data.length > 400) return data.slice(0,400)+'…'; - if (typeof data === 'object') { - const clone = { ...data }; - if (clone.element instanceof HTMLElement) clone.element = '[HTMLElement]'; - return clone; + if (cfg.DEBUG_SHOW_PANEL) this.mount(); + this.info(`Debug console ready (level=${cfg.DEBUG_LEVEL})`); } - } catch {} - return data; - } - _log(numericLevel, levelName, msg, data) { - if (!this.cfg.DEBUG_MODE) return; - const thresholdMap = { 0: 0, 1: 1, 2: 3, 3: 4, 4: 5 }; - const threshold = thresholdMap[this.cfg.DEBUG_LEVEL] ?? 0; - if (numericLevel > threshold) return; + _loadPanelState() { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEYS.panel) || '{}'); + } catch { return {}; } + } + _savePanelState(partial) { + try { + const merged = { ...(this.panelState || {}), ...(partial || {}) }; + this.panelState = merged; + localStorage.setItem(STORAGE_KEYS.panel, JSON.stringify(merged)); + } catch {} + } - const entry = { ts: this.nowIso(), level: levelName, msg: String(msg), data: this._sanitize(data) }; - this.buf.push(entry); - if (this.buf.length > this.cfg.DEBUG_MAX_LINES) this.buf.splice(0, this.buf.length - this.cfg.DEBUG_MAX_LINES); - if (this.cfg.DEBUG_LEVEL >= 3) { - const prefix = `[AI RC]`; - if (entry.data != null) console.log(prefix, entry.level, entry.msg, entry.data); - else console.log(prefix, entry.level, entry.msg); - } - if (this.panel) this._renderRow(entry); - } + // Levels + error(msg, data) { this._log(1, 'ERROR', msg, data); } + warn(msg, data) { this._log(2, 'WARN', msg, data); } + info(msg, data) { this._log(3, 'INFO', msg, data); } + verbose(msg, data){ this._log(4, 'VERB', msg, data); } + trace(msg, data) { this._log(5, 'TRACE', msg, data); } - _renderRow(e) { - if (!this.bodyLogs) return; - const row = document.createElement('div'); - row.style.cssText = 'padding:4px 0;border-bottom:1px dashed #2a2a34;white-space:pre-wrap;word-break:break-word;'; - row.textContent = `${e.ts} ${e.level.padEnd(5)} ${e.msg}${e.data? ' ' + JSON.stringify(e.data): ''}`; - this.bodyLogs.appendChild(row); - while (this.bodyLogs.children.length > this.cfg.DEBUG_MAX_LINES) this.bodyLogs.firstChild.remove(); - this.bodyLogs.scrollTop = this.bodyLogs.scrollHeight; - } + command(action, status, extra={}) { + const icon = { detected:'👁️', parsing:'📝', validating:'✓', debouncing:'⏳', executing:'⚙️', complete:'✅', error:'❌' }[status] || '•'; + this.info(`${icon} ${action} [${status}]`, extra); + } - flashBtn(btn, label = 'Done', ms = 900) { - if (!btn) return; - const old = btn.textContent; - btn.disabled = true; - btn.textContent = `${label} ✓`; - btn.style.opacity = '0.7'; - setTimeout(() => { - btn.disabled = false; - btn.textContent = old; - btn.style.opacity = ''; - }, ms); - } + nowIso() { return new Date().toISOString(); } + withinWatch() { return Date.now() - this.startedAt <= this.cfg.DEBUG_WATCH_MS; } - toast(msg, ms = 1200) { - if (!this.panel) return; - const t = document.createElement('div'); - t.textContent = msg; - t.style.cssText = ` + logLoop(kind, msg) { + const k = `${kind}:${msg}`; + const cur = this.loopCounts.get(k) || 0; + if (!this.withinWatch() && kind !== 'WARN') return; + if (cur >= 10) return; + this.loopCounts.set(k, cur + 1); + const suffix = (cur + 1) > 1 ? ` (${cur + 1}x)` : ''; + if (kind === 'ERROR') this.error(`${msg}${suffix}`); + else if (kind === 'WARN') this.warn(`${msg}${suffix}`); + else this.info(`${msg}${suffix}`); + } + + copyLast(n=50) { + const lines = this.buf.slice(-n).map(e => `${e.ts} ${e.level.padEnd(5)} ${e.msg}${e.data? ' ' + JSON.stringify(e.data): ''}`).join('\n'); + + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(lines).then(() => { + this.info(`Copied last ${Math.min(n, this.buf.length)} lines to clipboard`); + }).catch(e => this._fallbackCopy(lines, e)); + } else { + this._fallbackCopy(lines); + } + } + + _fallbackCopy(text, originalError = null) { + try { + // Show a minimal manual copy UI (no deprecated execCommand) + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;z-index:999999;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;'; + const panel = document.createElement('div'); + panel.style.cssText = 'background:#1f2937;color:#e5e7eb;padding:12px 12px 8px;border-radius:8px;width:min(760px,90vw);max-height:70vh;display:flex;flex-direction:column;gap:8px;box-shadow:0 10px 30px rgba(0,0,0,0.5)'; + const title = document.createElement('div'); + title.textContent = 'Copy to clipboard'; + title.style.cssText = 'font:600 14px system-ui,sans-serif;'; + const hint = document.createElement('div'); + hint.textContent = 'Press Ctrl+C (Windows/Linux) or ⌘+C (macOS) to copy the selected text.'; + hint.style.cssText = 'font:12px system-ui,sans-serif;opacity:0.85;'; + const ta = document.createElement('textarea'); + ta.value = text; + ta.readOnly = true; + ta.style.cssText = 'width:100%;height:40vh;font:12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;'; + const row = document.createElement('div'); + row.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;'; + const close = document.createElement('button'); + close.textContent = 'Close'; + close.style.cssText = 'padding:6px 10px;background:#374151;color:#e5e7eb;border:1px solid #4b5563;border-radius:6px;cursor:pointer;'; + close.onclick = () => overlay.remove(); + panel.append(title, hint, ta, row); + row.append(close); + overlay.append(panel); + document.body.appendChild(overlay); + // Focus and select + ta.focus(); + ta.select(); + this.warn('Clipboard API unavailable; showing manual copy UI', { error: originalError?.message }); + } catch (e) { + this.warn('Clipboard copy failed', { error: originalError?.message || e.message }); + } + } + + setLevel(n) { + const lv = Math.max(0, Math.min(4, n)); + this.cfg.DEBUG_LEVEL = lv; + saveConfig(this.cfg); + this.info(`Log level => ${lv}`); + } + + _sanitize(data) { + if (!data) return null; + try { + if (data instanceof HTMLElement) return '[HTMLElement]'; + if (typeof data === 'string' && data.length > 400) return data.slice(0,400)+'…'; + if (typeof data === 'object') { + const clone = { ...data }; + if (clone.element instanceof HTMLElement) clone.element = '[HTMLElement]'; + return clone; + } + } catch {} + return data; + } + + _log(numericLevel, levelName, msg, data) { + if (!this.cfg.DEBUG_MODE) return; + const thresholdMap = { 0: 0, 1: 1, 2: 3, 3: 4, 4: 5 }; + const threshold = thresholdMap[this.cfg.DEBUG_LEVEL] ?? 0; + if (numericLevel > threshold) return; + + const entry = { ts: this.nowIso(), level: levelName, msg: String(msg), data: this._sanitize(data) }; + this.buf.push(entry); + if (this.buf.length > this.cfg.DEBUG_MAX_LINES) this.buf.splice(0, this.buf.length - this.cfg.DEBUG_MAX_LINES); + if (this.cfg.DEBUG_LEVEL >= 3) { + const prefix = `[AI RC]`; + if (entry.data != null) console.log(prefix, entry.level, entry.msg, entry.data); + else console.log(prefix, entry.level, entry.msg); + } + if (this.panel) this._renderRow(entry); + } + + _renderRow(e) { + if (!this.bodyLogs) return; + const row = document.createElement('div'); + row.style.cssText = 'padding:4px 0;border-bottom:1px dashed #2a2a34;white-space:pre-wrap;word-break:break-word;'; + row.textContent = `${e.ts} ${e.level.padEnd(5)} ${e.msg}${e.data? ' ' + JSON.stringify(e.data): ''}`; + this.bodyLogs.appendChild(row); + while (this.bodyLogs.children.length > this.cfg.DEBUG_MAX_LINES) this.bodyLogs.firstChild.remove(); + this.bodyLogs.scrollTop = this.bodyLogs.scrollHeight; + } + + flashBtn(btn, label = 'Done', ms = 900) { + if (!btn) return; + const old = btn.textContent; + btn.disabled = true; + btn.textContent = `${label} ✓`; + btn.style.opacity = '0.7'; + setTimeout(() => { + btn.disabled = false; + btn.textContent = old; + btn.style.opacity = ''; + }, ms); + } + + toast(msg, ms = 1200) { + if (!this.panel) return; + const t = document.createElement('div'); + t.textContent = msg; + t.style.cssText = ` position:absolute; right:12px; bottom:12px; padding:6px 10px; background:#111827; color:#e5e7eb; border:1px solid #374151; border-radius:6px; font:12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; opacity:.98; pointer-events:none; box-shadow:0 6px 20px rgba(0,0,0,.35) `; - this.panel.appendChild(t); - setTimeout(() => t.remove(), ms); - } + this.panel.appendChild(t); + setTimeout(() => t.remove(), ms); + } - mount() { - if (!document.body) { setTimeout(() => this.mount(), 100); return; } + mount() { + if (!document.body) { setTimeout(() => this.mount(), 100); return; } - const root = document.createElement('div'); - root.style.cssText = ` + const root = document.createElement('div'); + root.style.cssText = ` position: fixed; ${this.panelState.left!==undefined ? `left:${this.panelState.left}px; top:${this.panelState.top}px;` : 'right:16px; bottom:16px;'} z-index: 2147483647; width: 460px; max-height: 55vh; display: flex; flex-direction: column; @@ -324,7 +325,7 @@ color:#e5e7eb; font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; box-shadow: 0 16px 40px rgba(0,0,0,0.55); backdrop-filter: blur(4px); `; - root.innerHTML = ` + root.innerHTML = `
AI Repo Commander
@@ -432,2512 +433,2652 @@
`; - document.body.appendChild(root); - this.panel = root; - this.bodyLogs = root.querySelector('.rc-body-logs'); - this.bodyTools = root.querySelector('.rc-body-tools'); + document.body.appendChild(root); + this.panel = root; + this.bodyLogs = root.querySelector('.rc-body-logs'); + this.bodyTools = root.querySelector('.rc-body-tools'); - // Controls - const sel = root.querySelector('.rc-level'); - sel.value = String(this.cfg.DEBUG_LEVEL); - sel.addEventListener('change', () => this.setLevel(parseInt(sel.value,10))); + // Controls + const sel = root.querySelector('.rc-level'); + sel.value = String(this.cfg.DEBUG_LEVEL); + sel.addEventListener('change', () => this.setLevel(parseInt(sel.value,10))); - root.querySelector('.rc-copy').addEventListener('click', (e) => { - this.copyLast(50); - this.flashBtn(e.currentTarget, 'Copied'); - this.toast('Copied last 50 logs'); - }); - - root.querySelector('.rc-copy-200').addEventListener('click', (e) => { - this.copyLast(200); - this.flashBtn(e.currentTarget, 'Copied'); - this.toast('Copied last 200 logs'); - }); - - const pauseBtn = root.querySelector('.rc-pause'); - pauseBtn.addEventListener('click', () => { - const wasPaused = this.cfg.RUNTIME.PAUSED; - this.cfg.RUNTIME.PAUSED = !this.cfg.RUNTIME.PAUSED; - saveConfig(this.cfg); - - pauseBtn.textContent = this.cfg.RUNTIME.PAUSED ? 'Resume' : 'Pause'; - pauseBtn.style.background = this.cfg.RUNTIME.PAUSED ? '#f59e0b' : ''; - pauseBtn.style.color = this.cfg.RUNTIME.PAUSED ? '#111827' : ''; - this.flashBtn(pauseBtn, this.cfg.RUNTIME.PAUSED ? 'Paused' : 'Resumed'); - this.toast(this.cfg.RUNTIME.PAUSED ? 'Paused scanning' : 'Resumed scanning'); - this.info(`Runtime ${this.cfg.RUNTIME.PAUSED ? 'paused' : 'resumed'}`); - - // When RESUMING: start a short cold-start window and mark current hits as processed. - if (wasPaused && !this.cfg.RUNTIME.PAUSED) { - if (commandMonitor) { - commandMonitor.coldStartUntil = Date.now() + (CONFIG.COLD_START_MS || 2000); - } - markExistingHitsAsProcessed(); - } - }); - - - // Queue clear button - const queueBtn = root.querySelector('.rc-queue-clear'); - queueBtn.addEventListener('click', (e) => { - window.AI_REPO_QUEUE?.clear?.(); - this.flashBtn(e.currentTarget, 'Cleared'); - this.toast('Queue cleared'); - this.warn('Command queue cleared'); - }); - - root.querySelector('.rc-stop').addEventListener('click', (e) => { - window.AI_REPO_STOP?.(); - this.flashBtn(e.currentTarget, 'Stopped'); - this.toast('Emergency STOP activated'); - this.warn('Emergency STOP activated'); - }); - - // Tabs - const tabLogs = root.querySelector('.rc-tab-logs'); - const tabTools = root.querySelector('.rc-tab-tools'); - const selectTab = (tools=false) => { - this.bodyLogs.style.display = tools ? 'none' : 'block'; - this.bodyTools.style.display = tools ? 'block' : 'none'; - tabLogs.style.background = tools ? '#111827' : '#1f2937'; - tabTools.style.background = tools ? '#1f2937' : '#111827'; - }; - tabLogs.addEventListener('click', () => selectTab(false)); - - tabTools.addEventListener('click', () => { - selectTab(true); - root.querySelectorAll('.rc-toggle').forEach(inp => { - const key = inp.dataset.key; - inp.checked = !!this.cfg[key]; - }); - root.querySelectorAll('.rc-num').forEach(inp => { - inp.value = String(this.cfg[inp.dataset.key] ?? ''); - }); - - const dump = JSON.parse(JSON.stringify(this.cfg)); - if (dump.BRIDGE_KEY) dump.BRIDGE_KEY = '•'.repeat(8); - root.querySelector('.rc-json').value = JSON.stringify(dump, null, 2); - - const bridgeKeyInput = root.querySelector('.rc-bridge-key'); - if (bridgeKeyInput) bridgeKeyInput.value = this.cfg.BRIDGE_KEY ? '•'.repeat(8) : ''; - }); - - // Collapse - const collapseBtn = root.querySelector('.rc-collapse'); - const setCollapsed = (c) => { - this.collapsed = c; - this.bodyLogs.style.display = c ? 'none' : 'block'; - this.bodyTools.style.display = 'none'; - collapseBtn.textContent = c ? '▸' : '▾'; - this._savePanelState({ collapsed: c }); - }; - setCollapsed(!!this.panelState.collapsed); - collapseBtn.addEventListener('click', () => setCollapsed(!this.collapsed)); - - // Dragging - const header = root.querySelector('.rc-header'); - header.addEventListener('mousedown', (e) => { - const tgt = e.target instanceof Element ? e.target : e.target?.parentElement; - if (tgt?.closest('button,select,input,textarea,label')) return; - this.drag.active = true; - const rect = root.getBoundingClientRect(); - this.drag.dx = e.clientX - rect.left; - this.drag.dy = e.clientY - rect.top; - root.style.right = 'auto'; root.style.bottom = 'auto'; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }); - const onMove = (e) => { - if (!this.drag.active) return; - const x = Math.max(0, Math.min(window.innerWidth - this.panel.offsetWidth, e.clientX - this.drag.dx)); - const y = Math.max(0, Math.min(window.innerHeight - 40, e.clientY - this.drag.dy)); - this.panel.style.left = `${x}px`; - this.panel.style.top = `${y}px`; - }; - const onUp = () => { - if (!this.drag.active) return; - this.drag.active = false; - this._savePanelState({ left: parseInt(this.panel.style.left||'0',10), top: parseInt(this.panel.style.top||'0',10) }); - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - }; - - // Tools: Clear History - root.querySelector('.rc-clear-history').addEventListener('click', (e) => { - try { - commandMonitor?.history?.resetAll?.(); - RC_DEBUG?.info('Conversation history cleared'); - GM_notification({ title: 'AI Repo Commander', text: 'Execution marks cleared', timeout: 2500 }); - } catch { - localStorage.removeItem(STORAGE_KEYS.history); - RC_DEBUG?.info('Legacy history key cleared'); - } - this.flashBtn(e.currentTarget, 'Cleared'); - this.toast('Conversation marks cleared'); - }); - - // Tools: toggles & numbers - root.querySelectorAll('.rc-toggle').forEach(inp => { - const key = inp.dataset.key; - inp.checked = !!this.cfg[key]; - inp.addEventListener('change', () => { - this.cfg[key] = !!inp.checked; - saveConfig(this.cfg); - this.toast(`${key} = ${this.cfg[key] ? 'on' : 'off'}`); - this.info(`Config ${key} => ${this.cfg[key]}`); - }); - }); - root.querySelectorAll('.rc-num').forEach(inp => { - inp.value = String(this.cfg[inp.dataset.key] ?? ''); - inp.addEventListener('change', () => { - const v = parseInt(inp.value, 10); - if (!Number.isNaN(v)) { - this.cfg[inp.dataset.key] = v; - saveConfig(this.cfg); - this.toast(`${inp.dataset.key} = ${v}`); - this.info(`Config ${inp.dataset.key} => ${v}`); - } - }); - }); - - // Tools: JSON input - root.querySelector('.rc-save-json').addEventListener('click', (e) => { - try { - const raw = root.querySelector('.rc-json').value; - const parsed = JSON.parse(raw); - - if (Object.prototype.hasOwnProperty.call(parsed, 'BRIDGE_KEY')) { - const v = (parsed.BRIDGE_KEY ?? '').toString().trim(); - if (v && !/^•+$/.test(v)) { - this.cfg.BRIDGE_KEY = v; - BRIDGE_KEY = v; - } - delete parsed.BRIDGE_KEY; - } - - Object.assign(this.cfg, parsed); - saveConfig(this.cfg); - - const dump = JSON.parse(JSON.stringify(this.cfg)); - if (dump.BRIDGE_KEY) dump.BRIDGE_KEY = '•'.repeat(8); - root.querySelector('.rc-json').value = JSON.stringify(dump, null, 2); - - this.flashBtn(e.currentTarget, 'Saved'); - this.toast('Config saved'); - this.info('Config JSON saved'); - } catch (err) { - this.toast('Invalid JSON', 1500); - this.warn('Invalid JSON in config textarea', { error: String(err) }); - } - }); - - root.querySelector('.rc-reset-defaults').addEventListener('click', (e) => { - Object.assign(this.cfg, structuredClone(DEFAULT_CONFIG)); - saveConfig(this.cfg); - BRIDGE_KEY = null; - const bridgeKeyInput = root.querySelector('.rc-bridge-key'); - if (bridgeKeyInput) bridgeKeyInput.value = ''; - this.flashBtn(e.currentTarget, 'Reset'); - this.toast('Defaults restored'); - this.info('Config reset to defaults'); - }); - - // Set initial UI states - pauseBtn.textContent = this.cfg.RUNTIME.PAUSED ? 'Resume' : 'Pause'; - if (this.cfg.RUNTIME.PAUSED) { - pauseBtn.style.background = '#f59e0b'; - pauseBtn.style.color = '#111827'; - } - - // Bridge Key handlers - const bridgeKeyInput = root.querySelector('.rc-bridge-key'); - bridgeKeyInput.value = this.cfg.BRIDGE_KEY ? '•'.repeat(8) : ''; - - root.querySelector('.rc-save-bridge-key').addEventListener('click', (e) => { - const raw = (bridgeKeyInput.value || '').trim(); - if (/^•+$/.test(raw)) { - this.info('Bridge key unchanged'); - GM_notification({ title: 'AI Repo Commander', text: 'Bridge key unchanged', timeout: 2000 }); - return; - } - this.cfg.BRIDGE_KEY = raw; - saveConfig(this.cfg); - BRIDGE_KEY = raw || null; - bridgeKeyInput.value = this.cfg.BRIDGE_KEY ? '•'.repeat(8) : ''; - this.flashBtn(e.currentTarget, 'Saved'); - this.toast('Bridge key saved'); - this.info('Bridge key saved (masked)'); - GM_notification({ title: 'AI Repo Commander', text: 'Bridge key saved', timeout: 2500 }); - }); - - root.querySelector('.rc-clear-bridge-key').addEventListener('click', (e) => { - this.cfg.BRIDGE_KEY = ''; - bridgeKeyInput.value = ''; - saveConfig(this.cfg); - BRIDGE_KEY = null; - this.flashBtn(e.currentTarget, 'Cleared'); - this.toast('Bridge key cleared'); - this.info('Bridge key cleared'); - GM_notification({ title: 'AI Repo Commander', text: 'Bridge key cleared', timeout: 2500 }); - }); - } - - destroy() { - try { clearInterval(this.loopCleanupInterval); } catch {} - if (this.panel && this.panel.parentNode) this.panel.parentNode.removeChild(this.panel); - } - } - - // ---------------------- Platform selectors ---------------------- - const PLATFORM_SELECTORS = { - 'chat.openai.com': { messages: '[data-message-author-role]', input: '#prompt-textarea, textarea, [contenteditable="true"]', content: '.markdown' }, - 'chatgpt.com': { messages: '[data-message-author-role]', input: '#prompt-textarea, textarea, [contenteditable="true"]', content: '.markdown' }, - 'claude.ai': { messages: '.chat-message', input: '[contenteditable="true"]', content: '.content' }, - 'gemini.google.com': { messages: '.message-content', input: 'textarea, [contenteditable="true"]', content: '.message-text' } - }; - - // ---------------------- Fingerprinting helpers (portable) ---------------------- - const MSG_SELECTORS = [ - '[data-message-author-role]', // ChatGPT/OpenAI - '.chat-message', // Claude - '.message-content' // Gemini - ]; - - // Consistent DJB2 variant - function _hash(s) { - let h = 5381; - for (let i = 0; i < s.length; i++) h = ((h << 5) + h) ^ s.charCodeAt(i); - return (h >>> 0).toString(36); - } - - // Normalize text a bit to reduce spurious diffs - function _norm(s) { - return (s || '') - .replace(/\r/g, '') - .replace(/\u200b/g, '') // zero-width - .replace(/[ \t]+\n/g, '\n') // trailing ws - .trim(); - } - - // Extract the *command block* if present; else fall back to element text - function _commandLikeText(el) { - // Mirror parser's detector: require header, action, and '@end@' - const blocks = el.querySelectorAll('pre code, pre, code'); - for (const b of blocks) { - const t = _norm(b.textContent || ''); - if (/@end@\s*$/m.test(t) && /(^|\n)\s*@bridge@\b/m.test(t) && /(^|\n)\s*action\s*:/m.test(t)) { - return t; - } - } - // No command block found; use element text (capped) - return _norm((el.textContent || '').slice(0, 2000)); - } - - // Hash of the command (or element text) capped to 2000 chars - function _hashCommand(el) { - const t = _commandLikeText(el); - return _hash(t.slice(0, 2000)); - } - - // Hash of the *preceding context*: concatenate previous messages' text until ~2000 chars - function _hashPrevContext(el) { - const all = Array.from(document.querySelectorAll(MSG_SELECTORS.join(','))); - const idx = all.indexOf(el); - if (idx <= 0) return '0'; - - let remaining = 2000; - let buf = ''; - for (let i = idx - 1; i >= 0 && remaining > 0; i--) { - const t = _norm(all[i].textContent || ''); - if (!t) continue; - // Prepend last slice so the nearest history weighs most - const take = t.slice(-remaining); - buf = take + buf; - remaining -= take.length; - } - // Hash only the *last* 2000 chars of the collected buffer (stable, compact) - return _hash(buf.slice(-2000)); - } - - // Ordinal among messages that share the same (commandHash, prevCtxHash) - function _ordinalForKey(el, key) { - const list = Array.from(document.querySelectorAll(MSG_SELECTORS.join(','))); - let n = 0; - for (const node of list) { - const nodeKey = node === el - ? key - : (() => { - // Compute on the fly only if needed - const ch = _hashCommand(node); - const ph = _hashPrevContext(node); - return `ch:${ch}|ph:${ph}`; - })(); - if (nodeKey === key) n++; - if (node === el) return n; // 1-based ordinal - } - return 1; - } - - // DOM path hint for extra fingerprint stability - function _domHint(node) { - const p = []; - let n = node; - for (let i = 0; n && i < 4; i++) { // last 4 ancestors - const tag = (n.tagName || '').toLowerCase(); - const cls = (n.className || '').toString().split(/\s+/).slice(0, 2).join('.'); - p.push(tag + (cls ? '.' + cls : '')); - n = n.parentElement; - } - return p.join('>'); - } - - // Main fingerprint function - function fingerprintElement(el) { - // Always use content-based fingerprinting for reliability across reloads - const ch = _hashCommand(el); - const ph = _hashPrevContext(el); - const dh = _hash(_domHint(el)); - const key = `ch:${ch}|ph:${ph}`; - const n = _ordinalForKey(el, key); - const fingerprint = `${key}|hint:${dh}|n:${n}`; - - RC_DEBUG?.trace('Generated fingerprint', { - fingerprint: fingerprint.slice(0, 60) + '...', - commandHash: ch, - prevContextHash: ph, - domHint: dh, - ordinal: n - }); - - return fingerprint; - } - - // ---------------------- Multi-block extraction helpers ---------------------- - function extractAllCompleteBlocks(text) { - const out = []; - const re = /^\s*@bridge@[ \t]*\n([\s\S]*?)\n@end@[ \t]*(?:\n|$)/gm; - let m; - while ((m = re.exec(text)) !== null) { - const inner = (m[1] || '').trimEnd(); - if (inner && /(^|\n)\s*action\s*:/m.test(inner)) out.push(inner); - } - return out; // array of inner texts (without @bridge@/@end@) - } - - function findAllCommandsInMessage(el) { - const blocks = el.querySelectorAll('pre code, pre, code'); - const hits = []; - for (const b of blocks) { - const txt = (b.textContent || '').trim(); - const parts = extractAllCompleteBlocks(txt); - for (const part of parts) hits.push({ blockElement: b, text: `@bridge@\n${part}\n@end@` }); - } - return hits; - } - - // Chainable actions that may trigger cluster rescan - const chainableActions = ['create_repo', 'create_file', 'create_branch', 'update_file', 'delete_file', 'create_pr']; - - // 1) Check if we should trigger a cluster rescan after executing an action - function shouldTriggerClusterRescan(anchorEl, justExecutedAction) { - if (!chainableActions.includes(justExecutedAction)) return false; - - // Check if next sibling is an unprocessed assistant message - let nextSibling = anchorEl?.nextElementSibling; - while (nextSibling) { - // Stop at user messages - if (commandMonitor && !commandMonitor.isAssistantMessage(nextSibling)) return false; - - // Check if it's an assistant message - if (commandMonitor && commandMonitor.isAssistantMessage(nextSibling)) { - // Check if unprocessed (no processed marker) - const hasMarker = nextSibling?.dataset?.aiRcProcessed === '1' || !!nextSibling.querySelector('.ai-rc-queue-badge'); - return !hasMarker; - } - - nextSibling = nextSibling.nextElementSibling; - } - return false; - } - - // 2) Schedule a cluster rescan to check adjacent assistant messages - async function scheduleClusterRescan(anchorEl) { - if (!anchorEl) return; - - RC_DEBUG?.info('Scheduling cluster rescan', { anchor: anchorEl }); - - const deadline = Date.now() + CONFIG.CLUSTER_RESCAN_MS; - let scanned = 0; - let currentEl = anchorEl.nextElementSibling; - - while (currentEl && scanned < CONFIG.CLUSTER_MAX_LOOKAHEAD && Date.now() < deadline) { - // Stop at user message boundaries - if (commandMonitor && !commandMonitor.isAssistantMessage(currentEl)) { - RC_DEBUG?.verbose('Cluster rescan hit user message boundary'); - break; - } - - // Only process assistant messages - if (commandMonitor && commandMonitor.isAssistantMessage(currentEl)) { - // Check if already processed - const hasMarker = currentEl?.dataset?.aiRcProcessed === '1' || !!currentEl.querySelector('.ai-rc-queue-badge'); - if (!hasMarker) { - // Look for new @bridge@ blocks - const hits = findAllCommandsInMessage(currentEl); - if (hits.length > 0) { - RC_DEBUG?.info('Cluster rescan found commands in adjacent message', { count: hits.length }); - - // 1) Set dataset marker - currentEl.dataset.aiRcProcessed = '1'; - - // 2) Slice hits to CONFIG.QUEUE_MAX_PER_MESSAGE - const capped = hits.slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE); - - // 3) Mark and enqueue each command - capped.forEach((h, idx) => { - if (commandMonitor) { - commandMonitor.history.markElement(currentEl, idx + 1); - commandMonitor.enqueueCommand(currentEl, h, idx); - } + root.querySelector('.rc-copy').addEventListener('click', (e) => { + this.copyLast(50); + this.flashBtn(e.currentTarget, 'Copied'); + this.toast('Copied last 50 logs'); }); - // 4) Add queue badge with capped count - attachQueueBadge(currentEl, capped.length); - } + root.querySelector('.rc-copy-200').addEventListener('click', (e) => { + this.copyLast(200); + this.flashBtn(e.currentTarget, 'Copied'); + this.toast('Copied last 200 logs'); + }); + + const pauseBtn = root.querySelector('.rc-pause'); + pauseBtn.addEventListener('click', () => { + const wasPaused = this.cfg.RUNTIME.PAUSED; + this.cfg.RUNTIME.PAUSED = !this.cfg.RUNTIME.PAUSED; + saveConfig(this.cfg); + + pauseBtn.textContent = this.cfg.RUNTIME.PAUSED ? 'Resume' : 'Pause'; + pauseBtn.style.background = this.cfg.RUNTIME.PAUSED ? '#f59e0b' : ''; + pauseBtn.style.color = this.cfg.RUNTIME.PAUSED ? '#111827' : ''; + this.flashBtn(pauseBtn, this.cfg.RUNTIME.PAUSED ? 'Paused' : 'Resumed'); + this.toast(this.cfg.RUNTIME.PAUSED ? 'Paused scanning' : 'Resumed scanning'); + this.info(`Runtime ${this.cfg.RUNTIME.PAUSED ? 'paused' : 'resumed'}`); + + // When RESUMING: start a short cold-start window and mark current hits as processed. + if (wasPaused && !this.cfg.RUNTIME.PAUSED) { + if (commandMonitor) { + commandMonitor.coldStartUntil = Date.now() + (CONFIG.COLD_START_MS || 2000); + } + markExistingHitsAsProcessed(); + } + }); + + + // Queue clear button + const queueBtn = root.querySelector('.rc-queue-clear'); + queueBtn.addEventListener('click', (e) => { + window.AI_REPO_QUEUE?.clear?.(); + this.flashBtn(e.currentTarget, 'Cleared'); + this.toast('Queue cleared'); + this.warn('Command queue cleared'); + }); + + root.querySelector('.rc-stop').addEventListener('click', (e) => { + window.AI_REPO_STOP?.(); + this.flashBtn(e.currentTarget, 'Stopped'); + this.toast('Emergency STOP activated'); + this.warn('Emergency STOP activated'); + }); + + // Tabs + const tabLogs = root.querySelector('.rc-tab-logs'); + const tabTools = root.querySelector('.rc-tab-tools'); + const selectTab = (tools=false) => { + this.bodyLogs.style.display = tools ? 'none' : 'block'; + this.bodyTools.style.display = tools ? 'block' : 'none'; + tabLogs.style.background = tools ? '#111827' : '#1f2937'; + tabTools.style.background = tools ? '#1f2937' : '#111827'; + }; + tabLogs.addEventListener('click', () => selectTab(false)); + + tabTools.addEventListener('click', () => { + selectTab(true); + root.querySelectorAll('.rc-toggle').forEach(inp => { + const key = inp.dataset.key; + inp.checked = !!this.cfg[key]; + }); + root.querySelectorAll('.rc-num').forEach(inp => { + inp.value = String(this.cfg[inp.dataset.key] ?? ''); + }); + + const dump = JSON.parse(JSON.stringify(this.cfg)); + if (dump.BRIDGE_KEY) dump.BRIDGE_KEY = '•'.repeat(8); + dump.VERSION = DEFAULT_CONFIG.VERSION; + root.querySelector('.rc-json').value = JSON.stringify(dump, null, 2); + + const bridgeKeyInput = root.querySelector('.rc-bridge-key'); + if (bridgeKeyInput) bridgeKeyInput.value = this.cfg.BRIDGE_KEY ? '•'.repeat(8) : ''; + }); + + // Collapse + const collapseBtn = root.querySelector('.rc-collapse'); + const setCollapsed = (c) => { + this.collapsed = c; + this.bodyLogs.style.display = c ? 'none' : 'block'; + this.bodyTools.style.display = 'none'; + collapseBtn.textContent = c ? '▸' : '▾'; + this._savePanelState({ collapsed: c }); + }; + setCollapsed(!!this.panelState.collapsed); + collapseBtn.addEventListener('click', () => setCollapsed(!this.collapsed)); + + // Dragging + const header = root.querySelector('.rc-header'); + header.addEventListener('mousedown', (e) => { + const tgt = e.target instanceof Element ? e.target : e.target?.parentElement; + if (tgt?.closest('button,select,input,textarea,label')) return; + this.drag.active = true; + const rect = root.getBoundingClientRect(); + this.drag.dx = e.clientX - rect.left; + this.drag.dy = e.clientY - rect.top; + root.style.right = 'auto'; root.style.bottom = 'auto'; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + const onMove = (e) => { + if (!this.drag.active) return; + const x = Math.max(0, Math.min(window.innerWidth - this.panel.offsetWidth, e.clientX - this.drag.dx)); + const y = Math.max(0, Math.min(window.innerHeight - 40, e.clientY - this.drag.dy)); + this.panel.style.left = `${x}px`; + this.panel.style.top = `${y}px`; + }; + const onUp = () => { + if (!this.drag.active) return; + this.drag.active = false; + this._savePanelState({ left: parseInt(this.panel.style.left||'0',10), top: parseInt(this.panel.style.top||'0',10) }); + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + + // Tools: Clear History + root.querySelector('.rc-clear-history').addEventListener('click', (e) => { + try { + commandMonitor?.history?.resetAll?.(); + RC_DEBUG?.info('Conversation history cleared'); + GM_notification({ title: 'AI Repo Commander', text: 'Execution marks cleared', timeout: 2500 }); + } catch { + localStorage.removeItem(STORAGE_KEYS.history); + RC_DEBUG?.info('Legacy history key cleared'); + } + this.flashBtn(e.currentTarget, 'Cleared'); + this.toast('Conversation marks cleared'); + }); + + // Tools: toggles & numbers + root.querySelectorAll('.rc-toggle').forEach(inp => { + const key = inp.dataset.key; + inp.checked = !!this.cfg[key]; + inp.addEventListener('change', () => { + this.cfg[key] = !!inp.checked; + saveConfig(this.cfg); + this.toast(`${key} = ${this.cfg[key] ? 'on' : 'off'}`); + this.info(`Config ${key} => ${this.cfg[key]}`); + }); + }); + root.querySelectorAll('.rc-num').forEach(inp => { + inp.value = String(this.cfg[inp.dataset.key] ?? ''); + inp.addEventListener('change', () => { + const v = parseInt(inp.value, 10); + if (!Number.isNaN(v)) { + this.cfg[inp.dataset.key] = v; + saveConfig(this.cfg); + this.toast(`${inp.dataset.key} = ${v}`); + this.info(`Config ${inp.dataset.key} => ${v}`); + } + }); + }); + + // Tools: JSON input + root.querySelector('.rc-save-json').addEventListener('click', (e) => { + try { + const raw = root.querySelector('.rc-json').value; + const parsed = JSON.parse(raw); + + if (Object.prototype.hasOwnProperty.call(parsed, 'BRIDGE_KEY')) { + const v = (parsed.BRIDGE_KEY ?? '').toString().trim(); + if (v && !/^•+$/.test(v)) { + this.cfg.BRIDGE_KEY = v; + BRIDGE_KEY = v; + } + delete parsed.BRIDGE_KEY; + } + + // Prevent overriding ephemeral fields from pasted JSON + delete parsed.VERSION; + delete parsed.RUNTIME; + + Object.assign(this.cfg, parsed); + saveConfig(this.cfg); + + const dump = JSON.parse(JSON.stringify(this.cfg)); + if (dump.BRIDGE_KEY) dump.BRIDGE_KEY = '•'.repeat(8); + root.querySelector('.rc-json').value = JSON.stringify(dump, null, 2); + + this.flashBtn(e.currentTarget, 'Saved'); + this.toast('Config saved'); + this.info('Config JSON saved'); + } catch (err) { + this.toast('Invalid JSON', 1500); + this.warn('Invalid JSON in config textarea', { error: String(err) }); + } + }); + + root.querySelector('.rc-reset-defaults').addEventListener('click', (e) => { + Object.assign(this.cfg, structuredClone(DEFAULT_CONFIG)); + saveConfig(this.cfg); + BRIDGE_KEY = null; + const bridgeKeyInput = root.querySelector('.rc-bridge-key'); + if (bridgeKeyInput) bridgeKeyInput.value = ''; + this.flashBtn(e.currentTarget, 'Reset'); + this.toast('Defaults restored'); + this.info('Config reset to defaults'); + }); + + // Set initial UI states + pauseBtn.textContent = this.cfg.RUNTIME.PAUSED ? 'Resume' : 'Pause'; + if (this.cfg.RUNTIME.PAUSED) { + pauseBtn.style.background = '#f59e0b'; + pauseBtn.style.color = '#111827'; + } + + // Bridge Key handlers + const bridgeKeyInput = root.querySelector('.rc-bridge-key'); + bridgeKeyInput.value = this.cfg.BRIDGE_KEY ? '•'.repeat(8) : ''; + + root.querySelector('.rc-save-bridge-key').addEventListener('click', (e) => { + const raw = (bridgeKeyInput.value || '').trim(); + if (/^•+$/.test(raw)) { + this.info('Bridge key unchanged'); + GM_notification({ title: 'AI Repo Commander', text: 'Bridge key unchanged', timeout: 2000 }); + return; + } + this.cfg.BRIDGE_KEY = raw; + saveConfig(this.cfg); + BRIDGE_KEY = raw || null; + bridgeKeyInput.value = this.cfg.BRIDGE_KEY ? '•'.repeat(8) : ''; + this.flashBtn(e.currentTarget, 'Saved'); + this.toast('Bridge key saved'); + this.info('Bridge key saved (masked)'); + GM_notification({ title: 'AI Repo Commander', text: 'Bridge key saved', timeout: 2500 }); + }); + + root.querySelector('.rc-clear-bridge-key').addEventListener('click', (e) => { + this.cfg.BRIDGE_KEY = ''; + bridgeKeyInput.value = ''; + saveConfig(this.cfg); + BRIDGE_KEY = null; + this.flashBtn(e.currentTarget, 'Cleared'); + this.toast('Bridge key cleared'); + this.info('Bridge key cleared'); + GM_notification({ title: 'AI Repo Commander', text: 'Bridge key cleared', timeout: 2500 }); + }); } - scanned++; - } - currentEl = currentEl.nextElementSibling; - - // Small delay between checks - if (currentEl && Date.now() < deadline) { - await ExecutionManager.delay(100); - } + destroy() { + try { clearInterval(this.loopCleanupInterval); } catch {} + if (this.panel && this.panel.parentNode) this.panel.parentNode.removeChild(this.panel); + } } - RC_DEBUG?.verbose('Cluster rescan completed', { scanned, deadline: Date.now() >= deadline }); - } + // ---------------------- Platform selectors ---------------------- + const PLATFORM_SELECTORS = { + 'chat.openai.com': { messages: '[data-message-author-role]', input: '#prompt-textarea, textarea, [contenteditable="true"]', content: '.markdown' }, + 'chatgpt.com': { messages: '[data-message-author-role]', input: '#prompt-textarea, textarea, [contenteditable="true"]', content: '.markdown' }, + 'claude.ai': { messages: '.chat-message', input: '[contenteditable="true"]', content: '.content' }, + 'gemini.google.com': { messages: '.message-content', input: 'textarea, [contenteditable="true"]', content: '.message-text' } + }; - // Tiny badge on the message showing how many got queued - function attachQueueBadge(el, count) { - if (el.querySelector('.ai-rc-queue-badge')) return; - const badge = document.createElement('span'); - badge.className = 'ai-rc-queue-badge'; - badge.textContent = `${count} command${count>1?'s':''} queued`; - badge.style.cssText = ` + // ---------------------- Fingerprinting helpers (portable) ---------------------- + const MSG_SELECTORS = [ + '[data-message-author-role]', // ChatGPT/OpenAI + '.chat-message', // Claude + '.message-content' // Gemini + ]; + + // Consistent DJB2 variant + function _hash(s) { + let h = 5381; + for (let i = 0; i < s.length; i++) h = ((h << 5) + h) ^ s.charCodeAt(i); + return (h >>> 0).toString(36); + } + + // Normalize text a bit to reduce spurious diffs + function _norm(s) { + return (s || '') + .replace(/\r/g, '') + .replace(/\u200b/g, '') // zero-width + .replace(/[ \t]+\n/g, '\n') // trailing ws + .trim(); + } + + // Extract the *command block* if present; else fall back to element text + function _commandLikeText(el) { + // Mirror parser's detector: require header, action, and '@end@' + const blocks = el.querySelectorAll('pre code, pre, code'); + for (const b of blocks) { + const t = _norm(b.textContent || ''); + if (/@end@\s*$/m.test(t) && /(^|\n)\s*@bridge@\b/m.test(t) && /(^|\n)\s*action\s*:/m.test(t)) { + return t; + } + } + // No command block found; use element text (capped) + return _norm((el.textContent || '').slice(0, 2000)); + } + + // Hash of the command (or element text) capped to 2000 chars + function _hashCommand(el) { + const t = _commandLikeText(el); + return _hash(t.slice(0, 2000)); + } + + // Hash of the *preceding context*: concatenate previous messages' text until ~2000 chars + function _hashPrevContext(el) { + const all = Array.from(document.querySelectorAll(MSG_SELECTORS.join(','))); + const idx = all.indexOf(el); + if (idx <= 0) return '0'; + + let remaining = 2000; + let buf = ''; + for (let i = idx - 1; i >= 0 && remaining > 0; i--) { + const t = _norm(all[i].textContent || ''); + if (!t) continue; + // Prepend last slice so the nearest history weighs most + const take = t.slice(-remaining); + buf = take + buf; + remaining -= take.length; + } + // Hash only the *last* 2000 chars of the collected buffer (stable, compact) + return _hash(buf.slice(-2000)); + } + + // Ordinal among messages that share the same (commandHash, prevCtxHash) + function _ordinalForKey(el, key) { + const list = Array.from(document.querySelectorAll(MSG_SELECTORS.join(','))); + let n = 0; + for (const node of list) { + const nodeKey = node === el + ? key + : (() => { + // Compute on the fly only if needed + const ch = _hashCommand(node); + const ph = _hashPrevContext(node); + return `ch:${ch}|ph:${ph}`; + })(); + if (nodeKey === key) n++; + if (node === el) return n; // 1-based ordinal + } + return 1; + } + + // DOM path hint for extra fingerprint stability + function _domHint(node) { + const p = []; + let n = node; + for (let i = 0; n && i < 4; i++) { // last 4 ancestors + const tag = (n.tagName || '').toLowerCase(); + const cls = (n.className || '').toString().split(/\s+/).slice(0, 2).join('.'); + p.push(tag + (cls ? '.' + cls : '')); + n = n.parentElement; + } + return p.join('>'); + } + + // Main fingerprint function + function fingerprintElement(el) { + // Always use content-based fingerprinting for reliability across reloads + const ch = _hashCommand(el); + const ph = _hashPrevContext(el); + const dh = _hash(_domHint(el)); + const key = `ch:${ch}|ph:${ph}`; + const n = _ordinalForKey(el, key); + const fingerprint = `${key}|hint:${dh}|n:${n}`; + + RC_DEBUG?.trace('Generated fingerprint', { + fingerprint: fingerprint.slice(0, 60) + '...', + commandHash: ch, + prevContextHash: ph, + domHint: dh, + ordinal: n + }); + + return fingerprint; + } + + // ---------------------- Multi-block extraction helpers ---------------------- + function extractAllCompleteBlocks(text) { + const out = []; + const re = /^\s*@bridge@[ \t]*\n([\s\S]*?)\n@end@[ \t]*(?:\n|$)/gm; + let m; + while ((m = re.exec(text)) !== null) { + const inner = (m[1] || '').trimEnd(); + if (inner && /(^|\n)\s*action\s*:/m.test(inner)) out.push(inner); + } + return out; // array of inner texts (without @bridge@/@end@) + } + + function findAllCommandsInMessage(el) { + const hits = []; + const seen = new Set(); + + // 1) First scan code elements (pre code, pre, code) + const blocks = el.querySelectorAll('pre code, pre, code'); + for (const b of blocks) { + const txt = (b.textContent || '').trim(); + const parts = extractAllCompleteBlocks(txt); + for (const part of parts) { + const normalized = _norm(part); + seen.add(normalized); + hits.push({ blockElement: b, text: `@bridge@\n${part}\n@end@` }); + } + } + + // 2) Also scan the entire element's textContent for plain-text blocks + const wholeText = _norm(el.textContent || ''); + const plainParts = extractAllCompleteBlocks(wholeText); + for (const part of plainParts) { + const normalized = _norm(part); + if (!seen.has(normalized)) { + seen.add(normalized); + hits.push({ blockElement: null, text: `@bridge@\n${part}\n@end@` }); + } + } + + return hits; + } + + // Chainable actions that may trigger cluster rescan + const chainableActions = ['create_repo', 'create_file', 'create_branch', 'update_file', 'delete_file', 'create_pr']; + + // 1) Check if we should trigger a cluster rescan after executing an action + function shouldTriggerClusterRescan(anchorEl, justExecutedAction) { + if (!chainableActions.includes(justExecutedAction)) return false; + + // Check if next sibling is an unprocessed assistant message + let nextSibling = anchorEl?.nextElementSibling; + while (nextSibling) { + // Stop at user messages + if (commandMonitor && !commandMonitor.isAssistantMessage(nextSibling)) return false; + + // Check if it's an assistant message + if (commandMonitor && commandMonitor.isAssistantMessage(nextSibling)) { + // Check if unprocessed (no processed marker) + const hasMarker = nextSibling?.dataset?.aiRcProcessed === '1' || !!nextSibling.querySelector('.ai-rc-queue-badge'); + return !hasMarker; + } + + nextSibling = nextSibling.nextElementSibling; + } + return false; + } + + // 2) Schedule a cluster rescan to check adjacent assistant messages + async function scheduleClusterRescan(anchorEl) { + if (!anchorEl) return; + + RC_DEBUG?.info('Scheduling cluster rescan', { anchor: anchorEl }); + + const deadline = Date.now() + CONFIG.CLUSTER_RESCAN_MS; + let scanned = 0; + let currentEl = anchorEl.nextElementSibling; + + while (currentEl && scanned < CONFIG.CLUSTER_MAX_LOOKAHEAD && Date.now() < deadline) { + // Stop at user message boundaries + if (commandMonitor && !commandMonitor.isAssistantMessage(currentEl)) { + RC_DEBUG?.verbose('Cluster rescan hit user message boundary'); + break; + } + + // Only process assistant messages + if (commandMonitor && commandMonitor.isAssistantMessage(currentEl)) { + // Check if already processed + const hasMarker = currentEl?.dataset?.aiRcProcessed === '1' || !!currentEl.querySelector('.ai-rc-queue-badge'); + if (!hasMarker) { + // Look for new @bridge@ blocks + const hits = findAllCommandsInMessage(currentEl); + if (hits.length > 0) { + RC_DEBUG?.info('Cluster rescan found commands in adjacent message', { count: hits.length }); + + // 1) Set dataset marker + currentEl.dataset.aiRcProcessed = '1'; + + // 2) Slice hits to CONFIG.QUEUE_MAX_PER_MESSAGE + const capped = hits.slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE); + + // 3) Mark and enqueue each command + capped.forEach((h, idx) => { + if (commandMonitor) { + commandMonitor.history.markElement(currentEl, idx + 1); + commandMonitor.enqueueCommand(currentEl, h, idx); + } + }); + + // 4) Add queue badge with capped count + attachQueueBadge(currentEl, capped.length); + } + } + scanned++; + } + + currentEl = currentEl.nextElementSibling; + + // Small delay between checks + if (currentEl && Date.now() < deadline) { + await ExecutionManager.delay(100); + } + } + + RC_DEBUG?.verbose('Cluster rescan completed', { scanned, deadline: Date.now() >= deadline }); + } + + // Tiny badge on the message showing how many got queued + function attachQueueBadge(el, count) { + if (el.querySelector('.ai-rc-queue-badge')) return; + const badge = document.createElement('span'); + badge.className = 'ai-rc-queue-badge'; + badge.textContent = `${count} command${count>1?'s':''} queued`; + badge.style.cssText = ` display:inline-block; padding:2px 6px; margin:4px 0; background:#3b82f6; color:#fff; border-radius:4px; font:11px ui-monospace, monospace;`; - el.insertBefore(badge, el.firstChild); - } - - // Wait until it's safe to paste/submit - async function waitForComposerReady({ timeoutMs = CONFIG.QUEUE_WAIT_FOR_COMPOSER_MS, pollMs = 200 } = {}) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - // basic "assistant still typing" check - const lastMsg = Array.from(document.querySelectorAll(MSG_SELECTORS.join(','))).pop(); - if (lastMsg?.querySelector?.('[aria-busy="true"], .typing-indicator')) { - await ExecutionManager.delay(400); - continue; - } - const el = getVisibleInputCandidate(); - const btn = findSendButton(el); - const btnReady = CONFIG.SUBMIT_MODE === 'enter_only' - ? true - : (!!btn && !btn.disabled && btn.getAttribute('aria-disabled') !== 'true'); - const scope = el?.closest('form, [data-testid="composer"], main, body') || document; - - // 1) Add typing indicator to busy selector - const busy = scope.querySelector('[aria-busy="true"], [data-state="loading"], .typing-indicator'); - - // 2) Check if composer has unsent content - let hasUnsent = false; - if (el) { - try { - const currentText = (el.textContent || el.value || '').trim(); - if (currentText.startsWith('@bridge@') || currentText.startsWith('### [')) { - hasUnsent = true; - } - } catch (e) { - RC_DEBUG?.verbose('Failed to check composer content', { error: String(e) }); - } - } - - if (el && btnReady && !busy && !hasUnsent) return true; - await ExecutionManager.delay(pollMs); - } - RC_DEBUG?.warn('Composer not ready within timeout'); - return false; - } - - // ---------------------- Conversation-Aware Element History ---------------------- - function getConversationId() { - const host = location.hostname.replace('chat.openai.com', 'chatgpt.com'); // normalize - - // ChatGPT - if (/chatgpt\.com/.test(host)) { - // Try GPT-specific conversation path first: /g/[gpt-id]/c/[conv-id] - const gptMatch = location.pathname.match(/\/g\/[^/]+\/c\/([a-f0-9-]+)/i); - if (gptMatch?.[1]) { - RC_DEBUG?.verbose('Conversation ID from GPT URL', { id: gptMatch[1].slice(0, 12) + '...' }); - return `chatgpt:${gptMatch[1]}`; - } - - // Regular conversation path: /c/[conv-id] - const m = location.pathname.match(/\/c\/([a-f0-9-]+)/i); - if (m?.[1]) { - RC_DEBUG?.verbose('Conversation ID from URL path', { id: m[1].slice(0, 12) + '...' }); - return `chatgpt:${m[1]}`; - } - - // Try embedded page state (best-effort) - try { - const scripts = document.querySelectorAll('script[type="application/json"]'); - for (const s of scripts) { - const t = s.textContent || ''; - const j = /"conversationId":"([a-f0-9-]+)"/i.exec(t); - if (j?.[1]) { - RC_DEBUG?.verbose('Conversation ID from page state', { id: j[1].slice(0, 12) + '...' }); - return `chatgpt:${j[1]}`; - } - } - } catch {} - - return `chatgpt:${location.pathname || '/'}`; + el.insertBefore(badge, el.firstChild); } - // Claude - if (/claude\.ai/.test(host)) { - const m1 = location.pathname.match(/\/chat\/([^/]+)/i); - const m2 = location.pathname.match(/\/thread\/([^/]+)/i); - const id = (m1?.[1] || m2?.[1]); - if (id) { - RC_DEBUG?.verbose('Conversation ID (Claude)', { id: id.slice(0, 12) + '...' }); - } - return `claude:${id || (location.pathname || '/')}`; - } - - // Gemini / others - const generic = `${host}:${location.pathname || '/'}`; - RC_DEBUG?.verbose('Conversation ID (generic)', { id: generic }); - return generic; - } - - // ---------------------- Command requirements ---------------------- - const REQUIRED_FIELDS = { - 'get_file': ['action', 'repo', 'path'], - 'update_file': ['action', 'repo', 'path', 'content'], - 'create_file': ['action', 'repo', 'path', 'content'], - 'delete_file': ['action', 'repo', 'path'], - 'list_files': ['action', 'repo', 'path'], - 'create_repo': ['action', 'repo'], - 'create_branch': ['action', 'repo', 'branch'], - 'create_pr': ['action', 'repo', 'title', 'head', 'base'], - 'merge_pr': ['action', 'repo', 'pr_number'], - 'close_pr': ['action', 'repo', 'pr_number'], - 'create_issue': ['action', 'repo', 'title'], - 'comment_issue': ['action', 'repo', 'issue_number', 'body'], - 'close_issue': ['action', 'repo', 'issue_number'], - 'rollback': ['action', 'repo', 'commit_sha'], - 'create_tag': ['action', 'repo', 'tag', 'target'], - 'create_release': ['action', 'repo', 'tag_name', 'name'] - }; - - // noinspection JSUnusedGlobalSymbols - const FIELD_VALIDATORS = { - repo: (v) => /^[\w\-.]+(\/[\w\-.]+)?$/.test(v), - path: (v) => !v.includes('..') && !v.startsWith('/') && !v.includes('\\'), - action: (v) => Object.keys(REQUIRED_FIELDS).includes(v), - url: (v) => !v || /^https?:\/\/[^/\s]+(?:\/|$)/i.test(v), - owner: (v) => !v || /^[\w\-.]+$/.test(v), - branch: (v) => v && v.length > 0 && !v.includes('..'), - source_branch:(v) => !v || (v.length > 0 && !v.includes('..')), - head: (v) => v && v.length > 0, - base: (v) => v && v.length > 0, - pr_number: (v) => !isNaN(parseInt(v)) && parseInt(v) > 0, - issue_number: (v) => !isNaN(parseInt(v)) && parseInt(v) > 0, - commit_sha: (v) => /^[a-f0-9]{7,40}$/i.test(v), - tag: (v) => v && v.length > 0 && !v.includes(' '), - tag_name: (v) => v && v.length > 0 && !v.includes(' '), - target: (v) => v && v.length > 0, - title: (v) => v && v.length > 0, - name: (v) => v && v.length > 0, - body: (v) => typeof v === 'string', - message: (v) => typeof v === 'string' - }; - - const STATUS_TEMPLATES = { - SUCCESS: '[{action}: Success] {details}', - ERROR: '[{action}: Error] {details}', - VALIDATION_ERROR: '[{action}: Invalid] {details}', - EXECUTING: '[{action}: Processing...]', - MOCK: '[{action}: Mock] {details}' - }; - - const COMMAND_STATES = { - DETECTED: 'detected', - PARSING: 'parsing', - VALIDATING: 'validating', - DEBOUNCING: 'debouncing', - EXECUTING: 'executing', - COMPLETE: 'complete', - ERROR: 'error' - }; - - // ---------------------- Persistent Command History ---------------------- - class ConvHistory { - constructor() { - this.convId = getConversationId(); - this.key = `ai_rc:conv:${this.convId}:processed`; - this.session = new Set(); - this.cache = this._load(); - this._cleanupTTL(); - - RC_DEBUG?.info('ConvHistory initialized', { - convId: this.convId.slice(0, 50) + (this.convId.length > 50 ? '...' : ''), - cachedCount: Object.keys(this.cache).length - }); - } - - _load() { - try { - return JSON.parse(localStorage.getItem(this.key) || '{}'); - } catch { - return {}; - } - } - - _save() { - try { - localStorage.setItem(this.key, JSON.stringify(this.cache)); - } catch {} - } - - _cleanupTTL() { - const ttl = CONFIG.DEDUPE_TTL_MS || (30 * 24 * 60 * 60 * 1000); - const now = Date.now(); - let dirty = false; - - for (const [fp, ts] of Object.entries(this.cache)) { - if (!ts || (now - ts) > ttl) { - delete this.cache[fp]; - dirty = true; - } - } - - if (dirty) { - this._save(); - RC_DEBUG?.verbose('Cleaned expired fingerprints from cache'); - } - } - - /** - * @param {Element} el - * @param {string|number} [suffix] - */ - hasElement(el, suffix = '') { - let fp = fingerprintElement(el); - if (suffix !== '' && suffix != null) fp += `#${String(suffix)}`; - const result = this.session.has(fp) || (fp in this.cache); - - if (result && CONFIG.DEBUG_LEVEL >= 4) { - RC_DEBUG?.trace('Element already processed', { - fingerprint: fp.slice(0, 60) + '...', - inSession: this.session.has(fp), - inCache: fp in this.cache - }); - } - - return result; - } - - /** - * @param {Element} el - * @param {string|number} [suffix] - */ - markElement(el, suffix = '') { - let fp = fingerprintElement(el); - if (suffix !== '' && suffix != null) fp += `#${String(suffix)}`; - this.session.add(fp); - this.cache[fp] = Date.now(); - this._save(); - - RC_DEBUG?.verbose('Marked element as processed', { - fingerprint: fp.slice(0, 60) + '...' - }); - - if (CONFIG.SHOW_EXECUTED_MARKER && el instanceof HTMLElement) { - try { - el.style.borderLeft = '3px solid #10B981'; - el.title = 'Command executed — use "Run again" to re-run'; - } catch {} - } - } - - resetAll() { - this.session.clear(); - localStorage.removeItem(this.key); - this.cache = {}; - RC_DEBUG?.info('All conversation history cleared'); - } - } - - // Global helpers (stable) - // noinspection JSUnusedGlobalSymbols - window.AI_REPO = { - clearHistory: () => { - try { commandMonitor?.history?.resetAll?.(); } catch {} - localStorage.removeItem(STORAGE_KEYS.history); // legacy - }, - getConfig: () => structuredClone(CONFIG), - setConfig: (patch) => { Object.assign(CONFIG, patch||{}); saveConfig(CONFIG); }, - }; - - // Replace the whole attachRunAgainUI with this per-command version (and keep a thin wrapper for back-compat) - function attachRunAgainPerCommand(containerEl, hits, onRunOneIdx, onRunAll) { - // Rebuild if an old single-button bar exists - const old = containerEl.querySelector('.ai-rc-rerun'); - if (old) old.remove(); - - const bar = document.createElement('div'); - bar.className = 'ai-rc-rerun'; - bar.style.cssText = 'margin:8px 0; display:flex; gap:8px; align-items:center; flex-wrap:wrap;'; - - const msg = document.createElement('span'); - msg.textContent = `Already executed. Re-run:`; - msg.style.cssText = 'font-size:13px; opacity:.9; margin-right:6px;'; - bar.appendChild(msg); - // "Run all again" button (optional legacy support) - const runAllBtn = document.createElement('button'); - runAllBtn.textContent = 'Run all again'; - runAllBtn.style.cssText = 'padding:4px 10px; border:1px solid #374151; border-radius:4px; background:#1f2937; color:#e5e7eb;'; - runAllBtn.addEventListener('click', (ev) => { - RC_DEBUG?.flashBtn?.(ev.currentTarget, 'Running'); - try { - if (typeof onRunAll === 'function') { - onRunAll(); - } else { - // Fallback: run each per-command callback in order - hits.forEach((_, idx) => { - try { onRunOneIdx?.(idx); } catch (e) { - RC_DEBUG?.warn('Run-all fallback failed for index', { idx, error: String(e) }); + // Wait until it's safe to paste/submit + async function waitForComposerReady({ timeoutMs = CONFIG.QUEUE_WAIT_FOR_COMPOSER_MS, pollMs = 200 } = {}) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + // basic "assistant still typing" check + const lastMsg = Array.from(document.querySelectorAll(MSG_SELECTORS.join(','))).pop(); + if (lastMsg?.querySelector?.('[aria-busy="true"], .typing-indicator')) { + await ExecutionManager.delay(400); + continue; } - }); + const el = getVisibleInputCandidate(); + // Pre-paste: the send button can be disabled. Don't block on it here. + const btn = findSendButton(el); + + // Narrow scope to composer's local container + const scope = el?.closest('form, [data-testid="composer"], [data-testid="composer-container"], main, body') || document; + + // 1) Narrow "busy" detection to the scope and ignore hidden spinners + const busy = scope.querySelector('[aria-busy="true"], [data-state="loading"], .typing-indicator'); + + // Debug logging when composer or button not found + if (!el || !btn) { + RC_DEBUG?.verbose('Composer probe', { + foundEl: !!el, + elTag: el?.tagName, + elClasses: el ? Array.from(el.classList || []).join(' ') : null, + hasBtn: !!btn, + busyFound: !!busy + }); + } + + // 2) Only block if there is real (non-whitespace) unsent content already present + let hasUnsent = false; + if (el) { + try { + const currentText = (el.textContent || el.value || '').trim(); + hasUnsent = currentText.length > 0 && !/^\s*$/.test(currentText); + } catch (e) { + RC_DEBUG?.verbose('Failed to check composer content', { error: String(e) }); + } + } + + // Ready to paste as soon as composer exists, not busy, and no unsent text. + if (el && !busy && !hasUnsent) return true; + await ExecutionManager.delay(pollMs); } - } catch (e) { - RC_DEBUG?.warn('Run-all handler failed', { error: String(e) }); - } - }); - bar.appendChild(runAllBtn); - - hits.forEach((_, idx) => { - const btn = document.createElement('button'); - btn.textContent = `Run again [#${idx + 1}]`; - btn.style.cssText = 'padding:4px 10px; border:1px solid #374151; border-radius:4px; background:#1f2937; color:#e5e7eb;'; - btn.addEventListener('click', (ev) => { - RC_DEBUG?.flashBtn?.(ev.currentTarget, 'Running'); - try { onRunOneIdx(idx); } catch (e) { - RC_DEBUG?.warn('Run-again handler failed', { error: String(e) }); - } - }); - bar.appendChild(btn); - }); - - const dismiss = document.createElement('button'); - dismiss.textContent = 'Dismiss'; - dismiss.style.cssText = 'padding:4px 10px; border:1px solid #374151; border-radius:4px; background:#111827; color:#9ca3af;'; - dismiss.addEventListener('click', (ev) => { RC_DEBUG?.flashBtn?.(ev.currentTarget, 'Dismissed'); bar.remove(); }); - bar.appendChild(dismiss); - - containerEl.appendChild(bar); - } - - // Back-compat thin wrapper used elsewhere; now renders per-command for whatever is currently in the message. - function attachRunAgainUI(containerEl, onRunAllLegacy) { - const hitsNow = findAllCommandsInMessage(containerEl).slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE); - attachRunAgainPerCommand(containerEl, hitsNow, (idx) => { - // Preserve legacy behavior if a caller passed a single callback: - // default to re-enqueue just the selected index. - const h = hitsNow[idx]; - if (!h) return; - commandMonitor.enqueueCommand(containerEl, h, idx); - }, () => { - // Legacy "run all" behavior for old callers - if (typeof onRunAllLegacy === 'function') { - onRunAllLegacy(); - return; - } - hitsNow.forEach((h, i) => commandMonitor.enqueueCommand(containerEl, h, i)); - }); - } - - - // When resuming from pause, treat like a cold start & mark all currently-visible commands as processed. - // Adds "Run again" buttons so nothing auto-executes. - function markExistingHitsAsProcessed() { - try { - const messages = document.querySelectorAll(MSG_SELECTORS.join(',')); - messages.forEach((el) => { - const hits = findAllCommandsInMessage(el); - if (!hits.length) return; - - el.dataset.aiRcProcessed = '1'; - const capped = hits.slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE); - - capped.forEach((_, idx) => { - commandMonitor?.history?.markElement?.(el, idx + 1); - }); - - attachRunAgainPerCommand(el, capped, (idx) => { - const nowHits = findAllCommandsInMessage(el).slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE); - const h = nowHits[idx]; - if (h) commandMonitor.enqueueCommand(el, h, idx); - }, () => { - const nowHits = findAllCommandsInMessage(el).slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE); - nowHits.forEach((h, i) => commandMonitor.enqueueCommand(el, h, i)); - }); - }); - RC_DEBUG?.info('Resume-safe guard: marked visible commands as processed & attached Run again buttons'); - } catch (e) { - RC_DEBUG?.warn('Resume-safe guard failed', { error: String(e) }); + RC_DEBUG?.warn('Composer not ready within timeout'); + return false; } - } - // ---------------------- UI feedback ---------------------- - class UIFeedback { - static ensureBoard(containerEl) { - let board = containerEl.querySelector('.ai-rc-status-board'); - if (!board) { - board = document.createElement('div'); - board.className = 'ai-rc-status-board'; - board.style.cssText = ` + // ---------------------- Conversation-Aware Element History ---------------------- + function getConversationId() { + const host = location.hostname.replace('chat.openai.com', 'chatgpt.com'); // normalize + + // ChatGPT + if (/chatgpt\.com/.test(host)) { + // Try GPT-specific conversation path first: /g/[gpt-id]/c/[conv-id] + const gptMatch = location.pathname.match(/\/g\/[^/]+\/c\/([a-f0-9-]+)/i); + if (gptMatch?.[1]) { + RC_DEBUG?.verbose('Conversation ID from GPT URL', { id: gptMatch[1].slice(0, 12) + '...' }); + return `chatgpt:${gptMatch[1]}`; + } + + // Regular conversation path: /c/[conv-id] + const m = location.pathname.match(/\/c\/([a-f0-9-]+)/i); + if (m?.[1]) { + RC_DEBUG?.verbose('Conversation ID from URL path', { id: m[1].slice(0, 12) + '...' }); + return `chatgpt:${m[1]}`; + } + + // Try embedded page state (best-effort) + try { + const scripts = document.querySelectorAll('script[type="application/json"]'); + for (const s of scripts) { + const t = s.textContent || ''; + const j = /"conversationId":"([a-f0-9-]+)"/i.exec(t); + if (j?.[1]) { + RC_DEBUG?.verbose('Conversation ID from page state', { id: j[1].slice(0, 12) + '...' }); + return `chatgpt:${j[1]}`; + } + } + } catch {} + + return `chatgpt:${location.pathname || '/'}`; + } + + // Claude + if (/claude\.ai/.test(host)) { + const m1 = location.pathname.match(/\/chat\/([^/]+)/i); + const m2 = location.pathname.match(/\/thread\/([^/]+)/i); + const id = (m1?.[1] || m2?.[1]); + if (id) { + RC_DEBUG?.verbose('Conversation ID (Claude)', { id: id.slice(0, 12) + '...' }); + } + return `claude:${id || (location.pathname || '/')}`; + } + + // Gemini / others + const generic = `${host}:${location.pathname || '/'}`; + RC_DEBUG?.verbose('Conversation ID (generic)', { id: generic }); + return generic; + } + + // ---------------------- Command requirements ---------------------- + const REQUIRED_FIELDS = { + 'get_file': ['action', 'repo', 'path'], + 'update_file': ['action', 'repo', 'path', 'content'], + 'create_file': ['action', 'repo', 'path', 'content'], + 'delete_file': ['action', 'repo', 'path'], + 'list_files': ['action', 'repo', 'path'], + 'create_repo': ['action', 'repo'], + 'create_branch': ['action', 'repo', 'branch'], + 'create_pr': ['action', 'repo', 'title', 'head', 'base'], + 'merge_pr': ['action', 'repo', 'pr_number'], + 'close_pr': ['action', 'repo', 'pr_number'], + 'create_issue': ['action', 'repo', 'title'], + 'comment_issue': ['action', 'repo', 'issue_number', 'body'], + 'close_issue': ['action', 'repo', 'issue_number'], + 'rollback': ['action', 'repo', 'commit_sha'], + 'create_tag': ['action', 'repo', 'tag', 'target'], + 'create_release': ['action', 'repo', 'tag_name', 'name'] + }; + + // noinspection JSUnusedGlobalSymbols + const FIELD_VALIDATORS = { + repo: (v) => /^[\w\-.]+(\/[\w\-.]+)?$/.test(v), + path: (v) => !v.includes('..') && !v.startsWith('/') && !v.includes('\\'), + action: (v) => Object.keys(REQUIRED_FIELDS).includes(v), + url: (v) => !v || /^https?:\/\/[^/\s]+(?:\/|$)/i.test(v), + owner: (v) => !v || /^[\w\-.]+$/.test(v), + branch: (v) => v && v.length > 0 && !v.includes('..'), + source_branch:(v) => !v || (v.length > 0 && !v.includes('..')), + head: (v) => v && v.length > 0, + base: (v) => v && v.length > 0, + pr_number: (v) => !isNaN(parseInt(v)) && parseInt(v) > 0, + issue_number: (v) => !isNaN(parseInt(v)) && parseInt(v) > 0, + commit_sha: (v) => /^[a-f0-9]{7,40}$/i.test(v), + tag: (v) => v && v.length > 0 && !v.includes(' '), + tag_name: (v) => v && v.length > 0 && !v.includes(' '), + target: (v) => v && v.length > 0, + title: (v) => v && v.length > 0, + name: (v) => v && v.length > 0, + body: (v) => typeof v === 'string', + message: (v) => typeof v === 'string' + }; + + const STATUS_TEMPLATES = { + SUCCESS: '[{action}: Success] {details}', + ERROR: '[{action}: Error] {details}', + VALIDATION_ERROR: '[{action}: Invalid] {details}', + EXECUTING: '[{action}: Processing...]', + MOCK: '[{action}: Mock] {details}' + }; + + const COMMAND_STATES = { + DETECTED: 'detected', + PARSING: 'parsing', + VALIDATING: 'validating', + DEBOUNCING: 'debouncing', + EXECUTING: 'executing', + COMPLETE: 'complete', + ERROR: 'error' + }; + + // ---------------------- Persistent Command History ---------------------- + class ConvHistory { + constructor() { + this.convId = getConversationId(); + this.key = `ai_rc:conv:${this.convId}:processed`; + this.session = new Set(); + this.cache = this._load(); + this._cleanupTTL(); + + RC_DEBUG?.info('ConvHistory initialized', { + convId: this.convId.slice(0, 50) + (this.convId.length > 50 ? '...' : ''), + cachedCount: Object.keys(this.cache).length + }); + } + + _load() { + try { + return JSON.parse(localStorage.getItem(this.key) || '{}'); + } catch { + return {}; + } + } + + _save() { + try { + localStorage.setItem(this.key, JSON.stringify(this.cache)); + } catch {} + } + + _cleanupTTL() { + const ttl = CONFIG.DEDUPE_TTL_MS || (30 * 24 * 60 * 60 * 1000); + const now = Date.now(); + let dirty = false; + + for (const [fp, ts] of Object.entries(this.cache)) { + if (!ts || (now - ts) > ttl) { + delete this.cache[fp]; + dirty = true; + } + } + + if (dirty) { + this._save(); + RC_DEBUG?.verbose('Cleaned expired fingerprints from cache'); + } + } + + /** + * @param {Element} el + * @param {string|number} [suffix] + */ + hasElement(el, suffix = '') { + let fp = fingerprintElement(el); + if (suffix !== '' && suffix != null) fp += `#${String(suffix)}`; + const result = this.session.has(fp) || (fp in this.cache); + + if (result && CONFIG.DEBUG_LEVEL >= 4) { + RC_DEBUG?.trace('Element already processed', { + fingerprint: fp.slice(0, 60) + '...', + inSession: this.session.has(fp), + inCache: fp in this.cache + }); + } + + return result; + } + + /** + * @param {Element} el + * @param {string|number} [suffix] + */ + markElement(el, suffix = '') { + let fp = fingerprintElement(el); + if (suffix !== '' && suffix != null) fp += `#${String(suffix)}`; + this.session.add(fp); + this.cache[fp] = Date.now(); + this._save(); + + RC_DEBUG?.verbose('Marked element as processed', { + fingerprint: fp.slice(0, 60) + '...' + }); + + if (CONFIG.SHOW_EXECUTED_MARKER && el instanceof HTMLElement) { + try { + el.style.borderLeft = '3px solid #10B981'; + el.title = 'Command executed — use "Run again" to re-run'; + } catch {} + } + } + + resetAll() { + this.session.clear(); + localStorage.removeItem(this.key); + this.cache = {}; + RC_DEBUG?.info('All conversation history cleared'); + } + } + + // Global helpers (stable) + // noinspection JSUnusedGlobalSymbols + window.AI_REPO = { + clearHistory: () => { + try { commandMonitor?.history?.resetAll?.(); } catch {} + localStorage.removeItem(STORAGE_KEYS.history); // legacy + }, + getConfig: () => structuredClone(CONFIG), + setConfig: (patch) => { Object.assign(CONFIG, patch||{}); saveConfig(CONFIG); }, + }; + + // Replace the whole attachRunAgainUI with this per-command version (and keep a thin wrapper for back-compat) + function attachRunAgainPerCommand(containerEl, hits, onRunOneIdx, onRunAll) { + // Rebuild if an old single-button bar exists + const old = containerEl.querySelector('.ai-rc-rerun'); + if (old) old.remove(); + + const bar = document.createElement('div'); + bar.className = 'ai-rc-rerun'; + bar.style.cssText = 'margin:8px 0; display:flex; gap:8px; align-items:center; flex-wrap:wrap;'; + + const msg = document.createElement('span'); + msg.textContent = `Already executed. Re-run:`; + msg.style.cssText = 'font-size:13px; opacity:.9; margin-right:6px;'; + bar.appendChild(msg); + // "Run all again" button (optional legacy support) + const runAllBtn = document.createElement('button'); + runAllBtn.textContent = 'Run all again'; + runAllBtn.style.cssText = 'padding:4px 10px; border:1px solid #374151; border-radius:4px; background:#1f2937; color:#e5e7eb;'; + runAllBtn.addEventListener('click', (ev) => { + RC_DEBUG?.flashBtn?.(ev.currentTarget, 'Running'); + try { + if (typeof onRunAll === 'function') { + onRunAll(); + } else { + // Fallback: run each per-command callback in order + hits.forEach((_, idx) => { + try { onRunOneIdx?.(idx); } catch (e) { + RC_DEBUG?.warn('Run-all fallback failed for index', { idx, error: String(e) }); + } + }); + } + } catch (e) { + RC_DEBUG?.warn('Run-all handler failed', { error: String(e) }); + } + }); + bar.appendChild(runAllBtn); + + hits.forEach((_, idx) => { + const btn = document.createElement('button'); + btn.textContent = `Run again [#${idx + 1}]`; + btn.style.cssText = 'padding:4px 10px; border:1px solid #374151; border-radius:4px; background:#1f2937; color:#e5e7eb;'; + btn.addEventListener('click', (ev) => { + RC_DEBUG?.flashBtn?.(ev.currentTarget, 'Running'); + try { onRunOneIdx(idx); } catch (e) { + RC_DEBUG?.warn('Run-again handler failed', { error: String(e) }); + } + }); + bar.appendChild(btn); + }); + + const dismiss = document.createElement('button'); + dismiss.textContent = 'Dismiss'; + dismiss.style.cssText = 'padding:4px 10px; border:1px solid #374151; border-radius:4px; background:#111827; color:#9ca3af;'; + dismiss.addEventListener('click', (ev) => { RC_DEBUG?.flashBtn?.(ev.currentTarget, 'Dismissed'); bar.remove(); }); + bar.appendChild(dismiss); + + containerEl.appendChild(bar); + } + + // Back-compat thin wrapper used elsewhere; now renders per-command for whatever is currently in the message. + function attachRunAgainUI(containerEl, onRunAllLegacy) { + const hitsNow = findAllCommandsInMessage(containerEl).slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE); + attachRunAgainPerCommand(containerEl, hitsNow, (idx) => { + // Preserve legacy behavior if a caller passed a single callback: + // default to re-enqueue just the selected index. + const h = hitsNow[idx]; + if (!h) return; + commandMonitor.enqueueCommand(containerEl, h, idx); + }, () => { + // Legacy "run all" behavior for old callers + if (typeof onRunAllLegacy === 'function') { + onRunAllLegacy(); + return; + } + hitsNow.forEach((h, i) => commandMonitor.enqueueCommand(containerEl, h, i)); + }); + } + + + // When resuming from pause, treat like a cold start & mark all currently-visible commands as processed. + // Adds "Run again" buttons so nothing auto-executes. + function markExistingHitsAsProcessed() { + try { + const messages = document.querySelectorAll(MSG_SELECTORS.join(',')); + messages.forEach((el) => { + const hits = findAllCommandsInMessage(el); + if (!hits.length) return; + + el.dataset.aiRcProcessed = '1'; + const capped = hits.slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE); + + capped.forEach((_, idx) => { + commandMonitor?.history?.markElement?.(el, idx + 1); + }); + + attachRunAgainPerCommand(el, capped, (idx) => { + const nowHits = findAllCommandsInMessage(el).slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE); + const h = nowHits[idx]; + if (h) commandMonitor.enqueueCommand(el, h, idx); + }, () => { + const nowHits = findAllCommandsInMessage(el).slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE); + nowHits.forEach((h, i) => commandMonitor.enqueueCommand(el, h, i)); + }); + }); + RC_DEBUG?.info('Resume-safe guard: marked visible commands as processed & attached Run again buttons'); + } catch (e) { + RC_DEBUG?.warn('Resume-safe guard failed', { error: String(e) }); + } + } + + // ---------------------- UI feedback ---------------------- + class UIFeedback { + static ensureBoard(containerEl) { + let board = containerEl.querySelector('.ai-rc-status-board'); + if (!board) { + board = document.createElement('div'); + board.className = 'ai-rc-status-board'; + board.style.cssText = ` margin:10px 0;padding:8px;border:1px solid rgba(255,255,255,0.15); border-radius:6px;background:rgba(255,255,255,0.06);font-family:monospace; `; - containerEl.appendChild(board); - } - return board; - } + containerEl.appendChild(board); + } + return board; + } - static appendStatus(containerEl, templateType, data) { - // Back-compat: when no key provided, fall through to single-line behavior - if (!data || !data.key) { - const statusElement = this.createStatusElement(templateType, data); - const existing = containerEl.querySelector('.ai-repo-commander-status'); - if (existing) existing.remove(); - statusElement.classList.add('ai-repo-commander-status'); - containerEl.appendChild(statusElement); - return; - } - // Multi-line board (preferred) - const board = this.ensureBoard(containerEl); - const entry = this.upsertEntry(board, data.key); - entry.textContent = this.renderLine(templateType, data); - entry.dataset.state = templateType; - entry.style.borderLeft = `4px solid ${this.color(templateType)}`; - } + static appendStatus(containerEl, templateType, data) { + // Back-compat: when no key provided, fall through to single-line behavior + if (!data || !data.key) { + const statusElement = this.createStatusElement(templateType, data); + const existing = containerEl.querySelector('.ai-repo-commander-status'); + if (existing) existing.remove(); + statusElement.classList.add('ai-repo-commander-status'); + containerEl.appendChild(statusElement); + return; + } + // Multi-line board (preferred) + const board = this.ensureBoard(containerEl); + const entry = this.upsertEntry(board, data.key); + entry.textContent = this.renderLine(templateType, data); + entry.dataset.state = templateType; + entry.style.borderLeft = `4px solid ${this.color(templateType)}`; + } - static upsertEntry(board, key) { - let el = board.querySelector(`[data-entry-key="${key}"]`); - if (!el) { - el = document.createElement('div'); - el.dataset.entryKey = key; - el.style.cssText = ` + static upsertEntry(board, key) { + let el = board.querySelector(`[data-entry-key="${key}"]`); + if (!el) { + el = document.createElement('div'); + el.dataset.entryKey = key; + el.style.cssText = ` padding:6px 8px;margin:4px 0;border-left:4px solid transparent; background:rgba(0,0,0,0.15);border-radius:4px; white-space:pre-wrap;word-break:break-word; `; - board.appendChild(el); - } - return el; - } + board.appendChild(el); + } + return el; + } - static renderLine(templateType, data) { - const { action, details, label } = data || {}; - const state = ({ - SUCCESS:'Success', ERROR:'Error', VALIDATION_ERROR:'Invalid', - EXECUTING:'Processing...', MOCK:'Mock' - })[templateType] || templateType; - const left = label || action || 'Command'; - return `${left} — ${state}${details ? `: ${details}` : ''}`; - } + static renderLine(templateType, data) { + const { action, details, label } = data || {}; + const state = ({ + SUCCESS:'Success', ERROR:'Error', VALIDATION_ERROR:'Invalid', + EXECUTING:'Processing...', MOCK:'Mock' + })[templateType] || templateType; + const left = label || action || 'Command'; + return `${left} — ${state}${details ? `: ${details}` : ''}`; + } - static createStatusElement(templateType, data) { - const template = STATUS_TEMPLATES[templateType] || STATUS_TEMPLATES.ERROR; - const message = template.replace('{action}', data.action).replace('{details}', data.details); - const el = document.createElement('div'); - el.textContent = message; - el.style.cssText = ` + static createStatusElement(templateType, data) { + const template = STATUS_TEMPLATES[templateType] || STATUS_TEMPLATES.ERROR; + const message = template.replace('{action}', data.action).replace('{details}', data.details); + const el = document.createElement('div'); + el.textContent = message; + el.style.cssText = ` padding: 8px 12px; margin: 10px 0; border-radius: 4px; border-left: 4px solid ${this.color(templateType)}; background-color: rgba(255,255,255,0.08); font-family: monospace; font-size: 14px; white-space: pre-wrap; word-wrap: break-word; border: 1px solid rgba(255,255,255,0.15); `; - return el; - } - - static color(t) { - const c = { SUCCESS:'#10B981', ERROR:'#EF4444', VALIDATION_ERROR:'#F59E0B', EXECUTING:'#3B82F6', MOCK:'#8B5CF6' }; - return c[t] || '#6B7280'; - } - } - - // ---------------------- Paste + Submit helpers ---------------------- - function getVisibleInputCandidate() { - const candidates = [ - '.ProseMirror#prompt-textarea', - '#prompt-textarea.ProseMirror', - '#prompt-textarea', - '.ProseMirror', - '[contenteditable="true"]', - 'textarea' - ]; - for (const sel of candidates) { - const el = document.querySelector(sel); - if (!el) continue; - const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') continue; - if (el.offsetParent === null && style.position !== 'fixed') continue; - return el; - } - return null; - } - - function findSendButton(scopeEl) { - const scope = scopeEl?.closest('form, [data-testid="composer"], main') || document; - const selectors = [ - 'button[data-testid="send-button"]', - 'button[aria-label*="Send"]', - 'button[aria-label*="send"]', - 'button[aria-label*="Submit"]', - 'button[aria-label*="submit"]', - 'form button[type="submit"]' - ]; - for (const s of selectors) { - const btn = scope.querySelector(s); - if (!btn) continue; - const style = window.getComputedStyle(btn); - const disabled = btn.disabled || btn.getAttribute('aria-disabled') === 'true'; - if (style.display === 'none' || style.visibility === 'hidden') continue; - if (btn.offsetParent === null && style.position !== 'fixed') continue; - if (!disabled) return btn; - } - return null; - } - - function pressEnterOn(el) { - const events = ['keydown','keypress','keyup']; - for (const type of events) { - const ok = el.dispatchEvent(new KeyboardEvent(type, { - key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true - })); - if (!ok) return false; - } - return true; - } - - async function submitComposer() { - try { - // 1) Get composer element first - const el = getVisibleInputCandidate(); - if (!el) return false; - - // 2) Find send button scoped to composer - const btn = findSendButton(el); - - // 3) Check SUBMIT_MODE and click or press Enter - if (CONFIG.SUBMIT_MODE !== 'enter_only' && btn) { - btn.click(); - return true; - } - - return pressEnterOn(el); - } catch { - return false; - } - } - - function pasteToComposer(text) { - RC_DEBUG?.info('🔵 pasteToComposer CALLED', { textLength: text.length, preview: text.substring(0, 100) }); - - try { - const el = getVisibleInputCandidate(); - if (!el) { - RC_DEBUG?.warn('❌ No input element found'); - GM_notification({ title: 'AI Repo Commander', text: 'No input box found to paste file content.', timeout: 4000 }); - return false; - } - - RC_DEBUG?.verbose('Found input element', { - tagName: el.tagName, - classList: Array.from(el.classList || []).join(' '), - contentEditable: el.getAttribute('contenteditable') - }); - - const payload = CONFIG.APPEND_TRAILING_NEWLINE ? (text.endsWith('\n') ? text : text + '\n') : text; - el.focus(); - - // Method 1: ClipboardEvent - try { - const dt = new DataTransfer(); - dt.setData('text/plain', payload); - const pasteEvt = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }); - const dispatched = el.dispatchEvent(pasteEvt); - const notPrevented = !pasteEvt.defaultPrevented; - - RC_DEBUG?.verbose('ClipboardEvent attempt', { dispatched, notPrevented }); - - if (dispatched && notPrevented) { - RC_DEBUG?.info('✅ Paste method succeeded: ClipboardEvent'); - return true; + return el; } - } catch (e) { - RC_DEBUG?.verbose('ClipboardEvent failed', { error: String(e) }); - } - // Method 2: ProseMirror - const isPM = el.classList && el.classList.contains('ProseMirror'); - if (isPM) { - RC_DEBUG?.verbose('Attempting ProseMirror paste'); - - // Pad with blank lines before/after to preserve ``` fences visually. - const payload2 = `\n${payload.replace(/\n?$/, '\n')}\n`; - - // Use text node to preserve code fences better - const textNode = document.createTextNode(payload2); - el.innerHTML = ''; - el.appendChild(textNode); - el.dispatchEvent(new Event('input', { bubbles: true })); - el.dispatchEvent(new Event('change', { bubbles: true })); - RC_DEBUG?.info('✅ Paste method succeeded: ProseMirror'); - return true; - } - - // Method 3: Selection API insertion (non-deprecated) - try { - const sel = window.getSelection && window.getSelection(); - if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') { - // Ensure there's a caret; if not, place it at the end - if (sel && sel.rangeCount === 0) { - const r = document.createRange(); - r.selectNodeContents(el); r.collapse(false); sel.removeAllRanges(); sel.addRange(r); - RC_DEBUG?.verbose('Selection range set for contentEditable'); - } - if (sel && sel.rangeCount > 0) { - const range = sel.getRangeAt(0); - range.deleteContents(); - const node = document.createTextNode(payload); - range.insertNode(node); - // Move caret after inserted node - range.setStartAfter(node); - range.setEndAfter(node); - sel.removeAllRanges(); sel.addRange(range); - el.dispatchEvent(new Event('input', { bubbles: true })); - el.dispatchEvent(new Event('change', { bubbles: true })); - RC_DEBUG?.info('✅ Paste method succeeded: Selection API (contentEditable)'); - return true; - } - } else if (typeof el.setRangeText === 'function') { - // For inputs/text-areas supporting setRangeText - const start = el.selectionStart ?? 0; - const end = el.selectionEnd ?? start; - el.setRangeText(payload, start, end, 'end'); - el.dispatchEvent(new Event('input', { bubbles: true })); - RC_DEBUG?.info('✅ Paste method succeeded: setRangeText'); - return true; + static color(t) { + const c = { SUCCESS:'#10B981', ERROR:'#EF4444', VALIDATION_ERROR:'#F59E0B', EXECUTING:'#3B82F6', MOCK:'#8B5CF6' }; + return c[t] || '#6B7280'; } - } catch (e) { - RC_DEBUG?.verbose('Selection API insertion failed', { error: String(e) }); - } - - // Method 4: TEXTAREA/INPUT - if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') { - RC_DEBUG?.verbose('Attempting TEXTAREA/INPUT paste'); - el.value = payload; - el.dispatchEvent(new Event('input', { bubbles: true })); - RC_DEBUG?.info('✅ Paste method succeeded: TEXTAREA/INPUT'); - return true; - } - - // Method 5: contentEditable - if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') { - RC_DEBUG?.verbose('Attempting contentEditable paste'); - el.textContent = payload; - el.dispatchEvent(new Event('input', { bubbles: true })); - RC_DEBUG?.info('✅ Paste method succeeded: contentEditable'); - return true; - } - - // Fallback: GM_setClipboard - RC_DEBUG?.warn('All paste methods failed, trying GM_setClipboard fallback'); - try { - if (typeof GM_setClipboard === 'function') { - GM_setClipboard(payload, { type: 'text', mimetype: 'text/plain' }); - RC_DEBUG?.warn('📋 Clipboard fallback used — manual paste may be required', { - length: payload.length - }); - GM_notification({ - title: 'AI Repo Commander', - text: 'Content copied to clipboard — press Ctrl/Cmd+V to paste.', - timeout: 5000 - }); - RC_DEBUG?.info('✅ Paste method succeeded: GM_setClipboard (manual paste required)'); - } - } catch (e) { - RC_DEBUG?.warn('GM_setClipboard failed', { error: String(e) }); - } - - return false; - - } catch (e) { - RC_DEBUG?.warn('pasteToComposer fatal error', { error: String(e) }); - return false; - } - } - - async function pasteAndMaybeSubmit(text, attempt = 0, startedAt = Date.now(), submitRetry = 0) { - // 1) Check if elapsed time exceeds MAX_COMPOSER_WAIT_MS - const elapsed = Date.now() - startedAt; - if (elapsed > CONFIG.MAX_COMPOSER_WAIT_MS) { - RC_DEBUG?.error('pasteAndMaybeSubmit gave up after max wait time', { - elapsed, - maxWait: CONFIG.MAX_COMPOSER_WAIT_MS, - attempt, - submitRetry - }); - GM_notification({ - title: 'AI Repo Commander', - text: `Paste/submit failed: composer not ready after ${Math.floor(elapsed / 1000)}s`, - timeout: 6000 - }); - return false; } - // 2) Quick readiness probe with 1200ms timeout - const ready = await waitForComposerReady({ timeoutMs: 1200 }); - if (!ready) { - // 3) Not ready, requeue with exponential backoff (600ms base, cap at 30s) - const backoffMs = Math.min(30_000, Math.floor(600 * Math.pow(1.6, attempt))); - RC_DEBUG?.warn('Composer not ready; re-queueing paste with backoff', { - attempt, - backoffMs, - elapsed - }); - setTimeout(() => { - execQueue.push(async () => { - await pasteAndMaybeSubmit(text, attempt + 1, startedAt, submitRetry); + // ---------------------- Paste + Submit helpers ---------------------- + function getVisibleInputCandidate() { + const candidates = [ + // ChatGPT / GPT + '#prompt-textarea', + '.ProseMirror#prompt-textarea', + '.ProseMirror[role="textbox"][contenteditable="true"]', + '[data-testid="composer"] [contenteditable="true"][role="textbox"]', + 'main [contenteditable="true"][role="textbox"]', + + // Claude + '.chat-message + [contenteditable="true"]', + '[contenteditable="true"][data-testid="chat-input"]', + + // Gemini + 'textarea[data-testid="input-area"]', + '[contenteditable="true"][aria-label*="Message"]', + + // Generic fallbacks + 'textarea', + '[contenteditable="true"]' + ]; + for (const sel of candidates) { + const el = document.querySelector(sel); + if (!el) continue; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') continue; + if (el.offsetParent === null && style.position !== 'fixed') continue; + return el; + } + return null; + } + + function findSendButton(scopeEl) { + // Try multiple scope strategies + const formScope = scopeEl?.closest('form'); + const composerScope = scopeEl?.closest('[data-testid="composer"]'); + const mainScope = scopeEl?.closest('main'); + const scope = formScope || composerScope || mainScope || document; + + // Debug: Log scoping information + RC_DEBUG?.verbose('findSendButton: scope resolution', { + hasScopeEl: !!scopeEl, + scopeElTag: scopeEl?.tagName, + formScope: !!formScope, + composerScope: !!composerScope, + mainScope: !!mainScope, + usingDocument: scope === document }); - }, backoffMs); - return false; - } - // 4) Only paste if text is non-empty (enables submit-only retries) - if (text && text.length > 0) { - const pasted = pasteToComposer(text); - if (!pasted) return false; + const selectors = [ + 'button[data-testid="send-button"]', + 'button#composer-submit-button', + '[id="composer-submit-button"]', + 'button.composer-submit-btn', + 'button[data-testid="composer-send-button"]', + 'button[aria-label="Send"]', + 'button[aria-label*="Send prompt"]', + 'button[aria-label*="Send message"]', + 'button[aria-label*="Send"]', + 'button[aria-label*="send"]', + 'button[aria-label*="Submit"]', + 'button[aria-label*="submit"]', + // Some pages omit type=submit; keep generic button-in-form last + 'form button' + ]; - try { - const el = getVisibleInputCandidate(); - const actualContent = el?.textContent || el?.value || '[no content found]'; - RC_DEBUG?.info('📋 Content in composer after paste', { - expectedLength: text.length, - actualLength: actualContent.length, - actualPreview: actualContent.substring(0, 200) - }); - } catch (e) { - RC_DEBUG?.warn('Could not read composer content', { error: String(e) }); - } - } + // First pass: Try scoped search + for (const s of selectors) { + const btn = scope.querySelector(s); + if (btn) { + const style = window.getComputedStyle(btn); + const disabled = btn.disabled || btn.getAttribute('aria-disabled') === 'true'; + const hidden = style.display === 'none' || style.visibility === 'hidden'; + const notRendered = btn.offsetParent === null && style.position !== 'fixed'; - if (!CONFIG.AUTO_SUBMIT) return true; + RC_DEBUG?.verbose('findSendButton: found candidate (scoped)', { + selector: s, + id: btn.id, + disabled, + hidden, + notRendered, + willReturn: !disabled && !hidden && !notRendered + }); - // 5) After paste, wait POST_PASTE_DELAY_MS - await ExecutionManager.delay(CONFIG.POST_PASTE_DELAY_MS); - - // 6) Try submitComposer() - const ok = await submitComposer(); - if (!ok) { - // 7) If submit fails, and we haven't hit SUBMIT_MAX_RETRIES, requeue submit-only retry - if (submitRetry < CONFIG.SUBMIT_MAX_RETRIES) { - const submitBackoffMs = Math.min(30_000, Math.floor(500 * Math.pow(1.6, submitRetry))); - RC_DEBUG?.warn('Submit failed; re-queueing submit-only retry with backoff', { - submitRetry, - submitBackoffMs - }); - setTimeout(() => { - execQueue.push(async () => { - // Empty text for submit-only retry, increment submitRetry - await pasteAndMaybeSubmit('', attempt, startedAt, submitRetry + 1); - }); - }, submitBackoffMs); - return false; - } else { - RC_DEBUG?.error('Submit failed after max retries', { submitRetry, maxRetries: CONFIG.SUBMIT_MAX_RETRIES }); - GM_notification({ - title: 'AI Repo Commander', - text: `Pasted content, but auto-submit failed after ${CONFIG.SUBMIT_MAX_RETRIES} retries.`, - timeout: 6000 - }); - } - } - return true; - } - - // ---------------------- Parser (strict, require @end@) ---------------------- - class CommandParser { - static parseYAMLCommand(codeBlockText) { - const block = this.extractCompleteBlock(codeBlockText); - if (!block) throw new Error('No complete @bridge@ command found (missing @end@ terminator).'); - const parsed = this.parseKeyValuePairs(block); - - // Defaults - parsed.url = parsed.url || 'https://n8n.brrd.tech/webhook/ai-gitea-bridge'; - parsed.owner = parsed.owner || 'rob'; - - // Helpful default: create_branch without source_branch defaults to 'main' - if (parsed.action === 'create_branch' && !parsed.source_branch) { - parsed.source_branch = 'main'; - } - - // Expand owner/repo shorthand - if (parsed.repo && typeof parsed.repo === 'string' && parsed.repo.includes('/')) { - const [owner, repo] = parsed.repo.split('/', 2); - if (!parsed.owner) parsed.owner = owner; - parsed.repo = repo; - } - return parsed; - } - - static extractCompleteBlock(text) { - // Require terminator @end@ (clearer than --- which appears in markdown) - const pattern = /^\s*@bridge@[ \t]*\n([\s\S]*?)\n@end@[ \t]*(?:\n|$)/m; - const m = text.match(pattern); - if (!m) return null; - const inner = m[1]?.trimEnd(); - if (!inner) return null; - return inner; - } - - static parseKeyValuePairs(block) { - const lines = block.split('\n'); - const result = {}; - let currentKey = null; - let collecting = false; - let buf = []; - - for (const raw of lines) { - const line = raw.replace(/\r$/, ''); - - if (collecting) { - const looksKey = /^[A-Za-z_][\w\-]*\s*:/.test(line); - const unindented = !/^[ \t]/.test(line); - // End the current '|' block on ANY unindented key, not just a small whitelist - if (looksKey && unindented) { - result[currentKey] = buf.join('\n').trimEnd(); - collecting = false; buf = []; - } else { - buf.push(line); continue; - } - } - - const idx = line.indexOf(':'); - if (idx !== -1) { - const key = line.slice(0, idx).trim(); - let value = line.slice(idx + 1).trim(); - if (value === '|') { - currentKey = key; collecting = true; buf = []; - } else if (value === '') { - currentKey = key; result[key] = ''; - } else { - result[key] = value; currentKey = null; - } - } else if (currentKey && result[currentKey] === '') { - result[currentKey] += (result[currentKey] ? '\n' : '') + line.trimEnd(); - } - } - if (collecting && currentKey) result[currentKey] = buf.join('\n').trimEnd(); - return result; - } - - static validateStructure(parsed) { - const errors = []; - // Example commands are treated as valid but inert - const isExample = - parsed.example === true || - parsed.example === 'true' || - String(parsed.example || '').toLowerCase() === 'yes'; - - const action = parsed.action; - - if (isExample) { - return { isValid: true, errors, example: true }; - } - - if (!action) { errors.push('Missing required field: action'); return { isValid:false, errors }; } - const req = REQUIRED_FIELDS[action]; - if (!req) { errors.push(`Unknown action: ${action}`); return { isValid:false, errors }; } - - for (const f of req) if (!parsed[f] && parsed[f] !== '') errors.push(`Missing required field: ${f}`); - - for (const [field, value] of Object.entries(parsed)) { - const validator = FIELD_VALIDATORS[field]; - if (validator && !validator(value)) errors.push(`Invalid format for field: ${field}`); - } - return { isValid: errors.length === 0, errors }; - } - - } - - // ---------------------- Execution ---------------------- - class ExecutionManager { - static async executeCommand(command, sourceElement, renderKey = '', label = '') { - try { - if ((command.action === 'update_file' || command.action === 'create_file') && !command.commit_message) { - command.commit_message = `AI Repo Commander: ${command.path} (${new Date().toISOString()})`; - } - - if (!CONFIG.ENABLE_API) { - UIFeedback.appendStatus(sourceElement, 'EXECUTING', { action: command.action, details: 'Mocking...', key: renderKey, label }); - return await this.mockExecution(command, sourceElement, renderKey, label); - } - - UIFeedback.appendStatus(sourceElement, 'EXECUTING', { action: command.action, details: 'Making API request...', key: renderKey, label }); - - const res = await this.makeAPICallWithRetry(command); - return this.handleSuccess(res, command, sourceElement, false, renderKey, label); - - } catch (error) { - return this.handleError(error, command, sourceElement, renderKey, label); - } - } - - static async mockExecution(command, sourceElement, renderKey = '', label = '') { - await this.delay(500); - const mock = { status: 200, responseText: JSON.stringify({ success: true, message: `Mock execution completed for ${command.action}` }) }; - return this.handleSuccess(mock, command, sourceElement, true, renderKey, label); - } - - - static async makeAPICallWithRetry(command, attempt = 0) { - try { - requireBridgeKeyIfNeeded(); - return await this.makeAPICall(command); - } catch (err) { - if (attempt < CONFIG.MAX_RETRIES) { - await this.delay(1000 * (attempt + 1)); - return this.makeAPICallWithRetry(command, attempt + 1); - } - const totalAttempts = attempt + 1; - throw new Error(`${err.message} (failed after ${totalAttempts} attempts; max ${CONFIG.MAX_RETRIES + 1})`); - } - } - - static makeAPICall(command) { - return new Promise((resolve, reject) => { - const bridgeKey = requireBridgeKeyIfNeeded(); - GM_xmlhttpRequest({ - method: 'POST', - url: command.url, - headers: { 'X-Bridge-Key': bridgeKey, 'Content-Type': 'application/json' }, - data: JSON.stringify(command), - timeout: CONFIG.API_TIMEOUT_MS || 60000, - - onload: (response) => { - if (response.status >= 200 && response.status < 300) { - return resolve(response); + if (!disabled && !hidden && !notRendered) return btn; } - const body = response.responseText ? ` body=${response.responseText.slice(0,300)}` : ''; - reject(new Error(`API Error ${response.status}: ${response.statusText}${body}`)); - }, + } - onerror: (error) => { - const msg = (error && (error.error || error.message)) - ? (error.error || error.message) - : JSON.stringify(error ?? {}); - reject(new Error(`Network error: ${msg}`)); - }, + // Second pass: Fallback to global search with detailed logging + RC_DEBUG?.verbose('findSendButton: no button found in scope, trying global search'); + for (const s of selectors) { + const btn = document.querySelector(s); + if (btn) { + const style = window.getComputedStyle(btn); + const disabled = btn.disabled || btn.getAttribute('aria-disabled') === 'true'; + const hidden = style.display === 'none' || style.visibility === 'hidden'; + const notRendered = btn.offsetParent === null && style.position !== 'fixed'; - ontimeout: () => reject(new Error(`API request timeout after ${CONFIG.API_TIMEOUT_MS}ms`)) - }); - }); - } + RC_DEBUG?.verbose('findSendButton: found candidate (global)', { + selector: s, + id: btn.id, + disabled, + hidden, + notRendered, + inScope: scope.contains(btn), + willReturn: !disabled && !hidden && !notRendered + }); - - static _extractGetFileBody(payload) { - const item = Array.isArray(payload) ? payload[0] : payload; - return ( - item?.result?.content?.data ?? - item?.content?.data ?? - payload?.result?.content?.data ?? - null - ); - } - - static _extractFilesArray(payload) { - const obj = Array.isArray(payload) ? payload[0] : payload; - let files = obj?.result?.files ?? obj?.files ?? null; - if (!files) { - const res = obj?.result; - if (res) { - for (const [k, v] of Object.entries(res)) { - if (Array.isArray(v) && v.length && (k.toLowerCase().includes('file') || typeof v[0] === 'string' || v[0]?.path || v[0]?.name)) { - files = v; break; + if (!disabled && !hidden && !notRendered) return btn; } - } } - } - return Array.isArray(files) ? files : null; - } - static _formatFilesListing(files) { - const pickPath = (f) => { - if (typeof f === 'string') return f; - if (typeof f?.path === 'string') return f.path; - if (f?.dir && f?.name) return `${f.dir.replace(/\/+$/,'')}/${f.name}`; - if (f?.name) return f.name; - try { return JSON.stringify(f); } catch { return String(f); } - }; - - const lines = files.map(pickPath).filter(Boolean).sort(); - return '```text\n' + lines.join('\n') + '\n```'; - } - - static async handleSuccess(response, command, sourceElement, isMock = false, renderKey = '', label = '') { - let data; try { data = JSON.parse(response.responseText || '{}'); } - catch { data = { message: 'Operation completed (no JSON body)' }; } - - UIFeedback.appendStatus(sourceElement, isMock ? 'MOCK' : 'SUCCESS', { - action: command.action, - details: data.message || 'Operation completed successfully', - key: renderKey, - label - }); - - if (command.action === 'get_file') { - const body = this._extractGetFileBody(data); - if (typeof body === 'string' && body.length) { - (window.AI_REPO_RESPONSES || new ResponseBuffer()).push({ label, content: body }); - } else { - GM_notification({ title: 'AI Repo Commander', text: 'get_file succeeded, but no content to paste.', timeout: 4000 }); + // Final fallback: XPath for exact button location (works if structure hasn't drifted) + try { + const xp = '/html/body/div[1]/div/div/div[2]/main/div/div/div[2]/div[1]/div/div[2]/form/div[2]/div/div[3]/div/button'; + const node = document.evaluate(xp, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; + if (node instanceof HTMLButtonElement) { + RC_DEBUG?.verbose('findSendButton: found via XPath fallback', { id: node.id }); + return node; + } + } catch (e) { + RC_DEBUG?.verbose('findSendButton: XPath fallback failed', { error: String(e) }); } - } - if (command.action === 'list_files') { - const files = this._extractFilesArray(data); - if (files && files.length) { - const listing = this._formatFilesListing(files); - (window.AI_REPO_RESPONSES || new ResponseBuffer()).push({ label, content: listing }); - } else { - const fallback = '```json\n' + JSON.stringify(data, null, 2) + '\n```'; - (window.AI_REPO_RESPONSES || new ResponseBuffer()).push({ label, content: fallback }); - GM_notification({ - title: 'AI Repo Commander', - text: 'list_files succeeded, but response had no obvious files array. Pasted raw JSON.', - timeout: 5000 - }); + RC_DEBUG?.warn('findSendButton: no valid button found anywhere'); + return null; + } + + function pressEnterOn(el) { + const events = ['keydown','keypress','keyup']; + for (const type of events) { + const ok = el.dispatchEvent(new KeyboardEvent(type, { + key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true + })); + if (!ok) return false; } - } + return true; + } - // Trigger cluster rescan for chainable commands - try { - if (shouldTriggerClusterRescan(sourceElement, command.action)) { - await scheduleClusterRescan(sourceElement); + async function submitComposer() { + try { + // 1) Get composer element first + const el = getVisibleInputCandidate(); + if (!el) return false; + + // 2) Find send button scoped to composer + const btn = findSendButton(el); + + // 3) Check SUBMIT_MODE and click or press Enter + if (CONFIG.SUBMIT_MODE !== 'enter_only' && btn) { + btn.click(); + return true; + } + + return pressEnterOn(el); + } catch { + return false; } - } catch (e) { - RC_DEBUG?.verbose('Cluster rescan failed', { error: String(e) }); - } - - return { success: true, data, isMock }; } - static handleError(error, command, sourceElement, renderKey = '', label = '') { - UIFeedback.appendStatus(sourceElement, 'ERROR', { - action: command.action || 'Command', - details: error.message, - key: renderKey, - label - }); - return { success: false, error: error.message }; + function pasteToComposer(text) { + RC_DEBUG?.info('🔵 pasteToComposer CALLED', { textLength: text.length, preview: text.substring(0, 100) }); + + try { + const el = getVisibleInputCandidate(); + if (!el) { + RC_DEBUG?.warn('❌ No input element found'); + GM_notification({ title: 'AI Repo Commander', text: 'No input box found to paste file content.', timeout: 4000 }); + return false; + } + + RC_DEBUG?.verbose('Found input element', { + tagName: el.tagName, + classList: Array.from(el.classList || []).join(' '), + contentEditable: el.getAttribute('contenteditable') + }); + + const payload = CONFIG.APPEND_TRAILING_NEWLINE ? (text.endsWith('\n') ? text : text + '\n') : text; + el.focus(); + + // Method 1: ClipboardEvent + try { + const dt = new DataTransfer(); + dt.setData('text/plain', payload); + const pasteEvt = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }); + const dispatched = el.dispatchEvent(pasteEvt); + const notPrevented = !pasteEvt.defaultPrevented; + + RC_DEBUG?.verbose('ClipboardEvent attempt', { dispatched, notPrevented }); + + if (dispatched && notPrevented) { + RC_DEBUG?.info('✅ Paste method succeeded: ClipboardEvent'); + return true; + } + } catch (e) { + RC_DEBUG?.verbose('ClipboardEvent failed', { error: String(e) }); + } + + // Method 2: ProseMirror + const isPM = el.classList && el.classList.contains('ProseMirror'); + if (isPM) { + RC_DEBUG?.verbose('Attempting ProseMirror paste'); + + // Pad with blank lines before/after to preserve ``` fences visually. + const payload2 = `\n${payload.replace(/\n?$/, '\n')}\n`; + + // Use text node to preserve code fences better + const textNode = document.createTextNode(payload2); + el.innerHTML = ''; + el.appendChild(textNode); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + RC_DEBUG?.info('✅ Paste method succeeded: ProseMirror'); + return true; + } + + // Method 3: Selection API insertion (non-deprecated) + try { + const sel = window.getSelection && window.getSelection(); + if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') { + // Ensure there's a caret; if not, place it at the end + if (sel && sel.rangeCount === 0) { + const r = document.createRange(); + r.selectNodeContents(el); r.collapse(false); sel.removeAllRanges(); sel.addRange(r); + RC_DEBUG?.verbose('Selection range set for contentEditable'); + } + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0); + range.deleteContents(); + const node = document.createTextNode(payload); + range.insertNode(node); + // Move caret after inserted node + range.setStartAfter(node); + range.setEndAfter(node); + sel.removeAllRanges(); sel.addRange(range); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + RC_DEBUG?.info('✅ Paste method succeeded: Selection API (contentEditable)'); + return true; + } + } else if (typeof el.setRangeText === 'function') { + // For inputs/text-areas supporting setRangeText + const start = el.selectionStart ?? 0; + const end = el.selectionEnd ?? start; + el.setRangeText(payload, start, end, 'end'); + el.dispatchEvent(new Event('input', { bubbles: true })); + RC_DEBUG?.info('✅ Paste method succeeded: setRangeText'); + return true; + } + } catch (e) { + RC_DEBUG?.verbose('Selection API insertion failed', { error: String(e) }); + } + + // Method 4: TEXTAREA/INPUT + if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') { + RC_DEBUG?.verbose('Attempting TEXTAREA/INPUT paste'); + el.value = payload; + el.dispatchEvent(new Event('input', { bubbles: true })); + RC_DEBUG?.info('✅ Paste method succeeded: TEXTAREA/INPUT'); + return true; + } + + // Method 5: contentEditable + if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') { + RC_DEBUG?.verbose('Attempting contentEditable paste'); + el.textContent = payload; + el.dispatchEvent(new Event('input', { bubbles: true })); + RC_DEBUG?.info('✅ Paste method succeeded: contentEditable'); + return true; + } + + // Fallback: GM_setClipboard + RC_DEBUG?.warn('All paste methods failed, trying GM_setClipboard fallback'); + try { + if (typeof GM_setClipboard === 'function') { + GM_setClipboard(payload, { type: 'text', mimetype: 'text/plain' }); + RC_DEBUG?.warn('📋 Clipboard fallback used — manual paste may be required', { + length: payload.length + }); + GM_notification({ + title: 'AI Repo Commander', + text: 'Content copied to clipboard — press Ctrl/Cmd+V to paste.', + timeout: 5000 + }); + RC_DEBUG?.info('✅ Paste method succeeded: GM_setClipboard (manual paste required)'); + } + } catch (e) { + RC_DEBUG?.warn('GM_setClipboard failed', { error: String(e) }); + } + + return false; + + } catch (e) { + RC_DEBUG?.warn('pasteToComposer fatal error', { error: String(e) }); + return false; + } } - static delay(ms) { return new Promise(r => setTimeout(r, ms)); } - } + async function pasteAndMaybeSubmit(text, attempt = 0, startedAt = Date.now(), submitRetry = 0) { + // 1) Check if elapsed time exceeds MAX_COMPOSER_WAIT_MS + const elapsed = Date.now() - startedAt; + if (elapsed > CONFIG.MAX_COMPOSER_WAIT_MS) { + RC_DEBUG?.error('pasteAndMaybeSubmit gave up after max wait time', { + elapsed, + maxWait: CONFIG.MAX_COMPOSER_WAIT_MS, + attempt, + submitRetry + }); + GM_notification({ + title: 'AI Repo Commander', + text: `Paste/submit failed: composer not ready after ${Math.floor(elapsed / 1000)}s`, + timeout: 6000 + }); + return false; + } - // ---------------------- Execution Queue ---------------------- - class ExecutionQueue { - constructor({ minDelayMs = CONFIG.QUEUE_MIN_DELAY_MS, maxPerMinute = CONFIG.QUEUE_MAX_PER_MINUTE } = {}) { - this.q = []; - this.running = false; - this.minDelayMs = minDelayMs; - this.maxPerMinute = maxPerMinute; - this.timestamps = []; - this.onSizeChange = null; - } - push(task) { - this.q.push(task); - this.onSizeChange?.(this.q.length); - if (!this.running) void this._drain(); - } - clear() { - this.q.length = 0; - this.onSizeChange?.(0); - } - cancelOne(predicate) { - const i = this.q.findIndex(predicate); - if (i >= 0) this.q.splice(i, 1); - this.onSizeChange?.(this.q.length); - } - _withinBudget() { - const now = Date.now(); - this.timestamps = this.timestamps.filter(t => now - t < 60_000); - return this.timestamps.length < this.maxPerMinute; - } - async _drain() { - if (this.running) return; - this.running = true; - const origLen = this.q.length; - while (this.q.length) { - // rate cap - while (!this._withinBudget()) await ExecutionManager.delay(500); - const fn = this.q.shift(); - this.onSizeChange?.(this.q.length); - RC_DEBUG?.toast?.(`Executing command ${origLen - this.q.length}/${origLen}`, 800); - try { await fn(); } catch { /* error already surfaced */ } - this.timestamps.push(Date.now()); - await ExecutionManager.delay(this.minDelayMs); - } - this.running = false; - } - } + // 2) Quick readiness probe with 1200ms timeout + const ready = await waitForComposerReady({ timeoutMs: 1200 }); + if (!ready) { + // 3) Not ready, requeue with exponential backoff (600ms base, cap at 30s) + const backoffMs = Math.min(30_000, Math.floor(600 * Math.pow(1.6, attempt))); + RC_DEBUG?.warn('Composer not ready; re-queueing paste with backoff', { + attempt, + backoffMs, + elapsed + }); + setTimeout(() => { + execQueue.push(async () => { + await pasteAndMaybeSubmit(text, attempt + 1, startedAt, submitRetry); + }); + }, backoffMs); + return false; + } - const execQueue = new ExecutionQueue(); - // noinspection JSUnusedGlobalSymbols + // 4) Only paste if text is non-empty (enables submit-only retries) + if (text && text.length > 0) { + const pasted = pasteToComposer(text); + if (!pasted) return false; + + try { + const el = getVisibleInputCandidate(); + const actualContent = el?.textContent || el?.value || '[no content found]'; + RC_DEBUG?.info('📋 Content in composer after paste', { + expectedLength: text.length, + actualLength: actualContent.length, + actualPreview: actualContent.substring(0, 200) + }); + } catch (e) { + RC_DEBUG?.warn('Could not read composer content', { error: String(e) }); + } + } + + if (!CONFIG.AUTO_SUBMIT) return true; + + // 5) After paste, wait POST_PASTE_DELAY_MS + await ExecutionManager.delay(CONFIG.POST_PASTE_DELAY_MS); + + // 6) Try submitComposer() + const ok = await submitComposer(); + if (!ok) { + // 7) If submit fails, and we haven't hit SUBMIT_MAX_RETRIES, requeue submit-only retry + if (submitRetry < CONFIG.SUBMIT_MAX_RETRIES) { + const submitBackoffMs = Math.min(30_000, Math.floor(500 * Math.pow(1.6, submitRetry))); + RC_DEBUG?.warn('Submit failed; re-queueing submit-only retry with backoff', { + submitRetry, + submitBackoffMs + }); + setTimeout(() => { + execQueue.push(async () => { + // Empty text for submit-only retry, increment submitRetry + await pasteAndMaybeSubmit('', attempt, startedAt, submitRetry + 1); + }); + }, submitBackoffMs); + return false; + } else { + RC_DEBUG?.error('Submit failed after max retries', { submitRetry, maxRetries: CONFIG.SUBMIT_MAX_RETRIES }); + GM_notification({ + title: 'AI Repo Commander', + text: `Pasted content, but auto-submit failed after ${CONFIG.SUBMIT_MAX_RETRIES} retries.`, + timeout: 6000 + }); + } + } + return true; + } + + // ---------------------- Parser (strict, require @end@) ---------------------- + class CommandParser { + static parseYAMLCommand(codeBlockText) { + const block = this.extractCompleteBlock(codeBlockText); + if (!block) throw new Error('No complete @bridge@ command found (missing @end@ terminator).'); + const parsed = this.parseKeyValuePairs(block); + + // Defaults + parsed.url = parsed.url || 'https://n8n.brrd.tech/webhook/ai-gitea-bridge'; + parsed.owner = parsed.owner || 'rob'; + + // Helpful default: create_branch without source_branch defaults to 'main' + if (parsed.action === 'create_branch' && !parsed.source_branch) { + parsed.source_branch = 'main'; + } + + // Expand owner/repo shorthand + if (parsed.repo && typeof parsed.repo === 'string' && parsed.repo.includes('/')) { + const [owner, repo] = parsed.repo.split('/', 2); + if (!parsed.owner) parsed.owner = owner; + parsed.repo = repo; + } + return parsed; + } + + static extractCompleteBlock(text) { + // Require terminator @end@ (clearer than --- which appears in markdown) + const pattern = /^\s*@bridge@[ \t]*\n([\s\S]*?)\n@end@[ \t]*(?:\n|$)/m; + const m = text.match(pattern); + if (!m) return null; + const inner = m[1]?.trimEnd(); + if (!inner) return null; + return inner; + } + + static parseKeyValuePairs(block) { + const lines = block.split('\n'); + const result = {}; + let currentKey = null; + let collecting = false; + let buf = []; + + for (const raw of lines) { + const line = raw.replace(/\r$/, ''); + + if (collecting) { + const looksKey = /^[A-Za-z_][\w\-]*\s*:/.test(line); + const unindented = !/^[ \t]/.test(line); + // End the current '|' block on ANY unindented key, not just a small whitelist + if (looksKey && unindented) { + result[currentKey] = buf.join('\n').trimEnd(); + collecting = false; buf = []; + } else { + buf.push(line); continue; + } + } + + const idx = line.indexOf(':'); + if (idx !== -1) { + const key = line.slice(0, idx).trim(); + let value = line.slice(idx + 1).trim(); + if (value === '|') { + currentKey = key; collecting = true; buf = []; + } else if (value === '') { + currentKey = key; result[key] = ''; + } else { + result[key] = value; currentKey = null; + } + } else if (currentKey && result[currentKey] === '') { + result[currentKey] += (result[currentKey] ? '\n' : '') + line.trimEnd(); + } + } + if (collecting && currentKey) result[currentKey] = buf.join('\n').trimEnd(); + return result; + } + + static validateStructure(parsed) { + const errors = []; + // Example commands are treated as valid but inert + const isExample = + parsed.example === true || + parsed.example === 'true' || + String(parsed.example || '').toLowerCase() === 'yes'; + + const action = parsed.action; + + if (isExample) { + return { isValid: true, errors, example: true }; + } + + if (!action) { errors.push('Missing required field: action'); return { isValid:false, errors }; } + const req = REQUIRED_FIELDS[action]; + if (!req) { errors.push(`Unknown action: ${action}`); return { isValid:false, errors }; } + + for (const f of req) if (!parsed[f] && parsed[f] !== '') errors.push(`Missing required field: ${f}`); + + for (const [field, value] of Object.entries(parsed)) { + const validator = FIELD_VALIDATORS[field]; + if (validator && !validator(value)) errors.push(`Invalid format for field: ${field}`); + } + return { isValid: errors.length === 0, errors }; + } + + } + + // ---------------------- ResponseBuffer (helper functions and class) ---------------------- + function chunkByLines(s, limit) { + const out = []; + let start = 0; + while (start < s.length) { + const endSoft = s.lastIndexOf('\n', Math.min(start + limit, s.length)); + const end = endSoft > start ? endSoft + 1 : Math.min(start + limit, s.length); + out.push(s.slice(start, end)); + start = end; + } + return out; + } + + function isSingleFencedBlock(s) { + return /^```[^\n]*\n[\s\S]*\n```$/.test(s.trim()); + } + + function splitRespectingCodeFence(text, limit) { + const trimmed = text.trim(); + if (!isSingleFencedBlock(trimmed)) { + // Not a single fence → just line-friendly chunking + return chunkByLines(text, limit); + } + // Extract inner payload & language hint + const m = /^```([^\n]*)\n([\s\S]*)\n```$/.exec(trimmed); + const lang = (m?.[1] || 'text').trim(); + const inner = m?.[2] ?? ''; + const chunks = chunkByLines(inner, limit - 16 - lang.length); // budget for fences + return chunks.map(c => '```' + lang + '\n' + c.replace(/\n?$/, '\n') + '```'); + } + + class ResponseBuffer { + constructor() { + this.pending = []; // { label, content } + this.timer = null; + this.flushing = false; + } + + push(item) { + if (!item || !item.content) return; + this.pending.push(item); + this.scheduleFlush(); + } + + scheduleFlush() { + if (this.timer) clearTimeout(this.timer); + this.timer = setTimeout(() => this.flush(), CONFIG.RESPONSE_BUFFER_FLUSH_DELAY_MS || 500); + } + + buildCombined() { + const parts = []; + for (const { label, content } of this.pending) { + if (CONFIG.RESPONSE_BUFFER_SECTION_HEADINGS && label) { + parts.push(`### ${label}\n`); + } + parts.push(String(content).trimEnd()); + parts.push(''); // blank line between sections + } + return parts.join('\n'); + } + + async flush() { + if (this.flushing) return; + if (!this.pending.length) return; + this.flushing = true; + + const toPaste = this.buildCombined(); + this.pending.length = 0; // clear + + try { + const limit = CONFIG.MAX_PASTE_CHARS || 250_000; + + if (CONFIG.SPLIT_LONG_RESPONSES && toPaste.length > limit) { + const chunks = splitRespectingCodeFence(toPaste, limit); + + RC_DEBUG?.warn(`Splitting long response into ${chunks.length} message(s)`, { + totalChars: toPaste.length, perChunkLimit: limit + }); + + chunks.forEach((chunk, i) => { + const header = CONFIG.RESPONSE_BUFFER_SECTION_HEADINGS + ? `### Part ${i+1}/${chunks.length}\n` + : ''; + const payload = header + chunk; + + execQueue.push(async () => { + await pasteAndMaybeSubmit(payload); + }); + }); + + return; // done: queued as multiple messages + } + + // Normal single-message path + execQueue.push(async () => { + await pasteAndMaybeSubmit(toPaste); + }); + + } finally { + this.flushing = false; + } + } + + } + + // Initialize singleton + window.AI_REPO_RESPONSES = new ResponseBuffer(); + + // ---------------------- Execution ---------------------- + class ExecutionManager { + static async executeCommand(command, sourceElement, renderKey = '', label = '') { + try { + if ((command.action === 'update_file' || command.action === 'create_file') && !command.commit_message) { + command.commit_message = `AI Repo Commander: ${command.path} (${new Date().toISOString()})`; + } + + if (!CONFIG.ENABLE_API) { + UIFeedback.appendStatus(sourceElement, 'EXECUTING', { action: command.action, details: 'Mocking...', key: renderKey, label }); + return await this.mockExecution(command, sourceElement, renderKey, label); + } + + UIFeedback.appendStatus(sourceElement, 'EXECUTING', { action: command.action, details: 'Making API request...', key: renderKey, label }); + + const res = await this.makeAPICallWithRetry(command); + return this.handleSuccess(res, command, sourceElement, false, renderKey, label); + + } catch (error) { + return this.handleError(error, command, sourceElement, renderKey, label); + } + } + + static async mockExecution(command, sourceElement, renderKey = '', label = '') { + await this.delay(500); + const mock = { status: 200, responseText: JSON.stringify({ success: true, message: `Mock execution completed for ${command.action}` }) }; + return this.handleSuccess(mock, command, sourceElement, true, renderKey, label); + } + + + static async makeAPICallWithRetry(command, attempt = 0) { + try { + requireBridgeKeyIfNeeded(); + return await this.makeAPICall(command); + } catch (err) { + if (attempt < CONFIG.MAX_RETRIES) { + await this.delay(1000 * (attempt + 1)); + return this.makeAPICallWithRetry(command, attempt + 1); + } + const totalAttempts = attempt + 1; + throw new Error(`${err.message} (failed after ${totalAttempts} attempts; max ${CONFIG.MAX_RETRIES + 1})`); + } + } + + static makeAPICall(command) { + return new Promise((resolve, reject) => { + const bridgeKey = requireBridgeKeyIfNeeded(); + GM_xmlhttpRequest({ + method: 'POST', + url: command.url, + headers: { 'X-Bridge-Key': bridgeKey, 'Content-Type': 'application/json' }, + data: JSON.stringify(command), + timeout: CONFIG.API_TIMEOUT_MS || 60000, + + onload: (response) => { + if (response.status >= 200 && response.status < 300) { + return resolve(response); + } + const body = response.responseText ? ` body=${response.responseText.slice(0,300)}` : ''; + reject(new Error(`API Error ${response.status}: ${response.statusText}${body}`)); + }, + + onerror: (error) => { + const msg = (error && (error.error || error.message)) + ? (error.error || error.message) + : JSON.stringify(error ?? {}); + reject(new Error(`Network error: ${msg}`)); + }, + + ontimeout: () => reject(new Error(`API request timeout after ${CONFIG.API_TIMEOUT_MS}ms`)) + }); + }); + } + + + static _extractGetFileBody(payload) { + const item = Array.isArray(payload) ? payload[0] : payload; + return ( + item?.result?.content?.data ?? + item?.content?.data ?? + payload?.result?.content?.data ?? + null + ); + } + + static _extractFilesArray(payload) { + const obj = Array.isArray(payload) ? payload[0] : payload; + let files = obj?.result?.files ?? obj?.files ?? null; + if (!files) { + const res = obj?.result; + if (res) { + for (const [k, v] of Object.entries(res)) { + if (Array.isArray(v) && v.length && (k.toLowerCase().includes('file') || typeof v[0] === 'string' || v[0]?.path || v[0]?.name)) { + files = v; break; + } + } + } + } + return Array.isArray(files) ? files : null; + } + + static _formatFilesListing(files) { + const pickPath = (f) => { + if (typeof f === 'string') return f; + if (typeof f?.path === 'string') return f.path; + if (f?.dir && f?.name) return `${f.dir.replace(/\/+$/,'')}/${f.name}`; + if (f?.name) return f.name; + try { return JSON.stringify(f); } catch { return String(f); } + }; + + const lines = files.map(pickPath).filter(Boolean).sort(); + return '```text\n' + lines.join('\n') + '\n```'; + } + + static async handleSuccess(response, command, sourceElement, isMock = false, renderKey = '', label = '') { + let data; try { data = JSON.parse(response.responseText || '{}'); } + catch { data = { message: 'Operation completed (no JSON body)' }; } + + UIFeedback.appendStatus(sourceElement, isMock ? 'MOCK' : 'SUCCESS', { + action: command.action, + details: data.message || 'Operation completed successfully', + key: renderKey, + label + }); + + if (command.action === 'get_file') { + const body = this._extractGetFileBody(data); + if (typeof body === 'string' && body.length) { + window.AI_REPO_RESPONSES.push({ label, content: body }); + } else { + GM_notification({ title: 'AI Repo Commander', text: 'get_file succeeded, but no content to paste.', timeout: 4000 }); + } + } + + if (command.action === 'list_files') { + const files = this._extractFilesArray(data); + if (files && files.length) { + const listing = this._formatFilesListing(files); + window.AI_REPO_RESPONSES.push({ label, content: listing }); + } else { + const fallback = '```json\n' + JSON.stringify(data, null, 2) + '\n```'; + window.AI_REPO_RESPONSES.push({ label, content: fallback }); + GM_notification({ + title: 'AI Repo Commander', + text: 'list_files succeeded, but response had no obvious files array. Pasted raw JSON.', + timeout: 5000 + }); + } + } + + // Trigger cluster rescan for chainable commands + try { + if (shouldTriggerClusterRescan(sourceElement, command.action)) { + await scheduleClusterRescan(sourceElement); + } + } catch (e) { + RC_DEBUG?.verbose('Cluster rescan failed', { error: String(e) }); + } + + return { success: true, data, isMock }; + } + + static handleError(error, command, sourceElement, renderKey = '', label = '') { + UIFeedback.appendStatus(sourceElement, 'ERROR', { + action: command.action || 'Command', + details: error.message, + key: renderKey, + label + }); + return { success: false, error: error.message }; + } + + static delay(ms) { return new Promise(r => setTimeout(r, ms)); } + } + + // ---------------------- Execution Queue ---------------------- + class ExecutionQueue { + constructor({ minDelayMs = CONFIG.QUEUE_MIN_DELAY_MS, maxPerMinute = CONFIG.QUEUE_MAX_PER_MINUTE } = {}) { + this.q = []; + this.running = false; + this.minDelayMs = minDelayMs; + this.maxPerMinute = maxPerMinute; + this.timestamps = []; + this.onSizeChange = null; + } + push(task) { + this.q.push(task); + this.onSizeChange?.(this.q.length); + if (!this.running) void this._drain(); + } + clear() { + this.q.length = 0; + this.onSizeChange?.(0); + } + cancelOne(predicate) { + const i = this.q.findIndex(predicate); + if (i >= 0) this.q.splice(i, 1); + this.onSizeChange?.(this.q.length); + } + _withinBudget() { + const now = Date.now(); + this.timestamps = this.timestamps.filter(t => now - t < 60_000); + return this.timestamps.length < this.maxPerMinute; + } + async _drain() { + if (this.running) return; + this.running = true; + const origLen = this.q.length; + while (this.q.length) { + // rate cap + while (!this._withinBudget()) await ExecutionManager.delay(500); + const fn = this.q.shift(); + this.onSizeChange?.(this.q.length); + RC_DEBUG?.toast?.(`Executing command ${origLen - this.q.length}/${origLen}`, 800); + try { await fn(); } catch { /* error already surfaced */ } + this.timestamps.push(Date.now()); + await ExecutionManager.delay(this.minDelayMs); + } + this.running = false; + } + } + + const execQueue = new ExecutionQueue(); + // noinspection JSUnusedGlobalSymbols window.AI_REPO_QUEUE = { - clear: () => execQueue.clear(), - size: () => execQueue.q.length, - cancelOne: (cb) => execQueue.cancelOne(cb), - }; + clear: () => execQueue.clear(), + size: () => execQueue.q.length, + cancelOne: (cb) => execQueue.cancelOne(cb), + }; - function chunkByLines(s, limit) { - const out = []; - let start = 0; - while (start < s.length) { - const endSoft = s.lastIndexOf('\n', Math.min(start + limit, s.length)); - const end = endSoft > start ? endSoft + 1 : Math.min(start + limit, s.length); - out.push(s.slice(start, end)); - start = end; - } - return out; - } + // ---------------------- Bridge Key ---------------------- + let BRIDGE_KEY = null; - function isSingleFencedBlock(s) { - return /^```[^\n]*\n[\s\S]*\n```$/.test(s.trim()); - } + function requireBridgeKeyIfNeeded() { + if (!CONFIG.ENABLE_API) return BRIDGE_KEY; - function splitRespectingCodeFence(text, limit) { - const trimmed = text.trim(); - if (!isSingleFencedBlock(trimmed)) { - // Not a single fence → just line-friendly chunking - return chunkByLines(text, limit); - } - // Extract inner payload & language hint - const m = /^```([^\n]*)\n([\s\S]*)\n```$/.exec(trimmed); - const lang = (m?.[1] || 'text').trim(); - const inner = m?.[2] ?? ''; - const chunks = chunkByLines(inner, limit - 16 - lang.length); // budget for fences - return chunks.map(c => '```' + lang + '\n' + c.replace(/\n?$/, '\n') + '```'); - } - - // ---------------------- ResponseBuffer ---------------------- - class ResponseBuffer { - constructor() { - this.pending = []; // { label, content } - this.timer = null; - this.flushing = false; - } - - push(item) { - if (!item || !item.content) return; - this.pending.push(item); - this.scheduleFlush(); - } - - scheduleFlush() { - if (this.timer) clearTimeout(this.timer); - this.timer = setTimeout(() => this.flush(), CONFIG.RESPONSE_BUFFER_FLUSH_DELAY_MS || 500); - } - - buildCombined() { - const parts = []; - for (const { label, content } of this.pending) { - if (CONFIG.RESPONSE_BUFFER_SECTION_HEADINGS && label) { - parts.push(`### ${label}\n`); + // 1) Try runtime + if (BRIDGE_KEY && typeof BRIDGE_KEY === 'string' && BRIDGE_KEY.length) { + return BRIDGE_KEY; } - parts.push(String(content).trimEnd()); - parts.push(''); // blank line between sections - } - return parts.join('\n'); + + // 2) Try saved config + if (CONFIG.BRIDGE_KEY && typeof CONFIG.BRIDGE_KEY === 'string' && CONFIG.BRIDGE_KEY.length) { + BRIDGE_KEY = CONFIG.BRIDGE_KEY; + RC_DEBUG?.info('Using saved bridge key from config'); + return BRIDGE_KEY; + } + + // 3) Prompt fallback + const entered = prompt( + '[AI Repo Commander] Enter your bridge key for this session (or set it in Tools → Bridge Configuration to avoid this prompt):' + ); + if (!entered) throw new Error('Bridge key required when API is enabled.'); + + BRIDGE_KEY = entered; + + // Offer to save for next time + try { + if (confirm('Save this bridge key in Settings → Bridge Configuration to avoid future prompts?')) { + CONFIG.BRIDGE_KEY = BRIDGE_KEY; + saveConfig(CONFIG); + RC_DEBUG?.info('Bridge key saved to config'); + } + } catch { /* ignore */ } + + return BRIDGE_KEY; } - async flush() { - if (this.flushing) return; - if (!this.pending.length) return; - this.flushing = true; + // Optional: expose a safe setter for console use (won't log the key) + window.AI_REPO_SET_KEY = function setBridgeKey(k) { + BRIDGE_KEY = (k || '').trim() || null; + if (BRIDGE_KEY) { + RC_DEBUG?.info('Bridge key set for this session'); + } else { + RC_DEBUG?.info('Bridge key cleared for this session'); + } + }; - const toPaste = this.buildCombined(); - this.pending.length = 0; // clear + // Helper function to find command text in an element (code blocks or plain text) + function findCommandTextInElement(el) { + // Helper to check if text is a complete command + const isComplete = (txt) => { + if (!CONFIG.REQUIRE_TERMINATOR) return /(^|\n)\s*@bridge@\b/m.test(txt) && /(^|\n)\s*action\s*:/m.test(txt); + return /(^|\n)\s*@bridge@\b/m.test(txt) + && /(^|\n)\s*action\s*:/m.test(txt) + && /@end@\s*$/m.test(txt); + }; - try { - const limit = CONFIG.MAX_PASTE_CHARS || 250_000; + // 1) First try to find in code blocks + const blocks = el.querySelectorAll('pre code, pre, code'); + for (const b of blocks) { + const txt = (b.textContent || '').trim(); + if (isComplete(txt)) { + return { blockElement: b, text: txt }; + } + } - if (CONFIG.SPLIT_LONG_RESPONSES && toPaste.length > limit) { - const chunks = splitRespectingCodeFence(toPaste, limit); + // 2) If not found in code blocks, check raw message text + const wholeText = _norm(el.textContent || ''); + const parts = extractAllCompleteBlocks(wholeText); + if (parts.length > 0) { + const part = parts[0]; + return { blockElement: null, text: `@bridge@\n${part}\n@end@` }; + } - RC_DEBUG?.warn(`Splitting long response into ${chunks.length} message(s)`, { - totalChars: toPaste.length, perChunkLimit: limit - }); + // 3) No complete command found + return null; + } - chunks.forEach((chunk, i) => { - const header = CONFIG.RESPONSE_BUFFER_SECTION_HEADINGS - ? `### Part ${i+1}/${chunks.length}\n` - : ''; - const payload = header + chunk; + // ---------------------- Monitor (with streaming "settle" & complete-block check) ---------------------- + class CommandMonitor { + constructor() { + this.trackedMessages = new Map(); + this.history = new ConvHistory(); + this.coldStartUntil = Date.now() + (CONFIG.COLD_START_MS || 0); + this.observer = null; + this.currentPlatform = null; + this._idCounter = 0; + this.cleanupIntervalId = null; + this.initialize(); + } + + getReadableMessageId(element) { + this._idCounter += 1; + const id = `cmd-${this._idCounter}-${Math.random().toString(36).slice(2,6)}`; + if (element?.dataset) element.dataset.aiRcId = id; + return id; + } + + extractAction(text) { + const m = /(^|\n)\s*action\s*:\s*([A-Za-z_][\w\-]*)/m.exec(text || ''); + return m ? m[2] : 'unknown'; + } + + initialize() { + this.detectPlatform(); + this.startObservation(); + this.setupEmergencyStop(); + RC_DEBUG?.info('AI Repo Commander initialized', { + ENABLE_API: CONFIG.ENABLE_API, + DEBUG_MODE: CONFIG.DEBUG_MODE, + DEBOUNCE_DELAY: CONFIG.DEBOUNCE_DELAY, + MAX_RETRIES: CONFIG.MAX_RETRIES, + VERSION: CONFIG.VERSION + }); + if (CONFIG.ENABLE_API) { + if (CONFIG.BRIDGE_KEY) { + RC_DEBUG?.info('API is enabled — using saved bridge key from config'); + } else { + RC_DEBUG?.warn('API is enabled — you will be prompted for your bridge key on first command.'); + } + } + this.cleanupIntervalId = setInterval(() => this.cleanupProcessedCommands(), CONFIG.CLEANUP_INTERVAL_MS); + + // Wire up queue size updates to UI + if (execQueue) { + execQueue.onSizeChange = (n) => { + const queueBtn = document.querySelector('.rc-queue-clear'); + if (queueBtn) queueBtn.textContent = `Clear Queue (${n})`; + }; + } + } + + detectPlatform() { + const host = window.location.hostname; + this.currentPlatform = PLATFORM_SELECTORS[host] || PLATFORM_SELECTORS['chat.openai.com']; + } + + startObservation() { + let scanPending = false; + let lastScan = 0; + let lastMessageCount = 0; + + const scheduleScan = () => { + if (scanPending) return; + scanPending = true; + const delay = Math.max(0, CONFIG.SCAN_DEBOUNCE_MS - (Date.now() - lastScan)); + setTimeout(() => { + scanPending = false; + lastScan = Date.now(); + this.scanMessages(); + }, delay); + }; + + // MutationObserver for immediate detection - watching edits AND additions + this.observer = new MutationObserver((mutations) => { + let shouldScan = false; + let adjacentToProcessed = false; + let reasons = new Set(); + + for (const m of mutations) { + // A) New nodes under the DOM (previous fast path) + if (m.type === 'childList') { + for (const node of m.addedNodes) { + if (node.nodeType !== 1) continue; + if (node.matches?.('pre, code') || node.querySelector?.('pre, code')) { + reasons.add('code block added'); + shouldScan = true; + break; + } + // Also consider new assistant messages appearing + if (node.matches?.(this.currentPlatform.messages) || + node.querySelector?.(this.currentPlatform.messages)) { + reasons.add('message added'); + shouldScan = true; + break; + } + } + } + + // B) Text *inside* existing nodes changed (critical for streaming) + if (m.type === 'characterData') { + const el = m.target?.parentElement; + if (!el) continue; + if (el.closest?.('pre, code') || el.closest?.(this.currentPlatform.messages)) { + reasons.add('text changed'); + shouldScan = true; + } + } + + // C) Attribute toggles that show/hide or "finalize" code + if (m.type === 'attributes') { + const target = m.target; + if (target?.matches?.('pre, code') || + target?.closest?.(this.currentPlatform.messages)) { + reasons.add('attribute changed'); + shouldScan = true; + } + } + + if (shouldScan) break; + } + + // D) Check for new assistant messages adjacent to already-processed ones (split messages) + if (!adjacentToProcessed) { + for (const m of mutations) { + if (m.type === 'childList') { + for (const node of m.addedNodes) { + if (node.nodeType !== 1) continue; + + // Check if it's an assistant message + const isAssistantMsg = node.matches?.(this.currentPlatform.messages) && + this.isAssistantMessage(node); + + if (isAssistantMsg) { + // Check if previous sibling is a processed assistant message + const prev = node.previousElementSibling; + if (prev && prev.dataset?.aiRcProcessed === '1' && this.isAssistantMessage(prev)) { + reasons.add('split message detected'); + adjacentToProcessed = true; + break; + } + } + } + if (adjacentToProcessed) break; + } + } + } + + if (shouldScan || adjacentToProcessed) { + RC_DEBUG?.trace('MO: scan triggered', { reasons: Array.from(reasons).join(', ') }); + scheduleScan(); + } + }); + + // Observe all changes - no attributeFilter to catch any streaming-related attrs + this.observer.observe(document.body, { + subtree: true, + childList: true, + characterData: true, + attributes: true + }); + + // Polling fallback - scan every 2 seconds if message count changed + this.pollingInterval = setInterval(() => { + if (CONFIG.RUNTIME.PAUSED) return; + + const messages = document.querySelectorAll(this.currentPlatform.messages); + const currentCount = messages.length; + + if (currentCount !== lastMessageCount) { + RC_DEBUG?.trace('Polling detected message count change', { + old: lastMessageCount, + new: currentCount + }); + lastMessageCount = currentCount; + scheduleScan(); + } + }, 2000); + + if (CONFIG.PROCESS_EXISTING) { + setTimeout(() => { + RC_DEBUG?.info('Initial scan after page load (PROCESS_EXISTING=true)'); + this.scanMessages(); + }, 600); + } else { + RC_DEBUG?.info('Initial scan skipped (PROCESS_EXISTING=false)'); + } + } + + isCompleteCommandText(txt) { + if (!CONFIG.REQUIRE_TERMINATOR) return /(^|\n)\s*@bridge@\b/m.test(txt) && /(^|\n)\s*action\s*:/m.test(txt); + return /(^|\n)\s*@bridge@\b/m.test(txt) + && /(^|\n)\s*action\s*:/m.test(txt) + && /@end@\s*$/m.test(txt); + } + + findCommandInCodeBlock(el) { + return findCommandTextInElement(el); + } + + scanMessages() { + if (CONFIG.RUNTIME.PAUSED) { + RC_DEBUG?.logLoop('loop', 'scan paused'); + return; + } + + const messages = document.querySelectorAll(this.currentPlatform.messages); + let skipped = 0, found = 0; + + messages.forEach((el) => { + if (!this.isAssistantMessage(el)) return; + if (el.dataset.aiRcProcessed) return; + + const hits = findAllCommandsInMessage(el); + if (!hits.length) return; + + if (hits.length === 1) { + el.dataset.aiRcProcessed = '1'; + if (this.history.hasElement(el, 1)) { + attachRunAgainUI(el, () => this.trackMessage(el, hits[0].text, this.getReadableMessageId(el))); + return; + } + this.history.markElement(el, 1); + this.trackMessage(el, hits[0].text, this.getReadableMessageId(el)); + return; + } + + const withinColdStart = Date.now() < this.coldStartUntil; + const alreadyAll = hits.every((_, i) => this.history.hasElement(el, i + 1)); + + RC_DEBUG?.trace('Evaluating message', { + withinColdStart, + alreadyAll, + commandCount: hits.length + }); + + // Skip if cold start or already processed (but DON'T mark new ones in history during cold start) + if (withinColdStart || alreadyAll) { + el.dataset.aiRcProcessed = '1'; + + RC_DEBUG?.verbose( + 'Skipping command(s) - ' + + (withinColdStart ? 'page load (cold start)' : 'already executed in this conversation'), + { fingerprint: fingerprintElement(el).slice(0, 40) + '...', commandCount: hits.length } + ); + + attachRunAgainPerCommand(el, hits.slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE), (idx) => { + el.dataset.aiRcProcessed = '1'; + const hit2 = findAllCommandsInMessage(el).slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE); + const h = hit2[idx]; + if (h) this.enqueueCommand(el, h, idx); + }); + skipped += hits.length; + return; + } + + // New message that hasn't been executed → auto-execute once + el.dataset.aiRcProcessed = '1'; + const capped = hits.slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE); + attachQueueBadge(el, capped.length); + + capped.forEach((hit, idx) => { + // mark each sub-command immediately to avoid re-exec on reloads + this.history.markElement(el, idx + 1); + this.enqueueCommand(el, hit, idx); + }); + found += capped.length; + }); + + if (skipped) RC_DEBUG?.info(`Skipped ${skipped} command(s) - Run Again buttons added`); + if (found) RC_DEBUG?.info(`Auto-executing ${found} new command(s)`); + } + + enqueueCommand(element, hit, idx) { + const messageId = this.getReadableMessageId(element); + const subId = `${messageId}#${idx + 1}`; execQueue.push(async () => { - await pasteAndMaybeSubmit(payload); - }); - }); + // Micro-settle: wait for text to stabilize before parsing + try { + const blockElement = hit.blockElement; + if (blockElement) { + let lastText = blockElement.textContent || ''; + const maxWait = 400; + const checkInterval = 80; + const startTime = Date.now(); - return; // done: queued as multiple messages - } - - // Normal single-message path - execQueue.push(async () => { - await pasteAndMaybeSubmit(toPaste); - }); - - } finally { - this.flushing = false; - } - } - - } - - - window.AI_REPO_RESPONSES = new ResponseBuffer(); // optional debug handle - - // ---------------------- Bridge Key ---------------------- - let BRIDGE_KEY = null; - - function requireBridgeKeyIfNeeded() { - if (!CONFIG.ENABLE_API) return BRIDGE_KEY; - - // 1) Try runtime - if (BRIDGE_KEY && typeof BRIDGE_KEY === 'string' && BRIDGE_KEY.length) { - return BRIDGE_KEY; - } - - // 2) Try saved config - if (CONFIG.BRIDGE_KEY && typeof CONFIG.BRIDGE_KEY === 'string' && CONFIG.BRIDGE_KEY.length) { - BRIDGE_KEY = CONFIG.BRIDGE_KEY; - RC_DEBUG?.info('Using saved bridge key from config'); - return BRIDGE_KEY; - } - - // 3) Prompt fallback - const entered = prompt( - '[AI Repo Commander] Enter your bridge key for this session (or set it in Tools → Bridge Configuration to avoid this prompt):' - ); - if (!entered) throw new Error('Bridge key required when API is enabled.'); - - BRIDGE_KEY = entered; - - // Offer to save for next time - try { - if (confirm('Save this bridge key in Settings → Bridge Configuration to avoid future prompts?')) { - CONFIG.BRIDGE_KEY = BRIDGE_KEY; - saveConfig(CONFIG); - RC_DEBUG?.info('Bridge key saved to config'); - } - } catch { /* ignore */ } - - return BRIDGE_KEY; - } - - // Optional: expose a safe setter for console use (won't log the key) - window.AI_REPO_SET_KEY = function setBridgeKey(k) { - BRIDGE_KEY = (k || '').trim() || null; - if (BRIDGE_KEY) { - RC_DEBUG?.info('Bridge key set for this session'); - } else { - RC_DEBUG?.info('Bridge key cleared for this session'); - } - }; - - // ---------------------- Monitor (with streaming "settle" & complete-block check) ---------------------- - class CommandMonitor { - constructor() { - this.trackedMessages = new Map(); - this.history = new ConvHistory(); - this.coldStartUntil = Date.now() + (CONFIG.COLD_START_MS || 0); - this.observer = null; - this.currentPlatform = null; - this._idCounter = 0; - this.cleanupIntervalId = null; - this.initialize(); - } - - getReadableMessageId(element) { - this._idCounter += 1; - const id = `cmd-${this._idCounter}-${Math.random().toString(36).slice(2,6)}`; - if (element?.dataset) element.dataset.aiRcId = id; - return id; - } - - extractAction(text) { - const m = /(^|\n)\s*action\s*:\s*([A-Za-z_][\w\-]*)/m.exec(text || ''); - return m ? m[2] : 'unknown'; - } - - initialize() { - this.detectPlatform(); - this.startObservation(); - this.setupEmergencyStop(); - RC_DEBUG?.info('AI Repo Commander initialized', { - ENABLE_API: CONFIG.ENABLE_API, - DEBUG_MODE: CONFIG.DEBUG_MODE, - DEBOUNCE_DELAY: CONFIG.DEBOUNCE_DELAY, - MAX_RETRIES: CONFIG.MAX_RETRIES, - VERSION: CONFIG.VERSION - }); - if (CONFIG.ENABLE_API) { - if (CONFIG.BRIDGE_KEY) { - RC_DEBUG?.info('API is enabled — using saved bridge key from config'); - } else { - RC_DEBUG?.warn('API is enabled — you will be prompted for your bridge key on first command.'); - } - } - this.cleanupIntervalId = setInterval(() => this.cleanupProcessedCommands(), CONFIG.CLEANUP_INTERVAL_MS); - - // Wire up queue size updates to UI - if (execQueue) { - execQueue.onSizeChange = (n) => { - const queueBtn = document.querySelector('.rc-queue-clear'); - if (queueBtn) queueBtn.textContent = `Clear Queue (${n})`; - }; - } - } - - detectPlatform() { - const host = window.location.hostname; - this.currentPlatform = PLATFORM_SELECTORS[host] || PLATFORM_SELECTORS['chat.openai.com']; - } - - startObservation() { - let scanPending = false; - let lastScan = 0; - let lastMessageCount = 0; - - const scheduleScan = () => { - if (scanPending) return; - scanPending = true; - const delay = Math.max(0, CONFIG.SCAN_DEBOUNCE_MS - (Date.now() - lastScan)); - setTimeout(() => { - scanPending = false; - lastScan = Date.now(); - this.scanMessages(); - }, delay); - }; - - // MutationObserver for immediate detection - watching edits AND additions - this.observer = new MutationObserver((mutations) => { - let shouldScan = false; - let adjacentToProcessed = false; - let reasons = new Set(); - - for (const m of mutations) { - // A) New nodes under the DOM (previous fast path) - if (m.type === 'childList') { - for (const node of m.addedNodes) { - if (node.nodeType !== 1) continue; - if (node.matches?.('pre, code') || node.querySelector?.('pre, code')) { - reasons.add('code block added'); - shouldScan = true; - break; - } - // Also consider new assistant messages appearing - if (node.matches?.(this.currentPlatform.messages) || - node.querySelector?.(this.currentPlatform.messages)) { - reasons.add('message added'); - shouldScan = true; - break; - } - } - } - - // B) Text *inside* existing nodes changed (critical for streaming) - if (m.type === 'characterData') { - const el = m.target?.parentElement; - if (!el) continue; - if (el.closest?.('pre, code') || el.closest?.(this.currentPlatform.messages)) { - reasons.add('text changed'); - shouldScan = true; - } - } - - // C) Attribute toggles that show/hide or "finalize" code - if (m.type === 'attributes') { - const target = m.target; - if (target?.matches?.('pre, code') || - target?.closest?.(this.currentPlatform.messages)) { - reasons.add('attribute changed'); - shouldScan = true; - } - } - - if (shouldScan) break; - } - - // D) Check for new assistant messages adjacent to already-processed ones (split messages) - if (!adjacentToProcessed) { - for (const m of mutations) { - if (m.type === 'childList') { - for (const node of m.addedNodes) { - if (node.nodeType !== 1) continue; - - // Check if it's an assistant message - const isAssistantMsg = node.matches?.(this.currentPlatform.messages) && - this.isAssistantMessage(node); - - if (isAssistantMsg) { - // Check if previous sibling is a processed assistant message - const prev = node.previousElementSibling; - if (prev && prev.dataset?.aiRcProcessed === '1' && this.isAssistantMessage(prev)) { - reasons.add('split message detected'); - adjacentToProcessed = true; - break; - } + while (Date.now() - startTime < maxWait) { + await new Promise(r => setTimeout(r, checkInterval)); + const currentText = blockElement.textContent || ''; + if (currentText === lastText) break; // Text stabilized + lastText = currentText; + } + } + } catch (e) { + RC_DEBUG?.verbose('Micro-settle failed, continuing anyway', { error: String(e) }); } - } - if (adjacentToProcessed) break; - } - } - } - if (shouldScan || adjacentToProcessed) { - RC_DEBUG?.trace('MO: scan triggered', { reasons: Array.from(reasons).join(', ') }); - scheduleScan(); - } - }); + const finalTxt = hit.text; + let parsed; + try { + parsed = CommandParser.parseYAMLCommand(finalTxt); + } catch (err) { + UIFeedback.appendStatus(element, 'ERROR', { + action: 'Command', + details: err.message, + key: subId, // <<< key per sub-command + label: `[${idx+1}] parse` + }); + this.attachRetryUI(element, subId); + return; + } + const val = CommandParser.validateStructure(parsed); + if (!val.isValid) { + UIFeedback.appendStatus(element, 'ERROR', { + action: 'Command', + details: `Validation failed: ${val.errors.join(', ')}`, + key: subId, + label: `[${idx+1}] parse` + }); + this.attachRetryUI(element, subId); + return; + } - // Observe all changes - no attributeFilter to catch any streaming-related attrs - this.observer.observe(document.body, { - subtree: true, - childList: true, - characterData: true, - attributes: true - }); + this.updateState(subId, COMMAND_STATES.EXECUTING); + const res = await ExecutionManager.executeCommand( + parsed, + element, + /* renderKey: */ subId, // <<< pass key down + /* label: */ `[${idx+1}] ${this.extractAction(finalTxt)}` + ); - // Polling fallback - scan every 2 seconds if message count changed - this.pollingInterval = setInterval(() => { - if (CONFIG.RUNTIME.PAUSED) return; - - const messages = document.querySelectorAll(this.currentPlatform.messages); - const currentCount = messages.length; - - if (currentCount !== lastMessageCount) { - RC_DEBUG?.trace('Polling detected message count change', { - old: lastMessageCount, - new: currentCount - }); - lastMessageCount = currentCount; - scheduleScan(); - } - }, 2000); - - if (CONFIG.PROCESS_EXISTING) { - setTimeout(() => { - RC_DEBUG?.info('Initial scan after page load (PROCESS_EXISTING=true)'); - this.scanMessages(); - }, 600); - } else { - RC_DEBUG?.info('Initial scan skipped (PROCESS_EXISTING=false)'); - } - } - - isCompleteCommandText(txt) { - if (!CONFIG.REQUIRE_TERMINATOR) return /(^|\n)\s*@bridge@\b/m.test(txt) && /(^|\n)\s*action\s*:/m.test(txt); - return /(^|\n)\s*@bridge@\b/m.test(txt) - && /(^|\n)\s*action\s*:/m.test(txt) - && /@end@\s*$/m.test(txt); - } - - findCommandInCodeBlock(el) { - const blocks = el.querySelectorAll('pre code, pre, code'); - // 🔍 LOG: What we found - RC_DEBUG?.trace('🔍 DOM: Searching for command block', { - blocksFound: blocks.length - }); - for (const b of blocks) { - const txt = (b.textContent || '').trim(); - if (this.isCompleteCommandText(txt)) { - return { blockElement: b, text: txt }; - } - } - return null; - } - - scanMessages() { - if (CONFIG.RUNTIME.PAUSED) { - RC_DEBUG?.logLoop('loop', 'scan paused'); - return; - } - - const messages = document.querySelectorAll(this.currentPlatform.messages); - let skipped = 0, found = 0; - - messages.forEach((el) => { - if (!this.isAssistantMessage(el)) return; - if (el.dataset.aiRcProcessed) return; - - const hits = findAllCommandsInMessage(el); - if (!hits.length) return; - - if (hits.length === 1) { - el.dataset.aiRcProcessed = '1'; - if (this.history.hasElement(el, 1)) { - attachRunAgainUI(el, () => this.trackMessage(el, hits[0].text, this.getReadableMessageId(el))); - return; - } - this.history.markElement(el, 1); - this.trackMessage(el, hits[0].text, this.getReadableMessageId(el)); - return; - } - - const withinColdStart = Date.now() < this.coldStartUntil; - const alreadyAll = hits.every((_, i) => this.history.hasElement(el, i + 1)); - - RC_DEBUG?.trace('Evaluating message', { - withinColdStart, - alreadyAll, - commandCount: hits.length - }); - - // Skip if cold start or already processed (but DON'T mark new ones in history during cold start) - if (withinColdStart || alreadyAll) { - el.dataset.aiRcProcessed = '1'; - - RC_DEBUG?.verbose( - 'Skipping command(s) - ' + - (withinColdStart ? 'page load (cold start)' : 'already executed in this conversation'), - { fingerprint: fingerprintElement(el).slice(0, 40) + '...', commandCount: hits.length } - ); - - attachRunAgainPerCommand(el, hits.slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE), (idx) => { - el.dataset.aiRcProcessed = '1'; - const hit2 = findAllCommandsInMessage(el).slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE); - const h = hit2[idx]; - if (h) this.enqueueCommand(el, h, idx); - }); - skipped += hits.length; - return; - } - - // New message that hasn't been executed → auto-execute once - el.dataset.aiRcProcessed = '1'; - const capped = hits.slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE); - attachQueueBadge(el, capped.length); - - capped.forEach((hit, idx) => { - // mark each sub-command immediately to avoid re-exec on reloads - this.history.markElement(el, idx + 1); - this.enqueueCommand(el, hit, idx); - }); - found += capped.length; - }); - - if (skipped) RC_DEBUG?.info(`Skipped ${skipped} command(s) - Run Again buttons added`); - if (found) RC_DEBUG?.info(`Auto-executing ${found} new command(s)`); - } - - enqueueCommand(element, hit, idx) { - const messageId = this.getReadableMessageId(element); - const subId = `${messageId}#${idx + 1}`; - - execQueue.push(async () => { - // Micro-settle: wait for text to stabilize before parsing - try { - const blockElement = hit.blockElement; - if (blockElement) { - let lastText = blockElement.textContent || ''; - const maxWait = 400; - const checkInterval = 80; - const startTime = Date.now(); - - while (Date.now() - startTime < maxWait) { - await new Promise(r => setTimeout(r, checkInterval)); - const currentText = blockElement.textContent || ''; - if (currentText === lastText) break; // Text stabilized - lastText = currentText; - } - } - } catch (e) { - RC_DEBUG?.verbose('Micro-settle failed, continuing anyway', { error: String(e) }); - } - - const finalTxt = hit.text; - let parsed; - try { - parsed = CommandParser.parseYAMLCommand(finalTxt); - } catch (err) { - UIFeedback.appendStatus(element, 'ERROR', { - action: 'Command', - details: err.message, - key: subId, // <<< key per sub-command - label: `[${idx+1}] parse` - }); - this.attachRetryUI(element, subId); - return; - } - const val = CommandParser.validateStructure(parsed); - if (!val.isValid) { - UIFeedback.appendStatus(element, 'ERROR', { - action: 'Command', - details: `Validation failed: ${val.errors.join(', ')}`, - key: subId, - label: `[${idx+1}] parse` - }); - this.attachRetryUI(element, subId); - return; - } - - this.updateState(subId, COMMAND_STATES.EXECUTING); - const res = await ExecutionManager.executeCommand( - parsed, - element, - /* renderKey: */ subId, // <<< pass key down - /* label: */ `[${idx+1}] ${this.extractAction(finalTxt)}` - ); - - if (!res || res.success === false) { - this.updateState(subId, COMMAND_STATES.ERROR); - this.attachRetryUI(element, subId); - return; - } - this.updateState(subId, COMMAND_STATES.COMPLETE); - }); - } - - isAssistantMessage(el) { - if (!CONFIG.ASSISTANT_ONLY) return true; - const host = location.hostname; - if (/chat\.openai\.com|chatgpt\.com/.test(host)) { - const roleEl = el.closest?.('[data-message-author-role]') || el; - return roleEl?.getAttribute?.('data-message-author-role') === 'assistant'; - } - if (/claude\.ai/.test(host)) { - const isUser = !!el.closest?.('[data-message-author-role="user"]'); - return !isUser; - } - if (/gemini\.google\.com/.test(host)) return true; - return true; - } - - trackMessage(element, text, messageId) { - RC_DEBUG?.info('New command detected', { messageId, preview: text.substring(0, 120) }); - this.trackedMessages.set(messageId, { - element, - originalText: text, - state: COMMAND_STATES.DETECTED, - startTime: Date.now(), - lastUpdate: Date.now(), - cancelToken: { cancelled: false } - }); - this.updateState(messageId, COMMAND_STATES.PARSING); - void this.processCommand(messageId); - } - - async debounceWithCancel(messageId) { - const start = Date.now(); - const delay = CONFIG.DEBOUNCE_DELAY; - const checkInterval = 100; - - while (Date.now() - start < delay) { - const msg = this.trackedMessages.get(messageId); - if (!msg || msg.cancelToken?.cancelled) return; - - msg.lastUpdate = Date.now(); - this.trackedMessages.set(messageId, msg); - - await ExecutionManager.delay(Math.min(checkInterval, delay - (Date.now() - start))); - } - } - - async waitForStableCompleteBlock(element, initialText, messageId) { - let deadline = Date.now() + Math.max(0, CONFIG.SETTLE_CHECK_MS); - let last = initialText; - - RC_DEBUG?.info('🔵 SETTLE: Starting stability check', { - messageId, - initialLength: initialText.length, - initialPreview: initialText.substring(0, 100), - settleWindow: CONFIG.SETTLE_CHECK_MS - }); - - while (Date.now() < deadline) { - const rec = this.trackedMessages.get(messageId); - if (!rec || rec.cancelToken?.cancelled) { - RC_DEBUG?.warn('🔴 SETTLE: Cancelled', { messageId }); - return ''; - } - - rec.lastUpdate = Date.now(); - this.trackedMessages.set(messageId, rec); - - await ExecutionManager.delay(CONFIG.SETTLE_POLL_MS); - const hit = this.findCommandInCodeBlock(element); - const txt = hit ? hit.text : ''; - - RC_DEBUG?.trace('🔵 SETTLE: Poll iteration', { - messageId, - foundCommand: !!hit, - textLength: txt.length, - textPreview: txt.substring(0, 80), - isComplete: this.isCompleteCommandText(txt), - unchanged: txt === last, - timeRemaining: deadline - Date.now() - }); - - if (!txt || !this.isCompleteCommandText(txt)) { - RC_DEBUG?.verbose('🟡 SETTLE: Command not complete yet', { - messageId, - hasText: !!txt, - isComplete: this.isCompleteCommandText(txt) - }); - continue; - } - - if (txt === last) { - RC_DEBUG?.trace('🟢 SETTLE: Text stable, continuing wait', { messageId }); - } else { - RC_DEBUG?.info('🟡 SETTLE: Text changed, resetting deadline', { - messageId, - oldLength: last.length, - newLength: txt.length, - newDeadline: CONFIG.SETTLE_CHECK_MS - }); - last = txt; - deadline = Date.now() + Math.max(0, CONFIG.SETTLE_CHECK_MS); - } - } - - // 🔧 FIX: Return the stable text we verified, not a new DOM lookup - RC_DEBUG?.info('🔵 SETTLE: Deadline reached, returning stable text', { - messageId, - stableTextLength: last.length, - stablePreview: last.substring(0, 100) - }); - - return last; // ← FIXED: Return what we verified as stable - } - - attachRetryUI(element, messageId) { - const all = findAllCommandsInMessage(element).slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE); - if (!all.length) return; - - // Parse failing index - const m = /#(\d+)$/.exec(messageId); - const failedIdx = m ? Math.max(0, parseInt(m[1], 10) - 1) : 0; - - attachRunAgainPerCommand(element, all, (idx) => { - element.dataset.aiRcProcessed = '1'; - const pick = all[idx]?.text; - if (!pick) return; - this.trackedMessages.delete(messageId); - const newId = this.getReadableMessageId(element); - this.trackMessage(element, pick, newId); - }); - - // Highlight failed one - try { - const bar = element.querySelector('.ai-rc-rerun'); - const buttons = [...bar.querySelectorAll('button')].filter(b => /Run again \[#\d+]/.test(b.textContent || '')); - const b = buttons[failedIdx]; - if (b) b.style.outline = '2px solid #ef4444'; - } catch {} - } - - - updateState(messageId, state) { - const msg = this.trackedMessages.get(messageId); - if (!msg) return; - const old = msg.state; - msg.state = state; - msg.lastUpdate = Date.now(); - this.trackedMessages.set(messageId, msg); - RC_DEBUG?.command(this.extractAction(msg.originalText), state, { - messageId, transition: `${old} -> ${state}` - }); - } - - async processCommand(messageId) { - if (CONFIG.RUNTIME.PAUSED) { - RC_DEBUG?.info('process paused, skipping', { messageId }); - return; - } - - const started = Date.now(); - try { - const message = this.trackedMessages.get(messageId); - if (!message) { - RC_DEBUG?.error('Message not found', { messageId }); - return; - } - - if (message.cancelToken?.cancelled) { - RC_DEBUG?.warn('Operation cancelled', { messageId }); - return; - } - - // 1) Parse - let parsed; - try { - parsed = CommandParser.parseYAMLCommand(message.originalText); - // 🔍 LOG: Parsed successfully - RC_DEBUG?.verbose('✅ PARSE: Success', { - messageId, - action: parsed.action, - textLength: message.originalText.length - }); - } catch (err) { - RC_DEBUG?.error(`Command parsing failed: ${err.message}`, { messageId }); - this.updateState(messageId, COMMAND_STATES.ERROR); - if (/No complete @bridge@/.test(err.message)) return; - - this.attachRetryUI(message.element, messageId); - UIFeedback.appendStatus(message.element, 'ERROR', { action: 'Command', details: err.message }); - return; - } - - if (message.cancelToken?.cancelled) { - RC_DEBUG?.warn('Operation cancelled after parse', { messageId }); - return; - } - - // 2) Validate - this.updateState(messageId, COMMAND_STATES.VALIDATING); - let validation = CommandParser.validateStructure(parsed); - - // Silently skip examples (already marked in history by the scanner) - if (validation.example) { - RC_DEBUG?.info('Example command detected — skipping execution'); - this.updateState(messageId, COMMAND_STATES.COMPLETE); - return; - } - - if (!validation.isValid) { - this.updateState(messageId, COMMAND_STATES.ERROR); - this.attachRetryUI(message.element, messageId); - UIFeedback.appendStatus(message.element, 'ERROR', { - action: 'Command', - details: `Validation failed: ${validation.errors.join(', ')}` - }); - return; - } - - // 3) Debounce - this.updateState(messageId, COMMAND_STATES.DEBOUNCING); - const before = message.originalText; - // 🔍 LOG: Before debounce - RC_DEBUG?.info('⏳ DEBOUNCE: Starting wait', { - messageId, - delay: CONFIG.DEBOUNCE_DELAY, - textLength: before.length, - isAlreadyComplete: this.isCompleteCommandText(before) - }); - await this.debounceWithCancel(messageId); - - if (message.cancelToken?.cancelled) { - RC_DEBUG?.warn('Operation cancelled after debounce', { messageId }); - return; - } - // 🔍 LOG: Before settle - RC_DEBUG?.info('🔵 Starting settle check', { - messageId, - beforeTextLength: before.length, - beforePreview: before.substring(0, 100) - }); - - const stable = await this.waitForStableCompleteBlock(message.element, before, messageId); - // 🔍 LOG: After settle - THIS IS THE KEY ONE - RC_DEBUG?.info('🔵 SETTLE: Returned from stability check', { - messageId, - beforeTextLength: before.length, - stableTextLength: stable.length, - stablePreview: stable.substring(0, 100), - isEmpty: !stable, - textChanged: stable !== before - }); - if (!stable) { - // 🔍 LOG: This is where your error happens - RC_DEBUG?.error('❌ SETTLE: Returned empty string - FAILING', { - messageId, - originalTextLength: before.length, - originalPreview: before.substring(0, 100), - // This will help us understand WHY it's empty - elementStillExists: !!message.element, - elementHasCodeBlocks: message.element?.querySelectorAll('pre code, pre, code').length - }); - this.updateState(messageId, COMMAND_STATES.ERROR); - return; - } - - if (stable !== before) { - RC_DEBUG?.info('Command changed after settle (re-validate)', { messageId }); - message.originalText = stable; - const reParsed = CommandParser.parseYAMLCommand(stable); - const reVal = CommandParser.validateStructure(reParsed); - if (!reVal.isValid) { - this.updateState(messageId, COMMAND_STATES.ERROR); - this.attachRetryUI(message.element, messageId); - UIFeedback.appendStatus(message.element, 'ERROR', { - action: 'Command', - details: `Final validation failed: ${reVal.errors.join(', ')}` + if (!res || res.success === false) { + this.updateState(subId, COMMAND_STATES.ERROR); + this.attachRetryUI(element, subId); + return; + } + this.updateState(subId, COMMAND_STATES.COMPLETE); }); - return; - } - parsed = reParsed; } - // 4) Execute - this.updateState(messageId, COMMAND_STATES.EXECUTING); - - const action = parsed?.action || 'unknown'; - const renderKey = `${messageId}#1`; - const label = `[1] ${action}`; - const result = await ExecutionManager.executeCommand(parsed, message.element, renderKey, label); - - if (!result || result.success === false) { - RC_DEBUG?.warn('Execution reported failure', { messageId }); - this.updateState(messageId, COMMAND_STATES.ERROR); - this.attachRetryUI(message.element, messageId); - return; + isAssistantMessage(el) { + if (!CONFIG.ASSISTANT_ONLY) return true; + const host = location.hostname; + if (/chat\.openai\.com|chatgpt\.com/.test(host)) { + const roleEl = el.closest?.('[data-message-author-role]') || el; + return roleEl?.getAttribute?.('data-message-author-role') === 'assistant'; + } + if (/claude\.ai/.test(host)) { + const isUser = !!el.closest?.('[data-message-author-role="user"]'); + return !isUser; + } + if (/gemini\.google\.com/.test(host)) return true; + return true; } - const duration = Date.now() - started; - if (duration < CONFIG.FAST_WARN_MS) RC_DEBUG?.warn('Command completed very fast', { messageId, duration }); - if (duration > CONFIG.SLOW_WARN_MS) RC_DEBUG?.warn('Command took very long', { messageId, duration }); - - this.updateState(messageId, COMMAND_STATES.COMPLETE); - - } catch (error) { - const duration = Date.now() - started; - RC_DEBUG?.error(`Command processing error: ${error.message}`, { messageId, duration }); - this.updateState(messageId, COMMAND_STATES.ERROR); - const message = this.trackedMessages.get(messageId); - if (/No complete @bridge@/.test(error.message)) return; - if (message) { - this.attachRetryUI(message.element, messageId); - UIFeedback.appendStatus(message.element, 'ERROR', { action: 'Command', details: error.message }); - } - } - } - - cleanupProcessedCommands() { - const now = Date.now(); - let count = 0; - - for (const [id, msg] of this.trackedMessages.entries()) { - const age = now - (msg.lastUpdate || msg.startTime || now); - const finished = (msg.state === COMMAND_STATES.COMPLETE || msg.state === COMMAND_STATES.ERROR); - - const shouldCleanup = - (finished && age > CONFIG.CLEANUP_AFTER_MS) || - (age > CONFIG.STUCK_AFTER_MS); - - if (shouldCleanup) { - if (age > CONFIG.STUCK_AFTER_MS && !finished) { - RC_DEBUG?.warn('Cleaning stuck entry', { messageId: id, state: msg.state, age }); - } - this.trackedMessages.delete(id); - count++; - } - } - - if (count) RC_DEBUG?.info(`Cleaned ${count} tracked entries`); - } - - stopAllProcessing() { - this.trackedMessages.clear(); - if (this.observer) this.observer.disconnect(); - if (this.pollingInterval) { - clearInterval(this.pollingInterval); - this.pollingInterval = null; - } - if (this.cleanupIntervalId) { - clearInterval(this.cleanupIntervalId); - this.cleanupIntervalId = null; - } - RC_DEBUG?.destroy?.(); - } - - setupEmergencyStop() { - window.AI_REPO_STOP = () => { - CONFIG.ENABLE_API = false; - CONFIG.RUNTIME.PAUSED = true; - saveConfig(CONFIG); - - const queuedCount = execQueue.q.length; - execQueue.clear(); - RC_DEBUG?.error(`🚨 EMERGENCY STOP: cancelled ${queuedCount} queued command(s)`); - - for (const [id, msg] of this.trackedMessages.entries()) { - if (msg.cancelToken) msg.cancelToken.cancelled = true; - - if (msg.state === COMMAND_STATES.EXECUTING || msg.state === COMMAND_STATES.DEBOUNCING) { - RC_DEBUG?.error('Emergency stop - cancelling command', { messageId: id }); - this.updateState(id, COMMAND_STATES.ERROR); - } + trackMessage(element, text, messageId) { + RC_DEBUG?.info('New command detected', { messageId, preview: text.substring(0, 120) }); + this.trackedMessages.set(messageId, { + element, + originalText: text, + state: COMMAND_STATES.DETECTED, + startTime: Date.now(), + lastUpdate: Date.now(), + cancelToken: { cancelled: false } + }); + this.updateState(messageId, COMMAND_STATES.PARSING); + void this.processCommand(messageId); } - this.stopAllProcessing(); - RC_DEBUG?.error('🚨 EMERGENCY STOP ACTIVATED 🚨'); - GM_notification({ text: 'All command processing stopped', title: 'Emergency Stop', timeout: 5000 }); - }; + async debounceWithCancel(messageId) { + const start = Date.now(); + const delay = CONFIG.DEBOUNCE_DELAY; + const checkInterval = 100; + + while (Date.now() - start < delay) { + const msg = this.trackedMessages.get(messageId); + if (!msg || msg.cancelToken?.cancelled) return; + + msg.lastUpdate = Date.now(); + this.trackedMessages.set(messageId, msg); + + await ExecutionManager.delay(Math.min(checkInterval, delay - (Date.now() - start))); + } + } + + async waitForStableCompleteBlock(element, initialText, messageId) { + let deadline = Date.now() + Math.max(0, CONFIG.SETTLE_CHECK_MS); + let last = initialText; + + RC_DEBUG?.info('🔵 SETTLE: Starting stability check', { + messageId, + initialLength: initialText.length, + initialPreview: initialText.substring(0, 100), + settleWindow: CONFIG.SETTLE_CHECK_MS + }); + + while (Date.now() < deadline) { + const rec = this.trackedMessages.get(messageId); + if (!rec || rec.cancelToken?.cancelled) { + RC_DEBUG?.warn('🔴 SETTLE: Cancelled', { messageId }); + return ''; + } + + rec.lastUpdate = Date.now(); + this.trackedMessages.set(messageId, rec); + + await ExecutionManager.delay(CONFIG.SETTLE_POLL_MS); + const hit = this.findCommandInCodeBlock(element); + const txt = hit ? hit.text : ''; + + RC_DEBUG?.trace('🔵 SETTLE: Poll iteration', { + messageId, + foundCommand: !!hit, + textLength: txt.length, + textPreview: txt.substring(0, 80), + isComplete: this.isCompleteCommandText(txt), + unchanged: txt === last, + timeRemaining: deadline - Date.now() + }); + + if (!txt || !this.isCompleteCommandText(txt)) { + RC_DEBUG?.verbose('🟡 SETTLE: Command not complete yet', { + messageId, + hasText: !!txt, + isComplete: this.isCompleteCommandText(txt) + }); + continue; + } + + if (txt === last) { + RC_DEBUG?.trace('🟢 SETTLE: Text stable, continuing wait', { messageId }); + } else { + RC_DEBUG?.info('🟡 SETTLE: Text changed, resetting deadline', { + messageId, + oldLength: last.length, + newLength: txt.length, + newDeadline: CONFIG.SETTLE_CHECK_MS + }); + last = txt; + deadline = Date.now() + Math.max(0, CONFIG.SETTLE_CHECK_MS); + } + } + + // 🔧 FIX: Return the stable text we verified, not a new DOM lookup + RC_DEBUG?.info('🔵 SETTLE: Deadline reached, returning stable text', { + messageId, + stableTextLength: last.length, + stablePreview: last.substring(0, 100) + }); + + return last; // ← FIXED: Return what we verified as stable + } + + attachRetryUI(element, messageId) { + const all = findAllCommandsInMessage(element).slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE); + if (!all.length) return; + + // Parse failing index + const m = /#(\d+)$/.exec(messageId); + const failedIdx = m ? Math.max(0, parseInt(m[1], 10) - 1) : 0; + + attachRunAgainPerCommand(element, all, (idx) => { + element.dataset.aiRcProcessed = '1'; + const pick = all[idx]?.text; + if (!pick) return; + this.trackedMessages.delete(messageId); + const newId = this.getReadableMessageId(element); + this.trackMessage(element, pick, newId); + }); + + // Highlight failed one + try { + const bar = element.querySelector('.ai-rc-rerun'); + const buttons = [...bar.querySelectorAll('button')].filter(b => /Run again \[#\d+]/.test(b.textContent || '')); + const b = buttons[failedIdx]; + if (b) b.style.outline = '2px solid #ef4444'; + } catch {} + } + + + updateState(messageId, state) { + const msg = this.trackedMessages.get(messageId); + if (!msg) return; + const old = msg.state; + msg.state = state; + msg.lastUpdate = Date.now(); + this.trackedMessages.set(messageId, msg); + RC_DEBUG?.command(this.extractAction(msg.originalText), state, { + messageId, transition: `${old} -> ${state}` + }); + } + + async processCommand(messageId) { + if (CONFIG.RUNTIME.PAUSED) { + RC_DEBUG?.info('process paused, skipping', { messageId }); + return; + } + + const started = Date.now(); + try { + const message = this.trackedMessages.get(messageId); + if (!message) { + RC_DEBUG?.error('Message not found', { messageId }); + return; + } + + if (message.cancelToken?.cancelled) { + RC_DEBUG?.warn('Operation cancelled', { messageId }); + return; + } + + // 1) Parse + let parsed; + try { + parsed = CommandParser.parseYAMLCommand(message.originalText); + // 🔍 LOG: Parsed successfully + RC_DEBUG?.verbose('✅ PARSE: Success', { + messageId, + action: parsed.action, + textLength: message.originalText.length + }); + } catch (err) { + RC_DEBUG?.error(`Command parsing failed: ${err.message}`, { messageId }); + this.updateState(messageId, COMMAND_STATES.ERROR); + if (/No complete @bridge@/.test(err.message)) return; + + this.attachRetryUI(message.element, messageId); + UIFeedback.appendStatus(message.element, 'ERROR', { action: 'Command', details: err.message }); + return; + } + + if (message.cancelToken?.cancelled) { + RC_DEBUG?.warn('Operation cancelled after parse', { messageId }); + return; + } + + // 2) Validate + this.updateState(messageId, COMMAND_STATES.VALIDATING); + let validation = CommandParser.validateStructure(parsed); + + // Silently skip examples (already marked in history by the scanner) + if (validation.example) { + RC_DEBUG?.info('Example command detected — skipping execution'); + this.updateState(messageId, COMMAND_STATES.COMPLETE); + return; + } + + if (!validation.isValid) { + this.updateState(messageId, COMMAND_STATES.ERROR); + this.attachRetryUI(message.element, messageId); + UIFeedback.appendStatus(message.element, 'ERROR', { + action: 'Command', + details: `Validation failed: ${validation.errors.join(', ')}` + }); + return; + } + + // 3) Debounce + this.updateState(messageId, COMMAND_STATES.DEBOUNCING); + const before = message.originalText; + // 🔍 LOG: Before debounce + RC_DEBUG?.info('⏳ DEBOUNCE: Starting wait', { + messageId, + delay: CONFIG.DEBOUNCE_DELAY, + textLength: before.length, + isAlreadyComplete: this.isCompleteCommandText(before) + }); + await this.debounceWithCancel(messageId); + + if (message.cancelToken?.cancelled) { + RC_DEBUG?.warn('Operation cancelled after debounce', { messageId }); + return; + } + // 🔍 LOG: Before settle + RC_DEBUG?.info('🔵 Starting settle check', { + messageId, + beforeTextLength: before.length, + beforePreview: before.substring(0, 100) + }); + + const stable = await this.waitForStableCompleteBlock(message.element, before, messageId); + // 🔍 LOG: After settle - THIS IS THE KEY ONE + RC_DEBUG?.info('🔵 SETTLE: Returned from stability check', { + messageId, + beforeTextLength: before.length, + stableTextLength: stable.length, + stablePreview: stable.substring(0, 100), + isEmpty: !stable, + textChanged: stable !== before + }); + if (!stable) { + // 🔍 LOG: This is where your error happens + RC_DEBUG?.error('❌ SETTLE: Returned empty string - FAILING', { + messageId, + originalTextLength: before.length, + originalPreview: before.substring(0, 100), + // This will help us understand WHY it's empty + elementStillExists: !!message.element, + elementHasCodeBlocks: message.element?.querySelectorAll('pre code, pre, code').length + }); + this.updateState(messageId, COMMAND_STATES.ERROR); + return; + } + + if (stable !== before) { + RC_DEBUG?.info('Command changed after settle (re-validate)', { messageId }); + message.originalText = stable; + const reParsed = CommandParser.parseYAMLCommand(stable); + const reVal = CommandParser.validateStructure(reParsed); + if (!reVal.isValid) { + this.updateState(messageId, COMMAND_STATES.ERROR); + this.attachRetryUI(message.element, messageId); + UIFeedback.appendStatus(message.element, 'ERROR', { + action: 'Command', + details: `Final validation failed: ${reVal.errors.join(', ')}` + }); + return; + } + parsed = reParsed; + } + + // 4) Execute + this.updateState(messageId, COMMAND_STATES.EXECUTING); + + const action = parsed?.action || 'unknown'; + const renderKey = `${messageId}#1`; + const label = `[1] ${action}`; + const result = await ExecutionManager.executeCommand(parsed, message.element, renderKey, label); + + if (!result || result.success === false) { + RC_DEBUG?.warn('Execution reported failure', { messageId }); + this.updateState(messageId, COMMAND_STATES.ERROR); + this.attachRetryUI(message.element, messageId); + return; + } + + const duration = Date.now() - started; + if (duration < CONFIG.FAST_WARN_MS) RC_DEBUG?.warn('Command completed very fast', { messageId, duration }); + if (duration > CONFIG.SLOW_WARN_MS) RC_DEBUG?.warn('Command took very long', { messageId, duration }); + + this.updateState(messageId, COMMAND_STATES.COMPLETE); + + } catch (error) { + const duration = Date.now() - started; + RC_DEBUG?.error(`Command processing error: ${error.message}`, { messageId, duration }); + this.updateState(messageId, COMMAND_STATES.ERROR); + const message = this.trackedMessages.get(messageId); + if (/No complete @bridge@/.test(error.message)) return; + if (message) { + this.attachRetryUI(message.element, messageId); + UIFeedback.appendStatus(message.element, 'ERROR', { action: 'Command', details: error.message }); + } + } + } + + cleanupProcessedCommands() { + const now = Date.now(); + let count = 0; + + for (const [id, msg] of this.trackedMessages.entries()) { + const age = now - (msg.lastUpdate || msg.startTime || now); + const finished = (msg.state === COMMAND_STATES.COMPLETE || msg.state === COMMAND_STATES.ERROR); + + const shouldCleanup = + (finished && age > CONFIG.CLEANUP_AFTER_MS) || + (age > CONFIG.STUCK_AFTER_MS); + + if (shouldCleanup) { + if (age > CONFIG.STUCK_AFTER_MS && !finished) { + RC_DEBUG?.warn('Cleaning stuck entry', { messageId: id, state: msg.state, age }); + } + this.trackedMessages.delete(id); + count++; + } + } + + if (count) RC_DEBUG?.info(`Cleaned ${count} tracked entries`); + } + + stopAllProcessing() { + this.trackedMessages.clear(); + if (this.observer) this.observer.disconnect(); + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + this.pollingInterval = null; + } + if (this.cleanupIntervalId) { + clearInterval(this.cleanupIntervalId); + this.cleanupIntervalId = null; + } + RC_DEBUG?.destroy?.(); + } + + setupEmergencyStop() { + window.AI_REPO_STOP = () => { + CONFIG.ENABLE_API = false; + CONFIG.RUNTIME.PAUSED = true; + saveConfig(CONFIG); + + const queuedCount = execQueue.q.length; + execQueue.clear(); + RC_DEBUG?.error(`🚨 EMERGENCY STOP: cancelled ${queuedCount} queued command(s)`); + + for (const [id, msg] of this.trackedMessages.entries()) { + if (msg.cancelToken) msg.cancelToken.cancelled = true; + + if (msg.state === COMMAND_STATES.EXECUTING || msg.state === COMMAND_STATES.DEBOUNCING) { + RC_DEBUG?.error('Emergency stop - cancelling command', { messageId: id }); + this.updateState(id, COMMAND_STATES.ERROR); + } + } + + this.stopAllProcessing(); + RC_DEBUG?.error('🚨 EMERGENCY STOP ACTIVATED 🚨'); + GM_notification({ text: 'All command processing stopped', title: 'Emergency Stop', timeout: 5000 }); + }; + } } - } - // ---------------------- Manual retry helpers ---------------------- - let commandMonitor; + // ---------------------- Manual retry helpers ---------------------- + let commandMonitor; - window.AI_REPO_RETRY_COMMAND_TEXT = () => { - RC_DEBUG?.warn('Retry by text is deprecated. Use AI_REPO_RETRY_MESSAGE(messageId) or click "Run again".'); - }; + window.AI_REPO_RETRY_COMMAND_TEXT = () => { + RC_DEBUG?.warn('Retry by text is deprecated. Use AI_REPO_RETRY_MESSAGE(messageId) or click "Run again".'); + }; - window.AI_REPO_RETRY_MESSAGE = (messageId) => { - try { - const msg = commandMonitor?.trackedMessages?.get(messageId); - if (!msg) { - RC_DEBUG?.warn('Message not found for retry', { messageId }); - return; - } + window.AI_REPO_RETRY_MESSAGE = (messageId) => { + try { + const msg = commandMonitor?.trackedMessages?.get(messageId); + if (!msg) { + RC_DEBUG?.warn('Message not found for retry', { messageId }); + return; + } - msg.element.dataset.aiRcProcessed = '1'; + msg.element.dataset.aiRcProcessed = '1'; - RC_DEBUG?.info('Retrying message now', { messageId }); - commandMonitor.updateState(messageId, COMMAND_STATES.PARSING); - void commandMonitor.processCommand(messageId); - } catch (e) { - RC_DEBUG?.error('Failed to retry message', { messageId, error: String(e) }); - } - }; + RC_DEBUG?.info('Retrying message now', { messageId }); + commandMonitor.updateState(messageId, COMMAND_STATES.PARSING); + void commandMonitor.processCommand(messageId); + } catch (e) { + RC_DEBUG?.error('Failed to retry message', { messageId, error: String(e) }); + } + }; - // ---------------------- Test commands ---------------------- - const TEST_COMMANDS = { - validUpdate: -`\ + // ---------------------- Test commands ---------------------- + const TEST_COMMANDS = { + validUpdate: + `\ \`\`\`yaml @bridge@ action: update_file @@ -2951,8 +3092,8 @@ content: | @end@ \`\`\` `, - getFile: -`\ + getFile: + `\ \`\`\`yaml @bridge@ action: get_file @@ -2961,8 +3102,8 @@ path: README.md @end@ \`\`\` `, - listFiles: -`\ + listFiles: + `\ \`\`\`yaml @bridge@ action: list_files @@ -2971,8 +3112,8 @@ path: . @end@ \`\`\` `, - createBranch: -`\ + createBranch: + `\ \`\`\`yaml @bridge@ action: create_branch @@ -2982,8 +3123,8 @@ source_branch: main @end@ \`\`\` `, - createPR: -`\ + createPR: + `\ \`\`\`yaml @bridge@ action: create_pr @@ -2998,8 +3139,8 @@ body: | @end@ \`\`\` `, - createIssue: -`\ + createIssue: + `\ \`\`\`yaml @bridge@ action: create_issue @@ -3013,8 +3154,8 @@ body: | @end@ \`\`\` `, - createTag: -`\ + createTag: + `\ \`\`\`yaml @bridge@ action: create_tag @@ -3025,8 +3166,8 @@ message: Release version 1.0.0 @end@ \`\`\` `, - createRelease: -`\ + createRelease: + `\ \`\`\`yaml @bridge@ action: create_release @@ -3044,8 +3185,8 @@ body: | @end@ \`\`\` `, - multiCommand: -`\ + multiCommand: + `\ \`\`\`yaml @bridge@ action: get_file @@ -3066,35 +3207,35 @@ path: . @end@ \`\`\` ` - }; + }; - // ---------------------- Init ---------------------- - function initializeRepoCommander() { - if (!RC_DEBUG) RC_DEBUG = new DebugConsole(CONFIG); + // ---------------------- Init ---------------------- + function initializeRepoCommander() { + if (!RC_DEBUG) RC_DEBUG = new DebugConsole(CONFIG); - if (!commandMonitor) { - commandMonitor = new CommandMonitor(); - // noinspection JSUnusedGlobalSymbols - window.AI_REPO_COMMANDER = { - monitor: commandMonitor, - config: CONFIG, - test: TEST_COMMANDS, - version: CONFIG.VERSION, - history: commandMonitor.history, - submitComposer, - queue: execQueue - }; - RC_DEBUG?.info('AI Repo Commander fully initialized'); - RC_DEBUG?.info('API Enabled:', { value: CONFIG.ENABLE_API }); - RC_DEBUG?.info('Test commands available in window.AI_REPO_COMMANDER.test'); - RC_DEBUG?.info('Reset history with Tools → Clear History or window.AI_REPO.clearHistory()'); - RC_DEBUG?.info('Queue management: window.AI_REPO_QUEUE.clear() / .size() / .cancelOne()'); + if (!commandMonitor) { + commandMonitor = new CommandMonitor(); + // noinspection JSUnusedGlobalSymbols + window.AI_REPO_COMMANDER = { + monitor: commandMonitor, + config: CONFIG, + test: TEST_COMMANDS, + version: CONFIG.VERSION, + history: commandMonitor.history, + submitComposer, + queue: execQueue + }; + RC_DEBUG?.info('AI Repo Commander fully initialized'); + RC_DEBUG?.info('API Enabled:', { value: CONFIG.ENABLE_API }); + RC_DEBUG?.info('Test commands available in window.AI_REPO_COMMANDER.test'); + RC_DEBUG?.info('Reset history with Tools → Clear History or window.AI_REPO.clearHistory()'); + RC_DEBUG?.info('Queue management: window.AI_REPO_QUEUE.clear() / .size() / .cancelOne()'); + } } - } - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializeRepoCommander); - } else { - initializeRepoCommander(); - } + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeRepoCommander); + } else { + initializeRepoCommander(); + } })(); \ No newline at end of file