@tigorhutasuhut/claude-retry 0.1.2 → 0.1.3

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 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 rate-limit banner, waits until that pane's reset time, then clears the input and injects `continue` to resume automatically. One daemon covers every session at once — even detached ones.
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, then confirms against Anthropic's usage API — the same data the `/usage` command shows — to get the **exact** reset time and to discard stale 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
 
@@ -59,16 +59,24 @@ Every pass (60s for `start`, 5s for single-pane `monitor`):
59
59
 
60
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 ones pruned.
61
61
  2. **Capture** — each pane's visible screen is dumped with `zellij --session <name> action dump-screen --pane-id <id>` (ANSI stripped). This works on detached sessions, no attached client required.
62
- 3. **Match** — the text is checked against the rate-limit patterns. Panes that aren't showing a limit banner are simply left alone — no pane-identification guesswork needed.
63
- 4. **Retry** — on detection, the reset time is parsed and the pane is marked `waiting`. Once the reset 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`.
62
+ 3. **Match** — the text is checked against the rate-limit patterns. Panes that aren't showing a limit banner are simply left alone.
63
+ 4. **Resolve** — on a banner match, the reset time is determined via a three-tier cascade:
64
+ - **Tier 1 — usage API (primary).** Once per pass, the daemon discovers every Claude account in use by reading `CLAUDE_CONFIG_DIR` from each Claude process via `/proc` (Linux). For each account it calls `GET https://api.anthropic.com/api/oauth/usage` with the OAuth token from `<CLAUDE_CONFIG_DIR>/.credentials.json`. If the account is **not** limited the banner is stale — it is silently ignored, no wait issued. If the account **is** limited the daemon waits until the API's exact `resets_at` timestamp. Credentials are re-read every pass, so token refreshes are picked up automatically.
65
+ - **Tier 2 — /proc pane→account bridge (planned).** Needed only when two or more accounts are limited simultaneously, so the daemon must map a specific pane to its account. This is a phase-2 stub; until implemented, that case falls through to tier 3.
66
+ - **Tier 3 — text fallback.** Used when the API is unreachable, the account is unknown, or tier 2 is unresolved. Falls back to parsing the reset time from the on-screen banner text (the original behavior). A banner is never silently ignored when the account is unknown — this ensures a real limit is never missed.
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`.
64
68
 
65
69
  Per-pane state (keyed by `session:paneId`) persists across passes, so a pane mid-wait isn't disturbed by rediscovery. It runs as a plain foreground process — no transparent session wrapping, no external daemon. The zellij pane is the daemon.
66
70
 
71
+ > **Multi-account note.** On Linux, account discovery reads `CLAUDE_CONFIG_DIR` from every live Claude process via `/proc`. This means the daemon polls usage for every account in use — not just the default one. On non-Linux systems it falls back to the default account (`~/.claude`) plus tier-3 text parsing.
72
+
67
73
  ## Requirements
68
74
 
69
- - Node.js >= 20
75
+ - Node.js >= 20 (required for global `fetch`, used by the usage API)
70
76
  - zellij >= 0.40
71
77
  - Must be inside a zellij session when running
78
+ - A logged-in Claude Code installation with a valid `<CLAUDE_CONFIG_DIR>/.credentials.json` (for usage-API tier 1 detection; without it the daemon degrades to text parsing)
79
+ - `/proc` account discovery is Linux-only; on other platforms the daemon uses the default account and text fallback
72
80
 
73
81
  ## Development
74
82
 
@@ -0,0 +1,35 @@
1
+ import { type AccountUsage } from './usage.ts';
2
+ import type { PaneTarget } from './zellij.ts';
3
+ export interface AccountSnapshot {
4
+ byDir: Map<string, AccountUsage>;
5
+ }
6
+ export declare function parseConfigDirFromEnviron(buf: string): string | null;
7
+ export interface DiscoverDeps {
8
+ platform: string;
9
+ readdir: (path: string) => Promise<string[]>;
10
+ readFile: (path: string) => Promise<string>;
11
+ defaultDir: () => string;
12
+ }
13
+ export interface ProcDeps {
14
+ platform?: string;
15
+ listProcPids: () => Promise<string[]>;
16
+ readCmdline: (pid: string) => Promise<string>;
17
+ readEnviron: (pid: string) => Promise<string>;
18
+ listFds: (pid: string) => Promise<string[]>;
19
+ readlink: (path: string) => Promise<string>;
20
+ }
21
+ interface ClaudeProc {
22
+ pid: string;
23
+ configDir: string;
24
+ pts: string | null;
25
+ }
26
+ interface ZellijServer {
27
+ session: string;
28
+ pts: Set<string>;
29
+ }
30
+ export declare function listClaudeProcs(deps: ProcDeps): Promise<ClaudeProc[]>;
31
+ export declare function listZellijServers(deps: ProcDeps): Promise<ZellijServer[]>;
32
+ export declare function discoverAccountDirs(deps?: Partial<DiscoverDeps>): Promise<string[]>;
33
+ export declare function resolvePaneConfigDir(target: PaneTarget, _snapshot: AccountSnapshot, deps?: ProcDeps): Promise<string | null>;
34
+ export {};
35
+ //# sourceMappingURL=accounts.d.ts.map
@@ -0,0 +1,197 @@
1
+ import { defaultConfigDir } from "./usage.js";
2
+ export function parseConfigDirFromEnviron(buf) {
3
+ const entries = buf.split('\0');
4
+ for (const entry of entries) {
5
+ if (entry.startsWith('CLAUDE_CONFIG_DIR=')) {
6
+ const val = entry.slice('CLAUDE_CONFIG_DIR='.length);
7
+ return val.length > 0 ? val : null;
8
+ }
9
+ }
10
+ return null;
11
+ }
12
+ function defaultProcDeps() {
13
+ return {
14
+ platform: process.platform,
15
+ listProcPids: async () => {
16
+ const { readdir } = await import('node:fs/promises');
17
+ const entries = await readdir('/proc');
18
+ return entries.filter(e => /^\d+$/.test(e));
19
+ },
20
+ readCmdline: async (pid) => {
21
+ const { readFile } = await import('node:fs/promises');
22
+ return readFile(`/proc/${pid}/cmdline`, 'utf8');
23
+ },
24
+ readEnviron: async (pid) => {
25
+ const { readFile } = await import('node:fs/promises');
26
+ return readFile(`/proc/${pid}/environ`, 'utf8');
27
+ },
28
+ listFds: async (pid) => {
29
+ const { readdir } = await import('node:fs/promises');
30
+ return readdir(`/proc/${pid}/fd`);
31
+ },
32
+ readlink: async (path) => {
33
+ const { readlink } = await import('node:fs/promises');
34
+ return readlink(path);
35
+ },
36
+ };
37
+ }
38
+ const CONTRACT_VERSION_SEG = '/contract_version_1/';
39
+ export async function listClaudeProcs(deps) {
40
+ const result = [];
41
+ let pids;
42
+ try {
43
+ pids = await deps.listProcPids();
44
+ }
45
+ catch {
46
+ return result;
47
+ }
48
+ await Promise.all(pids.map(async (pid) => {
49
+ try {
50
+ const cmdline = await deps.readCmdline(pid);
51
+ if (!cmdline.includes('claude'))
52
+ return;
53
+ let configDir;
54
+ try {
55
+ const environ = await deps.readEnviron(pid);
56
+ configDir = parseConfigDirFromEnviron(environ) ?? defaultConfigDir();
57
+ }
58
+ catch {
59
+ configDir = defaultConfigDir();
60
+ }
61
+ let pts = null;
62
+ try {
63
+ const target = await deps.readlink(`/proc/${pid}/fd/0`);
64
+ if (/^\/dev\/pts\/\d+$/.test(target)) {
65
+ pts = target;
66
+ }
67
+ }
68
+ catch {
69
+ // fd/0 unreadable — pts stays null
70
+ }
71
+ result.push({ pid, configDir, pts });
72
+ }
73
+ catch {
74
+ // skip unreadable pid
75
+ }
76
+ }));
77
+ return result;
78
+ }
79
+ export async function listZellijServers(deps) {
80
+ const result = [];
81
+ let pids;
82
+ try {
83
+ pids = await deps.listProcPids();
84
+ }
85
+ catch {
86
+ return result;
87
+ }
88
+ await Promise.all(pids.map(async (pid) => {
89
+ try {
90
+ const cmdline = await deps.readCmdline(pid);
91
+ const args = cmdline.split('\0');
92
+ const serverIdx = args.indexOf('--server');
93
+ if (serverIdx === -1)
94
+ return;
95
+ const serverPath = args[serverIdx + 1];
96
+ if (serverPath === undefined)
97
+ return;
98
+ const idx = serverPath.lastIndexOf(CONTRACT_VERSION_SEG);
99
+ if (idx === -1)
100
+ return;
101
+ const session = serverPath.slice(idx + CONTRACT_VERSION_SEG.length).trimEnd();
102
+ if (!session)
103
+ return;
104
+ const pts = new Set();
105
+ try {
106
+ const fds = await deps.listFds(pid);
107
+ await Promise.all(fds.map(async (fd) => {
108
+ try {
109
+ const target = await deps.readlink(`/proc/${pid}/fd/${fd}`);
110
+ if (/^\/dev\/pts\/\d+$/.test(target)) {
111
+ pts.add(target);
112
+ }
113
+ }
114
+ catch {
115
+ // skip unreadable fd
116
+ }
117
+ }));
118
+ }
119
+ catch {
120
+ // listFds failed — server with empty pts still recorded
121
+ }
122
+ result.push({ session, pts });
123
+ }
124
+ catch {
125
+ // skip unreadable pid
126
+ }
127
+ }));
128
+ return result;
129
+ }
130
+ export async function discoverAccountDirs(deps) {
131
+ const platform = deps?.platform ?? process.platform;
132
+ const readdirFn = deps?.readdir ?? (async (p) => {
133
+ const { readdir } = await import('node:fs/promises');
134
+ return readdir(p);
135
+ });
136
+ const readFileFn = deps?.readFile ?? (async (p) => {
137
+ const { readFile } = await import('node:fs/promises');
138
+ return readFile(p, 'utf8');
139
+ });
140
+ const defaultDir = deps?.defaultDir ?? (() => defaultConfigDir());
141
+ const base = defaultDir();
142
+ const dirs = new Set([base]);
143
+ if (platform !== 'linux') {
144
+ return [base];
145
+ }
146
+ try {
147
+ const entries = await readdirFn('/proc');
148
+ const pids = entries.filter(e => /^\d+$/.test(e));
149
+ await Promise.all(pids.map(async (pid) => {
150
+ try {
151
+ const cmdline = await readFileFn(`/proc/${pid}/cmdline`);
152
+ if (!cmdline.includes('claude'))
153
+ return;
154
+ const environ = await readFileFn(`/proc/${pid}/environ`);
155
+ const dir = parseConfigDirFromEnviron(environ) ?? base;
156
+ dirs.add(dir);
157
+ }
158
+ catch {
159
+ // skip unreadable pids
160
+ }
161
+ }));
162
+ }
163
+ catch {
164
+ // /proc unreadable or other failure → return default only
165
+ return [base];
166
+ }
167
+ return Array.from(dirs);
168
+ }
169
+ export async function resolvePaneConfigDir(target, _snapshot, deps) {
170
+ try {
171
+ const d = deps ?? defaultProcDeps();
172
+ const platform = d.platform ?? process.platform;
173
+ if (platform !== 'linux')
174
+ return null;
175
+ const [servers, claudeProcs] = await Promise.all([
176
+ listZellijServers(d),
177
+ listClaudeProcs(d),
178
+ ]);
179
+ const server = servers.find(s => s.session === target.session);
180
+ if (server === undefined)
181
+ return null;
182
+ const dirs = new Set();
183
+ for (const proc of claudeProcs) {
184
+ if (proc.pts !== null && server.pts.has(proc.pts)) {
185
+ dirs.add(proc.configDir);
186
+ }
187
+ }
188
+ if (dirs.size === 1) {
189
+ return [...dirs][0];
190
+ }
191
+ return null;
192
+ }
193
+ catch {
194
+ return null;
195
+ }
196
+ }
197
+ //# sourceMappingURL=accounts.js.map
package/dist/cli.js CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { capturePane, inject, listPaneTargets, captureTarget, injectTarget, } from "./zellij.js";
3
3
  import { runMonitor, runMultiMonitor } from "./monitor.js";
4
+ import { readAccessToken, fetchUsage } from "./usage.js";
5
+ import { discoverAccountDirs, resolvePaneConfigDir } from "./accounts.js";
4
6
  const USAGE = `claude-retry — Auto-inject 'continue' when Claude hits a rate limit in zellij
5
7
 
6
8
  Usage:
@@ -37,6 +39,25 @@ const multiDeps = {
37
39
  now,
38
40
  sleep,
39
41
  log,
42
+ getAccountSnapshot: async () => {
43
+ const dirs = await discoverAccountDirs();
44
+ const byDir = new Map();
45
+ await Promise.all(dirs.map(async (dir) => {
46
+ try {
47
+ const tok = await readAccessToken(dir);
48
+ if (!tok)
49
+ return;
50
+ const usage = await fetchUsage(tok.token);
51
+ if (usage)
52
+ byDir.set(dir, usage);
53
+ }
54
+ catch {
55
+ // swallow per-account errors — one bad account must not break the pass
56
+ }
57
+ }));
58
+ return { byDir };
59
+ },
60
+ resolvePaneAccount: (t, snapshot) => resolvePaneConfigDir(t, snapshot),
40
61
  };
41
62
  const [, , subcommand, ...rest] = process.argv;
42
63
  async function main() {
@@ -54,6 +75,7 @@ async function main() {
54
75
  }
55
76
  case 'start': {
56
77
  log('claude-retry daemon starting — walking all sessions/panes (poll 60s)');
78
+ log('usage-API detection enabled — account-aware rate-limit resolution active');
57
79
  await runMultiMonitor(multiDeps);
58
80
  break;
59
81
  }
package/dist/monitor.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { PaneTarget } from './zellij.ts';
2
+ import type { AccountSnapshot } from './accounts.ts';
2
3
  export interface MonitorDeps {
3
4
  capture: (paneId: string) => Promise<string>;
4
5
  inject: (paneId: string, text: string) => Promise<void>;
@@ -15,6 +16,10 @@ export interface MultiMonitorDeps {
15
16
  sleep: (ms: number) => Promise<void>;
16
17
  /** Optional sink for chatty progress logs (wired to stderr by the CLI). */
17
18
  log?: (msg: string) => void;
19
+ /** If provided, called ONCE per multiTick pass to get current account usage snapshot. */
20
+ getAccountSnapshot?: () => Promise<AccountSnapshot>;
21
+ /** If provided, used to resolve which account a pane belongs to when ambiguous. */
22
+ resolvePaneAccount?: (t: PaneTarget, s: AccountSnapshot) => Promise<string | null>;
18
23
  }
19
24
  export type PaneStates = Map<string, MonitorState>;
20
25
  export type MonitorStatus = 'monitoring' | 'rate-limited' | 'retried' | 'exited';
package/dist/monitor.js CHANGED
@@ -7,8 +7,11 @@ export function createState() {
7
7
  * Core state transition for one pane, given its current screen text.
8
8
  * `injectContinue` is called only when a wait period has elapsed. Shared by
9
9
  * single-pane (tick) and multi-session (tickTarget) monitoring.
10
+ *
11
+ * When snapshot/resolver/target/log are provided, applies the three-tier
12
+ * account-aware limit resolution. Single-pane callers omit these — text path only.
10
13
  */
11
- async function stepState(state, screenText, now, injectContinue, marginSeconds, fallbackHours) {
14
+ async function stepState(state, screenText, now, injectContinue, marginSeconds, fallbackHours, snapshot, resolvePaneAccount, target, log) {
12
15
  if (state.status === 'waiting') {
13
16
  if (now < state.waitUntil) {
14
17
  return 'rate-limited';
@@ -22,6 +25,57 @@ async function stepState(state, screenText, now, injectContinue, marginSeconds,
22
25
  // state.status === 'monitoring'
23
26
  const result = match(screenText);
24
27
  if (result.limited) {
28
+ const label = target?.label ?? 'pane';
29
+ const logger = log ?? (() => { });
30
+ // Tier 1: account-aware resolution when snapshot is available
31
+ if (snapshot !== undefined) {
32
+ let accountDir = null;
33
+ if (snapshot.byDir.size === 1) {
34
+ // Single account — always attributable, regardless of limited state
35
+ // (covers both fresh-limit and stale-banner cases without resolver)
36
+ accountDir = [...snapshot.byDir.keys()][0];
37
+ }
38
+ else {
39
+ // Find dirs that are limited in snapshot
40
+ const limitedDirs = [];
41
+ for (const [dir, usage] of snapshot.byDir) {
42
+ if (usage.limited)
43
+ limitedDirs.push(dir);
44
+ }
45
+ if (limitedDirs.length === 1) {
46
+ // Exactly one limited account — attribute banner to it
47
+ accountDir = limitedDirs[0];
48
+ }
49
+ else if (target !== undefined && resolvePaneAccount !== undefined) {
50
+ // Ambiguous (0 or 2+) — try proc bridge (phase 2 stub, returns null)
51
+ accountDir = await resolvePaneAccount(target, snapshot);
52
+ }
53
+ }
54
+ if (accountDir !== null) {
55
+ const usage = snapshot.byDir.get(accountDir);
56
+ if (usage !== undefined) {
57
+ if (!usage.limited) {
58
+ // Staleness gate: account is not limited → banner is stale → ignore
59
+ logger(`${label} stale banner ignored (account not limited)`);
60
+ return 'monitoring';
61
+ }
62
+ // Account confirmed limited — use resetsAtMs if available
63
+ const marginMs = (marginSeconds ?? 60) * 1000;
64
+ if (usage.resetsAtMs !== null) {
65
+ state.waitUntil = usage.resetsAtMs + marginMs;
66
+ state.status = 'waiting';
67
+ logger(`${label} account ${accountDir} limited, reset ${new Date(usage.resetsAtMs).toISOString()}`);
68
+ return 'rate-limited';
69
+ }
70
+ // resetsAtMs null — fall through to text parse for the time, but
71
+ // we know account is limited so we don't need to gate on text
72
+ // (fall through to tier 3 below)
73
+ }
74
+ // usage missing for this dir — fall through to tier 3
75
+ }
76
+ // accountDir null or usage missing — fall through to tier 3
77
+ }
78
+ // Tier 3: text fallback (current behavior, unchanged)
25
79
  const resetLine = result.resetLine ?? '';
26
80
  const parsed = parseResetTime(resetLine);
27
81
  const waitMs = calculateWaitMs(parsed, marginSeconds, fallbackHours, new Date(now));
@@ -35,9 +89,9 @@ export async function tick(paneId, state, deps, marginSeconds, fallbackHours) {
35
89
  const screenText = await deps.capture(paneId);
36
90
  return stepState(state, screenText, deps.now(), () => deps.inject(paneId, 'continue'), marginSeconds, fallbackHours);
37
91
  }
38
- async function tickTarget(target, state, deps, marginSeconds, fallbackHours) {
92
+ async function tickTarget(target, state, deps, marginSeconds, fallbackHours, snapshot) {
39
93
  const screenText = await deps.capture(target);
40
- return stepState(state, screenText, deps.now(), () => deps.inject(target, 'continue'), marginSeconds, fallbackHours);
94
+ return stepState(state, screenText, deps.now(), () => deps.inject(target, 'continue'), marginSeconds, fallbackHours, snapshot, deps.resolvePaneAccount, target, deps.log);
41
95
  }
42
96
  export async function runMonitor(paneId, deps, pollIntervalMs, marginSeconds, fallbackHours) {
43
97
  const state = createState();
@@ -66,6 +120,16 @@ export async function multiTick(states, deps, marginSeconds, fallbackHours) {
66
120
  log('scan failed: could not list sessions/panes (will retry)');
67
121
  return;
68
122
  }
123
+ // Fetch account snapshot once per pass (swallow errors → undefined).
124
+ let snapshot;
125
+ if (deps.getAccountSnapshot !== undefined) {
126
+ try {
127
+ snapshot = await deps.getAccountSnapshot();
128
+ }
129
+ catch {
130
+ snapshot = undefined;
131
+ }
132
+ }
69
133
  // Prune state for panes that no longer exist.
70
134
  const live = new Set(targets.map((t) => t.label));
71
135
  for (const key of [...states.keys()]) {
@@ -86,7 +150,7 @@ export async function multiTick(states, deps, marginSeconds, fallbackHours) {
86
150
  }
87
151
  const before = state.status;
88
152
  try {
89
- const status = await tickTarget(target, state, deps, marginSeconds, fallbackHours);
153
+ const status = await tickTarget(target, state, deps, marginSeconds, fallbackHours, snapshot);
90
154
  logPaneStatus(log, target.label, before, state, status);
91
155
  }
92
156
  catch {
@@ -0,0 +1,23 @@
1
+ export interface WindowUsage {
2
+ utilization: number;
3
+ resetsAtMs: number | null;
4
+ }
5
+ export interface AccountUsage {
6
+ limited: boolean;
7
+ resetsAtMs: number | null;
8
+ }
9
+ export type FetchFn = (url: string, init: {
10
+ headers: Record<string, string>;
11
+ }) => Promise<{
12
+ status: number;
13
+ json: () => Promise<unknown>;
14
+ }>;
15
+ export type ReadFileFn = (path: string) => Promise<string>;
16
+ export declare const LIMIT_THRESHOLD: number;
17
+ export declare function defaultConfigDir(env?: NodeJS.ProcessEnv): string;
18
+ export declare function readAccessToken(configDir: string, readFile?: ReadFileFn): Promise<{
19
+ token: string;
20
+ expiresAtMs: number | null;
21
+ } | null>;
22
+ export declare function fetchUsage(token: string, fetchFn?: FetchFn, threshold?: number): Promise<AccountUsage | null>;
23
+ //# sourceMappingURL=usage.d.ts.map
package/dist/usage.js ADDED
@@ -0,0 +1,91 @@
1
+ import * as os from 'node:os';
2
+ import * as path from 'node:path';
3
+ import { readFile as fsReadFile } from 'node:fs/promises';
4
+ export const LIMIT_THRESHOLD = (() => {
5
+ const raw = process.env['CLAUDE_RETRY_LIMIT_THRESHOLD'];
6
+ if (raw !== undefined) {
7
+ const n = Number(raw);
8
+ if (!Number.isNaN(n))
9
+ return n;
10
+ }
11
+ return 90;
12
+ })();
13
+ export function defaultConfigDir(env) {
14
+ const e = env ?? process.env;
15
+ return e['CLAUDE_CONFIG_DIR'] || path.join(os.homedir(), '.claude');
16
+ }
17
+ export async function readAccessToken(configDir, readFile = (p) => fsReadFile(p, 'utf8')) {
18
+ try {
19
+ const credPath = path.join(configDir, '.credentials.json');
20
+ const raw = await readFile(credPath);
21
+ const parsed = JSON.parse(raw);
22
+ const oauth = parsed['claudeAiOauth'];
23
+ if (oauth === null || typeof oauth !== 'object')
24
+ return null;
25
+ const oauthObj = oauth;
26
+ const token = oauthObj['accessToken'];
27
+ if (typeof token !== 'string' || token === '')
28
+ return null;
29
+ const expiresAt = oauthObj['expiresAt'];
30
+ const expiresAtMs = typeof expiresAt === 'number' ? expiresAt : null;
31
+ return { token, expiresAtMs };
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
38
+ const WINDOW_KEYS = ['five_hour', 'seven_day', 'seven_day_opus', 'seven_day_sonnet'];
39
+ function defaultFetchFn(url, init) {
40
+ return fetch(url, init).then((r) => ({
41
+ status: r.status,
42
+ json: () => r.json(),
43
+ }));
44
+ }
45
+ export async function fetchUsage(token, fetchFn = defaultFetchFn, threshold = LIMIT_THRESHOLD) {
46
+ try {
47
+ const res = await fetchFn(USAGE_URL, {
48
+ headers: {
49
+ 'Authorization': `Bearer ${token}`,
50
+ 'anthropic-beta': 'oauth-2025-04-20',
51
+ 'anthropic-version': '2023-06-01',
52
+ },
53
+ });
54
+ if (res.status !== 200)
55
+ return null;
56
+ const body = await res.json();
57
+ if (body === null || typeof body !== 'object')
58
+ return null;
59
+ const data = body;
60
+ const windows = [];
61
+ for (const key of WINDOW_KEYS) {
62
+ const w = data[key];
63
+ if (w === null || w === undefined || typeof w !== 'object')
64
+ continue;
65
+ const wObj = w;
66
+ const utilization = wObj['utilization'];
67
+ const resetsAt = wObj['resets_at'];
68
+ if (typeof utilization !== 'number')
69
+ continue;
70
+ const resetsAtMs = typeof resetsAt === 'string' ? Date.parse(resetsAt) : null;
71
+ windows.push({ utilization, resetsAtMs: resetsAtMs !== null && !Number.isNaN(resetsAtMs) ? resetsAtMs : null });
72
+ }
73
+ const overThreshold = windows.filter((w) => w.utilization >= threshold);
74
+ const limited = overThreshold.length > 0;
75
+ let resetsAtMs = null;
76
+ if (limited) {
77
+ for (const w of overThreshold) {
78
+ if (w.resetsAtMs !== null) {
79
+ if (resetsAtMs === null || w.resetsAtMs > resetsAtMs) {
80
+ resetsAtMs = w.resetsAtMs;
81
+ }
82
+ }
83
+ }
84
+ }
85
+ return { limited, resetsAtMs };
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ //# sourceMappingURL=usage.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tigorhutasuhut/claude-retry",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Monitor Claude CLI in a zellij pane, auto-inject continue on rate-limit",
5
5
  "type": "module",
6
6
  "license": "MIT",