fix: robust foreground detection for macOS and multiple bug fixes

- Use `ps -o stat=` to check process foreground state directly,
  fixing macOS where tmux pane_current_command reports "zsh" instead
  of the child process. Falls back to pane_current_command safely.
- Fix print mode retry off-by-one (maxRetries=5 only gave 4 retries)
- Fix sendKeys failure leaving state unupdated (tight error loop)
- Validate customPatterns config entries (non-string/invalid regex crashed monitor)
- Parse "resets in: N hours" format (was detected but not time-parsed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
CheapestInference
2026-03-31 13:11:33 +02:00
parent 0652a23274
commit a81d376322
9 changed files with 80 additions and 24 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "claude-auto-retry", "name": "claude-auto-retry",
"version": "0.2.1", "version": "0.2.2",
"description": "Automatically retry Claude Code sessions when hitting Anthropic subscription rate limits", "description": "Automatically retry Claude Code sessions when hitting Anthropic subscription rate limits",
"type": "module", "type": "module",
"bin": { "bin": {
+5
View File
@@ -27,6 +27,11 @@ function validate(cfg) {
} }
if (!Array.isArray(cfg.customPatterns)) { if (!Array.isArray(cfg.customPatterns)) {
cfg.customPatterns = DEFAULT_CONFIG.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 (cfg.foregroundCommands !== undefined) {
if (!Array.isArray(cfg.foregroundCommands) || cfg.foregroundCommands.length === 0) { if (!Array.isArray(cfg.foregroundCommands) || cfg.foregroundCommands.length === 0) {
+1 -1
View File
@@ -113,7 +113,7 @@ async function launchPrintMode(args) {
// Rate limited — discard buffer, wait and retry // Rate limited — discard buffer, wait and retry
retries++; retries++;
if (retries >= config.maxRetries) { if (retries > config.maxRetries) {
process.stderr.write(`[claude-auto-retry] Max retries (${config.maxRetries}) reached.\n`); process.stderr.write(`[claude-auto-retry] Max retries (${config.maxRetries}) reached.\n`);
return 1; return 1;
} }
+13 -8
View File
@@ -1,6 +1,6 @@
import { stripAnsi, isRateLimited, findRateLimitMessage } from './patterns.js'; import { stripAnsi, isRateLimited, findRateLimitMessage } from './patterns.js';
import { parseResetTime, calculateWaitMs } from './time-parser.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 { loadConfig } from './config.js';
import { createLogger } from './logger.js'; import { createLogger } from './logger.js';
@@ -34,22 +34,27 @@ export async function processOneTick(state, tmuxAdapter, pane, config, isAlive)
return 'max-retries'; return 'max-retries';
} }
// 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 fg = await tmuxAdapter.getPaneCommand(pane);
const fgCommands = config.foregroundCommands || DEFAULT_FOREGROUND_COMMANDS; const fgCommands = config.foregroundCommands || DEFAULT_FOREGROUND_COMMANDS;
if (!fgCommands.some(c => fg.toLowerCase().includes(c))) { 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.waitUntil = Date.now() + (config.pollIntervalSeconds * 1000 * 6);
state._lastForeground = fg; state._lastForeground = fg;
return 'skipped-not-claude'; 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++; 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; state.waitUntil = Date.now() + 30_000;
await tmuxAdapter.sendKeys(pane, config.retryMessage);
return 'retried'; return 'retried';
} }
@@ -74,7 +79,7 @@ export async function startMonitor(pane, pid) {
await logger.info(`Monitor started for pane ${pane} (claude PID: ${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 isAlive = () => { try { process.kill(pid, 0); return true; } catch { return false; } };
const loop = async () => { const loop = async () => {
+1 -1
View File
@@ -1,5 +1,5 @@
const RESET_TIME_REGEX = /resets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(([^)]+)\))?/i; 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) { export function parseResetTime(text) {
// Try absolute time first: "resets at 3pm (UTC)" // Try absolute time first: "resets at 3pm (UTC)"
+9
View File
@@ -40,5 +40,14 @@ export async function getPaneCommand(pane) {
return stdout.trim(); 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 isInsideTmux() { return !!process.env.TMUX; }
export function getCurrentPane() { return process.env.TMUX_PANE || null; } export function getCurrentPane() { return process.env.TMUX_PANE || null; }
+11
View File
@@ -53,6 +53,17 @@ describe('loadConfig', () => {
assert.equal(config.pollIntervalSeconds, 5); assert.equal(config.pollIntervalSeconds, 5);
} finally { await unlink(f); } } 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 () => { it('rejects negative numbers and falls back to defaults', async () => {
const { writeFile, unlink } = await import('node:fs/promises'); const { writeFile, unlink } = await import('node:fs/promises');
const { tmpdir } = await import('node:os'); const { tmpdir } = await import('node:os');
+23 -7
View File
@@ -3,12 +3,13 @@ import assert from 'node:assert/strict';
import { createMonitorState, processOneTick } from '../src/monitor.js'; import { createMonitorState, processOneTick } from '../src/monitor.js';
import { DEFAULT_CONFIG } from '../src/config.js'; import { DEFAULT_CONFIG } from '../src/config.js';
function mockTmux(paneContent = '', paneCommand = 'node') { function mockTmux(paneContent = '', paneCommand = 'node', claudeForeground = true) {
const t = { const t = {
_sent: [], _sent: [],
capturePane: async () => paneContent, capturePane: async () => paneContent,
getPaneCommand: async () => paneCommand, getPaneCommand: async () => paneCommand,
sendKeys: async (_p, text) => { t._sent.push(text); }, sendKeys: async (_p, text) => { t._sent.push(text); },
isClaudeForeground: async () => claudeForeground,
}; };
return t; return t;
} }
@@ -49,24 +50,39 @@ describe('processOneTick', () => {
assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'waiting'); assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'waiting');
assert.ok(s.waitUntil > Date.now()); assert.ok(s.waitUntil > Date.now());
}); });
it('skips when foreground is not claude', async () => { it('retries when Claude process is in foreground (fixes macOS zsh issue)', async () => {
const t = mockTmux('5-hour limit reached - resets 3pm (UTC)', 'vim'); 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(); const s = createMonitorState();
s.waitUntil = Date.now() - 1000; s.status = 'waiting'; s.waitUntil = Date.now() - 1000; s.status = 'waiting';
assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'skipped-not-claude'); assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'skipped-not-claude');
assert.equal(t._sent.length, 0); assert.equal(t._sent.length, 0);
assert.equal(s._lastForeground, 'vim'); assert.equal(s._lastForeground, 'vim');
}); });
it('accepts custom foregroundCommands from config', async () => { it('falls back to pane_current_command when process state is null', async () => {
const t = mockTmux('5-hour limit reached - resets 3pm (UTC)', 'my-claude-wrapper'); 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(); const s = createMonitorState();
s.waitUntil = Date.now() - 1000; s.status = 'waiting'; s.waitUntil = Date.now() - 1000; s.status = 'waiting';
const config = { ...DEFAULT_CONFIG, foregroundCommands: ['my-claude-wrapper'] }; const config = { ...DEFAULT_CONFIG, foregroundCommands: ['my-claude-wrapper'] };
assert.equal(await processOneTick(s, t, '%0', config, () => true), 'retried'); assert.equal(await processOneTick(s, t, '%0', config, () => true), 'retried');
assert.equal(t._sent.length, 1); assert.equal(t._sent.length, 1);
}); });
it('matches npx as default foreground command', async () => { it('matches npx in fallback path', async () => {
const t = mockTmux('5-hour limit reached - resets 3pm (UTC)', 'npx'); const t = mockTmux('5-hour limit reached - resets 3pm (UTC)', 'npx', null);
const s = createMonitorState(); const s = createMonitorState();
s.waitUntil = Date.now() - 1000; s.status = 'waiting'; s.waitUntil = Date.now() - 1000; s.status = 'waiting';
assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'retried'); assert.equal(await processOneTick(s, t, '%0', DEFAULT_CONFIG, () => true), 'retried');
+10
View File
@@ -46,6 +46,16 @@ describe('parseResetTime', () => {
assert.ok(r.relative); assert.ok(r.relative);
assert.equal(r.waitMs, 30 * 60_000); 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', () => { describe('calculateWaitMs', () => {