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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user