@tigorhutasuhut/claude-retry 0.1.2 → 0.1.4
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 +12 -4
- package/dist/accounts.d.ts +35 -0
- package/dist/accounts.js +197 -0
- package/dist/cli.js +22 -0
- package/dist/monitor.d.ts +5 -0
- package/dist/monitor.js +75 -6
- package/dist/usage.d.ts +23 -0
- package/dist/usage.js +91 -0
- 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 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, 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
|
|
63
|
-
4. **
|
|
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
|
package/dist/accounts.js
ADDED
|
@@ -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,14 +7,22 @@ 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';
|
|
15
18
|
}
|
|
16
|
-
// Wait period elapsed — inject
|
|
17
|
-
|
|
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
|
+
}
|
|
18
26
|
state.status = 'monitoring';
|
|
19
27
|
state.waitUntil = 0;
|
|
20
28
|
return 'retried';
|
|
@@ -22,6 +30,57 @@ async function stepState(state, screenText, now, injectContinue, marginSeconds,
|
|
|
22
30
|
// state.status === 'monitoring'
|
|
23
31
|
const result = match(screenText);
|
|
24
32
|
if (result.limited) {
|
|
33
|
+
const label = target?.label ?? 'pane';
|
|
34
|
+
const logger = log ?? (() => { });
|
|
35
|
+
// Tier 1: account-aware resolution when snapshot is available
|
|
36
|
+
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];
|
|
53
|
+
}
|
|
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);
|
|
57
|
+
}
|
|
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
|
|
80
|
+
}
|
|
81
|
+
// accountDir null or usage missing — fall through to tier 3
|
|
82
|
+
}
|
|
83
|
+
// Tier 3: text fallback (current behavior, unchanged)
|
|
25
84
|
const resetLine = result.resetLine ?? '';
|
|
26
85
|
const parsed = parseResetTime(resetLine);
|
|
27
86
|
const waitMs = calculateWaitMs(parsed, marginSeconds, fallbackHours, new Date(now));
|
|
@@ -35,9 +94,9 @@ export async function tick(paneId, state, deps, marginSeconds, fallbackHours) {
|
|
|
35
94
|
const screenText = await deps.capture(paneId);
|
|
36
95
|
return stepState(state, screenText, deps.now(), () => deps.inject(paneId, 'continue'), marginSeconds, fallbackHours);
|
|
37
96
|
}
|
|
38
|
-
async function tickTarget(target, state, deps, marginSeconds, fallbackHours) {
|
|
97
|
+
async function tickTarget(target, state, deps, marginSeconds, fallbackHours, snapshot) {
|
|
39
98
|
const screenText = await deps.capture(target);
|
|
40
|
-
return stepState(state, screenText, deps.now(), () => deps.inject(target, 'continue'), marginSeconds, fallbackHours);
|
|
99
|
+
return stepState(state, screenText, deps.now(), () => deps.inject(target, 'continue'), marginSeconds, fallbackHours, snapshot, deps.resolvePaneAccount, target, deps.log);
|
|
41
100
|
}
|
|
42
101
|
export async function runMonitor(paneId, deps, pollIntervalMs, marginSeconds, fallbackHours) {
|
|
43
102
|
const state = createState();
|
|
@@ -66,6 +125,16 @@ export async function multiTick(states, deps, marginSeconds, fallbackHours) {
|
|
|
66
125
|
log('scan failed: could not list sessions/panes (will retry)');
|
|
67
126
|
return;
|
|
68
127
|
}
|
|
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
|
+
}
|
|
69
138
|
// Prune state for panes that no longer exist.
|
|
70
139
|
const live = new Set(targets.map((t) => t.label));
|
|
71
140
|
for (const key of [...states.keys()]) {
|
|
@@ -86,7 +155,7 @@ export async function multiTick(states, deps, marginSeconds, fallbackHours) {
|
|
|
86
155
|
}
|
|
87
156
|
const before = state.status;
|
|
88
157
|
try {
|
|
89
|
-
const status = await tickTarget(target, state, deps, marginSeconds, fallbackHours);
|
|
158
|
+
const status = await tickTarget(target, state, deps, marginSeconds, fallbackHours, snapshot);
|
|
90
159
|
logPaneStatus(log, target.label, before, state, status);
|
|
91
160
|
}
|
|
92
161
|
catch {
|
package/dist/usage.d.ts
ADDED
|
@@ -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
|