commit 1aad1e031344fc9ae9486071de90cab203a9f2e6 Author: CheapestInference Date: Wed Mar 18 11:43:36 2026 +0100 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) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b14d9f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 CheapestInference + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3741a59 --- /dev/null +++ b/README.md @@ -0,0 +1,299 @@ +# claude-auto-retry + +> Automatically retry Claude Code sessions when you hit Anthropic subscription rate limits. + +When Claude Code shows *"5-hour limit reached - resets 3pm"*, this tool waits for the reset and sends "continue" automatically. You come back to find your work done. + +**No dependencies. No workflow change. Just install and forget.** + +[![npm version](https://img.shields.io/npm/v/claude-auto-retry.svg)](https://www.npmjs.com/package/claude-auto-retry) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Node.js >= 18](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org) + +--- + +## The Problem + +You're in the middle of a complex task with Claude Code. After a while, you see: + +``` +You've hit your limit · resets 3pm (Europe/Dublin) +``` + +Claude stops. You have to wait hours, come back, and type "continue". If you're running long tasks overnight or while AFK, this kills your productivity. + +## The Solution + +```bash +npm i -g claude-auto-retry +claude-auto-retry install +``` + +That's it. Type `claude` as you always do. When the rate limit hits, the tool: + +1. Detects the rate limit message in the terminal +2. Parses the reset time (timezone-aware) +3. Waits until the limit resets + 60s margin +4. Verifies Claude is still the foreground process +5. Sends "continue" automatically + +You come back to find your task completed. + +## How it Works + +``` +You type "claude" + │ + ▼ + Shell function (injected in .bashrc/.zshrc) + │ + ├─ Already in tmux? ──▶ Start background monitor + │ Launch claude with full TUI + │ + └─ Not in tmux? ──▶ Create tmux session transparently + Launch claude + monitor inside + Attach (looks the same to you) + + MONITOR (background, ~0% CPU): + │ + ├─ Polls tmux pane every 5 seconds + ├─ Detects rate limit text + ├─ Parses reset time from message + ├─ Waits until reset + safety margin + ├─ Verifies Claude is still the foreground process + └─ Sends "continue" via tmux send-keys +``` + +### Why tmux? + +When you disconnect (SSH drops, close terminal, laptop sleeps), **tmux keeps running**. The monitor keeps waiting. When you reconnect with `tmux attach`, you find Claude working on your task. This is the key advantage over wrapper scripts. + +## Features + +- **Zero workflow change** — same `claude` command, same TUI, same everything +- **Works with and without tmux** — auto-creates tmux session if you're not already in one +- **Auto-installs tmux** if missing (apt, dnf, brew, pacman, apk) +- **Timezone-aware** — parses reset times with full IANA timezone support (including half-hour offsets) +- **DST-safe** — iterative offset correction handles daylight saving transitions +- **Safe send-keys** — verifies Claude is still the foreground process before injecting text +- **`--print` mode support** — buffers output, retries cleanly for piped/scripted usage +- **Configurable** — retry count, wait margin, custom patterns, retry message +- **Config validation** — bad config values fall back to safe defaults instead of crashing +- **Zero dependencies** — pure Node.js, no `node_modules` + +## Rate Limit Patterns Detected + +The tool detects these real-world Claude Code messages: + +| Pattern | Example | +|---------|---------| +| N-hour limit reached | `5-hour limit reached - resets 3pm (UTC)` | +| Usage limit | `Claude usage limit reached. Resets at 2pm` | +| Out of extra usage | `You're out of extra usage · resets 3pm` | +| Try again | `Please try again in 5 hours` | +| Hit your limit | `You've hit your limit · resets 3pm (Europe/Dublin)` | +| Rate limit | `Rate limit hit. Resets at 4pm` | + +Custom patterns can be added via config for future message format changes. + +## Configuration + +Optional. Create `~/.claude-auto-retry.json`: + +```json +{ + "maxRetries": 5, + "pollIntervalSeconds": 5, + "marginSeconds": 60, + "fallbackWaitHours": 5, + "retryMessage": "Continue where you left off. The previous attempt was rate limited.", + "customPatterns": ["my custom pattern"] +} +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `maxRetries` | `5` | Max retry attempts per rate-limit event | +| `pollIntervalSeconds` | `5` | How often to check the terminal (seconds) | +| `marginSeconds` | `60` | Extra wait after reset time (seconds) | +| `fallbackWaitHours` | `5` | Wait time if reset time can't be parsed | +| `retryMessage` | `"Continue where..."` | Message sent to Claude on retry | +| `customPatterns` | `[]` | Additional regex patterns to detect rate limits | + +All fields optional. Invalid values fall back to defaults automatically. + +## CLI Commands + +```bash +claude-auto-retry install # Install shell wrapper + tmux +claude-auto-retry uninstall # Remove shell wrapper +claude-auto-retry status # Show monitor activity + last log entries +claude-auto-retry logs # Tail today's log file in real-time +claude-auto-retry version # Print version +``` + +## Platform Support + +### Operating Systems + +| OS | tmux auto-install | Status | +|----|-------------------|--------| +| Ubuntu / Debian | `apt-get` | Fully supported | +| CentOS / RHEL / Fedora | `dnf` | Fully supported | +| Rocky Linux / Amazon Linux | `dnf` | Fully supported | +| macOS | `brew` | Fully supported | +| Arch Linux | `pacman` | Fully supported | +| Alpine | `apk` | Fully supported | + +### Requirements + +- **Node.js** >= 18 +- **tmux** >= 2.1 (auto-installed if missing) + +### Shell Support + +| Shell | Status | +|-------|--------| +| bash | Full (auto-install to `~/.bashrc`) | +| zsh | Full (auto-install to `~/.zshrc`) | +| fish | Manual setup (instructions printed on `install`) | + +## `--print` Mode + +For scripted/piped usage (`claude -p "..." | jq`), the tool: + +1. Buffers all output (nothing goes to stdout until done) +2. If rate-limited: discards partial output, waits, re-executes with same args +3. Consumer receives a single clean response + +```bash +# This just works — retries transparently if rate-limited +claude -p "Generate a JSON schema" | jq . +``` + +## Logging + +Logs are written to `~/.claude-auto-retry/logs/YYYY-MM-DD.log`: + +``` +[2026-03-18 15:00:05] [INFO] Monitor started for pane %3 (claude PID: 12345) +[2026-03-18 15:32:10] [INFO] Rate limit detected: "5-hour limit reached - resets 3pm". Waiting 3547s... +[2026-03-18 16:01:10] [INFO] Sent retry message (attempt 1) +``` + +Logs rotate daily. Files older than 7 days are cleaned automatically. + +## Uninstall + +```bash +claude-auto-retry uninstall +npm uninstall -g claude-auto-retry +``` + +This removes the shell function from your rc files. tmux is left installed. + +## Known Limitations + +1. **Retry message context** — The retry message is sent as plain text. If Claude was mid-confirmation or in a special input state, it may not interpret it as a continuation. You can customize the message via config. + +2. **Node version lock** — The launcher path is resolved at install time. If you switch Node versions with nvm, re-run `claude-auto-retry install`. + +3. **tmux required** — The tool needs tmux to monitor terminal output and inject keystrokes. It auto-installs if missing, but requires sudo for system package managers. + +## Contributing + +Contributions are welcome! Here's how to get started: + +### Development Setup + +```bash +git clone https://github.com/cheapestinference/claude-auto-retry.git +cd claude-auto-retry +npm test # Run all 59 tests +npm link # Install locally for testing +``` + +### Project Structure + +``` +claude-auto-retry/ +├── bin/cli.js # CLI: install/uninstall/status/logs/version +├── src/ +│ ├── patterns.js # Rate limit detection + ANSI stripping +│ ├── time-parser.js # Reset time parsing with timezone support +│ ├── config.js # Config loading + validation +│ ├── logger.js # File-based logging with rotation +│ ├── tmux.js # tmux command wrappers (execFile-based) +│ ├── monitor.js # Core monitoring loop + retry logic +│ ├── launcher.js # Process orchestration + signal forwarding +│ └── wrapper.sh # Shell function template +├── test/ # 59 tests across 7 test files +├── package.json +├── LICENSE +└── README.md +``` + +### Architecture Decisions + +- **Zero dependencies** — only Node.js built-ins. Reduces supply chain risk and install size. +- **`execFile` over `exec`** — all child process calls use array-based args to prevent shell injection. +- **`stdio: 'inherit'`** — Claude gets the real TTY for full TUI support. The monitor reads pane content independently via `tmux capture-pane`. +- **Iterative DST correction** — timezone offset is computed via 3-iteration convergence loop, not a single-shot formula that breaks at DST boundaries. +- **Config validation** — invalid user config values fall back to safe defaults instead of producing NaN/undefined behavior. + +### Running Tests + +```bash +npm test # All tests +node --test test/patterns.test.js # Single file +node --test --watch test/ # Watch mode +``` + +### Submitting Changes + +1. Fork the repo +2. Create a feature branch (`git checkout -b feat/my-feature`) +3. Write tests first (TDD) +4. Make your changes +5. Ensure all tests pass (`npm test`) +6. Submit a Pull Request + +### Areas for Contribution + +- **New rate limit patterns** — If you see a Claude Code rate limit message that isn't detected, open an issue with the exact text. +- **Fish shell support** — Auto-install for fish shell (currently manual). +- **Windows support** — WSL works, but native Windows would need a different approach. +- **Notification integration** — Desktop/Slack notification when rate limit detected or when Claude resumes. + +## Related Projects + +- [claude-code-queue](https://github.com/JCSnap/claude-code-queue) — Queue-based task system for Claude Code with rate limit handling +- [opencode-claude-quota](https://github.com/nguyenngothuong/opencode-claude-quota) — Rate limit quota monitoring (display only) + +## FAQ + +**Q: Does this work with Claude Max/Pro/Team?** +A: Yes. It works with any Anthropic subscription that has usage-based rate limits. + +**Q: Does it work outside of tmux?** +A: Yes. If you're not in tmux, it creates a tmux session transparently. You won't notice a difference. + +**Q: What if I continue manually before the retry fires?** +A: The monitor checks if the rate limit is still visible before sending keys. If you already continued, it resets and keeps watching. + +**Q: What if Claude exits while the monitor is waiting?** +A: The monitor checks the Claude process every 30 seconds during the wait. If Claude exits, the monitor shuts down cleanly. + +**Q: Does it consume a lot of resources?** +A: No. `tmux capture-pane` is extremely lightweight. The monitor uses ~0% CPU at a 5-second polling interval. + +**Q: Can it accidentally type into the wrong program?** +A: The monitor verifies the foreground process is `node` or `claude` before sending keys. If you've switched to vim, bash, or anything else, it skips the retry. + +## License + +MIT — see [LICENSE](LICENSE) for details. + +--- + +Made with care by [CheapestInference](https://github.com/cheapestinference). diff --git a/bin/cli.js b/bin/cli.js new file mode 100755 index 0000000..e93778e --- /dev/null +++ b/bin/cli.js @@ -0,0 +1,225 @@ +#!/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; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..111536a --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "claude-auto-retry", + "version": "0.1.0", + "description": "Automatically retry Claude Code sessions when hitting Anthropic subscription rate limits", + "type": "module", + "bin": { + "claude-auto-retry": "./bin/cli.js" + }, + "scripts": { + "test": "node --test test/*.test.js" + }, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "keywords": ["claude", "claude-code", "rate-limit", "retry", "tmux", "anthropic"], + "repository": { + "type": "git", + "url": "https://github.com/cheapestinference/claude-auto-retry" + }, + "files": ["bin/", "src/", "LICENSE", "README.md"] +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..a8c6a68 --- /dev/null +++ b/src/config.js @@ -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 }; + } +} diff --git a/src/launcher.js b/src/launcher.js new file mode 100644 index 0000000..59109e5 --- /dev/null +++ b/src/launcher.js @@ -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); diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..89539c1 --- /dev/null +++ b/src/logger.js @@ -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), + }; +} diff --git a/src/monitor.js b/src/monitor.js new file mode 100644 index 0000000..389a0ea --- /dev/null +++ b/src/monitor.js @@ -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 +const isDirectRun = process.argv[1]?.endsWith('monitor.js') && process.argv.length >= 4; +if (isDirectRun) { + startMonitor(process.argv[2], parseInt(process.argv[3], 10)); +} diff --git a/src/patterns.js b/src/patterns.js new file mode 100644 index 0000000..31ddb67 --- /dev/null +++ b/src/patterns.js @@ -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; +} diff --git a/src/time-parser.js b/src/time-parser.js new file mode 100644 index 0000000..e5aee19 --- /dev/null +++ b/src/time-parser.js @@ -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; +} diff --git a/src/tmux.js b/src/tmux.js new file mode 100644 index 0000000..a745306 --- /dev/null +++ b/src/tmux.js @@ -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; } diff --git a/src/wrapper.sh b/src/wrapper.sh new file mode 100644 index 0000000..ae7c77c --- /dev/null +++ b/src/wrapper.sh @@ -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 <<< diff --git a/test/cli.test.js b/test/cli.test.js new file mode 100644 index 0000000..0060bb8 --- /dev/null +++ b/test/cli.test.js @@ -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'); + }); +}); diff --git a/test/config.test.js b/test/config.test.js new file mode 100644 index 0000000..9990eed --- /dev/null +++ b/test/config.test.js @@ -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); } + }); +}); diff --git a/test/logger.test.js b/test/logger.test.js new file mode 100644 index 0000000..3f7ee35 --- /dev/null +++ b/test/logger.test.js @@ -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]')); + }); +}); diff --git a/test/monitor.test.js b/test/monitor.test.js new file mode 100644 index 0000000..c055a8f --- /dev/null +++ b/test/monitor.test.js @@ -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'); + }); +}); diff --git a/test/patterns.test.js b/test/patterns.test.js new file mode 100644 index 0000000..b95da30 --- /dev/null +++ b/test/patterns.test.js @@ -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); + }); +}); diff --git a/test/time-parser.test.js b/test/time-parser.test.js new file mode 100644 index 0000000..52ce600 --- /dev/null +++ b/test/time-parser.test.js @@ -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); + }); +}); diff --git a/test/tmux.test.js b/test/tmux.test.js new file mode 100644 index 0000000..8caf850 --- /dev/null +++ b/test/tmux.test.js @@ -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); }); +});