feat: claude-auto-retry v0.1.0
Auto-retry Claude Code sessions when hitting Anthropic subscription rate limits. Uses tmux monitoring + send-keys to detect rate limit messages, wait for reset, and send "continue" automatically. Zero dependencies, zero workflow change. - Shell wrapper intercepts `claude` command transparently - Background monitor polls tmux pane for rate limit patterns - Timezone-aware reset time parsing with DST safety - Safe send-keys with foreground process verification - --print mode: buffers output, retries cleanly for pipes - Config validation prevents bad values from causing crashes - 59 tests, 0 dependencies
This commit is contained in:
@@ -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 <<<
|
||||
Reference in New Issue
Block a user