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:
@@ -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.
|
||||||
@@ -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.**
|
||||||
|
|
||||||
|
[](https://www.npmjs.com/package/claude-auto-retry)
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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; }
|
||||||
@@ -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 <<<
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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); }
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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); });
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user