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');
});
});