@tigorhutasuhut/claude-retry 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -12
- package/dist/monitor.js +20 -4
- package/dist/patterns.d.ts +2 -0
- package/dist/patterns.js +33 -0
- package/dist/time-parser.js +9 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# claude-retry
|
|
2
2
|
|
|
3
|
-
Watches every pane across **all** your [zellij](https://zellij.dev/) sessions. When a pane hits Anthropic's usage/session limit, it detects the on-screen rate-limit banner
|
|
3
|
+
Watches every pane across **all** your [zellij](https://zellij.dev/) sessions. When a pane hits Anthropic's usage/session limit, it detects the on-screen rate-limit banner and cross-checks against Anthropic's usage API to get the **exact** reset time and discard stale or incidental banners. Once the reset elapses it clears the input and injects `continue` to resume automatically. One daemon covers every session at once — even detached ones.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -55,20 +55,34 @@ source "$(npm root -g)/claude-retry/shell/wrapper.bash"
|
|
|
55
55
|
|
|
56
56
|
## How it works
|
|
57
57
|
|
|
58
|
-
Every pass (60s for `start
|
|
58
|
+
Every pass (60s for `start`):
|
|
59
59
|
|
|
60
|
-
1. **Discover** — `zellij list-sessions` enumerates live sessions (EXITED ones and the daemon's own `$ZELLIJ_SESSION_NAME` are skipped). For each, `zellij --session <name> action list-panes -j` lists its panes; plugins and exited panes are dropped. New panes are added
|
|
61
|
-
2. **Capture** — each pane's visible screen is dumped with `zellij --session <name> action dump-screen --pane-id <id>` (ANSI stripped).
|
|
62
|
-
3. **
|
|
63
|
-
|
|
64
|
-
- **
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
5. **Retry** — once the resolved reset time elapses, the daemon sends **Ctrl+C** (clears any half-typed input — a single Ctrl+C in Claude Code doesn't quit), then types `continue` and Enter via `write-chars` / `write`.
|
|
60
|
+
1. **Discover** — `zellij list-sessions` enumerates live sessions (EXITED ones and the daemon's own `$ZELLIJ_SESSION_NAME` are skipped). For each, `zellij --session <name> action list-panes -j` lists its panes; plugins and exited panes are dropped. New panes are added; gone panes are tracked via a miss-counter and dropped only after **3 consecutive absent passes**, so a transient `list-panes` hiccup never loses a pane mid-wait.
|
|
61
|
+
2. **Capture** — each pane's visible screen is dumped with `zellij --session <name> action dump-screen --pane-id <id>` (ANSI stripped). Works on detached sessions — no attached client required.
|
|
62
|
+
3. **Signal check** — the screen is checked for two signals:
|
|
63
|
+
- **Loose banner match** — any rate-limit text present anywhere on screen (candidate trigger).
|
|
64
|
+
- **Canonical banner** (`isBlockedAtBanner`) — a high-confidence match anchored to the **bottom** of the screen, meaning Claude is parked at the limit right above its input box. This distinguishes an active block from incidental banner text in scrollback.
|
|
65
|
+
4. **API call (conditional)** — the usage API (`GET https://api.anthropic.com/api/oauth/usage`) is called **only** when at least one pane shows a banner or is already waiting. Zero API calls when nothing is limited. Account is resolved as: the sole account on the machine, else the sole limited account, else via the Linux `/proc` bridge (pane → pts → `CLAUDE_CONFIG_DIR`), else unknown.
|
|
66
|
+
5. **State machine per pane:**
|
|
68
67
|
|
|
69
|
-
|
|
68
|
+
**MONITORING:**
|
|
69
|
+
- No banner → idle, nothing to do.
|
|
70
|
+
- Banner + account **LIMITED** → enter WAITING until `resets_at`.
|
|
71
|
+
- Banner + account **CLEARED** → if a canonical banner sits at the bottom (Claude restarted after reset, or a reopened `claude --continue` left idle) → inject `continue`; otherwise ignore (stale or scrollback text — no false triggers).
|
|
72
|
+
- Banner + account **UNKNOWN** (API down) → parse reset time from on-screen text; future → enter WAITING; already-passed → inject `continue` if canonical banner at bottom, else ignore. A bare past time means the limit already reset — it is never rolled to tomorrow.
|
|
70
73
|
|
|
71
|
-
|
|
74
|
+
**WAITING:**
|
|
75
|
+
- Banner gone → abandon (Claude exited, user continued, or pane ID reused).
|
|
76
|
+
- Account cleared **or** timer elapsed → inject `continue`.
|
|
77
|
+
- Account still limited → keep waiting; `resets_at` refreshed live each pass.
|
|
78
|
+
|
|
79
|
+
6. **Inject** — Ctrl+C (clears any half-typed input; one Ctrl+C does not quit Claude Code), then `continue` + Enter via `write-chars` / `write`.
|
|
80
|
+
|
|
81
|
+
Per-pane state (keyed by `session:paneId`) persists across passes. Runs as a plain foreground process — the zellij pane is the daemon.
|
|
82
|
+
|
|
83
|
+
> **Single-pane `monitor <id>` mode** is text-only: no account API, just screen scraping against the current session.
|
|
84
|
+
|
|
85
|
+
> **Multi-account (Linux).** Account discovery reads `CLAUDE_CONFIG_DIR` from every live Claude process via `/proc` and polls usage for each account. On non-Linux the daemon uses the default account (`~/.claude`) and falls back to on-screen time parsing when the API is unavailable.
|
|
72
86
|
|
|
73
87
|
## Requirements
|
|
74
88
|
|
package/dist/monitor.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { match } from "./patterns.js";
|
|
1
|
+
import { match, isBlockedAtBanner } from "./patterns.js";
|
|
2
2
|
import { parseResetTime, calculateWaitMs } from "./time-parser.js";
|
|
3
3
|
const MAX_MISSES = 3;
|
|
4
4
|
export function createState() {
|
|
@@ -79,7 +79,13 @@ async function stepState(state, screenText, now, injectContinue, marginSeconds,
|
|
|
79
79
|
const { dir: accountDir, usage } = await resolveAccountUsage(snapshot, resolvePaneAccount, target);
|
|
80
80
|
if (accountDir !== null && usage !== undefined) {
|
|
81
81
|
if (!usage.limited) {
|
|
82
|
-
//
|
|
82
|
+
// Account cleared. Either truly stale/incidental, OR a pane parked at a
|
|
83
|
+
// limit banner whose quota just reset (restart-after-reset / reopened-idle).
|
|
84
|
+
if (isBlockedAtBanner(screenText)) {
|
|
85
|
+
await injectContinue();
|
|
86
|
+
logger(`${label} cleared-limit banner at bottom — injected continue`);
|
|
87
|
+
return 'retried';
|
|
88
|
+
}
|
|
83
89
|
logger(`${label} stale banner ignored (account not limited)`);
|
|
84
90
|
return 'monitoring';
|
|
85
91
|
}
|
|
@@ -101,6 +107,16 @@ async function stepState(state, screenText, now, injectContinue, marginSeconds,
|
|
|
101
107
|
const resetLine = result.resetLine ?? '';
|
|
102
108
|
const parsed = parseResetTime(resetLine);
|
|
103
109
|
const waitMs = calculateWaitMs(parsed, marginSeconds, fallbackHours, new Date(now));
|
|
110
|
+
if (waitMs <= 0) {
|
|
111
|
+
// Reset time already passed → limit is over (no roll-to-tomorrow).
|
|
112
|
+
if (isBlockedAtBanner(screenText)) {
|
|
113
|
+
await injectContinue();
|
|
114
|
+
logger(`${label} reset already passed — injected continue`);
|
|
115
|
+
return 'retried';
|
|
116
|
+
}
|
|
117
|
+
logger(`${label} stale banner ignored (reset already passed)`);
|
|
118
|
+
return 'monitoring';
|
|
119
|
+
}
|
|
104
120
|
state.waitUntil = now + waitMs;
|
|
105
121
|
state.status = 'waiting';
|
|
106
122
|
return 'rate-limited';
|
|
@@ -117,8 +133,8 @@ async function tickTarget(target, state, screenText, deps, marginSeconds, fallba
|
|
|
117
133
|
export async function runMonitor(paneId, deps, pollIntervalMs, marginSeconds, fallbackHours) {
|
|
118
134
|
const state = createState();
|
|
119
135
|
for (;;) {
|
|
120
|
-
await deps.sleep(pollIntervalMs ?? 5000);
|
|
121
136
|
await tick(paneId, state, deps, marginSeconds, fallbackHours);
|
|
137
|
+
await deps.sleep(pollIntervalMs ?? 5000);
|
|
122
138
|
}
|
|
123
139
|
}
|
|
124
140
|
/**
|
|
@@ -233,8 +249,8 @@ function logPaneStatus(log, label, before, state, status) {
|
|
|
233
249
|
export async function runMultiMonitor(deps, pollIntervalMs, marginSeconds, fallbackHours) {
|
|
234
250
|
const states = new Map();
|
|
235
251
|
for (;;) {
|
|
236
|
-
await deps.sleep(pollIntervalMs ?? 60000);
|
|
237
252
|
await multiTick(states, deps, marginSeconds, fallbackHours);
|
|
253
|
+
await deps.sleep(pollIntervalMs ?? 60000);
|
|
238
254
|
}
|
|
239
255
|
}
|
|
240
256
|
//# sourceMappingURL=monitor.js.map
|
package/dist/patterns.d.ts
CHANGED
|
@@ -4,4 +4,6 @@ export interface MatchResult {
|
|
|
4
4
|
resetLine: string | null;
|
|
5
5
|
}
|
|
6
6
|
export declare function match(text: string): MatchResult;
|
|
7
|
+
export declare function strictMatch(text: string): boolean;
|
|
8
|
+
export declare function isBlockedAtBanner(text: string, bottomLines?: number): boolean;
|
|
7
9
|
//# sourceMappingURL=patterns.d.ts.map
|
package/dist/patterns.js
CHANGED
|
@@ -47,4 +47,37 @@ export function match(text) {
|
|
|
47
47
|
}
|
|
48
48
|
return { limited: false, resetLine: null };
|
|
49
49
|
}
|
|
50
|
+
// Canonical Claude rate-limit banner phrasings — specific enough not to fire on
|
|
51
|
+
// normal code/output. Used for high-confidence detection.
|
|
52
|
+
const STRICT_PATTERNS = [
|
|
53
|
+
/you(?:'ve|'ve|'ve|\s+have)\s+hit\s+your\s+(?:usage|session)\s+limit/i,
|
|
54
|
+
/\d+-hour\s+limit\s+reached/i,
|
|
55
|
+
/usage\s+limit\s+reached/i,
|
|
56
|
+
/session\s+limit\b.*\bresets/i,
|
|
57
|
+
/upgrade\s+to\s+increase\s+your\s+usage\s+limit/i,
|
|
58
|
+
];
|
|
59
|
+
export function strictMatch(text) {
|
|
60
|
+
const stripped = stripAnsi(text);
|
|
61
|
+
return STRICT_PATTERNS.some((p) => p.test(stripped));
|
|
62
|
+
}
|
|
63
|
+
// True when a canonical banner sits in the bottom region of the screen — i.e.
|
|
64
|
+
// claude is parked at the limit message right above its input box, not merely
|
|
65
|
+
// displaying banner text somewhere in scrollback/discussion.
|
|
66
|
+
export function isBlockedAtBanner(text, bottomLines = 15) {
|
|
67
|
+
const stripped = stripAnsi(text);
|
|
68
|
+
const lines = stripped.split('\n');
|
|
69
|
+
// Trim trailing blank lines
|
|
70
|
+
let end = lines.length - 1;
|
|
71
|
+
while (end >= 0 && lines[end].trim() === '') {
|
|
72
|
+
end--;
|
|
73
|
+
}
|
|
74
|
+
// Collect last `bottomLines` non-empty lines
|
|
75
|
+
const nonEmpty = [];
|
|
76
|
+
for (let i = end; i >= 0 && nonEmpty.length < bottomLines; i--) {
|
|
77
|
+
if (lines[i].trim() !== '') {
|
|
78
|
+
nonEmpty.unshift(lines[i]);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return strictMatch(nonEmpty.join('\n'));
|
|
82
|
+
}
|
|
50
83
|
//# sourceMappingURL=patterns.js.map
|
package/dist/time-parser.js
CHANGED
|
@@ -99,17 +99,8 @@ export function calculateWaitMs(parsed, marginSeconds = 60, fallbackHours = 5, n
|
|
|
99
99
|
candidateUtcMs = corrected;
|
|
100
100
|
}
|
|
101
101
|
if (candidateUtcMs <= nowMs) {
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
candidateUtcMs = candidateLocalMs + tzOffset;
|
|
105
|
-
for (let i = 0; i < 3; i++) {
|
|
106
|
-
const candidateDate = new Date(candidateUtcMs);
|
|
107
|
-
const candidateTzOffset = getOffsetMs(timezone, candidateDate);
|
|
108
|
-
const corrected = candidateLocalMs + candidateTzOffset;
|
|
109
|
-
if (corrected === candidateUtcMs)
|
|
110
|
-
break;
|
|
111
|
-
candidateUtcMs = corrected;
|
|
112
|
-
}
|
|
102
|
+
// Reset time already passed — signal "already reset" (non-positive delta)
|
|
103
|
+
return candidateUtcMs - nowMs;
|
|
113
104
|
}
|
|
114
105
|
return candidateUtcMs - nowMs + marginSeconds * 1000;
|
|
115
106
|
}
|
|
@@ -118,9 +109,10 @@ export function calculateWaitMs(parsed, marginSeconds = 60, fallbackHours = 5, n
|
|
|
118
109
|
const nowMs = now.getTime();
|
|
119
110
|
const nowDate = now;
|
|
120
111
|
const midnight = Date.UTC(nowDate.getUTCFullYear(), nowDate.getUTCMonth(), nowDate.getUTCDate());
|
|
121
|
-
|
|
112
|
+
const candidateMs = midnight + h * 3600000 + minute * 60000;
|
|
122
113
|
if (candidateMs <= nowMs) {
|
|
123
|
-
|
|
114
|
+
// Reset time already passed — signal "already reset" (non-positive delta)
|
|
115
|
+
return candidateMs - nowMs;
|
|
124
116
|
}
|
|
125
117
|
return candidateMs - nowMs + marginSeconds * 1000;
|
|
126
118
|
}
|
|
@@ -131,18 +123,16 @@ export function calculateWaitMs(parsed, marginSeconds = 60, fallbackHours = 5, n
|
|
|
131
123
|
const amHour = hour === 12 ? 0 : hour;
|
|
132
124
|
const waitAm = tryCalculate(amHour);
|
|
133
125
|
const waitPm = tryCalculate(pmHour);
|
|
134
|
-
// Return the sooner positive wait
|
|
126
|
+
// Return the sooner positive wait; if both past, return least-negative (most recent reset)
|
|
135
127
|
if (waitAm > 0 && waitPm > 0)
|
|
136
128
|
return Math.min(waitAm, waitPm);
|
|
137
129
|
if (waitAm > 0)
|
|
138
130
|
return waitAm;
|
|
139
131
|
if (waitPm > 0)
|
|
140
132
|
return waitPm;
|
|
141
|
-
return
|
|
133
|
+
// Both past: return closest-to-zero (most recent reset)
|
|
134
|
+
return Math.max(waitAm, waitPm);
|
|
142
135
|
}
|
|
143
|
-
|
|
144
|
-
if (wait <= 0)
|
|
145
|
-
return fallbackMs;
|
|
146
|
-
return wait;
|
|
136
|
+
return tryCalculate(hour);
|
|
147
137
|
}
|
|
148
138
|
//# sourceMappingURL=time-parser.js.map
|