fix: multi-line TUI detection, stale scrollback, and false positives

The rate limit detection was fundamentally broken because Claude Code
renders rate limit messages across multiple lines in its TUI (Ink/React),
but all regex patterns required keywords on a single line.

Key fixes:
- Split detection into LIMIT_PATTERNS + RESET_PATTERNS with a 6-line
  sliding window, so multi-line TUI renders are detected correctly
- Capture only last 20 lines instead of 200 to avoid stale scrollback
- Add 30s post-retry cooldown to prevent re-detecting old messages
- Fix max-retries infinite loop (stay in waiting, check clear first)
- Add OSC/DCS sequence stripping for complete ANSI handling
- Parse relative times ("try again in 5 minutes") instead of 5h fallback
- Handle invalid timezones gracefully with fallback
- Fix zombie monitor on pane destruction (exit after 10 consecutive errors)
- Replace setInterval with recursive setTimeout to prevent concurrent ticks
- Restore user shell traps instead of clobbering them
- Remove overly broad /\blimit\b/ pattern that caused false positives
This commit is contained in:
CheapestInference
2026-03-28 10:58:16 +01:00
parent d47529a41a
commit fb9755730d
7 changed files with 240 additions and 46 deletions
+32 -9
View File
@@ -13,23 +13,27 @@ export function createMonitorState() {
export async function processOneTick(state, tmuxAdapter, pane, config, isAlive) {
if (!isAlive()) return 'exit';
const raw = await tmuxAdapter.capturePane(pane);
const raw = await tmuxAdapter.capturePane(pane, 20);
const stripped = stripAnsi(raw);
if (state.status === 'waiting') {
if (Date.now() < state.waitUntil) return 'waiting';
if (!isAlive()) return 'exit';
if (state.attempts >= config.maxRetries) {
state.status = 'monitoring';
return 'max-retries';
}
// Use same window size for detection and recovery check to avoid asymmetry
// Always check if rate limit cleared FIRST — even when maxRetries
// exhausted, the user (or time passing) may have resolved it.
if (!isRateLimited(stripped, config.customPatterns)) {
state.status = 'monitoring'; state.attempts = 0;
return 'user-continued';
}
if (state.attempts >= config.maxRetries) {
// Stay in 'waiting' to avoid re-detecting the stale rate limit
// on the next tick and creating an infinite max-retries loop.
state.waitUntil = Date.now() + (config.pollIntervalSeconds * 1000 * 12);
return 'max-retries';
}
const fg = await tmuxAdapter.getPaneCommand(pane);
if (!CLAUDE_COMMANDS.some(c => fg.toLowerCase().includes(c))) {
// Push waitUntil forward to avoid tight-loop polling every tick
@@ -39,7 +43,11 @@ export async function processOneTick(state, tmuxAdapter, pane, config, isAlive)
await tmuxAdapter.sendKeys(pane, config.retryMessage);
state.attempts++;
state.status = 'monitoring';
// Stay in 'waiting' with a 30s cooldown instead of going to 'monitoring'.
// This gives Claude time to process the retry and produce enough output
// to push the stale rate-limit message out of the recent-lines window.
// Without this, the next tick re-detects the old message and waits 24h.
state.waitUntil = Date.now() + 30_000;
return 'retried';
}
@@ -59,6 +67,8 @@ export async function startMonitor(pane, pid) {
const config = await loadConfig();
const logger = createLogger();
const state = createMonitorState();
let consecutiveErrors = 0;
const MAX_CONSECUTIVE_ERRORS = 10;
await logger.info(`Monitor started for pane ${pane} (claude PID: ${pid})`);
@@ -68,6 +78,7 @@ export async function startMonitor(pane, pid) {
const loop = async () => {
try {
const result = await processOneTick(state, tmuxAdapter, pane, config, isAlive);
consecutiveErrors = 0;
if (result === 'exit') { await logger.info('Claude exited. Monitor shutting down.'); process.exit(0); }
if (result === 'waiting' && state.lastRateLimitMessage) {
@@ -80,12 +91,24 @@ export async function startMonitor(pane, pid) {
if (result === 'max-retries') await logger.warn(`Max retries (${config.maxRetries}) reached. Monitor still active but will not send further retries until rate limit clears.`);
if (result === 'skipped-not-claude') await logger.warn('Foreground is not Claude. Skipping send-keys.');
} catch (err) {
consecutiveErrors++;
await logger.error(`Monitor tick error: ${err.message}`).catch(() => {});
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
await logger.error(`${MAX_CONSECUTIVE_ERRORS} consecutive errors. Pane likely destroyed. Exiting.`).catch(() => {});
process.exit(1);
}
}
};
setInterval(loop, config.pollIntervalSeconds * 1000);
loop();
// Use recursive setTimeout instead of setInterval to prevent concurrent
// tick execution when a tick takes longer than the poll interval.
const scheduleNext = () => {
setTimeout(async () => {
await loop();
scheduleNext();
}, config.pollIntervalSeconds * 1000);
};
loop().then(scheduleNext);
}
// Direct execution: node monitor.js <pane> <pid>
+70 -20
View File
@@ -1,37 +1,87 @@
// Full CSI sequence range per ECMA-48: parameter/intermediate bytes (0x20-0x3f) + final byte (0x40-0x7e)
// Covers standard, private-mode (\x1b[?25h), and extended sequences
const ANSI_REGEX = /\x1b\[[\x20-\x3f]*[\x40-\x7e]/g;
const CSI_REGEX = /\x1b\[[\x20-\x3f]*[\x40-\x7e]/g;
// OSC sequences: \x1b] ... (terminated by BEL \x07 or ST \x1b\\)
// Covers hyperlinks (\x1b]8;;url\x1b\\), window titles (\x1b]0;title\x07), etc.
const OSC_REGEX = /\x1b\][\s\S]*?(?:\x07|\x1b\\)/g;
// DCS sequences: \x1bP ... ST
const DCS_REGEX = /\x1bP[\s\S]*?(?:\x07|\x1b\\)/g;
// APC, SOS, PM sequences: \x1b[_X^] ... ST
const OTHER_ESC_REGEX = /\x1b[_X^][\s\S]*?(?:\x07|\x1b\\)/g;
export function stripAnsi(text) {
return text.replace(ANSI_REGEX, '');
return text
.replace(OSC_REGEX, '')
.replace(DCS_REGEX, '')
.replace(OTHER_ESC_REGEX, '')
.replace(CSI_REGEX, '');
}
const DEFAULT_PATTERNS = [
/\d+-hour limit reached/i,
/limit reached.*resets?\s/i,
/usage limit.*resets?\s/i,
/out of.*usage.*resets?\s/i,
/try again in \d+\s*(hours?|minutes?|h|m)/i,
/rate limit.*resets?\s/i,
/hit.*(?:your|the)?\s*limit.*resets?\s/i,
/\blimit\b.*resets?\s+(?:at\s+|in[:\s])\s*\d/i,
// Claude Code renders rate limits across multiple lines in its TUI, e.g.:
// "⚠ You've hit your limit"
// "· resets 3pm (UTC)"
// Detection: find a "limit" line and a "resets" line within 6 lines of each other.
const LIMIT_PATTERNS = [
/(?:hit|exceeded|reached).*(?:your|the)\s*(?:\d+-hour\s+)?limit/i, // "hit/exceeded/reached your limit"
/\d+-hour limit/i, // "5-hour limit"
/limit reached/i, // "limit reached"
/usage limit/i, // "usage limit"
/out of.*usage/i, // "out of extra usage"
/rate limit/i, // "rate limit"
/try again in/i, // "try again in X hours" (implies rate limiting)
];
const RESET_PATTERNS = [
/resets?\s+(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?/i, // "resets 3pm" / "resets at 3:00 PM"
/resets?\s+in[:\s]\s*\d/i, // "resets in: 3 hours"
/try again in \d+\s*(?:hours?|minutes?|h|m)/i, // "try again in 5 hours"
];
const WINDOW = 6;
function hasNearbyMatch(lines, idx, patterns) {
const start = Math.max(0, idx - WINDOW);
const end = Math.min(lines.length, idx + WINDOW + 1);
for (let j = start; j < end; j++) {
if (patterns.some(p => p.test(lines[j]))) return true;
}
return false;
}
export function isRateLimited(text, customPatterns = []) {
const stripped = stripAnsi(text);
const patterns = [...DEFAULT_PATTERNS, ...customPatterns.map(p =>
typeof p === 'string' ? new RegExp(p, 'i') : p
)];
return patterns.some(pattern => pattern.test(stripped));
const lines = stripAnsi(text).split('\n');
// Custom patterns: check full text (user controls their own regex)
if (customPatterns.length > 0) {
const full = lines.join('\n');
const custom = customPatterns.map(p => typeof p === 'string' ? new RegExp(p, 'i') : p);
if (custom.some(p => p.test(full))) return true;
}
// Find a "limit" line with a "resets" line nearby (works for both
// single-line messages and multi-line TUI renders)
for (let i = 0; i < lines.length; i++) {
if (LIMIT_PATTERNS.some(p => p.test(lines[i]))) {
if (hasNearbyMatch(lines, i, RESET_PATTERNS)) return true;
}
}
return false;
}
export function findRateLimitMessage(text, customPatterns = []) {
const lines = stripAnsi(text).split('\n');
const patterns = [...DEFAULT_PATTERNS, ...customPatterns.map(p =>
typeof p === 'string' ? new RegExp(p, 'i') : p
)];
// Return the "resets" line — that's what parseResetTime needs
for (const line of lines) {
if (patterns.some(pattern => pattern.test(line))) return line.trim();
if (RESET_PATTERNS.some(p => p.test(line))) return line.trim();
}
// Fallback: any "limit" line
for (const line of lines) {
if (LIMIT_PATTERNS.some(p => p.test(line))) return line.trim();
}
return null;
}
+37 -12
View File
@@ -1,27 +1,52 @@
const RESET_TIME_REGEX = /resets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(([^)]+)\))?/i;
const RELATIVE_TIME_REGEX = /(?:try again|wait)\s+(?:for\s+)?(?:in\s+)?(\d+)\s*(hours?|minutes?|mins?|h|m)\b/i;
export function parseResetTime(text) {
const match = text.match(RESET_TIME_REGEX);
if (!match) return null;
// Try absolute time first: "resets at 3pm (UTC)"
const absMatch = text.match(RESET_TIME_REGEX);
if (absMatch) {
let hour = parseInt(absMatch[1], 10);
const minute = absMatch[2] ? parseInt(absMatch[2], 10) : 0;
const ampm = absMatch[3]?.toLowerCase() || null;
const timezone = absMatch[4] || null;
let hour = parseInt(match[1], 10);
const minute = match[2] ? parseInt(match[2], 10) : 0;
const ampm = match[3]?.toLowerCase() || null;
const timezone = match[4] || null;
if (ampm === 'pm' && hour !== 12) hour += 12;
if (ampm === 'am' && hour === 12) hour = 0;
if (ampm === 'pm' && hour !== 12) hour += 12;
if (ampm === 'am' && hour === 12) hour = 0;
const ambiguous = !ampm && hour >= 1 && hour <= 12;
return { hour, minute, timezone, ambiguous };
}
// Ambiguous only when no am/pm AND hour is 1-12 (not 0, which is unambiguous 24h midnight)
const ambiguous = !ampm && hour >= 1 && hour <= 12;
// Try relative time: "try again in 5 minutes" / "wait 2 hours"
const relMatch = text.match(RELATIVE_TIME_REGEX);
if (relMatch) {
const amount = parseInt(relMatch[1], 10);
const unit = relMatch[2].toLowerCase();
const isMinutes = unit.startsWith('m');
const ms = amount * (isMinutes ? 60_000 : 3_600_000);
return { relative: true, waitMs: ms };
}
return { hour, minute, timezone, ambiguous };
return null;
}
export function calculateWaitMs(parsed, marginSeconds = 60, fallbackHours = 5, now = new Date()) {
if (!parsed) return (fallbackHours * 3600 + marginSeconds) * 1000;
const tz = parsed.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
// Handle relative times: "try again in 5 minutes"
if (parsed.relative) {
return parsed.waitMs + marginSeconds * 1000;
}
let tz;
try {
tz = parsed.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
// Validate timezone early to avoid cryptic errors later
Intl.DateTimeFormat('en-US', { timeZone: tz });
} catch {
// Invalid timezone (possibly garbled by TUI capture) — use fallback
return (fallbackHours * 3600 + marginSeconds) * 1000;
}
// DST-safe approach: binary search for the correct UTC timestamp
// that corresponds to the given hour:minute in the target timezone.
+7 -2
View File
@@ -5,11 +5,16 @@ claude() {
return $?
fi
export CLAUDE_AUTO_RETRY_ACTIVE=1
trap 'unset CLAUDE_AUTO_RETRY_ACTIVE' EXIT INT TERM
local _car_old_int_trap _car_old_term_trap
_car_old_int_trap=$(trap -p INT)
_car_old_term_trap=$(trap -p TERM)
trap 'unset CLAUDE_AUTO_RETRY_ACTIVE' INT TERM
node "__LAUNCHER_PATH__" "$@"
local _car_exit=$?
unset CLAUDE_AUTO_RETRY_ACTIVE
trap - EXIT INT TERM
# Restore previous traps instead of clobbering them
eval "${_car_old_int_trap:-trap - INT}"
eval "${_car_old_term_trap:-trap - TERM}"
return $_car_exit
}
# <<< claude-auto-retry <<<