@tigorhutasuhut/claude-retry 0.1.4 → 0.1.6
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/dist/monitor.d.ts +5 -0
- package/dist/monitor.js +124 -69
- package/package.json +1 -1
package/dist/monitor.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export type MonitorStatus = 'monitoring' | 'rate-limited' | 'retried' | 'exited'
|
|
|
26
26
|
export interface MonitorState {
|
|
27
27
|
status: 'monitoring' | 'waiting';
|
|
28
28
|
waitUntil: number;
|
|
29
|
+
missCount: number;
|
|
29
30
|
}
|
|
30
31
|
export declare function createState(): MonitorState;
|
|
31
32
|
export declare function tick(paneId: string, state: MonitorState, deps: MonitorDeps, marginSeconds?: number, fallbackHours?: number): Promise<MonitorStatus>;
|
|
@@ -38,6 +39,10 @@ export declare function runMonitor(paneId: string, deps: MonitorDeps, pollInterv
|
|
|
38
39
|
* target's label (session:paneId), and persists across calls. A failed
|
|
39
40
|
* discovery or a single pane's capture/inject error is swallowed so one bad
|
|
40
41
|
* pane never stops the others.
|
|
42
|
+
*
|
|
43
|
+
* Uses a miss counter (MAX_MISSES) so transient list-panes failures don't
|
|
44
|
+
* prune waiting state immediately — a pane must be absent for MAX_MISSES
|
|
45
|
+
* consecutive passes before its state is dropped.
|
|
41
46
|
*/
|
|
42
47
|
export declare function multiTick(states: PaneStates, deps: MultiMonitorDeps, marginSeconds?: number, fallbackHours?: number): Promise<void>;
|
|
43
48
|
export declare function runMultiMonitor(deps: MultiMonitorDeps, pollIntervalMs?: number, marginSeconds?: number, fallbackHours?: number): Promise<void>;
|
package/dist/monitor.js
CHANGED
|
@@ -1,7 +1,36 @@
|
|
|
1
1
|
import { match } from "./patterns.js";
|
|
2
2
|
import { parseResetTime, calculateWaitMs } from "./time-parser.js";
|
|
3
|
+
const MAX_MISSES = 3;
|
|
3
4
|
export function createState() {
|
|
4
|
-
return { status: 'monitoring', waitUntil: 0 };
|
|
5
|
+
return { status: 'monitoring', waitUntil: 0, missCount: 0 };
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Resolve which account dir (if any) applies to this pane, and return its usage.
|
|
9
|
+
* Mirrors the account-resolution logic previously inline in the monitoring branch.
|
|
10
|
+
*/
|
|
11
|
+
async function resolveAccountUsage(snapshot, resolvePaneAccount, target) {
|
|
12
|
+
if (snapshot === undefined) {
|
|
13
|
+
return { dir: null, usage: undefined };
|
|
14
|
+
}
|
|
15
|
+
let dir = null;
|
|
16
|
+
if (snapshot.byDir.size === 1) {
|
|
17
|
+
dir = [...snapshot.byDir.keys()][0];
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
const limitedDirs = [];
|
|
21
|
+
for (const [d, usage] of snapshot.byDir) {
|
|
22
|
+
if (usage.limited)
|
|
23
|
+
limitedDirs.push(d);
|
|
24
|
+
}
|
|
25
|
+
if (limitedDirs.length === 1) {
|
|
26
|
+
dir = limitedDirs[0];
|
|
27
|
+
}
|
|
28
|
+
else if (target !== undefined && resolvePaneAccount !== undefined) {
|
|
29
|
+
dir = await resolvePaneAccount(target, snapshot);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const usage = dir !== null ? snapshot.byDir.get(dir) : undefined;
|
|
33
|
+
return { dir, usage };
|
|
5
34
|
}
|
|
6
35
|
/**
|
|
7
36
|
* Core state transition for one pane, given its current screen text.
|
|
@@ -12,17 +41,36 @@ export function createState() {
|
|
|
12
41
|
* account-aware limit resolution. Single-pane callers omit these — text path only.
|
|
13
42
|
*/
|
|
14
43
|
async function stepState(state, screenText, now, injectContinue, marginSeconds, fallbackHours, snapshot, resolvePaneAccount, target, log) {
|
|
44
|
+
const logger = log ?? (() => { });
|
|
45
|
+
const label = target?.label ?? 'pane';
|
|
15
46
|
if (state.status === 'waiting') {
|
|
47
|
+
const limited = match(screenText).limited;
|
|
48
|
+
const { usage } = await resolveAccountUsage(snapshot, resolvePaneAccount, target);
|
|
49
|
+
const marginMs = (marginSeconds ?? 60) * 1000;
|
|
50
|
+
// 1. Banner gone → limit cleared / claude exited / user already continued / pane reused
|
|
51
|
+
if (!limited) {
|
|
52
|
+
state.status = 'monitoring';
|
|
53
|
+
state.waitUntil = 0;
|
|
54
|
+
logger(`${label} wait abandoned (banner gone)`);
|
|
55
|
+
return 'monitoring';
|
|
56
|
+
}
|
|
57
|
+
// 2. Account known and NOT limited → stale banner persisting → drop
|
|
58
|
+
if (usage !== undefined && !usage.limited) {
|
|
59
|
+
state.status = 'monitoring';
|
|
60
|
+
state.waitUntil = 0;
|
|
61
|
+
logger(`${label} wait abandoned (account not limited)`);
|
|
62
|
+
return 'monitoring';
|
|
63
|
+
}
|
|
64
|
+
// 3. Account known, still limited, with a fresh resetsAtMs → refresh waitUntil
|
|
65
|
+
if (usage !== undefined && usage.limited && usage.resetsAtMs !== null) {
|
|
66
|
+
state.waitUntil = usage.resetsAtMs + marginMs;
|
|
67
|
+
}
|
|
68
|
+
// 4. Timer not elapsed → keep waiting
|
|
16
69
|
if (now < state.waitUntil) {
|
|
17
70
|
return 'rate-limited';
|
|
18
71
|
}
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
// skip the injection and return to monitoring silently.
|
|
22
|
-
const stillLimited = match(screenText).limited;
|
|
23
|
-
if (stillLimited) {
|
|
24
|
-
await injectContinue();
|
|
25
|
-
}
|
|
72
|
+
// 5. Elapsed + banner still present (+ account limited or unknown) → inject
|
|
73
|
+
await injectContinue();
|
|
26
74
|
state.status = 'monitoring';
|
|
27
75
|
state.waitUntil = 0;
|
|
28
76
|
return 'retried';
|
|
@@ -30,53 +78,26 @@ async function stepState(state, screenText, now, injectContinue, marginSeconds,
|
|
|
30
78
|
// state.status === 'monitoring'
|
|
31
79
|
const result = match(screenText);
|
|
32
80
|
if (result.limited) {
|
|
33
|
-
const label = target?.label ?? 'pane';
|
|
34
|
-
const logger = log ?? (() => { });
|
|
35
81
|
// Tier 1: account-aware resolution when snapshot is available
|
|
36
82
|
if (snapshot !== undefined) {
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
else {
|
|
44
|
-
// Find dirs that are limited in snapshot
|
|
45
|
-
const limitedDirs = [];
|
|
46
|
-
for (const [dir, usage] of snapshot.byDir) {
|
|
47
|
-
if (usage.limited)
|
|
48
|
-
limitedDirs.push(dir);
|
|
49
|
-
}
|
|
50
|
-
if (limitedDirs.length === 1) {
|
|
51
|
-
// Exactly one limited account — attribute banner to it
|
|
52
|
-
accountDir = limitedDirs[0];
|
|
83
|
+
const { dir: accountDir, usage } = await resolveAccountUsage(snapshot, resolvePaneAccount, target);
|
|
84
|
+
if (accountDir !== null && usage !== undefined) {
|
|
85
|
+
if (!usage.limited) {
|
|
86
|
+
// Staleness gate: account is not limited → banner is stale → ignore
|
|
87
|
+
logger(`${label} stale banner ignored (account not limited)`);
|
|
88
|
+
return 'monitoring';
|
|
53
89
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
90
|
+
// Account confirmed limited — use resetsAtMs if available
|
|
91
|
+
const marginMs = (marginSeconds ?? 60) * 1000;
|
|
92
|
+
if (usage.resetsAtMs !== null) {
|
|
93
|
+
state.waitUntil = usage.resetsAtMs + marginMs;
|
|
94
|
+
state.status = 'waiting';
|
|
95
|
+
logger(`${label} account ${accountDir} limited, reset ${new Date(usage.resetsAtMs).toISOString()}`);
|
|
96
|
+
return 'rate-limited';
|
|
57
97
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (usage !== undefined) {
|
|
62
|
-
if (!usage.limited) {
|
|
63
|
-
// Staleness gate: account is not limited → banner is stale → ignore
|
|
64
|
-
logger(`${label} stale banner ignored (account not limited)`);
|
|
65
|
-
return 'monitoring';
|
|
66
|
-
}
|
|
67
|
-
// Account confirmed limited — use resetsAtMs if available
|
|
68
|
-
const marginMs = (marginSeconds ?? 60) * 1000;
|
|
69
|
-
if (usage.resetsAtMs !== null) {
|
|
70
|
-
state.waitUntil = usage.resetsAtMs + marginMs;
|
|
71
|
-
state.status = 'waiting';
|
|
72
|
-
logger(`${label} account ${accountDir} limited, reset ${new Date(usage.resetsAtMs).toISOString()}`);
|
|
73
|
-
return 'rate-limited';
|
|
74
|
-
}
|
|
75
|
-
// resetsAtMs null — fall through to text parse for the time, but
|
|
76
|
-
// we know account is limited so we don't need to gate on text
|
|
77
|
-
// (fall through to tier 3 below)
|
|
78
|
-
}
|
|
79
|
-
// usage missing for this dir — fall through to tier 3
|
|
98
|
+
// resetsAtMs null — fall through to text parse for the time, but
|
|
99
|
+
// we know account is limited so we don't need to gate on text
|
|
100
|
+
// (fall through to tier 3 below)
|
|
80
101
|
}
|
|
81
102
|
// accountDir null or usage missing — fall through to tier 3
|
|
82
103
|
}
|
|
@@ -94,8 +115,7 @@ export async function tick(paneId, state, deps, marginSeconds, fallbackHours) {
|
|
|
94
115
|
const screenText = await deps.capture(paneId);
|
|
95
116
|
return stepState(state, screenText, deps.now(), () => deps.inject(paneId, 'continue'), marginSeconds, fallbackHours);
|
|
96
117
|
}
|
|
97
|
-
async function tickTarget(target, state, deps, marginSeconds, fallbackHours, snapshot) {
|
|
98
|
-
const screenText = await deps.capture(target);
|
|
118
|
+
async function tickTarget(target, state, screenText, deps, marginSeconds, fallbackHours, snapshot) {
|
|
99
119
|
return stepState(state, screenText, deps.now(), () => deps.inject(target, 'continue'), marginSeconds, fallbackHours, snapshot, deps.resolvePaneAccount, target, deps.log);
|
|
100
120
|
}
|
|
101
121
|
export async function runMonitor(paneId, deps, pollIntervalMs, marginSeconds, fallbackHours) {
|
|
@@ -113,6 +133,10 @@ export async function runMonitor(paneId, deps, pollIntervalMs, marginSeconds, fa
|
|
|
113
133
|
* target's label (session:paneId), and persists across calls. A failed
|
|
114
134
|
* discovery or a single pane's capture/inject error is swallowed so one bad
|
|
115
135
|
* pane never stops the others.
|
|
136
|
+
*
|
|
137
|
+
* Uses a miss counter (MAX_MISSES) so transient list-panes failures don't
|
|
138
|
+
* prune waiting state immediately — a pane must be absent for MAX_MISSES
|
|
139
|
+
* consecutive passes before its state is dropped.
|
|
116
140
|
*/
|
|
117
141
|
export async function multiTick(states, deps, marginSeconds, fallbackHours) {
|
|
118
142
|
const log = deps.log ?? (() => { });
|
|
@@ -125,42 +149,73 @@ export async function multiTick(states, deps, marginSeconds, fallbackHours) {
|
|
|
125
149
|
log('scan failed: could not list sessions/panes (will retry)');
|
|
126
150
|
return;
|
|
127
151
|
}
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
if (deps.getAccountSnapshot !== undefined) {
|
|
131
|
-
try {
|
|
132
|
-
snapshot = await deps.getAccountSnapshot();
|
|
133
|
-
}
|
|
134
|
-
catch {
|
|
135
|
-
snapshot = undefined;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
// Prune state for panes that no longer exist.
|
|
152
|
+
// Prune state for panes that no longer exist, using miss counter to tolerate
|
|
153
|
+
// transient list-panes failures.
|
|
139
154
|
const live = new Set(targets.map((t) => t.label));
|
|
140
155
|
for (const key of [...states.keys()]) {
|
|
141
156
|
if (!live.has(key)) {
|
|
142
|
-
states.
|
|
143
|
-
|
|
157
|
+
const s = states.get(key);
|
|
158
|
+
s.missCount++;
|
|
159
|
+
if (s.missCount >= MAX_MISSES) {
|
|
160
|
+
states.delete(key);
|
|
161
|
+
log(`${key} gone — dropped after ${MAX_MISSES} misses`);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
log(`${key} missing (${s.missCount}/${MAX_MISSES}) — keeping state`);
|
|
165
|
+
}
|
|
144
166
|
}
|
|
145
167
|
}
|
|
146
168
|
log(targets.length === 0
|
|
147
169
|
? 'scan: no Claude panes found'
|
|
148
170
|
: `scan: watching ${targets.length} Claude pane(s) [${targets.map((t) => t.label).join(', ')}]`);
|
|
171
|
+
// Capture each target's screen once, collecting successes into a map.
|
|
172
|
+
// Capture failures are logged and that pane is skipped this round.
|
|
173
|
+
const screens = new Map();
|
|
174
|
+
for (const target of targets) {
|
|
175
|
+
try {
|
|
176
|
+
screens.set(target.label, await deps.capture(target));
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
log(`${target.label} — capture error (skipped this round)`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Decide whether usage API is needed this pass:
|
|
183
|
+
// - any pane already in 'waiting' state (among current targets), OR
|
|
184
|
+
// - any captured screen has a limit banner.
|
|
185
|
+
const anyWaiting = targets.some((t) => states.get(t.label)?.status === 'waiting');
|
|
186
|
+
const anyBanner = [...screens.values()].some((s) => match(s).limited);
|
|
187
|
+
const needUsage = anyWaiting || anyBanner;
|
|
188
|
+
// Fetch account snapshot only when needed (swallow errors → undefined).
|
|
189
|
+
let snapshot;
|
|
190
|
+
if (needUsage && deps.getAccountSnapshot !== undefined) {
|
|
191
|
+
try {
|
|
192
|
+
snapshot = await deps.getAccountSnapshot();
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
snapshot = undefined;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
149
198
|
for (const target of targets) {
|
|
199
|
+
const screenText = screens.get(target.label);
|
|
200
|
+
// Skip panes whose capture failed this round.
|
|
201
|
+
if (screenText === undefined)
|
|
202
|
+
continue;
|
|
150
203
|
let state = states.get(target.label);
|
|
151
204
|
if (!state) {
|
|
152
205
|
state = createState();
|
|
153
206
|
states.set(target.label, state);
|
|
154
207
|
log(`${target.label} — new Claude pane, now watching`);
|
|
155
208
|
}
|
|
209
|
+
// Reset miss counter for panes present this pass
|
|
210
|
+
state.missCount = 0;
|
|
156
211
|
const before = state.status;
|
|
157
212
|
try {
|
|
158
|
-
const status = await tickTarget(target, state, deps, marginSeconds, fallbackHours, snapshot);
|
|
213
|
+
const status = await tickTarget(target, state, screenText, deps, marginSeconds, fallbackHours, snapshot);
|
|
159
214
|
logPaneStatus(log, target.label, before, state, status);
|
|
160
215
|
}
|
|
161
216
|
catch {
|
|
162
|
-
// This pane's
|
|
163
|
-
log(`${target.label} —
|
|
217
|
+
// This pane's inject failed — leave its state, keep going.
|
|
218
|
+
log(`${target.label} — inject error (skipped this round)`);
|
|
164
219
|
}
|
|
165
220
|
}
|
|
166
221
|
}
|