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