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