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:
+32
-9
@@ -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
@@ -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
@@ -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
@@ -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 <<<
|
||||
|
||||
Reference in New Issue
Block a user