@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 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
- // Wait period elapsed only inject if the limit banner is still present.
20
- // If it's gone (claude exited, shell prompt, pane reused, user already continued)
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
- let accountDir = null;
38
- if (snapshot.byDir.size === 1) {
39
- // Single account — always attributable, regardless of limited state
40
- // (covers both fresh-limit and stale-banner cases without resolver)
41
- accountDir = [...snapshot.byDir.keys()][0];
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
- else if (target !== undefined && resolvePaneAccount !== undefined) {
55
- // Ambiguous (0 or 2+) try proc bridge (phase 2 stub, returns null)
56
- accountDir = await resolvePaneAccount(target, snapshot);
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
- if (accountDir !== null) {
60
- const usage = snapshot.byDir.get(accountDir);
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
- // Fetch account snapshot once per pass (swallow errors undefined).
129
- let snapshot;
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.delete(key);
143
- log(`${key} gone — dropped from watch`);
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 capture/inject failed — leave its state, keep going.
163
- log(`${target.label} — capture/inject error (skipped this round)`);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tigorhutasuhut/claude-retry",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Monitor Claude CLI in a zellij pane, auto-inject continue on rate-limit",
5
5
  "type": "module",
6
6
  "license": "MIT",