diff --git a/src/ai-repo-commander.user.js b/src/ai-repo-commander.user.js
index 4f54af0..af99a04 100644
--- a/src/ai-repo-commander.user.js
+++ b/src/ai-repo-commander.user.js
@@ -63,8 +63,10 @@
// Paste + submit behavior
APPEND_TRAILING_NEWLINE: true,
AUTO_SUBMIT: true,
- POST_PASTE_DELAY_MS: 250,
+ 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;
@@ -81,12 +83,14 @@
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: 800,
+ QUEUE_MIN_DELAY_MS: 1500,
QUEUE_MAX_PER_MINUTE: 15,
QUEUE_MAX_PER_MESSAGE: 5,
- QUEUE_WAIT_FOR_COMPOSER_MS: 6000,
+ QUEUE_WAIT_FOR_COMPOSER_MS: 12000,
RESPONSE_BUFFER_FLUSH_DELAY_MS: 500, // wait for siblings to finish
RESPONSE_BUFFER_SECTION_HEADINGS: true,
@@ -112,6 +116,11 @@
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;
@@ -826,6 +835,90 @@
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;
@@ -851,10 +944,28 @@
}
const el = getVisibleInputCandidate();
const btn = findSendButton(el);
- const btnReady = !btn || (!btn.disabled && btn.getAttribute('aria-disabled') !== 'true');
+ 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;
- const busy = scope.querySelector('[aria-busy="true"], [data-state="loading"]');
- if (el && btnReady && !busy) return true;
+
+ // 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');
@@ -1329,10 +1440,19 @@
async function submitComposer() {
try {
- const btn = findSendButton();
- if (CONFIG.SUBMIT_MODE !== 'enter_only' && btn) { btn.click(); return true; }
+ // 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;
@@ -1384,12 +1504,11 @@
// Pad with blank lines before/after to preserve ``` fences visually.
const payload2 = `\n${payload.replace(/\n?$/, '\n')}\n`;
- const escape = (s) => s.replace(/&/g,'&').replace(//g,'>');
-
- el.innerHTML = String(payload2)
- .split('\n')
- .map(line => line.length ? `
${escape(line)}
` : '
')
- .join('');
+
+ // 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');
@@ -1478,34 +1597,90 @@
}
}
- async function pasteAndMaybeSubmit(text) {
- const ready = await waitForComposerReady({ timeoutMs: CONFIG.QUEUE_WAIT_FOR_COMPOSER_MS });
- if (!ready) {
- RC_DEBUG?.warn('Composer not ready; re-queueing paste');
- execQueue.push(async () => { await pasteAndMaybeSubmit(text); });
+ 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;
}
- 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)
+ // 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
});
- } catch (e) {
- RC_DEBUG?.warn('Could not read composer content', { error: String(e) });
+ setTimeout(() => {
+ execQueue.push(async () => {
+ await pasteAndMaybeSubmit(text, attempt + 1, startedAt, submitRetry);
+ });
+ }, backoffMs);
+ return false;
+ }
+
+ // 4) Only paste if text is non-empty (enables submit-only retries)
+ if (text && text.length > 0) {
+ const pasted = pasteToComposer(text);
+ if (!pasted) return false;
+
+ try {
+ const el = getVisibleInputCandidate();
+ const actualContent = el?.textContent || el?.value || '[no content found]';
+ RC_DEBUG?.info('📋 Content in composer after paste', {
+ expectedLength: text.length,
+ actualLength: actualContent.length,
+ actualPreview: actualContent.substring(0, 200)
+ });
+ } catch (e) {
+ RC_DEBUG?.warn('Could not read composer content', { error: String(e) });
+ }
}
if (!CONFIG.AUTO_SUBMIT) return true;
+
+ // 5) After paste, wait POST_PASTE_DELAY_MS
await ExecutionManager.delay(CONFIG.POST_PASTE_DELAY_MS);
+
+ // 6) Try submitComposer()
const ok = await submitComposer();
if (!ok) {
- GM_notification({ title: 'AI Repo Commander', text: 'Pasted content, but auto-submit did not trigger.', timeout: 4000 });
+ // 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;
}
@@ -1743,7 +1918,7 @@
if (command.action === 'get_file') {
const body = this._extractGetFileBody(data);
if (typeof body === 'string' && body.length) {
- new ResponseBuffer().push({ label, content: body });
+ (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 });
}
@@ -1753,10 +1928,10 @@
const files = this._extractFilesArray(data);
if (files && files.length) {
const listing = this._formatFilesListing(files);
- new ResponseBuffer().push({ label, content: listing });
+ (window.AI_REPO_RESPONSES || new ResponseBuffer()).push({ label, content: listing });
} else {
const fallback = '```json\n' + JSON.stringify(data, null, 2) + '\n```';
- new ResponseBuffer().push({ label, content: fallback });
+ (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.',
@@ -1765,6 +1940,15 @@
}
}
+ // 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 };
}
@@ -2070,6 +2254,7 @@
// 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) {
@@ -2115,7 +2300,33 @@
if (shouldScan) break;
}
- if (shouldScan) {
+ // 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();
}
@@ -2256,7 +2467,27 @@
const subId = `${messageId}#${idx + 1}`;
execQueue.push(async () => {
- const finalTxt = hit.text; // <<< ADD THIS
+ // 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);