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
+21
View File
@@ -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.
+299
View File
@@ -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).
Executable
+225
View File
@@ -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;
}
+22
View File
@@ -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"]
}
+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 <<<
+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');
});
});
+68
View File
@@ -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); }
});
});
+37
View File
@@ -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]'));
});
});
+63
View File
@@ -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');
});
});
+82
View File
@@ -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);
});
});
+62
View File
@@ -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);
});
});
+27
View File
@@ -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); });
});