@tigorhutasuhut/claude-retry 0.1.4 → 0.1.5

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
  }
@@ -113,6 +134,10 @@ export async function runMonitor(paneId, deps, pollIntervalMs, marginSeconds, fa
113
134
  * target's label (session:paneId), and persists across calls. A failed
114
135
  * discovery or a single pane's capture/inject error is swallowed so one bad
115
136
  * pane never stops the others.
137
+ *
138
+ * Uses a miss counter (MAX_MISSES) so transient list-panes failures don't
139
+ * prune waiting state immediately — a pane must be absent for MAX_MISSES
140
+ * consecutive passes before its state is dropped.
116
141
  */
117
142
  export async function multiTick(states, deps, marginSeconds, fallbackHours) {
118
143
  const log = deps.log ?? (() => { });
@@ -135,12 +160,20 @@ export async function multiTick(states, deps, marginSeconds, fallbackHours) {
135
160
  snapshot = undefined;
136
161
  }
137
162
  }
138
- // Prune state for panes that no longer exist.
163
+ // Prune state for panes that no longer exist, using miss counter to tolerate
164
+ // transient list-panes failures.
139
165
  const live = new Set(targets.map((t) => t.label));
140
166
  for (const key of [...states.keys()]) {
141
167
  if (!live.has(key)) {
142
- states.delete(key);
143
- log(`${key} gone — dropped from watch`);
168
+ const s = states.get(key);
169
+ s.missCount++;
170
+ if (s.missCount >= MAX_MISSES) {
171
+ states.delete(key);
172
+ log(`${key} gone — dropped after ${MAX_MISSES} misses`);
173
+ }
174
+ else {
175
+ log(`${key} missing (${s.missCount}/${MAX_MISSES}) — keeping state`);
176
+ }
144
177
  }
145
178
  }
146
179
  log(targets.length === 0
@@ -153,6 +186,8 @@ export async function multiTick(states, deps, marginSeconds, fallbackHours) {
153
186
  states.set(target.label, state);
154
187
  log(`${target.label} — new Claude pane, now watching`);
155
188
  }
189
+ // Reset miss counter for panes present this pass
190
+ state.missCount = 0;
156
191
  const before = state.status;
157
192
  try {
158
193
  const status = await tickTarget(target, state, deps, marginSeconds, fallbackHours, snapshot);
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.5",
4
4
  "description": "Monitor Claude CLI in a zellij pane, auto-inject continue on rate-limit",
5
5
  "type": "module",
6
6
  "license": "MIT",