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
This commit is contained in:
rob 2025-10-08 18:32:38 +00:00
parent 3575dec2ee
commit f4ebb492b7
1 changed files with 492 additions and 113 deletions

View File

@ -41,10 +41,14 @@
PROCESS_EXISTING: false, PROCESS_EXISTING: false,
ASSISTANT_ONLY: true, ASSISTANT_ONLY: true,
BRIDGE_KEY: '', // Var to store the bridge key
// Persistent dedupe window // Persistent dedupe window
DEDUPE_TTL_MS: 30 * 24 * 60 * 60 * 1000, // 30 days 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 // Housekeeping
CLEANUP_AFTER_MS: 30000, CLEANUP_AFTER_MS: 30000,
CLEANUP_INTERVAL_MS: 60000, CLEANUP_INTERVAL_MS: 60000,
@ -61,7 +65,14 @@
SETTLE_POLL_MS: 200, // was 300 SETTLE_POLL_MS: 200, // was 300
// Runtime toggles // 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() { function loadSavedConfig() {
@ -292,6 +303,25 @@
API_TIMEOUT_MS <input class="rc-num" data-key="API_TIMEOUT_MS" type="number" min="10000" step="5000" style="width:120px;background:#0b1220;color:#e5e7eb;border:1px solid #374151;border-radius:4px;padding:2px 6px;"> API_TIMEOUT_MS <input class="rc-num" data-key="API_TIMEOUT_MS" type="number" min="10000" step="5000" style="width:120px;background:#0b1220;color:#e5e7eb;border:1px solid #374151;border-radius:4px;padding:2px 6px;">
</label> </label>
</div> </div>
<div style="grid-column:1 / -1;">
<h4 style="margin:8px 0 6px 0;">Bridge Configuration</h4>
<label style="display:flex;align-items:center;gap:8px;margin:4px 0;">
Bridge Key:
<input class="rc-bridge-key" type="password"
style="flex:1;background:#0b1220;color:#e5e7eb;border:1px solid #374151;border-radius:4px;padding:4px 8px;"
placeholder="Enter your bridge key here">
</label>
<div style="margin-top:6px;">
<button class="rc-save-bridge-key"
style="padding:6px 8px;border:1px solid #374151;border-radius:6px;background:#1f2937;color:#e5e7eb;">
Save Bridge Key
</button>
<button class="rc-clear-bridge-key"
style="padding:6px 8px;border:1px solid #374151;border-radius:6px;background:#1f2937;color:#e5e7eb;margin-left:8px;">
Clear Bridge Key
</button>
</div>
</div>
<div style="grid-column:1 / -1;"> <div style="grid-column:1 / -1;">
<h4 style="margin:8px 0 6px 0;">Config JSON</h4> <h4 style="margin:8px 0 6px 0;">Config JSON</h4>
<textarea class="rc-json" style="width:100%;height:140px;background:#0b1220;color:#e5e7eb;border:1px solid #374151;border-radius:6px;padding:6px;"></textarea> <textarea class="rc-json" style="width:100%;height:140px;background:#0b1220;color:#e5e7eb;border:1px solid #374151;border-radius:6px;padding:6px;"></textarea>
@ -341,9 +371,10 @@
tabTools.style.background = tools ? '#1f2937' : '#111827'; tabTools.style.background = tools ? '#1f2937' : '#111827';
}; };
tabLogs.addEventListener('click', () => selectTab(false)); tabLogs.addEventListener('click', () => selectTab(false));
tabTools.addEventListener('click', () => { tabTools.addEventListener('click', () => {
selectTab(true); selectTab(true);
// refresh tools view values // refresh toggles/nums
root.querySelectorAll('.rc-toggle').forEach(inp => { root.querySelectorAll('.rc-toggle').forEach(inp => {
const key = inp.dataset.key; const key = inp.dataset.key;
inp.checked = !!this.cfg[key]; inp.checked = !!this.cfg[key];
@ -351,7 +382,15 @@
root.querySelectorAll('.rc-num').forEach(inp => { root.querySelectorAll('.rc-num').forEach(inp => {
inp.value = String(this.cfg[inp.dataset.key] ?? ''); 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 // Collapse
@ -395,11 +434,18 @@
// Tools: Clear History // Tools: Clear History
root.querySelector('.rc-clear-history').addEventListener('click', () => { root.querySelector('.rc-clear-history').addEventListener('click', () => {
localStorage.removeItem(STORAGE_KEYS.history); try {
this.info('Command history cleared'); commandMonitor?.history?.resetAll?.();
GM_notification({ title: 'AI Repo Commander', text: 'Command history cleared', timeout: 2500 }); RC_DEBUG?.info('Conversation history cleared');
GM_notification({ title: 'AI Repo Commander', text: 'This conversations execution marks cleared', timeout: 2500 });
} catch {
// fallback: remove legacy
localStorage.removeItem(STORAGE_KEYS.history);
RC_DEBUG?.info('Legacy history key cleared');
}
}); });
// Tools: toggles & numbers // Tools: toggles & numbers
root.querySelectorAll('.rc-toggle').forEach(inp => { root.querySelectorAll('.rc-toggle').forEach(inp => {
const key = inp.dataset.key; const key = inp.dataset.key;
@ -427,16 +473,39 @@
try { try {
const raw = root.querySelector('.rc-json').value; const raw = root.querySelector('.rc-json').value;
const parsed = JSON.parse(raw); 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
// doesnt 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); Object.assign(this.cfg, parsed);
saveConfig(this.cfg); 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'); this.info('Config JSON saved');
} catch (e) { } catch (e) {
this.warn('Invalid JSON in config textarea', { error: String(e) }); this.warn('Invalid JSON in config textarea', { error: String(e) });
} }
}); });
root.querySelector('.rc-reset-defaults').addEventListener('click', () => { root.querySelector('.rc-reset-defaults').addEventListener('click', () => {
Object.assign(this.cfg, structuredClone(DEFAULT_CONFIG)); Object.assign(this.cfg, structuredClone(DEFAULT_CONFIG));
saveConfig(this.cfg); 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'); this.info('Config reset to defaults');
}); });
@ -447,6 +516,36 @@
pauseBtn.style.color = '#111827'; 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) { _renderRow(e) {
@ -472,6 +571,38 @@
'claude.ai': { messages: '.chat-message', input: '[contenteditable="true"]', content: '.content' }, 'claude.ai': { messages: '.chat-message', input: '[contenteditable="true"]', content: '.content' },
'gemini.google.com': { messages: '.message-content', input: 'textarea, [contenteditable="true"]', content: '.message-text' } '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<s.length;i++) h=((h<<5)+h) ^ s.charCodeAt(i);
return `pos:${idx}:h:${(h>>>0).toString(36)}`;
}
// ---------------------- Command requirements ---------------------- // ---------------------- Command requirements ----------------------
const REQUIRED_FIELDS = { const REQUIRED_FIELDS = {
@ -510,57 +641,109 @@
}; };
// ---------------------- Persistent Command History ---------------------- // ---------------------- Persistent Command History ----------------------
class CommandHistory { class ConvHistory {
constructor() { constructor() {
this.key = STORAGE_KEYS.history; this.convId = getConversationId();
this.ttl = CONFIG.DEDUPE_TTL_MS; this.key = `ai_rc:conv:${this.convId}:processed`;
this.cleanup(); this.session = new Set();
this.cache = this._load();
this._cleanupTTL(); // ← Add cleanup on init
} }
_load() { _load() {
try { return JSON.parse(localStorage.getItem(this.key) || '{}'); } try {
catch { return {}; } return JSON.parse(localStorage.getItem(this.key) || '{}');
} catch {
return {};
}
} }
_save(db) { localStorage.setItem(this.key, JSON.stringify(db)); }
_hash(s) { _save() {
let h = 5381; try {
for (let i = 0; i < s.length; i++) h = ((h << 5) + h) ^ s.charCodeAt(i); localStorage.setItem(this.key, JSON.stringify(this.cache));
return (h >>> 0).toString(36); } catch {}
} }
has(text) {
const db = this._load(); _cleanupTTL() {
const k = this._hash(text); const ttl = CONFIG.DEDUPE_TTL_MS || (30 * 24 * 60 * 60 * 1000);
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();
const now = Date.now(); const now = Date.now();
let dirty = false; 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) // Global helpers (stable)
window.AI_REPO = { window.AI_REPO = {
clearHistory: () => localStorage.removeItem(STORAGE_KEYS.history), clearHistory: () => {
try { commandMonitor?.history?.resetAll?.(); } catch {}
localStorage.removeItem(STORAGE_KEYS.history); // legacy
},
getConfig: () => structuredClone(CONFIG), getConfig: () => structuredClone(CONFIG),
setConfig: (patch) => { Object.assign(CONFIG, patch||{}); saveConfig(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 ---------------------- // ---------------------- UI feedback ----------------------
class UIFeedback { class UIFeedback {
static appendStatus(sourceElement, templateType, data) { static appendStatus(sourceElement, templateType, data) {
@ -853,11 +1036,23 @@
headers: { 'X-Bridge-Key': bridgeKey, 'Content-Type': 'application/json' }, headers: { 'X-Bridge-Key': bridgeKey, 'Content-Type': 'application/json' },
data: JSON.stringify(command), data: JSON.stringify(command),
timeout: CONFIG.API_TIMEOUT_MS || 60000, timeout: CONFIG.API_TIMEOUT_MS || 60000,
onload: (response) => (response.status >= 200 && response.status < 300)
? resolve(response) onload: (response) => {
: reject(new Error(`API Error ${response.status}: ${response.statusText}`)), if (response.status >= 200 && response.status < 300) {
onerror: (error) => reject(new Error(`Network error: ${error}`)), return resolve(response);
ontimeout: () => reject(new Error('API request timeout')) }
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 ---------------------- // ---------------------- Bridge Key ----------------------
let BRIDGE_KEY = null; let BRIDGE_KEY = null;
function requireBridgeKeyIfNeeded() { function requireBridgeKeyIfNeeded() {
if (CONFIG.ENABLE_API && !BRIDGE_KEY) { if (!CONFIG.ENABLE_API) return 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.'); // 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; 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) ---------------------- // ---------------------- Monitor (with streaming “settle” & complete-block check) ----------------------
class CommandMonitor { class CommandMonitor {
constructor() { constructor() {
this.trackedMessages = new Map(); 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.observer = null;
this.currentPlatform = null; this.currentPlatform = null;
this._idCounter = 0; this._idCounter = 0;
@ -1005,7 +1239,11 @@
VERSION: CONFIG.VERSION VERSION: CONFIG.VERSION
}); });
if (CONFIG.ENABLE_API) { 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); this.cleanupIntervalId = setInterval(() => this.cleanupProcessedCommands(), CONFIG.CLEANUP_INTERVAL_MS);
} }
@ -1017,10 +1255,17 @@
startObservation() { startObservation() {
let scanPending = false; let scanPending = false;
let lastScan = 0;
const scheduleScan = () => { const scheduleScan = () => {
if (scanPending) return; if (scanPending) return;
scanPending = true; 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) => { this.observer = new MutationObserver((mutations) => {
@ -1029,18 +1274,19 @@
if (node.nodeType !== 1) continue; if (node.nodeType !== 1) continue;
if (node.matches?.('pre, code') || node.querySelector?.('pre, code')) { if (node.matches?.('pre, code') || node.querySelector?.('pre, code')) {
scheduleScan(); scheduleScan();
break; return; // Early exit after scheduling
} }
} }
} }
}); });
this.observer.observe(document.body, { childList: true, subtree: true }); this.observer.observe(document.body, { childList: true, subtree: true });
if (CONFIG.PROCESS_EXISTING) { if (CONFIG.PROCESS_EXISTING) {
setTimeout(() => { setTimeout(() => {
RC_DEBUG?.info('Initial scan after page load (PROCESS_EXISTING=true)'); RC_DEBUG?.info('Initial scan after page load (PROCESS_EXISTING=true)');
this.scanMessages(); this.scanMessages();
}, 1000); }, 600);
} else { } else {
RC_DEBUG?.info('Initial scan skipped (PROCESS_EXISTING=false)'); RC_DEBUG?.info('Initial scan skipped (PROCESS_EXISTING=false)');
} }
@ -1066,31 +1312,11 @@
return null; 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() { 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); const messages = document.querySelectorAll(this.currentPlatform.messages);
let skipped = 0, found = 0; let skipped = 0, found = 0;
@ -1104,20 +1330,47 @@
const cmdText = hit.text; 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'; 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++; skipped++;
return; return;
} }
// New message that hasn't been executed → auto-execute once
el.dataset.aiRcProcessed = '1'; el.dataset.aiRcProcessed = '1';
this.history.markElement(el);
const id = this.getReadableMessageId(el); const id = this.getReadableMessageId(el);
this.trackMessage(el, cmdText, id); this.trackMessage(el, cmdText, id);
found++; found++;
}); });
if (skipped) RC_DEBUG?.logLoop('loop', `skipped already-executed (${skipped})`); if (skipped) RC_DEBUG?.info(`Skipped ${skipped} command(s) - Run Again buttons added`);
if (found) RC_DEBUG?.info(`Found ${found} new command(s)`); if (found) RC_DEBUG?.info(`Auto-executing ${found} new command(s)`);
} }
isAssistantMessage(el) { isAssistantMessage(el) {
@ -1137,12 +1390,86 @@
trackMessage(element, text, messageId) { trackMessage(element, text, messageId) {
RC_DEBUG?.info('New command detected', { messageId, preview: text.substring(0, 120) }); RC_DEBUG?.info('New command detected', { messageId, preview: text.substring(0, 120) });
this.trackedMessages.set(messageId, { 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.updateState(messageId, COMMAND_STATES.PARSING);
this.processCommand(messageId); 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) { updateState(messageId, state) {
const msg = this.trackedMessages.get(messageId); const msg = this.trackedMessages.get(messageId);
if (!msg) return; if (!msg) return;
@ -1156,61 +1483,98 @@
} }
async processCommand(messageId) { 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(); const started = Date.now();
try { try {
const message = this.trackedMessages.get(messageId); 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; let parsed;
try { try {
parsed = CommandParser.parseYAMLCommand(message.originalText); parsed = CommandParser.parseYAMLCommand(message.originalText);
} catch (err) { } catch (err) {
RC_DEBUG?.error(`Command parsing failed: ${err.message}`, { messageId }); RC_DEBUG?.error(`Command parsing failed: ${err.message}`, { messageId });
this.updateState(messageId, COMMAND_STATES.ERROR); 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 }); UIFeedback.appendStatus(message.element, 'ERROR', { action: 'Command', details: err.message });
return; return;
} }
// ← CHECK CANCELLATION
if (message.cancelToken?.cancelled) {
RC_DEBUG?.warn('Operation cancelled after parse', { messageId });
return;
}
// 2) Validate // 2) Validate
this.updateState(messageId, COMMAND_STATES.VALIDATING); this.updateState(messageId, COMMAND_STATES.VALIDATING);
let validation = CommandParser.validateStructure(parsed); 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); this.updateState(messageId, COMMAND_STATES.DEBOUNCING);
const before = message.originalText; const before = message.originalText;
await this.debounce(); await this.debounceWithCancel(messageId);
const stable = await this.waitForStableCompleteBlock(message.element, before);
if (!stable) { this.updateState(messageId, COMMAND_STATES.ERROR); return; } // ← 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) { if (stable !== before) {
RC_DEBUG?.info('Command changed after settle (re-validate)', { messageId }); RC_DEBUG?.info('Command changed after settle (re-validate)', { messageId });
message.originalText = stable; message.originalText = stable;
const reParsed = CommandParser.parseYAMLCommand(stable); const reParsed = CommandParser.parseYAMLCommand(stable);
const reVal = CommandParser.validateStructure(reParsed); 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; parsed = reParsed;
} }
// 4) Pre-mark to avoid duplicate runs even if failure later (explicit design) // 4) Execute
this.history.mark(message.originalText);
// 5) Execute
this.updateState(messageId, COMMAND_STATES.EXECUTING); this.updateState(messageId, COMMAND_STATES.EXECUTING);
const result = await ExecutionManager.executeCommand(parsed, message.element); const result = await ExecutionManager.executeCommand(parsed, message.element);
if (!result || result.success === false) { 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.updateState(messageId, COMMAND_STATES.ERROR);
this.attachRetryUI(message.element, messageId);
return; return;
} }
const duration = Date.now() - started; const duration = Date.now() - started;
if (duration < 50) RC_DEBUG?.warn('Command completed very fast', { messageId, duration }); if (duration < CONFIG.FAST_WARN_MS) RC_DEBUG?.warn('Command completed very fast', { messageId, duration });
if (duration > 60000) RC_DEBUG?.warn('Command took very long', { messageId, duration }); if (duration > CONFIG.SLOW_WARN_MS) RC_DEBUG?.warn('Command took very long', { messageId, duration });
this.updateState(messageId, COMMAND_STATES.COMPLETE); this.updateState(messageId, COMMAND_STATES.COMPLETE);
@ -1219,26 +1583,36 @@
RC_DEBUG?.error(`Command processing error: ${error.message}`, { messageId, duration }); RC_DEBUG?.error(`Command processing error: ${error.message}`, { messageId, duration });
this.updateState(messageId, COMMAND_STATES.ERROR); this.updateState(messageId, COMMAND_STATES.ERROR);
const message = this.trackedMessages.get(messageId); 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) { if (message) {
this.attachRetryUI(message.element, messageId);
UIFeedback.appendStatus(message.element, 'ERROR', { action: 'Command', details: error.message }); UIFeedback.appendStatus(message.element, 'ERROR', { action: 'Command', details: error.message });
} }
} }
} }
debounce() { return new Promise((r) => setTimeout(r, CONFIG.DEBOUNCE_DELAY)); }
cleanupProcessedCommands() { cleanupProcessedCommands() {
const now = Date.now(); const now = Date.now();
let count = 0; let count = 0;
for (const [id, msg] of this.trackedMessages.entries()) { for (const [id, msg] of this.trackedMessages.entries()) {
if ((msg.state === COMMAND_STATES.COMPLETE || msg.state === COMMAND_STATES.ERROR) && const age = now - (msg.lastUpdate || msg.startTime || now);
now - (msg.lastUpdate || now) > CONFIG.CLEANUP_AFTER_MS) { 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); this.trackedMessages.delete(id);
count++; count++;
} }
} }
if (count) RC_DEBUG?.info(`Cleaned ${count} processed entries`);
if (count) RC_DEBUG?.info(`Cleaned ${count} tracked entrie(s)`);
} }
stopAllProcessing() { stopAllProcessing() {
@ -1257,12 +1631,16 @@
CONFIG.RUNTIME.PAUSED = true; CONFIG.RUNTIME.PAUSED = true;
saveConfig(CONFIG); saveConfig(CONFIG);
// Cancel all pending operations
for (const [id, msg] of this.trackedMessages.entries()) { 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) { if (msg.state === COMMAND_STATES.EXECUTING || msg.state === COMMAND_STATES.DEBOUNCING) {
RC_DEBUG?.error('Emergency stop - cancelling command', { messageId: id }); RC_DEBUG?.error('Emergency stop - cancelling command', { messageId: id });
this.updateState(id, COMMAND_STATES.ERROR); this.updateState(id, COMMAND_STATES.ERROR);
} }
} }
this.stopAllProcessing(); this.stopAllProcessing();
RC_DEBUG?.error('🚨 EMERGENCY STOP ACTIVATED 🚨'); RC_DEBUG?.error('🚨 EMERGENCY STOP ACTIVATED 🚨');
GM_notification({ text: 'All command processing stopped', title: 'Emergency Stop', timeout: 5000 }); GM_notification({ text: 'All command processing stopped', title: 'Emergency Stop', timeout: 5000 });
@ -1273,20 +1651,20 @@
// ---------------------- Manual retry helpers ---------------------- // ---------------------- Manual retry helpers ----------------------
let commandMonitor; // forward ref let commandMonitor; // forward ref
window.AI_REPO_RETRY_COMMAND_TEXT = (text) => { window.AI_REPO_RETRY_COMMAND_TEXT = () => {
try { RC_DEBUG?.warn('Retry by text is deprecated. Use AI_REPO_RETRY_MESSAGE(messageId) or click "Run again".');
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_MESSAGE = (messageId) => { window.AI_REPO_RETRY_MESSAGE = (messageId) => {
try { try {
const msg = commandMonitor?.trackedMessages?.get(messageId); const msg = commandMonitor?.trackedMessages?.get(messageId);
if (!msg) { RC_DEBUG?.warn('Message not found for retry', { messageId }); return; } if (!msg) {
commandMonitor.history.unmark(msg.originalText); 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 }); RC_DEBUG?.info('Message unmarked; reprocessing now', { messageId });
commandMonitor.updateState(messageId, COMMAND_STATES.PARSING); commandMonitor.updateState(messageId, COMMAND_STATES.PARSING);
commandMonitor.processCommand(messageId); commandMonitor.processCommand(messageId);
@ -1295,6 +1673,7 @@
} }
}; };
// ---------------------- Test commands ---------------------- // ---------------------- Test commands ----------------------
const TEST_COMMANDS = { const TEST_COMMANDS = {
validUpdate: validUpdate: