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) <noreply@anthropic.com>
This commit is contained in:
+21
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+50
-2
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user