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
This commit is contained in:
CheapestInference
2026-03-18 11:43:36 +01:00
commit d47529a41a
19 changed files with 1516 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
export const DEFAULT_CONFIG = {
maxRetries: 5,
pollIntervalSeconds: 5,
marginSeconds: 60,
fallbackWaitHours: 5,
retryMessage: 'Continue where you left off. The previous attempt was rate limited.',
customPatterns: [],
};
const CONFIG_PATH = join(homedir(), '.claude-auto-retry.json');
function validNumber(val, min, fallback) {
return typeof val === 'number' && Number.isFinite(val) && val >= min ? val : fallback;
}
function validate(cfg) {
cfg.maxRetries = validNumber(cfg.maxRetries, 1, DEFAULT_CONFIG.maxRetries);
cfg.pollIntervalSeconds = validNumber(cfg.pollIntervalSeconds, 1, DEFAULT_CONFIG.pollIntervalSeconds);
cfg.marginSeconds = validNumber(cfg.marginSeconds, 0, DEFAULT_CONFIG.marginSeconds);
cfg.fallbackWaitHours = validNumber(cfg.fallbackWaitHours, 0.1, DEFAULT_CONFIG.fallbackWaitHours);
if (typeof cfg.retryMessage !== 'string' || !cfg.retryMessage) {
cfg.retryMessage = DEFAULT_CONFIG.retryMessage;
}
if (!Array.isArray(cfg.customPatterns)) {
cfg.customPatterns = DEFAULT_CONFIG.customPatterns;
}
return cfg;
}
export async function loadConfig(path = CONFIG_PATH) {
try {
const raw = await readFile(path, 'utf-8');
return validate({ ...DEFAULT_CONFIG, ...JSON.parse(raw) });
} catch {
return { ...DEFAULT_CONFIG };
}
}
+194
View File
@@ -0,0 +1,194 @@
import { spawn, fork } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { isInsideTmux, getCurrentPane, getTmuxVersion } from './tmux.js';
import { isRateLimited } from './patterns.js';
import { parseResetTime, calculateWaitMs } from './time-parser.js';
import { loadConfig } from './config.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const MONITOR_PATH = join(__dirname, 'monitor.js');
function findClaudeBinary() {
try {
return execFileSync('which', ['claude'], { encoding: 'utf-8' }).trim();
} catch {
return 'claude';
}
}
function isPrintMode(args) {
return args.includes('-p') || args.includes('--print');
}
function shellEscape(s) {
return "'" + s.replace(/'/g, "'\\''") + "'";
}
async function launchInteractive(args) {
const claudeBin = findClaudeBinary();
const pane = getCurrentPane();
const claude = spawn(claudeBin, args, {
stdio: 'inherit',
env: { ...process.env, CLAUDE_AUTO_RETRY_ACTIVE: '1' },
});
// Check spawn succeeded before using PID
if (claude.pid == null) {
claude.on('error', (err) => {
process.stderr.write(`[claude-auto-retry] Failed to start claude: ${err.message}\n`);
});
return new Promise((resolve) => {
claude.on('exit', (code) => resolve(code ?? 1));
claude.on('error', () => resolve(1));
});
}
// Forward SIGWINCH for terminal resize
process.on('SIGWINCH', () => {
try { claude.kill('SIGWINCH'); } catch {}
});
// Start monitor as detached background process
if (pane) {
const monitor = fork(MONITOR_PATH, [pane, String(claude.pid)], {
detached: true,
stdio: 'ignore',
});
monitor.unref();
}
// Forward signals to Claude
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
process.on(sig, () => {
try { claude.kill(sig); } catch {}
});
}
return new Promise((resolve) => {
claude.on('exit', (code) => resolve(code ?? 1));
});
}
async function launchPrintMode(args) {
const claudeBin = findClaudeBinary();
const config = await loadConfig();
let retries = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
const result = await new Promise((resolve) => {
const chunks = [];
const errChunks = [];
const claude = spawn(claudeBin, args, {
stdio: ['inherit', 'pipe', 'pipe'],
env: { ...process.env, CLAUDE_AUTO_RETRY_ACTIVE: '1' },
});
claude.stdout.on('data', (d) => chunks.push(d));
claude.stderr.on('data', (d) => errChunks.push(d));
claude.on('error', (err) => {
resolve({ code: 1, stdout: '', stderr: err.message });
});
claude.on('exit', (code) => {
resolve({
code: code ?? 1,
stdout: Buffer.concat(chunks).toString(),
stderr: Buffer.concat(errChunks).toString(),
});
});
});
const combined = result.stdout + result.stderr;
if (!isRateLimited(combined, config.customPatterns)) {
// Clean exit — write buffered output
process.stdout.write(result.stdout);
process.stderr.write(result.stderr);
return result.code;
}
// Rate limited — discard buffer, wait and retry
retries++;
if (retries >= config.maxRetries) {
process.stderr.write(`[claude-auto-retry] Max retries (${config.maxRetries}) reached.\n`);
return 1;
}
const parsed = parseResetTime(combined);
const waitMs = calculateWaitMs(parsed, config.marginSeconds, config.fallbackWaitHours);
process.stderr.write(`[claude-auto-retry] Rate limited. Waiting ${Math.round(waitMs / 1000)}s before retry ${retries}/${config.maxRetries}...\n`);
await new Promise((r) => setTimeout(r, waitMs));
}
}
async function createTmuxSession(args) {
const sessionName = `claude-retry-${process.pid}-${Date.now()}`;
const launcherPath = __filename;
// Build the command to run inside tmux
const escapedLauncher = shellEscape(launcherPath);
const escapedArgs = args.map(a => shellEscape(a)).join(' ');
const innerCmd = `CLAUDE_AUTO_RETRY_ACTIVE=1 node ${escapedLauncher} ${escapedArgs}; exec bash`;
// Build env propagation args
// tmux -e flag requires tmux >= 3.0; for older versions, prefix env exports in the command
const tmuxVer = getTmuxVersion();
let newSessionArgs;
if (tmuxVer >= 3.0) {
const envArgs = [];
for (const [k, v] of Object.entries(process.env)) {
if (k.startsWith('TMUX')) continue;
if (v == null) continue;
envArgs.push('-e', `${k}=${v}`);
}
newSessionArgs = ['new-session', '-d', '-s', sessionName, ...envArgs, innerCmd];
} else {
// For tmux < 3.0: export critical env vars inline in the command
const criticalVars = ['PATH', 'HOME', 'USER', 'SHELL', 'TERM', 'LANG',
'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'HTTP_PROXY', 'HTTPS_PROXY',
'NO_PROXY', 'NODE_OPTIONS', 'NVM_DIR', 'NODE_PATH'];
const exports = criticalVars
.filter(k => process.env[k])
.map(k => `export ${k}=${shellEscape(process.env[k])}`)
.join('; ');
const fullCmd = exports ? `${exports}; ${innerCmd}` : innerCmd;
newSessionArgs = ['new-session', '-d', '-s', sessionName, fullCmd];
}
try {
execFileSync('tmux', newSessionArgs);
// Attach to the session
const attachResult = spawn('tmux', ['attach-session', '-t', sessionName], {
stdio: 'inherit',
});
return new Promise((resolve) => {
attachResult.on('exit', (code) => resolve(code ?? 0));
attachResult.on('error', () => resolve(1));
});
} catch (err) {
process.stderr.write(`[claude-auto-retry] Failed to create tmux session: ${err.message}\n`);
return 1;
}
}
// Main
const args = process.argv.slice(2);
let exitCode;
if (isPrintMode(args)) {
exitCode = await launchPrintMode(args);
} else if (isInsideTmux()) {
exitCode = await launchInteractive(args);
} else {
exitCode = await createTmuxSession(args);
}
process.exit(exitCode);
+47
View File
@@ -0,0 +1,47 @@
import { appendFile, mkdir, readdir, unlink, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
const DEFAULT_LOG_DIR = join(homedir(), '.claude-auto-retry', 'logs');
const MAX_AGE_DAYS = 7;
const CLEANUP_INTERVAL_MS = 3600_000;
let lastCleanup = 0;
function timestamp() {
return new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, '');
}
function todayFile(dir) {
return join(dir, `${new Date().toISOString().split('T')[0]}.log`);
}
async function cleanup(dir) {
if (Date.now() - lastCleanup < CLEANUP_INTERVAL_MS) return;
lastCleanup = Date.now();
try {
const files = await readdir(dir);
const cutoff = Date.now() - MAX_AGE_DAYS * 86400_000;
for (const file of files) {
if (!file.endsWith('.log')) continue;
const s = await stat(join(dir, file));
if (s.mtimeMs < cutoff) await unlink(join(dir, file));
}
} catch { /* ignore */ }
}
export function createLogger(dir = DEFAULT_LOG_DIR) {
let dirCreated = false;
async function ensureDir() {
if (!dirCreated) { await mkdir(dir, { recursive: true }); dirCreated = true; }
}
async function log(level, message) {
await ensureDir();
await appendFile(todayFile(dir), `[${timestamp()}] [${level}] ${message}\n`);
cleanup(dir);
}
return {
info: (msg) => log('INFO', msg),
warn: (msg) => log('WARN', msg),
error: (msg) => log('ERROR', msg),
};
}
+95
View File
@@ -0,0 +1,95 @@
import { stripAnsi, isRateLimited, findRateLimitMessage } from './patterns.js';
import { parseResetTime, calculateWaitMs } from './time-parser.js';
import { capturePane, sendKeys, getPaneCommand } from './tmux.js';
import { loadConfig } from './config.js';
import { createLogger } from './logger.js';
const CLAUDE_COMMANDS = ['node', 'claude'];
export function createMonitorState() {
return { status: 'monitoring', waitUntil: 0, attempts: 0, lastRateLimitMessage: null };
}
export async function processOneTick(state, tmuxAdapter, pane, config, isAlive) {
if (!isAlive()) return 'exit';
const raw = await tmuxAdapter.capturePane(pane);
const stripped = stripAnsi(raw);
if (state.status === 'waiting') {
if (Date.now() < state.waitUntil) return 'waiting';
if (!isAlive()) return 'exit';
if (state.attempts >= config.maxRetries) {
state.status = 'monitoring';
return 'max-retries';
}
// Use same window size for detection and recovery check to avoid asymmetry
if (!isRateLimited(stripped, config.customPatterns)) {
state.status = 'monitoring'; state.attempts = 0;
return 'user-continued';
}
const fg = await tmuxAdapter.getPaneCommand(pane);
if (!CLAUDE_COMMANDS.some(c => fg.toLowerCase().includes(c))) {
// Push waitUntil forward to avoid tight-loop polling every tick
state.waitUntil = Date.now() + (config.pollIntervalSeconds * 1000 * 6);
return 'skipped-not-claude';
}
await tmuxAdapter.sendKeys(pane, config.retryMessage);
state.attempts++;
state.status = 'monitoring';
return 'retried';
}
if (isRateLimited(stripped, config.customPatterns)) {
const message = findRateLimitMessage(stripped, config.customPatterns);
state.lastRateLimitMessage = message;
const parsed = message ? parseResetTime(message) : null;
state.waitUntil = Date.now() + calculateWaitMs(parsed, config.marginSeconds, config.fallbackWaitHours);
state.status = 'waiting';
return 'waiting';
}
return 'monitoring';
}
export async function startMonitor(pane, pid) {
const config = await loadConfig();
const logger = createLogger();
const state = createMonitorState();
await logger.info(`Monitor started for pane ${pane} (claude PID: ${pid})`);
const tmuxAdapter = { capturePane, sendKeys, getPaneCommand };
const isAlive = () => { try { process.kill(pid, 0); return true; } catch { return false; } };
const loop = async () => {
try {
const result = await processOneTick(state, tmuxAdapter, pane, config, isAlive);
if (result === 'exit') { await logger.info('Claude exited. Monitor shutting down.'); process.exit(0); }
if (result === 'waiting' && state.lastRateLimitMessage) {
const secs = Math.round((state.waitUntil - Date.now()) / 1000);
await logger.info(`Rate limit detected: "${state.lastRateLimitMessage}". Waiting ${secs}s...`);
state.lastRateLimitMessage = null;
}
if (result === 'retried') await logger.info(`Sent retry message (attempt ${state.attempts})`);
if (result === 'user-continued') await logger.info('User already continued. Attempt counter reset.');
if (result === 'max-retries') await logger.warn(`Max retries (${config.maxRetries}) reached. Monitor still active but will not send further retries until rate limit clears.`);
if (result === 'skipped-not-claude') await logger.warn('Foreground is not Claude. Skipping send-keys.');
} catch (err) {
await logger.error(`Monitor tick error: ${err.message}`).catch(() => {});
}
};
setInterval(loop, config.pollIntervalSeconds * 1000);
loop();
}
// Direct execution: node monitor.js <pane> <pid>
const isDirectRun = process.argv[1]?.endsWith('monitor.js') && process.argv.length >= 4;
if (isDirectRun) {
startMonitor(process.argv[2], parseInt(process.argv[3], 10));
}
+37
View File
@@ -0,0 +1,37 @@
// Full CSI sequence range per ECMA-48: parameter/intermediate bytes (0x20-0x3f) + final byte (0x40-0x7e)
// Covers standard, private-mode (\x1b[?25h), and extended sequences
const ANSI_REGEX = /\x1b\[[\x20-\x3f]*[\x40-\x7e]/g;
export function stripAnsi(text) {
return text.replace(ANSI_REGEX, '');
}
const DEFAULT_PATTERNS = [
/\d+-hour limit reached/i,
/limit reached.*resets?\s/i,
/usage limit.*resets?\s/i,
/out of.*usage.*resets?\s/i,
/try again in \d+\s*(hours?|minutes?|h|m)/i,
/rate limit.*resets?\s/i,
/hit.*(?:your|the)?\s*limit.*resets?\s/i,
/\blimit\b.*resets?\s+(?:at\s+|in[:\s])\s*\d/i,
];
export function isRateLimited(text, customPatterns = []) {
const stripped = stripAnsi(text);
const patterns = [...DEFAULT_PATTERNS, ...customPatterns.map(p =>
typeof p === 'string' ? new RegExp(p, 'i') : p
)];
return patterns.some(pattern => pattern.test(stripped));
}
export function findRateLimitMessage(text, customPatterns = []) {
const lines = stripAnsi(text).split('\n');
const patterns = [...DEFAULT_PATTERNS, ...customPatterns.map(p =>
typeof p === 'string' ? new RegExp(p, 'i') : p
)];
for (const line of lines) {
if (patterns.some(pattern => pattern.test(line))) return line.trim();
}
return null;
}
+81
View File
@@ -0,0 +1,81 @@
const RESET_TIME_REGEX = /resets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(([^)]+)\))?/i;
export function parseResetTime(text) {
const match = text.match(RESET_TIME_REGEX);
if (!match) return null;
let hour = parseInt(match[1], 10);
const minute = match[2] ? parseInt(match[2], 10) : 0;
const ampm = match[3]?.toLowerCase() || null;
const timezone = match[4] || null;
if (ampm === 'pm' && hour !== 12) hour += 12;
if (ampm === 'am' && hour === 12) hour = 0;
// Ambiguous only when no am/pm AND hour is 1-12 (not 0, which is unambiguous 24h midnight)
const ambiguous = !ampm && hour >= 1 && hour <= 12;
return { hour, minute, timezone, ambiguous };
}
export function calculateWaitMs(parsed, marginSeconds = 60, fallbackHours = 5, now = new Date()) {
if (!parsed) return (fallbackHours * 3600 + marginSeconds) * 1000;
const tz = parsed.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
// DST-safe approach: binary search for the correct UTC timestamp
// that corresponds to the given hour:minute in the target timezone.
function getTargetTimestamp(h, m) {
// Get today's date in the target timezone
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
hour12: false,
}).formatToParts(now);
const y = parseInt(parts.find(p => p.type === 'year').value);
const mo = parseInt(parts.find(p => p.type === 'month').value) - 1;
const d = parseInt(parts.find(p => p.type === 'day').value);
// Construct target date string and parse as UTC as initial guess
const targetStr = `${y}-${String(mo + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}T${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00`;
const naiveUtc = new Date(targetStr + 'Z');
// Iterative correction: format the guess in the target TZ,
// compare with desired h:m, adjust, repeat up to 3 times for DST convergence
let candidate = naiveUtc.getTime();
for (let i = 0; i < 3; i++) {
const check = new Date(candidate);
const fp = new Intl.DateTimeFormat('en-US', {
timeZone: tz, hour: 'numeric', minute: 'numeric', hour12: false,
}).formatToParts(check);
const ch = parseInt(fp.find(p => p.type === 'hour').value) % 24;
const cm = parseInt(fp.find(p => p.type === 'minute').value);
const diffMin = (h - ch) * 60 + (m - cm);
if (diffMin === 0) break;
candidate += diffMin * 60_000;
}
return candidate;
}
if (parsed.ambiguous) {
const t1 = getTargetTimestamp(parsed.hour, parsed.minute);
const t2 = getTargetTimestamp(parsed.hour + 12, parsed.minute);
const d1 = t1 - now.getTime();
const d2 = t2 - now.getTime();
let target;
if (d1 > 0 && d2 > 0) target = Math.min(d1, d2);
else if (d1 > 0) target = d1;
else if (d2 > 0) target = d2;
else target = d1 + 86400_000; // tomorrow
return Math.max(0, target) + marginSeconds * 1000;
}
let diff = getTargetTimestamp(parsed.hour, parsed.minute) - now.getTime();
if (diff < 0) diff += 86400_000; // tomorrow
return diff + marginSeconds * 1000;
}
+44
View File
@@ -0,0 +1,44 @@
import { execFileSync, execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFileCb);
export function buildCaptureArgs(pane, lines = 200) {
return ['capture-pane', '-t', pane, '-p', '-S', `-${lines}`];
}
export function buildSendKeysArgs(pane, text) {
return ['send-keys', '-t', pane, text, 'Enter'];
}
export function buildDisplayArgs(pane, format) {
return ['display-message', '-t', pane, '-p', format];
}
export function parseTmuxVersion(versionString) {
const match = versionString.match(/tmux\s+(\d+\.\d+)/);
return match ? parseFloat(match[1]) : 0;
}
export function getTmuxVersion() {
try {
return parseTmuxVersion(execFileSync('tmux', ['-V'], { encoding: 'utf-8' }).trim());
} catch { return 0; }
}
export async function capturePane(pane, lines = 200) {
const { stdout } = await execFileAsync('tmux', buildCaptureArgs(pane, lines));
return stdout;
}
export async function sendKeys(pane, text) {
await execFileAsync('tmux', buildSendKeysArgs(pane, text));
}
export async function getPaneCommand(pane) {
const { stdout } = await execFileAsync('tmux', buildDisplayArgs(pane, '#{pane_current_command}'));
return stdout.trim();
}
export function isInsideTmux() { return !!process.env.TMUX; }
export function getCurrentPane() { return process.env.TMUX_PANE || null; }
+15
View File
@@ -0,0 +1,15 @@
# >>> claude-auto-retry >>>
claude() {
if [ "${CLAUDE_AUTO_RETRY_ACTIVE}" = "1" ]; then
command claude "$@"
return $?
fi
export CLAUDE_AUTO_RETRY_ACTIVE=1
trap 'unset CLAUDE_AUTO_RETRY_ACTIVE' EXIT INT TERM
node "__LAUNCHER_PATH__" "$@"
local _car_exit=$?
unset CLAUDE_AUTO_RETRY_ACTIVE
trap - EXIT INT TERM
return $_car_exit
}
# <<< claude-auto-retry <<<