feat: claude-auto-retry v0.1.0

Auto-retry Claude Code sessions when hitting Anthropic subscription rate limits.
Uses tmux monitoring + send-keys to detect rate limit messages, wait for reset,
and send "continue" automatically. Zero dependencies, zero workflow change.

- Shell wrapper intercepts `claude` command transparently
- Background monitor polls tmux pane for rate limit patterns
- Timezone-aware reset time parsing with DST safety
- Safe send-keys with foreground process verification
- --print mode: buffers output, retries cleanly for pipes
- Config validation prevents bad values from causing crashes
- 59 tests, 0 dependencies

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
CheapestInference
2026-03-18 11:43:36 +01:00
commit 1aad1e0313
19 changed files with 1516 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { stripAnsi, isRateLimited, findRateLimitMessage } from '../src/patterns.js';
describe('stripAnsi', () => {
it('removes bold codes', () => {
assert.equal(stripAnsi('\x1b[1mlimit\x1b[0m'), 'limit');
});
it('removes color codes', () => {
assert.equal(stripAnsi('\x1b[31mred\x1b[0m'), 'red');
});
it('removes cursor positioning', () => {
assert.equal(stripAnsi('\x1b[2Jhello\x1b[H'), 'hello');
});
it('leaves plain text unchanged', () => {
assert.equal(stripAnsi('plain text'), 'plain text');
});
it('handles mixed content', () => {
assert.equal(
stripAnsi('5-hour \x1b[1mlimit\x1b[0m reached - resets 3pm'),
'5-hour limit reached - resets 3pm'
);
});
});
describe('isRateLimited', () => {
it('detects "5-hour limit reached"', () => {
assert.equal(isRateLimited('5-hour limit reached - resets 3pm'), true);
});
it('detects "usage limit" with reset', () => {
assert.equal(isRateLimited('Claude usage limit reached. Resets at 2pm'), true);
});
it('detects "out of extra usage"', () => {
assert.equal(isRateLimited("You're out of extra usage · resets 3pm"), true);
});
it('detects "try again in 5 hours"', () => {
assert.equal(isRateLimited('Please try again in 5 hours'), true);
});
it('detects "rate limit resets"', () => {
assert.equal(isRateLimited('Rate limit hit. Resets at 4pm'), true);
});
it('returns false for normal output', () => {
assert.equal(isRateLimited('I can help you with that code'), false);
});
it('returns false for empty string', () => {
assert.equal(isRateLimited(''), false);
});
it('detects rate limit with ANSI codes embedded', () => {
assert.equal(isRateLimited('5-hour \x1b[1mlimit\x1b[0m reached - resets 3pm'), true);
});
it('matches custom patterns', () => {
assert.equal(isRateLimited('custom error xyz', [/custom error/i]), true);
});
it('detects "You\'ve hit your limit" (real Claude Code message)', () => {
assert.equal(isRateLimited("You've hit your limit · resets 3pm (Asia/Tbilisi)"), true);
});
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);
});
});
describe('stripAnsi (private-mode sequences)', () => {
it('strips cursor hide sequence', () => {
assert.equal(stripAnsi('\x1b[?25lhello\x1b[?25h'), 'hello');
});
it('strips bracketed paste mode', () => {
assert.equal(stripAnsi('\x1b[?2004htext\x1b[?2004l'), 'text');
});
});
describe('findRateLimitMessage', () => {
it('returns the matching line from multiline input', () => {
const text = 'Some output\n5-hour limit reached - resets 3pm (Europe/Dublin)\nMore output';
assert.equal(findRateLimitMessage(text), '5-hour limit reached - resets 3pm (Europe/Dublin)');
});
it('returns null when no match', () => {
assert.equal(findRateLimitMessage('normal output\nmore output'), null);
});
});