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