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: