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:
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
+13
-8
@@ -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';
|
||||
}
|
||||
|
||||
// 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))) {
|
||||
// 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';
|
||||
}
|
||||
}
|
||||
|
||||
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 () => {
|
||||
|
||||
+1
-1
@@ -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)"
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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');
|
||||
|
||||
+23
-7
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user