Files
CheapestInference 1aad1e0313 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>
2026-03-18 11:43:36 +01:00

226 lines
7.6 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { readFile, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { execFileSync, spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const SRC_DIR = join(__dirname, '..', 'src');
const LAUNCHER_PATH = join(SRC_DIR, 'launcher.js');
const WRAPPER_TEMPLATE = join(SRC_DIR, 'wrapper.sh');
export const MARKER_START = '# >>> claude-auto-retry >>>';
export const MARKER_END = '# <<< claude-auto-retry <<<';
// --- Wrapper injection ---
export async function injectWrapper(rcFile, launcherPath) {
let content = '';
try {
content = await readFile(rcFile, 'utf-8');
} catch {
// File doesn't exist, create it
}
const template = await readFile(WRAPPER_TEMPLATE, 'utf-8');
const wrapper = template.replace(/__LAUNCHER_PATH__/g, launcherPath);
// Remove existing wrapper if present
const startIdx = content.indexOf(MARKER_START);
const endIdx = content.indexOf(MARKER_END);
if (startIdx !== -1 && endIdx !== -1) {
const afterMarker = endIdx + MARKER_END.length;
// Skip the newline after MARKER_END if present, but don't blindly +1
const skipTo = content[afterMarker] === '\n' ? afterMarker + 1
: content.slice(afterMarker, afterMarker + 2) === '\r\n' ? afterMarker + 2
: afterMarker;
content = content.slice(0, startIdx) + content.slice(skipTo);
}
content = content.trimEnd() + '\n\n' + wrapper + '\n';
await writeFile(rcFile, content);
}
export async function removeWrapper(rcFile) {
let content;
try {
content = await readFile(rcFile, 'utf-8');
} catch {
return;
}
const startIdx = content.indexOf(MARKER_START);
const endIdx = content.indexOf(MARKER_END);
if (startIdx === -1 || endIdx === -1) return;
const before = content.slice(0, startIdx).trimEnd();
const after = content.slice(endIdx + MARKER_END.length).trimStart();
content = before + (after ? '\n' + after : '\n');
await writeFile(rcFile, content);
}
// --- tmux install ---
function detectOS() {
if (process.platform === 'darwin') return 'macos';
try {
const release = execFileSync('cat', ['/etc/os-release'], { encoding: 'utf-8' });
if (release.includes('ID=ubuntu') || release.includes('ID=debian') || release.includes('ID_LIKE=debian')) return 'debian';
if (release.includes('ID=fedora') || release.includes('ID=rhel') || release.includes('ID=centos')
|| release.includes('ID=rocky') || release.includes('ID="amzn"')
|| release.includes('ID_LIKE="rhel') || release.includes('ID_LIKE=rhel')) return 'rhel';
if (release.includes('ID=arch') || release.includes('ID_LIKE=arch')) return 'arch';
if (release.includes('ID=alpine')) return 'alpine';
} catch {}
return 'unknown';
}
function installTmux() {
const os = detectOS();
const cmds = {
debian: ['sudo', ['apt-get', 'install', '-y', 'tmux']],
rhel: ['sudo', ['dnf', 'install', '-y', 'tmux']],
arch: ['sudo', ['pacman', '-S', '--noconfirm', 'tmux']],
alpine: ['sudo', ['apk', 'add', 'tmux']],
macos: ['brew', ['install', 'tmux']],
};
const entry = cmds[os];
if (!entry) {
console.error('Could not detect OS. Please install tmux manually.');
process.exit(1);
}
console.log(`Installing tmux...`);
try {
execFileSync(entry[0], entry[1], { stdio: 'inherit' });
} catch {
console.error('Failed to install tmux. Please install it manually.');
process.exit(1);
}
}
function checkTmux() {
try {
const version = execFileSync('tmux', ['-V'], { encoding: 'utf-8' }).trim();
const match = version.match(/tmux\s+(\d+\.\d+)/);
if (match && parseFloat(match[1]) >= 2.1) return true;
console.error(`tmux version ${match?.[1] || 'unknown'} is too old. Requires >= 2.1.`);
return false;
} catch {
return false;
}
}
// --- CLI commands ---
async function cmdInstall() {
console.log('claude-auto-retry: installing...\n');
if (!checkTmux()) {
console.log('tmux not found or too old. Attempting install...');
installTmux();
if (!checkTmux()) { console.error('tmux install failed.'); process.exit(1); }
}
console.log('tmux OK');
const shell = process.env.SHELL || '/bin/bash';
if (shell.includes('fish')) {
console.error('\nFish shell detected. Automatic install not supported.');
console.error(`Add manually to ~/.config/fish/config.fish:`);
console.error(` function claude; set -x CLAUDE_AUTO_RETRY_ACTIVE 1; node "${LAUNCHER_PATH}" $argv; set -e CLAUDE_AUTO_RETRY_ACTIVE; end`);
process.exit(1);
}
const rcFiles = [];
const bashrc = join(homedir(), '.bashrc');
const zshrc = join(homedir(), '.zshrc');
if (existsSync(bashrc) || shell.includes('bash')) rcFiles.push(bashrc);
if (existsSync(zshrc) || shell.includes('zsh')) rcFiles.push(zshrc);
if (rcFiles.length === 0) rcFiles.push(bashrc);
for (const rc of rcFiles) {
await injectWrapper(rc, LAUNCHER_PATH);
console.log(`Shell function added to ${rc}`);
}
console.log(`\nInstalled! Launcher path: ${LAUNCHER_PATH}`);
console.log('\nRestart your shell or run:');
for (const rc of rcFiles) { console.log(` source ${rc}`); }
console.log('\nNote: If you switch Node versions (nvm), re-run: claude-auto-retry install');
}
async function cmdUninstall() {
const bashrc = join(homedir(), '.bashrc');
const zshrc = join(homedir(), '.zshrc');
for (const rc of [bashrc, zshrc]) { await removeWrapper(rc); }
console.log('Shell function removed. Restart your shell to complete.');
}
async function cmdStatus() {
const logDir = join(homedir(), '.claude-auto-retry', 'logs');
const today = new Date().toISOString().split('T')[0];
const logFile = join(logDir, `${today}.log`);
try {
const content = await readFile(logFile, 'utf-8');
const lines = content.trim().split('\n');
console.log(`Log file: ${logFile}\n`);
console.log('Last 10 entries:');
console.log(lines.slice(-10).join('\n'));
} catch {
console.log('No activity today. Log directory:', logDir);
}
}
async function cmdLogs() {
const logDir = join(homedir(), '.claude-auto-retry', 'logs');
const today = new Date().toISOString().split('T')[0];
const logFile = join(logDir, `${today}.log`);
if (!existsSync(logFile)) {
console.log(`No log file for today: ${logFile}`);
return;
}
const tail = spawn('tail', ['-f', logFile], { stdio: 'inherit' });
tail.on('error', (err) => {
console.error(`Failed to tail log: ${err.message}`);
});
await new Promise((resolve) => {
tail.on('exit', resolve);
tail.on('error', resolve);
});
}
async function cmdVersion() {
try {
const pkg = JSON.parse(await readFile(join(__dirname, '..', 'package.json'), 'utf-8'));
console.log(pkg.version);
} catch {
console.log('unknown');
}
}
// --- Main ---
const command = process.argv[2];
switch (command) {
case 'install': await cmdInstall(); break;
case 'uninstall': await cmdUninstall(); break;
case 'status': await cmdStatus(); break;
case 'logs': await cmdLogs(); break;
case 'version': case '--version': case '-v': await cmdVersion(); break;
default:
console.log('claude-auto-retry - Auto-retry Claude Code on subscription rate limits\n');
console.log('Usage:');
console.log(' claude-auto-retry install Install shell wrapper + tmux');
console.log(' claude-auto-retry uninstall Remove shell wrapper');
console.log(' claude-auto-retry status Show monitor status');
console.log(' claude-auto-retry logs Tail today\'s log');
console.log(' claude-auto-retry version Print version');
break;
}