diff --git a/package.json b/package.json index d99fb3b..861c8e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-auto-retry", - "version": "0.2.1", + "version": "0.2.2", "description": "Automatically retry Claude Code sessions when hitting Anthropic subscription rate limits", "type": "module", "bin": { diff --git a/src/config.js b/src/config.js index 054056a..ec1542c 100644 --- a/src/config.js +++ b/src/config.js @@ -27,6 +27,11 @@ function validate(cfg) { } if (!Array.isArray(cfg.customPatterns)) { cfg.customPatterns = DEFAULT_CONFIG.customPatterns; + } else { + cfg.customPatterns = cfg.customPatterns.filter(p => { + if (typeof p !== 'string') return false; + try { new RegExp(p); return true; } catch { return false; } + }); } if (cfg.foregroundCommands !== undefined) { if (!Array.isArray(cfg.foregroundCommands) || cfg.foregroundCommands.length === 0) { diff --git a/src/launcher.js b/src/launcher.js index 59109e5..bb802bb 100644 --- a/src/launcher.js +++ b/src/launcher.js @@ -113,7 +113,7 @@ async function launchPrintMode(args) { // Rate limited — discard buffer, wait and retry retries++; - if (retries >= config.maxRetries) { + if (retries > config.maxRetries) { process.stderr.write(`[claude-auto-retry] Max retries (${config.maxRetries}) reached.\n`); return 1; } diff --git a/src/monitor.js b/src/monitor.js index 72e54b1..3ee6cf1 100644 --- a/src/monitor.js +++ b/src/monitor.js @@ -1,6 +1,6 @@ import { stripAnsi, isRateLimited, findRateLimitMessage } from './patterns.js'; import { parseResetTime, calculateWaitMs } from './time-parser.js'; -import { capturePane, sendKeys, getPaneCommand } from './tmux.js'; +import { capturePane, sendKeys, getPaneCommand, isProcessForeground } from './tmux.js'; import { loadConfig } from './config.js'; import { createLogger } from './logger.js'; @@ -34,22 +34,27 @@ export async function processOneTick(state, tmuxAdapter, pane, config, isAlive) return 'max-retries'; } - const fg = await tmuxAdapter.getPaneCommand(pane); - const fgCommands = config.foregroundCommands || DEFAULT_FOREGROUND_COMMANDS; - if (!fgCommands.some(c => fg.toLowerCase().includes(c))) { - // Push waitUntil forward to avoid tight-loop polling every tick - state.waitUntil = Date.now() + (config.pollIntervalSeconds * 1000 * 6); - state._lastForeground = fg; - return 'skipped-not-claude'; + // Primary check: is the Claude process in the foreground process group? + // On macOS, pane_current_command reports "zsh" instead of the child process, + // so we use `ps -o stat=` to check the '+' (foreground) flag directly. + // `true` short-circuits past pane_current_command (fixes macOS). + // `false`/`null` falls back to pane_current_command for safety. + const isFg = await tmuxAdapter.isClaudeForeground(); + if (isFg !== true) { + const fg = await tmuxAdapter.getPaneCommand(pane); + const fgCommands = config.foregroundCommands || DEFAULT_FOREGROUND_COMMANDS; + if (!fgCommands.some(c => fg.toLowerCase().includes(c))) { + state.waitUntil = Date.now() + (config.pollIntervalSeconds * 1000 * 6); + state._lastForeground = fg; + return 'skipped-not-claude'; + } } - await tmuxAdapter.sendKeys(pane, config.retryMessage); + // Increment attempts and set cooldown BEFORE sendKeys so that a failure + // (e.g. pane destroyed) still consumes a retry and avoids tight-loop errors. state.attempts++; - // Stay in 'waiting' with a 30s cooldown instead of going to 'monitoring'. - // This gives Claude time to process the retry and produce enough output - // to push the stale rate-limit message out of the recent-lines window. - // Without this, the next tick re-detects the old message and waits 24h. state.waitUntil = Date.now() + 30_000; + await tmuxAdapter.sendKeys(pane, config.retryMessage); return 'retried'; } @@ -74,7 +79,7 @@ export async function startMonitor(pane, pid) { await logger.info(`Monitor started for pane ${pane} (claude PID: ${pid})`); - const tmuxAdapter = { capturePane, sendKeys, getPaneCommand }; + const tmuxAdapter = { capturePane, sendKeys, getPaneCommand, isClaudeForeground: () => isProcessForeground(pid) }; const isAlive = () => { try { process.kill(pid, 0); return true; } catch { return false; } }; const loop = async () => { diff --git a/src/time-parser.js b/src/time-parser.js index 4a49e0a..cec577b 100644 --- a/src/time-parser.js +++ b/src/time-parser.js @@ -1,5 +1,5 @@ const RESET_TIME_REGEX = /resets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(([^)]+)\))?/i; -const RELATIVE_TIME_REGEX = /(?:try again|wait)\s+(?:for\s+)?(?:in\s+)?(\d+)\s*(hours?|minutes?|mins?|h|m)\b/i; +const RELATIVE_TIME_REGEX = /(?:try again|wait|resets?\s+in)[:\s]\s*(?:for\s+)?(?:in\s+)?(\d+)\s*(hours?|minutes?|mins?|h|m)\b/i; export function parseResetTime(text) { // Try absolute time first: "resets at 3pm (UTC)" diff --git a/src/tmux.js b/src/tmux.js index a745306..7fc0fc2 100644 --- a/src/tmux.js +++ b/src/tmux.js @@ -40,5 +40,14 @@ export async function getPaneCommand(pane) { return stdout.trim(); } +export async function isProcessForeground(pid) { + try { + const { stdout } = await execFileAsync('ps', ['-o', 'stat=', '-p', String(pid)]); + return stdout.trim().includes('+'); + } catch { + return null; + } +} + export function isInsideTmux() { return !!process.env.TMUX; } export function getCurrentPane() { return process.env.TMUX_PANE || null; } diff --git a/test/config.test.js b/test/config.test.js index 9990eed..4dd4fdc 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -53,6 +53,17 @@ describe('loadConfig', () => { assert.equal(config.pollIntervalSeconds, 5); } finally { await unlink(f); } }); + it('filters invalid customPatterns entries', 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({ customPatterns: ["valid", 42, null, "[invalid"] })); + try { + const config = await loadConfig(f); + assert.deepEqual(config.customPatterns, ["valid"]); + } 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'); diff --git a/test/monitor.test.js b/test/monitor.test.js index 900db25..48c84e4 100644 --- a/test/monitor.test.js +++ b/test/monitor.test.js @@ -3,12 +3,13 @@ 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') { +function mockTmux(paneContent = '', paneCommand = 'node', claudeForeground = true) { const t = { _sent: [], capturePane: async () => paneContent, getPaneCommand: async () => paneCommand, sendKeys: async (_p, text) => { t._sent.push(text); }, + isClaudeForeground: async () => claudeForeground, }; return t; } @@ -49,24 +50,39 @@ describe('processOneTick', () => { assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'waiting'); assert.ok(s.waitUntil > Date.now()); }); - it('skips when foreground is not claude', async () => { - const t = mockTmux('5-hour limit reached - resets 3pm (UTC)', 'vim'); + it('retries when Claude process is in foreground (fixes macOS zsh issue)', async () => { + const t = mockTmux('5-hour limit reached - resets 3pm (UTC)', 'zsh', true); + 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); + }); + it('falls back to pane_current_command when process state is false', async () => { + const t = mockTmux('5-hour limit reached - resets 3pm (UTC)', 'vim', false); 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); assert.equal(s._lastForeground, 'vim'); }); - it('accepts custom foregroundCommands from config', async () => { - const t = mockTmux('5-hour limit reached - resets 3pm (UTC)', 'my-claude-wrapper'); + it('falls back to pane_current_command when process state is null', async () => { + const t = mockTmux('5-hour limit reached - resets 3pm (UTC)', 'vim', null); + 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); + assert.equal(s._lastForeground, 'vim'); + }); + it('accepts custom foregroundCommands in fallback path', async () => { + const t = mockTmux('5-hour limit reached - resets 3pm (UTC)', 'my-claude-wrapper', null); const s = createMonitorState(); s.waitUntil = Date.now() - 1000; s.status = 'waiting'; const config = { ...DEFAULT_CONFIG, foregroundCommands: ['my-claude-wrapper'] }; assert.equal(await processOneTick(s, t, '%0', config, () => true), 'retried'); assert.equal(t._sent.length, 1); }); - it('matches npx as default foreground command', async () => { - const t = mockTmux('5-hour limit reached - resets 3pm (UTC)', 'npx'); + it('matches npx in fallback path', async () => { + const t = mockTmux('5-hour limit reached - resets 3pm (UTC)', 'npx', null); const s = createMonitorState(); s.waitUntil = Date.now() - 1000; s.status = 'waiting'; assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'retried'); diff --git a/test/time-parser.test.js b/test/time-parser.test.js index 74d903b..ce92006 100644 --- a/test/time-parser.test.js +++ b/test/time-parser.test.js @@ -46,6 +46,16 @@ describe('parseResetTime', () => { assert.ok(r.relative); assert.equal(r.waitMs, 30 * 60_000); }); + it('parses "resets in: 3 hours" as relative time', () => { + const r = parseResetTime('usage limit · resets in: 3 hours'); + assert.ok(r.relative); + assert.equal(r.waitMs, 3 * 3_600_000); + }); + it('parses "resets in 2 hours" as relative time', () => { + const r = parseResetTime('resets in 2 hours'); + assert.ok(r.relative); + assert.equal(r.waitMs, 2 * 3_600_000); + }); }); describe('calculateWaitMs', () => {