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
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { writeFile, readFile, unlink } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { injectWrapper, removeWrapper, MARKER_START, MARKER_END } from '../bin/cli.js';
describe('injectWrapper', () => {
const testFile = join(tmpdir(), `car-rc-test-${Date.now()}`);
afterEach(async () => { try { await unlink(testFile); } catch {} });
it('adds wrapper to empty file', async () => {
await writeFile(testFile, '');
await injectWrapper(testFile, '/path/to/launcher.js');
const content = await readFile(testFile, 'utf-8');
assert.ok(content.includes(MARKER_START));
assert.ok(content.includes(MARKER_END));
assert.ok(content.includes('/path/to/launcher.js'));
});
it('adds wrapper to file with existing content', async () => {
await writeFile(testFile, 'export PATH=$HOME/bin:$PATH\n');
await injectWrapper(testFile, '/path/to/launcher.js');
const content = await readFile(testFile, 'utf-8');
assert.ok(content.includes('export PATH'));
assert.ok(content.includes(MARKER_START));
});
it('replaces existing wrapper', async () => {
await writeFile(testFile, `before\n${MARKER_START}\nold stuff\n${MARKER_END}\nafter\n`);
await injectWrapper(testFile, '/new/path/launcher.js');
const content = await readFile(testFile, 'utf-8');
assert.ok(content.includes('/new/path'));
assert.ok(!content.includes('old stuff'));
assert.ok(content.includes('before'));
assert.ok(content.includes('after'));
});
});
describe('removeWrapper', () => {
const testFile = join(tmpdir(), `car-rm-test-${Date.now()}`);
afterEach(async () => { try { await unlink(testFile); } catch {} });
it('removes wrapper and preserves surrounding content', async () => {
await writeFile(testFile, `before\n${MARKER_START}\nwrapper stuff\n${MARKER_END}\nafter\n`);
await removeWrapper(testFile);
const content = await readFile(testFile, 'utf-8');
assert.ok(!content.includes(MARKER_START));
assert.ok(content.includes('before'));
assert.ok(content.includes('after'));
});
it('does nothing when no wrapper present', async () => {
await writeFile(testFile, 'just normal content\n');
await removeWrapper(testFile);
const content = await readFile(testFile, 'utf-8');
assert.equal(content, 'just normal content\n');
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { loadConfig, DEFAULT_CONFIG } from '../src/config.js';
describe('DEFAULT_CONFIG', () => {
it('has expected defaults', () => {
assert.equal(DEFAULT_CONFIG.maxRetries, 5);
assert.equal(DEFAULT_CONFIG.pollIntervalSeconds, 5);
assert.equal(DEFAULT_CONFIG.marginSeconds, 60);
assert.equal(DEFAULT_CONFIG.fallbackWaitHours, 5);
assert.equal(typeof DEFAULT_CONFIG.retryMessage, 'string');
assert.deepEqual(DEFAULT_CONFIG.customPatterns, []);
});
});
describe('loadConfig', () => {
it('returns defaults when no config file exists', async () => {
const config = await loadConfig('/nonexistent/path/.claude-auto-retry.json');
assert.deepEqual(config, DEFAULT_CONFIG);
});
it('merges partial config with defaults', async () => {
const { writeFile, unlink } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const f = join(tmpdir(), `car-test-${Date.now()}.json`);
await writeFile(f, JSON.stringify({ maxRetries: 10 }));
try {
const config = await loadConfig(f);
assert.equal(config.maxRetries, 10);
assert.equal(config.pollIntervalSeconds, 5);
} finally { await unlink(f); }
});
it('returns defaults for invalid JSON', async () => {
const { writeFile, unlink } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const f = join(tmpdir(), `car-test-${Date.now()}.json`);
await writeFile(f, 'not json{{{');
try {
const config = await loadConfig(f);
assert.deepEqual(config, DEFAULT_CONFIG);
} finally { await unlink(f); }
});
it('rejects string values and falls back to defaults', async () => {
const { writeFile, unlink } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const f = join(tmpdir(), `car-test-${Date.now()}.json`);
await writeFile(f, JSON.stringify({ maxRetries: "never", pollIntervalSeconds: "fast" }));
try {
const config = await loadConfig(f);
assert.equal(config.maxRetries, 5);
assert.equal(config.pollIntervalSeconds, 5);
} finally { await unlink(f); }
});
it('rejects negative numbers and falls back to defaults', async () => {
const { writeFile, unlink } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const f = join(tmpdir(), `car-test-${Date.now()}.json`);
await writeFile(f, JSON.stringify({ maxRetries: -1, marginSeconds: -10 }));
try {
const config = await loadConfig(f);
assert.equal(config.maxRetries, 5);
assert.equal(config.marginSeconds, 60);
} finally { await unlink(f); }
});
});
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { createLogger } from '../src/logger.js';
import { readFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
describe('createLogger', () => {
const testDir = join(tmpdir(), `car-logger-test-${Date.now()}`);
afterEach(async () => { await rm(testDir, { recursive: true, force: true }); });
it('creates log directory and writes log entry', async () => {
const logger = createLogger(testDir);
await logger.info('test message');
const today = new Date().toISOString().split('T')[0];
const content = await readFile(join(testDir, `${today}.log`), 'utf-8');
assert.ok(content.includes('test message'));
assert.ok(content.includes('[INFO]'));
});
it('includes timestamp in log entries', async () => {
const logger = createLogger(testDir);
await logger.info('timestamped');
const today = new Date().toISOString().split('T')[0];
const content = await readFile(join(testDir, `${today}.log`), 'utf-8');
assert.match(content, /\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]/);
});
it('supports warn and error levels', async () => {
const logger = createLogger(testDir);
await logger.warn('warning msg');
await logger.error('error msg');
const today = new Date().toISOString().split('T')[0];
const content = await readFile(join(testDir, `${today}.log`), 'utf-8');
assert.ok(content.includes('[WARN]'));
assert.ok(content.includes('[ERROR]'));
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { createMonitorState, processOneTick } from '../src/monitor.js';
import { DEFAULT_CONFIG } from '../src/config.js';
function mockTmux(paneContent = '', paneCommand = 'node') {
const t = {
_sent: [],
capturePane: async () => paneContent,
getPaneCommand: async () => paneCommand,
sendKeys: async (_p, text) => { t._sent.push(text); },
};
return t;
}
describe('processOneTick', () => {
it('returns monitoring when no rate limit', async () => {
const t = mockTmux('Normal output');
const s = createMonitorState();
assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'monitoring');
assert.equal(t._sent.length, 0);
});
it('enters waiting on rate limit', async () => {
const t = mockTmux('5-hour limit reached - resets 3pm (UTC)');
const s = createMonitorState();
assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'waiting');
assert.ok(s.waitUntil > Date.now());
});
it('exits when PID dead', async () => {
const t = mockTmux('5-hour limit reached - resets 3pm (UTC)');
const s = createMonitorState();
s.waitUntil = Date.now() - 1000; s.status = 'waiting';
assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => false), 'exit');
});
it('sends retry when wait expired and rate limit visible', async () => {
const t = mockTmux('5-hour limit reached - resets 3pm (UTC)');
const s = createMonitorState();
s.waitUntil = Date.now() - 1000; s.status = 'waiting';
assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'retried');
assert.equal(t._sent.length, 1);
assert.equal(s.attempts, 1);
});
it('skips when foreground is not claude', async () => {
const t = mockTmux('5-hour limit reached - resets 3pm (UTC)', 'vim');
const s = createMonitorState();
s.waitUntil = Date.now() - 1000; s.status = 'waiting';
assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'skipped-not-claude');
assert.equal(t._sent.length, 0);
});
it('resets counter when rate limit disappears', async () => {
const t = mockTmux('Claude is working normally');
const s = createMonitorState();
s.waitUntil = Date.now() - 1000; s.status = 'waiting'; s.attempts = 2;
assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'user-continued');
assert.equal(s.attempts, 0);
});
it('stops retrying after max attempts', 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');
});
});
+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);
});
});
+62
View File
@@ -0,0 +1,62 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { parseResetTime, calculateWaitMs } from '../src/time-parser.js';
describe('parseResetTime', () => {
it('parses "resets 3pm (Europe/Dublin)"', () => {
const r = parseResetTime('5-hour limit reached - resets 3pm (Europe/Dublin)');
assert.equal(r.hour, 15); assert.equal(r.minute, 0);
assert.equal(r.timezone, 'Europe/Dublin');
});
it('parses "resets at 2pm (America/New_York)"', () => {
const r = parseResetTime('Usage limit. Resets at 2pm (America/New_York)');
assert.equal(r.hour, 14); assert.equal(r.timezone, 'America/New_York');
});
it('parses "resets 15:30 (Asia/Kolkata)"', () => {
const r = parseResetTime('resets 15:30 (Asia/Kolkata)');
assert.equal(r.hour, 15); assert.equal(r.minute, 30);
});
it('parses 12pm as noon', () => {
const r = parseResetTime('resets 12pm (UTC)');
assert.equal(r.hour, 12);
});
it('parses 12am as midnight', () => {
const r = parseResetTime('resets 12am (UTC)');
assert.equal(r.hour, 0);
});
it('handles no timezone', () => {
const r = parseResetTime('resets 3pm');
assert.equal(r.hour, 15); assert.equal(r.timezone, null);
});
it('returns null for unparseable text', () => {
assert.equal(parseResetTime('some random text'), null);
});
});
describe('calculateWaitMs', () => {
it('returns positive wait for future time', () => {
const now = new Date();
const futureHour = (now.getUTCHours() + 2) % 24;
const wait = calculateWaitMs({ hour: futureHour, minute: 0, timezone: 'UTC' }, 60, 5, now);
assert.ok(wait > 0);
assert.ok(wait <= 3 * 3600_000);
});
it('adds margin seconds', () => {
const now = new Date();
const futureHour = (now.getUTCHours() + 1) % 24;
const w0 = calculateWaitMs({ hour: futureHour, minute: 0, timezone: 'UTC' }, 0, 5, now);
const w120 = calculateWaitMs({ hour: futureHour, minute: 0, timezone: 'UTC' }, 120, 5, now);
assert.ok(w120 - w0 >= 119_000 && w120 - w0 <= 121_000);
});
it('returns fallback when parsed is null', () => {
const wait = calculateWaitMs(null, 60, 5);
assert.ok(Math.abs(wait - (5 * 3600 + 60) * 1000) < 2000);
});
it('handles ambiguous hour by picking soonest future', () => {
const now = new Date('2026-03-18T13:00:00Z');
const wait = calculateWaitMs(
{ hour: 3, minute: 0, timezone: 'UTC', ambiguous: true }, 0, 5, now
);
assert.ok(wait > 0 && wait <= 3 * 3600_000);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { buildCaptureArgs, buildSendKeysArgs, buildDisplayArgs, parseTmuxVersion } from '../src/tmux.js';
describe('buildCaptureArgs', () => {
it('builds correct args', () => {
assert.deepEqual(buildCaptureArgs('%3', 200),
['capture-pane', '-t', '%3', '-p', '-S', '-200']);
});
});
describe('buildSendKeysArgs', () => {
it('builds correct args with Enter', () => {
assert.deepEqual(buildSendKeysArgs('%3', 'hello world'),
['send-keys', '-t', '%3', 'hello world', 'Enter']);
});
});
describe('buildDisplayArgs', () => {
it('builds correct args', () => {
assert.deepEqual(buildDisplayArgs('%3', '#{pane_current_command}'),
['display-message', '-t', '%3', '-p', '#{pane_current_command}']);
});
});
describe('parseTmuxVersion', () => {
it('parses "tmux 3.4"', () => { assert.equal(parseTmuxVersion('tmux 3.4'), 3.4); });
it('parses "tmux 2.1"', () => { assert.equal(parseTmuxVersion('tmux 2.1'), 2.1); });
it('returns 0 for unparseable', () => { assert.equal(parseTmuxVersion('not tmux'), 0); });
});