From f4ebb492b7e3b48819b5e4ddbad8d89438e8b856 Mon Sep 17 00:00:00 2001 From: rob Date: Wed, 8 Oct 2025 18:32:38 +0000 Subject: [PATCH] Update src/ai-repo-commander.user.js changed command history aproach. More resilient - Handles edge cases in long conversations and streaming responses Better cleanup - Won't leak memory from stuck operations Cancellable operations - Can be stopped mid-flight without errors Better debugging - Magic numbers replaced with named config values Improved UX - Error states now offer recovery options --- src/ai-repo-commander.user.js | 605 +++++++++++++++++++++++++++------- 1 file changed, 492 insertions(+), 113 deletions(-) diff --git a/src/ai-repo-commander.user.js b/src/ai-repo-commander.user.js index 1d337d0..64f284c 100644 --- a/src/ai-repo-commander.user.js +++ b/src/ai-repo-commander.user.js @@ -41,10 +41,14 @@ PROCESS_EXISTING: false, ASSISTANT_ONLY: true, + BRIDGE_KEY: '', // Var to store the bridge key // Persistent dedupe window DEDUPE_TTL_MS: 30 * 24 * 60 * 60 * 1000, // 30 days + COLD_START_MS: 2000, // Optional: during first 2s after load, don't auto-run pre-existing messages + SHOW_EXECUTED_MARKER: true, // Add a green border on messages that executed + // Housekeeping CLEANUP_AFTER_MS: 30000, CLEANUP_INTERVAL_MS: 60000, @@ -61,7 +65,14 @@ SETTLE_POLL_MS: 200, // was 300 // Runtime toggles - RUNTIME: { PAUSED: false } + RUNTIME: { PAUSED: false }, + + // New additions for hardening + STUCK_AFTER_MS: 10 * 60 * 1000, // 10min: force cleanup stuck entries + SCAN_DEBOUNCE_MS: 250, // throttle MutationObserver scans + FAST_WARN_MS: 50, // warn if command completes suspiciously fast + SLOW_WARN_MS: 60_000, // warn if command takes >1min + }; function loadSavedConfig() { @@ -292,6 +303,25 @@ API_TIMEOUT_MS +
+

Bridge Configuration

+ +
+ + +
+

Config JSON

