Update src/ai-repo-commander.user.js

Double-execution / wrong “Run again” target:
You now fingerprint per message and per sub-command (markElement(el, idx+1)) and render per-command “Run again [#n]” buttons. That removes the old single-block ambiguity and prevents re-exec on reloads.

Cold-start / resume spam:
On resume you call markExistingHitsAsProcessed() which marks visible commands and injects Run-again buttons instead of auto-executing. That stops surprise replays after page refresh or unpause.

Streaming “half blocks” firing too early:
waitForStableCompleteBlock() + the required @end@ terminator and the settle window (SETTLE_CHECK_MS/SETTLE_POLL_MS) means you only act on a fully streamed, stable block.

Multi-command messages:
extractAllCompleteBlocks() + queueing each hit with unique render keys/labels gives correct detection, dedupe, status lines, and retries per block.

YAML | blocks cutting off at new keys:
The parser now ends a | block on any unindented key:—fixing prior cases where multi-line fields (content/body) swallowed following keys or vice-versa.

Composer/paste flakiness across models:
You added a robust paste stack (ClipboardEvent → ProseMirror HTML → execCommand → textarea/value → contentEditable → GM_setClipboard fallback) and waitForComposerReady() with send-button checks. That should address “pasted but empty” and “submit didn’t fire”.

Long response pastes breaking fences / hitting limits:
ResponseBuffer consolidates outputs, and splitRespectingCodeFence() keeps single fenced blobs intact across chunks while honoring MAX_PASTE_CHARS.

History that didn’t survive reloads / wrong thread:
Conversation-scoped persistent dedupe (ConvHistory with TTL) plus a stronger fingerprint (command hash + previous-context hash + DOM hint + ordinal) prevents cross-thread collisions and stale replays.

Emergency stop + rate limiting:
Queue rate-cap + STOP handler will keep you safe from API storms and let you halt mid-flight cleanly.
This commit is contained in:
rob 2025-10-11 02:49:14 +00:00
parent dd0427b598
commit 306482a281
1 changed files with 118 additions and 50 deletions

View File

@ -12,6 +12,7 @@
// @grant GM_notification // @grant GM_notification
// @grant GM_setClipboard // @grant GM_setClipboard
// @connect n8n.brrd.tech // @connect n8n.brrd.tech
// @connect *
// ==/UserScript== // ==/UserScript==
(function () { (function () {
@ -37,7 +38,7 @@
// If you see "debouncing → error" in logs (assistant streams very slowly), // If you see "debouncing → error" in logs (assistant streams very slowly),
// try bumping DEBOUNCE_DELAY by +10002000 and/or SETTLE_CHECK_MS by +400800. // try bumping DEBOUNCE_DELAY by +10002000 and/or SETTLE_CHECK_MS by +400800.
DEBOUNCE_DELAY: 3000, DEBOUNCE_DELAY: 6500,
MAX_RETRIES: 2, MAX_RETRIES: 2,
VERSION: '1.6.2', VERSION: '1.6.2',
API_TIMEOUT_MS: 60000, API_TIMEOUT_MS: 60000,
@ -66,15 +67,15 @@
// SETTLE_CHECK_MS is the "stable window" after last text change; // SETTLE_CHECK_MS is the "stable window" after last text change;
// SETTLE_POLL_MS is how often we re-check the code block. // SETTLE_POLL_MS is how often we re-check the code block.
REQUIRE_TERMINATOR: true, REQUIRE_TERMINATOR: true,
SETTLE_CHECK_MS: 800, SETTLE_CHECK_MS: 1300,
SETTLE_POLL_MS: 200, SETTLE_POLL_MS: 250,
// Runtime toggles // Runtime toggles
RUNTIME: { PAUSED: false }, RUNTIME: { PAUSED: false },
// New additions for hardening // New additions for hardening
STUCK_AFTER_MS: 10 * 60 * 1000, STUCK_AFTER_MS: 10 * 60 * 1000,
SCAN_DEBOUNCE_MS: 250, SCAN_DEBOUNCE_MS: 400,
FAST_WARN_MS: 50, FAST_WARN_MS: 50,
SLOW_WARN_MS: 60_000, SLOW_WARN_MS: 60_000,
@ -507,7 +508,8 @@
// Dragging // Dragging
const header = root.querySelector('.rc-header'); const header = root.querySelector('.rc-header');
header.addEventListener('mousedown', (e) => { header.addEventListener('mousedown', (e) => {
if ((e.target).closest('button,select,input,textarea,label')) return; const tgt = e.target instanceof Element ? e.target : e.target?.parentElement;
if (tgt?.closest('button,select,input,textarea,label')) return;
this.drag.active = true; this.drag.active = true;
const rect = root.getBoundingClientRect(); const rect = root.getBoundingClientRect();
this.drag.dx = e.clientX - rect.left; this.drag.dx = e.clientX - rect.left;
@ -828,12 +830,10 @@
continue; continue;
} }
const el = getVisibleInputCandidate(); const el = getVisibleInputCandidate();
const btn = findSendButton(); const btn = findSendButton(el);
const btnReady = !btn || (!btn.disabled && btn.getAttribute('aria-disabled') !== 'true'); const btnReady = !btn || (!btn.disabled && btn.getAttribute('aria-disabled') !== 'true');
const busy = document.querySelector( const scope = el?.closest('form, [data-testid="composer"], main, body') || document;
'[aria-busy="true"], [data-state="loading"], ' + const busy = scope.querySelector('[aria-busy="true"], [data-state="loading"]');
'button[disabled], button[aria-disabled="true"]'
);
if (el && btnReady && !busy) return true; if (el && btnReady && !busy) return true;
await ExecutionManager.delay(pollMs); await ExecutionManager.delay(pollMs);
} }
@ -918,8 +918,8 @@
repo: (v) => /^[\w\-\.]+(\/[\w\-\.]+)?$/.test(v), repo: (v) => /^[\w\-\.]+(\/[\w\-\.]+)?$/.test(v),
path: (v) => !v.includes('..') && !v.startsWith('/') && !v.includes('\\'), path: (v) => !v.includes('..') && !v.startsWith('/') && !v.includes('\\'),
action: (v) => Object.keys(REQUIRED_FIELDS).includes(v), action: (v) => Object.keys(REQUIRED_FIELDS).includes(v),
owner: (v) => !v || /^[\w\-]+$/.test(v), url: (v) => !v || /^https?:\/\/[^/\s]+(?:\/|$)/i.test(v),
url: (v) => !v || /^https?:\/\/.+\..+/.test(v), owner: (v) => !v || /^[\w\-.]+$/.test(v),
branch: (v) => v && v.length > 0 && !v.includes('..'), branch: (v) => v && v.length > 0 && !v.includes('..'),
source_branch:(v) => !v || (v.length > 0 && !v.includes('..')), source_branch:(v) => !v || (v.length > 0 && !v.includes('..')),
head: (v) => v && v.length > 0, head: (v) => v && v.length > 0,
@ -1068,26 +1068,85 @@
setConfig: (patch) => { Object.assign(CONFIG, patch||{}); saveConfig(CONFIG); }, setConfig: (patch) => { Object.assign(CONFIG, patch||{}); saveConfig(CONFIG); },
}; };
function attachRunAgainUI(containerEl, onRun) { // Replace the whole attachRunAgainUI with this per-command version (and keep a thin wrapper for back-compat)
if (containerEl.querySelector('.ai-rc-rerun')) return; 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'); const bar = document.createElement('div');
bar.className = 'ai-rc-rerun'; bar.className = 'ai-rc-rerun';
bar.style.cssText = 'margin:8px 0; display:flex; gap:8px; align-items:center;'; bar.style.cssText = 'margin:8px 0; display:flex; gap:8px; align-items:center; flex-wrap:wrap;';
const msg = document.createElement('span'); const msg = document.createElement('span');
msg.textContent = 'Already executed.'; msg.textContent = `Already executed. Re-run:`;
msg.style.cssText = 'flex:1; font-size:13px; opacity:.9;'; msg.style.cssText = 'font-size:13px; opacity:.9; margin-right:6px;';
const run = document.createElement('button'); bar.appendChild(msg);
run.textContent = 'Run again'; // "Run all again" button (optional legacy support)
run.style.cssText = 'padding:4px 10px; border:1px solid #374151; border-radius:4px; background:#1f2937; color:#e5e7eb;'; 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'); const dismiss = document.createElement('button');
dismiss.textContent = 'Dismiss'; dismiss.textContent = 'Dismiss';
dismiss.style.cssText = 'padding:4px 10px; border:1px solid #374151; border-radius:4px; background:#111827; color:#9ca3af;'; dismiss.style.cssText = 'padding:4px 10px; border:1px solid #374151; border-radius:4px; background:#111827; color:#9ca3af;';
run.onclick = (ev) => { RC_DEBUG?.flashBtn?.(ev.currentTarget, 'Running'); onRun(); }; dismiss.addEventListener('click', (ev) => { RC_DEBUG?.flashBtn?.(ev.currentTarget, 'Dismissed'); bar.remove(); });
dismiss.onclick = (ev) => { RC_DEBUG?.flashBtn?.(ev.currentTarget, 'Dismissed'); bar.remove(); }; bar.appendChild(dismiss);
bar.append(msg, run, dismiss);
containerEl.appendChild(bar); 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. // When resuming from pause, treat like a cold start & mark all currently-visible commands as processed.
// Adds "Run again" buttons so nothing auto-executes. // Adds "Run again" buttons so nothing auto-executes.
function markExistingHitsAsProcessed() { function markExistingHitsAsProcessed() {
@ -1104,7 +1163,11 @@
commandMonitor?.history?.markElement?.(el, idx + 1); commandMonitor?.history?.markElement?.(el, idx + 1);
}); });
attachRunAgainUI(el, () => { 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); const nowHits = findAllCommandsInMessage(el).slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE);
nowHits.forEach((h, i) => commandMonitor.enqueueCommand(el, h, i)); nowHits.forEach((h, i) => commandMonitor.enqueueCommand(el, h, i));
}); });
@ -1215,7 +1278,8 @@
return null; return null;
} }
function findSendButton() { function findSendButton(scopeEl) {
const scope = scopeEl?.closest('form, [data-testid="composer"], main') || document;
const selectors = [ const selectors = [
'button[data-testid="send-button"]', 'button[data-testid="send-button"]',
'button[aria-label*="Send"]', 'button[aria-label*="Send"]',
@ -2119,20 +2183,18 @@
if (withinColdStart || alreadyAll) { if (withinColdStart || alreadyAll) {
el.dataset.aiRcProcessed = '1'; el.dataset.aiRcProcessed = '1';
RC_DEBUG?.verbose('Skipping command(s) - ' + (withinColdStart ? 'page load (cold start)' : 'already executed in this conversation'), { RC_DEBUG?.verbose(
fingerprint: fingerprintElement(el).slice(0, 40) + '...', 'Skipping command(s) - ' +
commandCount: hits.length (withinColdStart ? 'page load (cold start)' : 'already executed in this conversation'),
}); { fingerprint: fingerprintElement(el).slice(0, 40) + '...', commandCount: hits.length }
);
attachRunAgainUI(el, () => { attachRunAgainPerCommand(el, hits.slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE), (idx) => {
el.dataset.aiRcProcessed = '1'; el.dataset.aiRcProcessed = '1';
const hit2 = findAllCommandsInMessage(el); const hit2 = findAllCommandsInMessage(el).slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE);
if (hit2.length) { const h = hit2[idx];
const capped = hit2.slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE); if (h) this.enqueueCommand(el, h, idx);
capped.forEach((h, i) => this.enqueueCommand(el, h, i));
}
}); });
skipped += hits.length; skipped += hits.length;
return; return;
} }
@ -2273,23 +2335,29 @@
} }
attachRetryUI(element, messageId) { attachRetryUI(element, messageId) {
if (element.querySelector('.ai-rc-rerun')) return; const all = findAllCommandsInMessage(element).slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE);
if (!all.length) return;
attachRunAgainUI(element, () => { // 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'; element.dataset.aiRcProcessed = '1';
const pick = all[idx]?.text;
// Parse sub-index from messageId like "...#3" if (!pick) return;
const m = /#(\d+)$/.exec(messageId); this.trackedMessages.delete(messageId);
const wantIdx = m ? Math.max(0, parseInt(m[1], 10) - 1) : 0; const newId = this.getReadableMessageId(element);
this.trackMessage(element, pick, newId);
const all = findAllCommandsInMessage(element);
const selected = all[wantIdx]?.text || all[0]?.text;
if (selected) {
this.trackedMessages.delete(messageId);
const newId = this.getReadableMessageId(element);
this.trackMessage(element, selected, newId);
}
}); });
// Highlight failed one
try {
const bar = element.querySelector('.ai-rc-rerun');
const btns = [...bar.querySelectorAll('button')].filter(b => /Run again \[#\d+\]/.test(b.textContent || ''));
const b = btns[failedIdx];
if (b) b.style.outline = '2px solid #ef4444';
} catch {}
} }