From db9f4f81af37e3f95cbe4c0198f5454f2f17ddc0 Mon Sep 17 00:00:00 2001 From: CheapestInference Date: Sat, 28 Mar 2026 10:58:16 +0100 Subject: [PATCH] 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 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/monitor.js | 41 ++++++++++++++---- src/patterns.js | 90 +++++++++++++++++++++++++++++++--------- src/time-parser.js | 49 ++++++++++++++++------ src/wrapper.sh | 9 +++- test/monitor.test.js | 22 +++++++++- test/patterns.test.js | 52 ++++++++++++++++++++++- test/time-parser.test.js | 23 ++++++++++ 7 files changed, 240 insertions(+), 46 deletions(-) diff --git a/src/monitor.js b/src/monitor.js index 389a0ea..377871f 100644 --- a/src/monitor.js +++ b/src/monitor.js @@ -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 diff --git a/src/patterns.js b/src/patterns.js index 31ddb67..c10b8df 100644 --- a/src/patterns.js +++ b/src/patterns.js @@ -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; } diff --git a/src/time-parser.js b/src/time-parser.js index e5aee19..4a49e0a 100644 --- a/src/time-parser.js +++ b/src/time-parser.js @@ -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. diff --git a/src/wrapper.sh b/src/wrapper.sh index ae7c77c..5cdf005 100644 --- a/src/wrapper.sh +++ b/src/wrapper.sh @@ -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 <<< diff --git a/test/monitor.test.js b/test/monitor.test.js index c055a8f..b50c828 100644 --- a/test/monitor.test.js +++ b/test/monitor.test.js @@ -39,6 +39,15 @@ describe('processOneTick', () => { assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'retried'); assert.equal(t._sent.length, 1); assert.equal(s.attempts, 1); + // Should stay in 'waiting' with a cooldown to let Claude process + assert.equal(s.status, 'waiting'); + assert.ok(s.waitUntil > Date.now()); + }); + it('detects multi-line TUI rate limit', async () => { + const t = mockTmux('⚠ You\'ve hit your limit\n· resets 3pm (UTC)'); + const s = createMonitorState(); + assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'waiting'); + assert.ok(s.waitUntil > Date.now()); }); it('skips when foreground is not claude', async () => { const t = mockTmux('5-hour limit reached - resets 3pm (UTC)', 'vim'); @@ -54,10 +63,21 @@ describe('processOneTick', () => { assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'user-continued'); assert.equal(s.attempts, 0); }); - it('stops retrying after max attempts', async () => { + it('stops retrying after max attempts and stays in waiting', async () => { const t = mockTmux('5-hour limit reached - resets 3pm (UTC)'); const s = createMonitorState(); s.waitUntil = Date.now() - 1000; s.status = 'waiting'; s.attempts = 5; assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'max-retries'); + // Should stay in 'waiting' to avoid re-detection loop + assert.equal(s.status, 'waiting'); + assert.ok(s.waitUntil > Date.now()); + }); + it('resets from max-retries when rate limit clears', async () => { + const t = mockTmux('Claude is working normally'); + const s = createMonitorState(); + s.waitUntil = Date.now() - 1000; s.status = 'waiting'; s.attempts = 10; + // Rate limit cleared → should detect user-continued before max-retries check + assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'user-continued'); + assert.equal(s.attempts, 0); }); }); diff --git a/test/patterns.test.js b/test/patterns.test.js index b95da30..039799e 100644 --- a/test/patterns.test.js +++ b/test/patterns.test.js @@ -57,8 +57,8 @@ describe('isRateLimited', () => { it('detects "hit the limit resets"', () => { assert.equal(isRateLimited('You hit the limit. Resets at 5pm'), true); }); - it('detects "limit resets in: 3 hours"', () => { - assert.equal(isRateLimited('limit resets in: 3 hours'), true); + it('detects "usage limit · resets in: 3 hours"', () => { + assert.equal(isRateLimited('usage limit · resets in: 3 hours'), true); }); }); @@ -79,4 +79,52 @@ describe('findRateLimitMessage', () => { it('returns null when no match', () => { assert.equal(findRateLimitMessage('normal output\nmore output'), null); }); + it('returns the resets line from multi-line TUI render', () => { + const text = '⚠ You\'ve hit your limit\n· resets 3pm (UTC)'; + assert.equal(findRateLimitMessage(text), '· resets 3pm (UTC)'); + }); + it('returns Resets line when limit and resets on different lines', () => { + const text = '5-hour limit reached\nResets at 3pm (UTC)'; + assert.ok(findRateLimitMessage(text).includes('3pm')); + }); +}); + +describe('isRateLimited (multi-line TUI renders)', () => { + it('detects limit + resets on separate lines', () => { + assert.ok(isRateLimited('⚠ You\'ve hit your limit\n· resets 3pm (UTC)')); + }); + it('detects box-drawing TUI format', () => { + const text = '╭──────────╮\n│ ⚠ You\'ve hit your limit │\n│ · resets 3pm │\n╰──────────╯'; + assert.ok(isRateLimited(text)); + }); + it('detects 5-hour limit + Resets on separate lines', () => { + assert.ok(isRateLimited('⚠ 5-hour limit reached\nResets at 3pm (UTC)')); + }); + it('detects middle-dot separated multi-line', () => { + assert.ok(isRateLimited('⚠ You\'ve hit your 5-hour limit\n· resets 3pm (Asia/Tbilisi)')); + }); + it('rejects limit + resets too far apart (>6 lines)', () => { + assert.equal(isRateLimited('hit your limit\n1\n2\n3\n4\n5\n6\n7\nresets 3pm'), false); + }); + it('rejects normal output with no rate limit keywords', () => { + assert.equal(isRateLimited('Working on your request\nHere is the code\nDone'), false); + }); +}); + +describe('stripAnsi (OSC sequences)', () => { + it('strips OSC hyperlinks (\\x1b]8;;url\\x1b\\\\)', () => { + const input = '\x1b]8;;https://example.com\x1b\\click here\x1b]8;;\x1b\\'; + assert.equal(stripAnsi(input), 'click here'); + }); + it('strips OSC window title (\\x1b]0;title\\x07)', () => { + assert.equal(stripAnsi('\x1b]0;My Terminal\x07hello'), 'hello'); + }); + it('strips OSC + CSI mixed sequences', () => { + const input = '\x1b]8;;url\x1b\\\x1b[33m5-hour limit reached - resets 3pm\x1b[0m\x1b]8;;\x1b\\'; + assert.equal(stripAnsi(input), '5-hour limit reached - resets 3pm'); + }); + it('rate limit detection works through OSC hyperlinks', () => { + const input = '\x1b]8;;link\x1b\\5-hour limit reached\x1b]8;;\x1b\\ - resets 3pm'; + assert.ok(isRateLimited(input)); + }); }); diff --git a/test/time-parser.test.js b/test/time-parser.test.js index 52ce600..74d903b 100644 --- a/test/time-parser.test.js +++ b/test/time-parser.test.js @@ -31,6 +31,21 @@ describe('parseResetTime', () => { it('returns null for unparseable text', () => { assert.equal(parseResetTime('some random text'), null); }); + it('parses "try again in 5 minutes" as relative time', () => { + const r = parseResetTime('try again in 5 minutes'); + assert.ok(r.relative); + assert.equal(r.waitMs, 5 * 60_000); + }); + it('parses "try again in 2 hours" as relative time', () => { + const r = parseResetTime('try again in 2 hours'); + assert.ok(r.relative); + assert.equal(r.waitMs, 2 * 3_600_000); + }); + it('parses "wait 30 mins" as relative time', () => { + const r = parseResetTime('wait 30 mins'); + assert.ok(r.relative); + assert.equal(r.waitMs, 30 * 60_000); + }); }); describe('calculateWaitMs', () => { @@ -59,4 +74,12 @@ describe('calculateWaitMs', () => { ); assert.ok(wait > 0 && wait <= 3 * 3600_000); }); + it('handles relative time correctly', () => { + const wait = calculateWaitMs({ relative: true, waitMs: 300_000 }, 60, 5); + assert.ok(Math.abs(wait - 360_000) < 2000); // 5 min + 60s margin + }); + it('falls back on invalid timezone', () => { + const wait = calculateWaitMs({ hour: 15, minute: 0, timezone: 'Invalid/Zone' }, 60, 5); + assert.ok(Math.abs(wait - (5 * 3600 + 60) * 1000) < 2000); // fallback + }); });