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",
|
"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": {
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-14
@@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
const fg = await tmuxAdapter.getPaneCommand(pane);
|
// Primary check: is the Claude process in the foreground process group?
|
||||||
const fgCommands = config.foregroundCommands || DEFAULT_FOREGROUND_COMMANDS;
|
// On macOS, pane_current_command reports "zsh" instead of the child process,
|
||||||
if (!fgCommands.some(c => fg.toLowerCase().includes(c))) {
|
// so we use `ps -o stat=` to check the '+' (foreground) flag directly.
|
||||||
// Push waitUntil forward to avoid tight-loop polling every tick
|
// `true` short-circuits past pane_current_command (fixes macOS).
|
||||||
state.waitUntil = Date.now() + (config.pollIntervalSeconds * 1000 * 6);
|
// `false`/`null` falls back to pane_current_command for safety.
|
||||||
state._lastForeground = fg;
|
const isFg = await tmuxAdapter.isClaudeForeground();
|
||||||
return 'skipped-not-claude';
|
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++;
|
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
@@ -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)"
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user