@@ -341,9 +371,10 @@ tabTools.style.background = tools ? '#1f2937' : '#111827'; }; tabLogs.addEventListener('click', () => selectTab(false)); + tabTools.addEventListener('click', () => { selectTab(true); - // refresh tools view values + // refresh toggles/nums root.querySelectorAll('.rc-toggle').forEach(inp => { const key = inp.dataset.key; inp.checked = !!this.cfg[key]; @@ -351,7 +382,15 @@ root.querySelectorAll('.rc-num').forEach(inp => { inp.value = String(this.cfg[inp.dataset.key] ?? ''); }); - root.querySelector('.rc-json').value = JSON.stringify(this.cfg, null, 2); + + // Mask BRIDGE_KEY in JSON dump + 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); + + // Mask the bridge key input (never show the real key) + const bridgeKeyInput = root.querySelector('.rc-bridge-key'); + if (bridgeKeyInput) bridgeKeyInput.value = this.cfg.BRIDGE_KEY ? '•'.repeat(8) : ''; }); // Collapse @@ -395,11 +434,18 @@ // Tools: Clear History root.querySelector('.rc-clear-history').addEventListener('click', () => { - localStorage.removeItem(STORAGE_KEYS.history); - this.info('Command history cleared'); - GM_notification({ title: 'AI Repo Commander', text: 'Command history cleared', timeout: 2500 }); + try { + commandMonitor?.history?.resetAll?.(); + RC_DEBUG?.info('Conversation history cleared'); + GM_notification({ title: 'AI Repo Commander', text: 'This conversation’s execution marks cleared', timeout: 2500 }); + } catch { + // fallback: remove legacy + localStorage.removeItem(STORAGE_KEYS.history); + RC_DEBUG?.info('Legacy history key cleared'); + } }); + // Tools: toggles & numbers root.querySelectorAll('.rc-toggle').forEach(inp => { const key = inp.dataset.key; @@ -427,16 +473,39 @@ try { const raw = root.querySelector('.rc-json').value; const parsed = JSON.parse(raw); + + // Handle BRIDGE_KEY specially: ignore masked or empty strings, + // accept a real value, then remove it from parsed so Object.assign + // doesn’t stomp it later. + 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); + + // Re-mask JSON view after save + 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.info('Config JSON saved'); } catch (e) { this.warn('Invalid JSON in config textarea', { error: String(e) }); } }); + root.querySelector('.rc-reset-defaults').addEventListener('click', () => { Object.assign(this.cfg, structuredClone(DEFAULT_CONFIG)); saveConfig(this.cfg); + BRIDGE_KEY = null; // <— add + const bridgeKeyInput = root.querySelector('.rc-bridge-key'); // <— add + if (bridgeKeyInput) bridgeKeyInput.value = ''; // <— add this.info('Config reset to defaults'); }); @@ -447,6 +516,36 @@ 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', () => { + const raw = (bridgeKeyInput.value || '').trim(); + // If user clicked into a masked field and didn't change it, do nothing + 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; // set runtime immediately + // re-mask UI + bridgeKeyInput.value = this.cfg.BRIDGE_KEY ? '•'.repeat(8) : ''; + 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', () => { + this.cfg.BRIDGE_KEY = ''; + bridgeKeyInput.value = ''; + saveConfig(this.cfg); + BRIDGE_KEY = null; // Clear the runtime key too + this.info('Bridge key cleared'); + GM_notification({ title: 'AI Repo Commander', text: 'Bridge key cleared', timeout: 2500 }); + }); + } _renderRow(e) { @@ -472,6 +571,38 @@ 'claude.ai': { messages: '.chat-message', input: '[contenteditable="true"]', content: '.content' }, 'gemini.google.com': { messages: '.message-content', input: 'textarea, [contenteditable="true"]', content: '.message-text' } }; + // ---------------------- Conversation-Aware Element History ---------------------- + + function getConversationId() { + const host = location.hostname; + // ChatGPT / OpenAI + if (/chatgpt\.com|chat\.openai\.com/.test(host)) { + const m = location.pathname.match(/\/c\/([^/]+)/); + return `chatgpt:${m ? m[1] : location.pathname}`; + } + // Claude + if (/claude\.ai/.test(host)) { + const m = location.pathname.match(/\/thread\/([^/]+)/); + return `claude:${m ? m[1] : location.pathname}`; + } + // Gemini / others + return `${host}:${location.pathname || '/'}`; + } + + function fingerprintElement(el) { + // Prefer platform IDs if available + const attrs = ['data-message-id','data-id','data-testid','id']; + for (const a of attrs) { + const v = el.getAttribute?.(a) || el.closest?.(`[${a}]`)?.getAttribute?.(a); + if (v) return `id:${a}:${v}`; + } + // Fallback: DOM position + short content hash + const nodes = Array.from(document.querySelectorAll('[data-message-author-role], .chat-message, .message-content')); + const idx = Math.max(0, nodes.indexOf(el)); + const s = (el.textContent || '').slice(0, 256); + let h = 5381; for (let i=0;i>>0).toString(36)}`; + } // ---------------------- Command requirements ---------------------- const REQUIRED_FIELDS = { @@ -510,57 +641,109 @@ }; // ---------------------- Persistent Command History ---------------------- - class CommandHistory { + class ConvHistory { constructor() { - this.key = STORAGE_KEYS.history; - this.ttl = CONFIG.DEDUPE_TTL_MS; - this.cleanup(); + this.convId = getConversationId(); + this.key = `ai_rc:conv:${this.convId}:processed`; + this.session = new Set(); + this.cache = this._load(); + this._cleanupTTL(); // ← Add cleanup on init } + _load() { - try { return JSON.parse(localStorage.getItem(this.key) || '{}'); } - catch { return {}; } + try { + return JSON.parse(localStorage.getItem(this.key) || '{}'); + } catch { + return {}; + } } - _save(db) { localStorage.setItem(this.key, JSON.stringify(db)); } - _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); + + _save() { + try { + localStorage.setItem(this.key, JSON.stringify(this.cache)); + } catch {} } - has(text) { - const db = this._load(); - const k = this._hash(text); - const ts = db[k]; - return !!ts && (Date.now() - ts) < this.ttl; - } - mark(text) { - const db = this._load(); - db[this._hash(text)] = Date.now(); - this._save(db); - } - unmark(text) { - const db = this._load(); - const k = this._hash(text); - if (k in db) { delete db[k]; this._save(db); } - } - cleanup() { - const db = this._load(); + + _cleanupTTL() { + const ttl = CONFIG.DEDUPE_TTL_MS || (30 * 24 * 60 * 60 * 1000); const now = Date.now(); let dirty = false; - for (const [k, ts] of Object.entries(db)) { - if (!ts || (now - ts) >= this.ttl) { delete db[k]; dirty = true; } + + for (const [fp, ts] of Object.entries(this.cache)) { + if (!ts || (now - ts) > ttl) { + delete this.cache[fp]; + dirty = true; + } } - if (dirty) this._save(db); + + if (dirty) this._save(); + } + + hasElement(el) { + const fp = fingerprintElement(el); + return this.session.has(fp) || (fp in this.cache); + } + + markElement(el) { + const fp = fingerprintElement(el); + this.session.add(fp); + this.cache[fp] = Date.now(); + this._save(); + + if (CONFIG.SHOW_EXECUTED_MARKER) { + try { + el.style.borderLeft = '3px solid #10B981'; + el.title = 'Command executed — use "Run again" to re-run'; + } catch {} + } + } + + unmarkElement(el) { + const fp = fingerprintElement(el); + this.session.delete(fp); + if (fp in this.cache) { + delete this.cache[fp]; + this._save(); + } + } + + resetAll() { + this.session.clear(); + localStorage.removeItem(this.key); + this.cache = {}; } - reset() { localStorage.removeItem(this.key); } } // Global helpers (stable) window.AI_REPO = { - clearHistory: () => localStorage.removeItem(STORAGE_KEYS.history), + clearHistory: () => { + try { commandMonitor?.history?.resetAll?.(); } catch {} + localStorage.removeItem(STORAGE_KEYS.history); // legacy + }, getConfig: () => structuredClone(CONFIG), setConfig: (patch) => { Object.assign(CONFIG, patch||{}); saveConfig(CONFIG); }, }; + function attachRunAgainUI(containerEl, onRun) { + if (containerEl.querySelector('.ai-rc-rerun')) return; + const bar = document.createElement('div'); + bar.className = 'ai-rc-rerun'; + bar.style.cssText = 'margin:8px 0; display:flex; gap:8px; align-items:center;'; + const msg = document.createElement('span'); + msg.textContent = 'Already executed.'; + msg.style.cssText = 'flex:1; font-size:13px; opacity:.9;'; + const run = document.createElement('button'); + run.textContent = 'Run again'; + run.style.cssText = 'padding:4px 10px; border:1px solid #374151; border-radius:4px; background:#1f2937; color:#e5e7eb;'; + 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;'; + run.onclick = onRun; + dismiss.onclick = () => bar.remove(); + bar.append(msg, run, dismiss); + containerEl.appendChild(bar); + } + // ---------------------- UI feedback ---------------------- class UIFeedback { static appendStatus(sourceElement, templateType, data) { @@ -853,11 +1036,23 @@ headers: { 'X-Bridge-Key': bridgeKey, 'Content-Type': 'application/json' }, data: JSON.stringify(command), timeout: CONFIG.API_TIMEOUT_MS || 60000, - onload: (response) => (response.status >= 200 && response.status < 300) - ? resolve(response) - : reject(new Error(`API Error ${response.status}: ${response.statusText}`)), - onerror: (error) => reject(new Error(`Network error: ${error}`)), - ontimeout: () => reject(new Error('API request timeout')) + + 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`)) }); }); } @@ -961,19 +1156,58 @@ // ---------------------- Bridge Key ---------------------- let BRIDGE_KEY = null; + function requireBridgeKeyIfNeeded() { - if (CONFIG.ENABLE_API && !BRIDGE_KEY) { - BRIDGE_KEY = prompt('[AI Repo Commander] Enter your bridge key for this session:'); - if (!BRIDGE_KEY) throw new Error('Bridge key required when API is enabled.'); + 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 CommandHistory(); + this.history = new ConvHistory(); + this.coldStartUntil = Date.now() + (CONFIG.COLD_START_MS || 0); // optional this.observer = null; this.currentPlatform = null; this._idCounter = 0; @@ -1005,7 +1239,11 @@ VERSION: CONFIG.VERSION }); if (CONFIG.ENABLE_API) { - RC_DEBUG?.warn('API is enabled — you will be prompted for your bridge key on first command.'); + 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); } @@ -1017,10 +1255,17 @@ startObservation() { let scanPending = false; + let lastScan = 0; + const scheduleScan = () => { if (scanPending) return; scanPending = true; - setTimeout(() => { scanPending = false; this.scanMessages(); }, 120); + const delay = Math.max(0, CONFIG.SCAN_DEBOUNCE_MS - (Date.now() - lastScan)); + setTimeout(() => { + scanPending = false; + lastScan = Date.now(); + this.scanMessages(); + }, delay); }; this.observer = new MutationObserver((mutations) => { @@ -1029,18 +1274,19 @@ if (node.nodeType !== 1) continue; if (node.matches?.('pre, code') || node.querySelector?.('pre, code')) { scheduleScan(); - break; + return; // Early exit after scheduling } } } }); + this.observer.observe(document.body, { childList: true, subtree: true }); if (CONFIG.PROCESS_EXISTING) { setTimeout(() => { RC_DEBUG?.info('Initial scan after page load (PROCESS_EXISTING=true)'); this.scanMessages(); - }, 1000); + }, 600); } else { RC_DEBUG?.info('Initial scan skipped (PROCESS_EXISTING=false)'); } @@ -1066,31 +1312,11 @@ return null; } - async waitForStableCompleteBlock(element, initialText) { - // After debounce, wait until the block remains unchanged for SETTLE_CHECK_MS, with terminator present. - const mustEnd = Date.now() + Math.max(0, CONFIG.SETTLE_CHECK_MS); - let last = initialText; - while (Date.now() < mustEnd) { - await ExecutionManager.delay(CONFIG.SETTLE_POLL_MS); - const hit = this.findCommandInCodeBlock(element); - const txt = hit ? hit.text : ''; - if (!txt || !this.isCompleteCommandText(txt)) { - // streaming not done yet; extend window slightly - continue; - } - if (txt === last) { - // stable during this poll; keep looping until timeout to ensure stability window - } else { - last = txt; // changed; reset window by moving mustEnd forward a bit - } - } - // final extract - const finalHit = this.findCommandInCodeBlock(element); - return finalHit ? finalHit.text : ''; - } - scanMessages() { - if (CONFIG.RUNTIME.PAUSED) { RC_DEBUG?.logLoop('loop', 'scan paused'); return; } + if (CONFIG.RUNTIME.PAUSED) { + RC_DEBUG?.logLoop('loop', 'scan paused'); + return; + } const messages = document.querySelectorAll(this.currentPlatform.messages); let skipped = 0, found = 0; @@ -1104,20 +1330,47 @@ const cmdText = hit.text; - if (this.history.has(cmdText)) { + // Check if we're in cold start OR if this specific message element was already executed + const withinColdStart = Date.now() < this.coldStartUntil; + const alreadyProcessed = this.history.hasElement(el); + + // If cold start OR already processed → show "Run Again" button (don't auto-execute) + if (withinColdStart || alreadyProcessed) { el.dataset.aiRcProcessed = '1'; + + const reason = withinColdStart + ? 'page load (cold start)' + : 'already executed in this conversation'; + + RC_DEBUG?.verbose(`Skipping command - ${reason}`, { + preview: cmdText.slice(0, 80) + }); + + attachRunAgainUI(el, () => { + el.dataset.aiRcProcessed = '1'; // ← Prevents scan loop double-enqueue + + const id = this.getReadableMessageId(el); + const hit2 = this.findCommandInCodeBlock(el); + if (hit2) { + this.trackMessage(el, hit2.text, id); + } + }); + skipped++; return; } + // New message that hasn't been executed → auto-execute once el.dataset.aiRcProcessed = '1'; + this.history.markElement(el); + const id = this.getReadableMessageId(el); this.trackMessage(el, cmdText, id); found++; }); - if (skipped) RC_DEBUG?.logLoop('loop', `skipped already-executed (${skipped})`); - if (found) RC_DEBUG?.info(`Found ${found} new command(s)`); + if (skipped) RC_DEBUG?.info(`Skipped ${skipped} command(s) - Run Again buttons added`); + if (found) RC_DEBUG?.info(`Auto-executing ${found} new command(s)`); } isAssistantMessage(el) { @@ -1137,12 +1390,86 @@ 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() + element, + originalText: text, + state: COMMAND_STATES.DETECTED, + startTime: Date.now(), + lastUpdate: Date.now(), + cancelToken: { cancelled: false } // ← ADD THIS }); this.updateState(messageId, COMMAND_STATES.PARSING); this.processCommand(messageId); } + // Debounce that checks cancellation + async debounceWithCancel(messageId) { + const start = Date.now(); + const delay = CONFIG.DEBOUNCE_DELAY; + const checkInterval = 100; // check every 100ms + while (Date.now() - start < delay) { + const msg = this.trackedMessages.get(messageId); + if (!msg || msg.cancelToken?.cancelled) return; + + // Update lastUpdate to prevent premature cleanup + msg.lastUpdate = Date.now(); + this.trackedMessages.set(messageId, msg); + + await ExecutionManager.delay(Math.min(checkInterval, delay - (Date.now() - start))); + } + } + + // Updated settle check with cancellation and lastUpdate bumps + async waitForStableCompleteBlock(element, initialText, messageId) { + let deadline = Date.now() + Math.max(0, CONFIG.SETTLE_CHECK_MS); + let last = initialText; + + while (Date.now() < deadline) { + // Check cancellation + const rec = this.trackedMessages.get(messageId); + if (!rec || rec.cancelToken?.cancelled) { + RC_DEBUG?.warn('Settle cancelled', { messageId }); + return ''; + } + + // Update lastUpdate to prevent cleanup during long wait + 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 : ''; + + if (!txt || !this.isCompleteCommandText(txt)) { + continue; + } + + if (txt === last) { + // stable; keep waiting + } else { + last = txt; + deadline = Date.now() + Math.max(0, CONFIG.SETTLE_CHECK_MS); + } + } + + const finalHit = this.findCommandInCodeBlock(element); + return finalHit ? finalHit.text : ''; + } + + // Retry UI helper + attachRetryUI(element, messageId) { + if (element.querySelector('.ai-rc-rerun')) return; + + attachRunAgainUI(element, () => { + element.dataset.aiRcProcessed = '1'; + const hit = this.findCommandInCodeBlock(element); + if (hit) { + // Clear old entry and create new one + this.trackedMessages.delete(messageId); + const newId = this.getReadableMessageId(element); + this.trackMessage(element, hit.text, newId); + } + }); + } updateState(messageId, state) { const msg = this.trackedMessages.get(messageId); if (!msg) return; @@ -1156,61 +1483,98 @@ } async processCommand(messageId) { - if (CONFIG.RUNTIME.PAUSED) { RC_DEBUG?.info('process paused, skipping', { messageId }); return; } + 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) { + RC_DEBUG?.error('Message not found', { messageId }); + return; + } - // 1) Initial parse check (strict: must include ---) + // ← CHECK CANCELLATION + if (message.cancelToken?.cancelled) { + RC_DEBUG?.warn('Operation cancelled', { messageId }); + return; + } + + // 1) Parse let parsed; try { parsed = CommandParser.parseYAMLCommand(message.originalText); } 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; // silent for partials + if (/No complete \^%\$bridge/.test(err.message)) return; + + // ← ADD RUN AGAIN ON ERRORS + this.attachRetryUI(message.element, messageId); + UIFeedback.appendStatus(message.element, 'ERROR', { action: 'Command', details: err.message }); return; } + // ← CHECK CANCELLATION + 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); - if (!validation.isValid) throw new Error(`Validation failed: ${validation.errors.join(', ')}`); + if (!validation.isValid) { + this.attachRetryUI(message.element, messageId); + throw new Error(`Validation failed: ${validation.errors.join(', ')}`); + } - // 3) Debounce, then settle: ensure final text complete & stable + // 3) Debounce this.updateState(messageId, COMMAND_STATES.DEBOUNCING); const before = message.originalText; - await this.debounce(); - const stable = await this.waitForStableCompleteBlock(message.element, before); - if (!stable) { this.updateState(messageId, COMMAND_STATES.ERROR); return; } + await this.debounceWithCancel(messageId); + + // ← CHECK CANCELLATION + if (message.cancelToken?.cancelled) { + RC_DEBUG?.warn('Operation cancelled after debounce', { messageId }); + return; + } + + const stable = await this.waitForStableCompleteBlock(message.element, before, messageId); + if (!stable) { + 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) throw new Error(`Final validation failed: ${reVal.errors.join(', ')}`); + if (!reVal.isValid) { + this.attachRetryUI(message.element, messageId); + throw new Error(`Final validation failed: ${reVal.errors.join(', ')}`); + } parsed = reParsed; } - // 4) Pre-mark to avoid duplicate runs even if failure later (explicit design) - this.history.mark(message.originalText); - - // 5) Execute + // 4) Execute this.updateState(messageId, COMMAND_STATES.EXECUTING); const result = await ExecutionManager.executeCommand(parsed, message.element); if (!result || result.success === false) { - RC_DEBUG?.warn('Execution reported failure; command remains marked (no auto-retry)', { messageId }); + 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 < 50) RC_DEBUG?.warn('Command completed very fast', { messageId, duration }); - if (duration > 60000) RC_DEBUG?.warn('Command took very long', { messageId, duration }); + 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); @@ -1219,26 +1583,36 @@ 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; // quiet for partials + 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 }); } } } - debounce() { return new Promise((r) => setTimeout(r, CONFIG.DEBOUNCE_DELAY)); } - cleanupProcessedCommands() { const now = Date.now(); let count = 0; + for (const [id, msg] of this.trackedMessages.entries()) { - if ((msg.state === COMMAND_STATES.COMPLETE || msg.state === COMMAND_STATES.ERROR) && - now - (msg.lastUpdate || now) > CONFIG.CLEANUP_AFTER_MS) { + 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); // Force cleanup for stuck items + + 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} processed entries`); + + if (count) RC_DEBUG?.info(`Cleaned ${count} tracked entrie(s)`); } stopAllProcessing() { @@ -1257,12 +1631,16 @@ CONFIG.RUNTIME.PAUSED = true; saveConfig(CONFIG); + // Cancel all pending operations 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 }); @@ -1273,20 +1651,20 @@ // ---------------------- Manual retry helpers ---------------------- let commandMonitor; // forward ref - window.AI_REPO_RETRY_COMMAND_TEXT = (text) => { - try { - commandMonitor?.history?.unmark?.(text); - RC_DEBUG?.info('Command unmarked for manual retry (by text)', { preview: String(text).slice(0,120) }); - } catch (e) { - RC_DEBUG?.error('Failed to unmark command by text', { error: String(e) }); - } + 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; } - commandMonitor.history.unmark(msg.originalText); + if (!msg) { + RC_DEBUG?.warn('Message not found for retry', { messageId }); + return; + } + + msg.element.dataset.aiRcProcessed = '1'; // ← Block scan loop + RC_DEBUG?.info('Message unmarked; reprocessing now', { messageId }); commandMonitor.updateState(messageId, COMMAND_STATES.PARSING); commandMonitor.processCommand(messageId); @@ -1295,6 +1673,7 @@ } }; + // ---------------------- Test commands ---------------------- const TEST_COMMANDS = { validUpdate: