@sooneocean/claude-hud 0.1.0

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.
Files changed (210) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +20 -0
  3. package/LICENSE +21 -0
  4. package/README.md +379 -0
  5. package/commands/configure.md +361 -0
  6. package/commands/export.md +43 -0
  7. package/commands/health.md +61 -0
  8. package/commands/setup.md +287 -0
  9. package/commands/theme.md +31 -0
  10. package/dist/alert.d.ts +31 -0
  11. package/dist/alert.d.ts.map +1 -0
  12. package/dist/alert.js +53 -0
  13. package/dist/alert.js.map +1 -0
  14. package/dist/burn-rate.d.ts +4 -0
  15. package/dist/burn-rate.d.ts.map +1 -0
  16. package/dist/burn-rate.js +36 -0
  17. package/dist/burn-rate.js.map +1 -0
  18. package/dist/cache.d.ts +6 -0
  19. package/dist/cache.d.ts.map +1 -0
  20. package/dist/cache.js +47 -0
  21. package/dist/cache.js.map +1 -0
  22. package/dist/claude-config-dir.d.ts +4 -0
  23. package/dist/claude-config-dir.d.ts.map +1 -0
  24. package/dist/claude-config-dir.js +24 -0
  25. package/dist/claude-config-dir.js.map +1 -0
  26. package/dist/config-io.d.ts +6 -0
  27. package/dist/config-io.d.ts.map +1 -0
  28. package/dist/config-io.js +27 -0
  29. package/dist/config-io.js.map +1 -0
  30. package/dist/config-reader.d.ts +8 -0
  31. package/dist/config-reader.d.ts.map +1 -0
  32. package/dist/config-reader.js +204 -0
  33. package/dist/config-reader.js.map +1 -0
  34. package/dist/config.d.ts +94 -0
  35. package/dist/config.d.ts.map +1 -0
  36. package/dist/config.js +358 -0
  37. package/dist/config.js.map +1 -0
  38. package/dist/constants.d.ts +11 -0
  39. package/dist/constants.d.ts.map +1 -0
  40. package/dist/constants.js +11 -0
  41. package/dist/constants.js.map +1 -0
  42. package/dist/cost-tracker.d.ts +9 -0
  43. package/dist/cost-tracker.d.ts.map +1 -0
  44. package/dist/cost-tracker.js +46 -0
  45. package/dist/cost-tracker.js.map +1 -0
  46. package/dist/debug.d.ts +6 -0
  47. package/dist/debug.d.ts.map +1 -0
  48. package/dist/debug.js +15 -0
  49. package/dist/debug.js.map +1 -0
  50. package/dist/extra-cmd.d.ts +20 -0
  51. package/dist/extra-cmd.d.ts.map +1 -0
  52. package/dist/extra-cmd.js +112 -0
  53. package/dist/extra-cmd.js.map +1 -0
  54. package/dist/git.d.ts +16 -0
  55. package/dist/git.d.ts.map +1 -0
  56. package/dist/git.js +94 -0
  57. package/dist/git.js.map +1 -0
  58. package/dist/health-check.d.ts +12 -0
  59. package/dist/health-check.d.ts.map +1 -0
  60. package/dist/health-check.js +37 -0
  61. package/dist/health-check.js.map +1 -0
  62. package/dist/index.d.ts +24 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +198 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/providers/agent-teams-provider.d.ts +10 -0
  67. package/dist/providers/agent-teams-provider.d.ts.map +1 -0
  68. package/dist/providers/agent-teams-provider.js +57 -0
  69. package/dist/providers/agent-teams-provider.js.map +1 -0
  70. package/dist/providers/agw-provider.d.ts +10 -0
  71. package/dist/providers/agw-provider.d.ts.map +1 -0
  72. package/dist/providers/agw-provider.js +49 -0
  73. package/dist/providers/agw-provider.js.map +1 -0
  74. package/dist/providers/index.d.ts +14 -0
  75. package/dist/providers/index.d.ts.map +1 -0
  76. package/dist/providers/index.js +25 -0
  77. package/dist/providers/index.js.map +1 -0
  78. package/dist/render/agents-line.d.ts +3 -0
  79. package/dist/render/agents-line.d.ts.map +1 -0
  80. package/dist/render/agents-line.js +40 -0
  81. package/dist/render/agents-line.js.map +1 -0
  82. package/dist/render/alert-line.d.ts +3 -0
  83. package/dist/render/alert-line.d.ts.map +1 -0
  84. package/dist/render/alert-line.js +11 -0
  85. package/dist/render/alert-line.js.map +1 -0
  86. package/dist/render/colors.d.ts +39 -0
  87. package/dist/render/colors.d.ts.map +1 -0
  88. package/dist/render/colors.js +109 -0
  89. package/dist/render/colors.js.map +1 -0
  90. package/dist/render/framework-line.d.ts +3 -0
  91. package/dist/render/framework-line.d.ts.map +1 -0
  92. package/dist/render/framework-line.js +32 -0
  93. package/dist/render/framework-line.js.map +1 -0
  94. package/dist/render/index.d.ts +3 -0
  95. package/dist/render/index.d.ts.map +1 -0
  96. package/dist/render/index.js +435 -0
  97. package/dist/render/index.js.map +1 -0
  98. package/dist/render/lines/environment.d.ts +3 -0
  99. package/dist/render/lines/environment.d.ts.map +1 -0
  100. package/dist/render/lines/environment.js +30 -0
  101. package/dist/render/lines/environment.js.map +1 -0
  102. package/dist/render/lines/identity.d.ts +3 -0
  103. package/dist/render/lines/identity.d.ts.map +1 -0
  104. package/dist/render/lines/identity.js +93 -0
  105. package/dist/render/lines/identity.js.map +1 -0
  106. package/dist/render/lines/index.d.ts +5 -0
  107. package/dist/render/lines/index.d.ts.map +1 -0
  108. package/dist/render/lines/index.js +5 -0
  109. package/dist/render/lines/index.js.map +1 -0
  110. package/dist/render/lines/project.d.ts +3 -0
  111. package/dist/render/lines/project.d.ts.map +1 -0
  112. package/dist/render/lines/project.js +100 -0
  113. package/dist/render/lines/project.js.map +1 -0
  114. package/dist/render/lines/usage.d.ts +3 -0
  115. package/dist/render/lines/usage.d.ts.map +1 -0
  116. package/dist/render/lines/usage.js +65 -0
  117. package/dist/render/lines/usage.js.map +1 -0
  118. package/dist/render/session-line.d.ts +7 -0
  119. package/dist/render/session-line.d.ts.map +1 -0
  120. package/dist/render/session-line.js +227 -0
  121. package/dist/render/session-line.js.map +1 -0
  122. package/dist/render/todos-line.d.ts +3 -0
  123. package/dist/render/todos-line.d.ts.map +1 -0
  124. package/dist/render/todos-line.js +29 -0
  125. package/dist/render/todos-line.js.map +1 -0
  126. package/dist/render/tools-line.d.ts +3 -0
  127. package/dist/render/tools-line.d.ts.map +1 -0
  128. package/dist/render/tools-line.js +45 -0
  129. package/dist/render/tools-line.js.map +1 -0
  130. package/dist/session-history.d.ts +15 -0
  131. package/dist/session-history.d.ts.map +1 -0
  132. package/dist/session-history.js +46 -0
  133. package/dist/session-history.js.map +1 -0
  134. package/dist/session-stats.d.ts +11 -0
  135. package/dist/session-stats.d.ts.map +1 -0
  136. package/dist/session-stats.js +48 -0
  137. package/dist/session-stats.js.map +1 -0
  138. package/dist/speed-tracker.d.ts +7 -0
  139. package/dist/speed-tracker.d.ts.map +1 -0
  140. package/dist/speed-tracker.js +34 -0
  141. package/dist/speed-tracker.js.map +1 -0
  142. package/dist/stdin.d.ts +9 -0
  143. package/dist/stdin.d.ts.map +1 -0
  144. package/dist/stdin.js +142 -0
  145. package/dist/stdin.js.map +1 -0
  146. package/dist/themes.d.ts +10 -0
  147. package/dist/themes.d.ts.map +1 -0
  148. package/dist/themes.js +81 -0
  149. package/dist/themes.js.map +1 -0
  150. package/dist/transcript.d.ts +3 -0
  151. package/dist/transcript.d.ts.map +1 -0
  152. package/dist/transcript.js +221 -0
  153. package/dist/transcript.js.map +1 -0
  154. package/dist/types.d.ts +124 -0
  155. package/dist/types.d.ts.map +1 -0
  156. package/dist/types.js +5 -0
  157. package/dist/types.js.map +1 -0
  158. package/dist/usage-api.d.ts +62 -0
  159. package/dist/usage-api.d.ts.map +1 -0
  160. package/dist/usage-api.js +908 -0
  161. package/dist/usage-api.js.map +1 -0
  162. package/dist/utils/format.d.ts +9 -0
  163. package/dist/utils/format.d.ts.map +1 -0
  164. package/dist/utils/format.js +75 -0
  165. package/dist/utils/format.js.map +1 -0
  166. package/dist/utils/terminal.d.ts +5 -0
  167. package/dist/utils/terminal.d.ts.map +1 -0
  168. package/dist/utils/terminal.js +42 -0
  169. package/dist/utils/terminal.js.map +1 -0
  170. package/package.json +36 -0
  171. package/src/alert.ts +75 -0
  172. package/src/burn-rate.ts +45 -0
  173. package/src/cache.ts +57 -0
  174. package/src/claude-config-dir.ts +27 -0
  175. package/src/config-io.ts +26 -0
  176. package/src/config-reader.ts +236 -0
  177. package/src/config.ts +496 -0
  178. package/src/constants.ts +10 -0
  179. package/src/cost-tracker.ts +53 -0
  180. package/src/debug.ts +16 -0
  181. package/src/extra-cmd.ts +125 -0
  182. package/src/git.ts +126 -0
  183. package/src/health-check.ts +50 -0
  184. package/src/index.ts +234 -0
  185. package/src/providers/agent-teams-provider.ts +56 -0
  186. package/src/providers/agw-provider.ts +47 -0
  187. package/src/providers/index.ts +27 -0
  188. package/src/render/agents-line.ts +51 -0
  189. package/src/render/alert-line.ts +11 -0
  190. package/src/render/colors.ts +145 -0
  191. package/src/render/framework-line.ts +34 -0
  192. package/src/render/index.ts +512 -0
  193. package/src/render/lines/environment.ts +41 -0
  194. package/src/render/lines/identity.ts +109 -0
  195. package/src/render/lines/index.ts +4 -0
  196. package/src/render/lines/project.ts +113 -0
  197. package/src/render/lines/usage.ts +79 -0
  198. package/src/render/session-line.ts +253 -0
  199. package/src/render/todos-line.ts +35 -0
  200. package/src/render/tools-line.ts +58 -0
  201. package/src/session-history.ts +62 -0
  202. package/src/session-stats.ts +65 -0
  203. package/src/speed-tracker.ts +51 -0
  204. package/src/stdin.ts +169 -0
  205. package/src/themes.ts +90 -0
  206. package/src/transcript.ts +268 -0
  207. package/src/types.ts +146 -0
  208. package/src/usage-api.ts +1090 -0
  209. package/src/utils/format.ts +79 -0
  210. package/src/utils/terminal.ts +46 -0
@@ -0,0 +1,1090 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ import * as net from 'net';
5
+ import * as tls from 'tls';
6
+ import * as https from 'https';
7
+ import { execFileSync } from 'child_process';
8
+ import { createHash } from 'crypto';
9
+ import type { UsageData } from './types.js';
10
+ import { createDebug } from './debug.js';
11
+ import { getClaudeConfigDir, getHudPluginDir } from './claude-config-dir.js';
12
+
13
+ export type { UsageData } from './types.js';
14
+
15
+ const debug = createDebug('usage');
16
+ const LEGACY_KEYCHAIN_SERVICE_NAME = 'Claude Code-credentials';
17
+
18
+ interface CredentialsFile {
19
+ claudeAiOauth?: {
20
+ accessToken?: string;
21
+ refreshToken?: string;
22
+ subscriptionType?: string;
23
+ rateLimitTier?: string;
24
+ expiresAt?: number; // Unix millisecond timestamp
25
+ scopes?: string[];
26
+ };
27
+ }
28
+
29
+ interface UsageApiResponse {
30
+ five_hour?: {
31
+ utilization?: number;
32
+ resets_at?: string;
33
+ };
34
+ seven_day?: {
35
+ utilization?: number;
36
+ resets_at?: string;
37
+ };
38
+ }
39
+
40
+ interface UsageApiResult {
41
+ data: UsageApiResponse | null;
42
+ error?: string;
43
+ /** Retry-After header value in seconds (from 429 responses) */
44
+ retryAfterSec?: number;
45
+ }
46
+
47
+ // File-based cache (HUD runs as new process each render, so in-memory cache won't persist)
48
+ const CACHE_TTL_MS = 5 * 60_000; // 5 minutes — matches Anthropic usage API rate limit window
49
+ const CACHE_FAILURE_TTL_MS = 15_000; // 15 seconds for failed requests
50
+ const CACHE_RATE_LIMITED_BASE_MS = 60_000; // 60s base for 429 backoff
51
+ const CACHE_RATE_LIMITED_MAX_MS = 5 * 60_000; // 5 min max backoff
52
+ const CACHE_LOCK_STALE_MS = 30_000;
53
+ const CACHE_LOCK_WAIT_MS = 2_000;
54
+ const CACHE_LOCK_POLL_MS = 50;
55
+ const KEYCHAIN_TIMEOUT_MS = 3000;
56
+ const KEYCHAIN_BACKOFF_MS = 60_000; // Backoff on keychain failures to avoid re-prompting
57
+ const USAGE_API_TIMEOUT_MS_DEFAULT = 15_000;
58
+ export const USAGE_API_USER_AGENT = 'claude-code/2.1';
59
+
60
+ /**
61
+ * Check if user is using a custom API endpoint instead of the default Anthropic API.
62
+ * When using custom providers (e.g., via cc-switch), the OAuth usage API is not applicable.
63
+ */
64
+ function isUsingCustomApiEndpoint(env: NodeJS.ProcessEnv = process.env): boolean {
65
+ const baseUrl = env.ANTHROPIC_BASE_URL?.trim() || env.ANTHROPIC_API_BASE_URL?.trim();
66
+
67
+ // No custom endpoint configured - using default Anthropic API
68
+ if (!baseUrl) {
69
+ return false;
70
+ }
71
+
72
+ try {
73
+ return new URL(baseUrl).origin !== 'https://api.anthropic.com';
74
+ } catch {
75
+ return true;
76
+ }
77
+ }
78
+
79
+ interface CacheFile {
80
+ data: UsageData;
81
+ timestamp: number;
82
+ /** Consecutive 429 count for exponential backoff */
83
+ rateLimitedCount?: number;
84
+ /** Absolute timestamp (ms) when retry is allowed (from Retry-After header) */
85
+ retryAfterUntil?: number;
86
+ /** Last successful API data — preserved across rate-limited periods */
87
+ lastGoodData?: UsageData;
88
+ }
89
+
90
+ interface CacheState {
91
+ data: UsageData;
92
+ timestamp: number;
93
+ isFresh: boolean;
94
+ }
95
+
96
+ type CacheLockStatus = 'acquired' | 'busy' | 'unsupported';
97
+
98
+ function getCachePath(homeDir: string): string {
99
+ return path.join(getHudPluginDir(homeDir), '.usage-cache.json');
100
+ }
101
+
102
+ function getCacheLockPath(homeDir: string): string {
103
+ return path.join(getHudPluginDir(homeDir), '.usage-cache.lock');
104
+ }
105
+
106
+ function hydrateCacheData(data: UsageData): UsageData {
107
+ // JSON.stringify converts Date to ISO string, so we need to reconvert on read.
108
+ // new Date() handles both Date objects and ISO strings safely.
109
+ if (data.fiveHourResetAt) {
110
+ data.fiveHourResetAt = new Date(data.fiveHourResetAt);
111
+ }
112
+ if (data.sevenDayResetAt) {
113
+ data.sevenDayResetAt = new Date(data.sevenDayResetAt);
114
+ }
115
+ return data;
116
+ }
117
+
118
+ type CacheTtls = { cacheTtlMs: number; failureCacheTtlMs: number };
119
+
120
+ function getRateLimitedTtlMs(count: number): number {
121
+ // Exponential backoff: 60s, 120s, 240s, capped at 5 min
122
+ return Math.min(CACHE_RATE_LIMITED_BASE_MS * Math.pow(2, Math.max(0, count - 1)), CACHE_RATE_LIMITED_MAX_MS);
123
+ }
124
+
125
+ function getRateLimitedRetryUntil(cache: CacheFile): number | null {
126
+ if (cache.data.apiError !== 'rate-limited') {
127
+ return null;
128
+ }
129
+
130
+ if (cache.retryAfterUntil && cache.retryAfterUntil > cache.timestamp) {
131
+ return cache.retryAfterUntil;
132
+ }
133
+
134
+ if (cache.rateLimitedCount && cache.rateLimitedCount > 0) {
135
+ return cache.timestamp + getRateLimitedTtlMs(cache.rateLimitedCount);
136
+ }
137
+
138
+ return null;
139
+ }
140
+
141
+ function withRateLimitedSyncing(data: UsageData): UsageData {
142
+ return {
143
+ ...data,
144
+ apiError: 'rate-limited',
145
+ };
146
+ }
147
+
148
+ function readUsageCacheState(homeDir: string, now: number, ttls: CacheTtls): CacheState | null {
149
+ try {
150
+ const cachePath = getCachePath(homeDir);
151
+ if (!fs.existsSync(cachePath)) return null;
152
+
153
+ const content = fs.readFileSync(cachePath, 'utf8');
154
+ const cache: CacheFile = JSON.parse(content);
155
+
156
+ // Only serve lastGoodData during rate-limit backoff. Other failures should remain visible.
157
+ const displayData = (cache.data.apiError === 'rate-limited' && cache.lastGoodData)
158
+ ? withRateLimitedSyncing(cache.lastGoodData)
159
+ : cache.data;
160
+
161
+ const rateLimitedRetryUntil = getRateLimitedRetryUntil(cache);
162
+ if (rateLimitedRetryUntil && now < rateLimitedRetryUntil) {
163
+ return { data: hydrateCacheData(displayData), timestamp: cache.timestamp, isFresh: true };
164
+ }
165
+
166
+ const ttl = cache.data.apiUnavailable ? ttls.failureCacheTtlMs : ttls.cacheTtlMs;
167
+
168
+ return {
169
+ data: hydrateCacheData(displayData),
170
+ timestamp: cache.timestamp,
171
+ isFresh: now - cache.timestamp < ttl,
172
+ };
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+
178
+ function readRateLimitedCount(homeDir: string): number {
179
+ try {
180
+ const cachePath = getCachePath(homeDir);
181
+ if (!fs.existsSync(cachePath)) return 0;
182
+ const content = fs.readFileSync(cachePath, 'utf8');
183
+ const cache: CacheFile = JSON.parse(content);
184
+ return cache.rateLimitedCount ?? 0;
185
+ } catch {
186
+ return 0;
187
+ }
188
+ }
189
+
190
+ function readLastGoodData(homeDir: string): UsageData | null {
191
+ try {
192
+ const cachePath = getCachePath(homeDir);
193
+ if (!fs.existsSync(cachePath)) return null;
194
+ const content = fs.readFileSync(cachePath, 'utf8');
195
+ const cache: CacheFile = JSON.parse(content);
196
+ return cache.lastGoodData ? hydrateCacheData(cache.lastGoodData) : null;
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ function readUsageCache(homeDir: string, now: number, ttls: CacheTtls): UsageData | null {
203
+ const cache = readUsageCacheState(homeDir, now, ttls);
204
+ return cache?.isFresh ? cache.data : null;
205
+ }
206
+
207
+ interface WriteCacheOpts {
208
+ rateLimitedCount?: number;
209
+ retryAfterUntil?: number;
210
+ lastGoodData?: UsageData;
211
+ }
212
+
213
+ function writeUsageCache(homeDir: string, data: UsageData, timestamp: number, opts?: WriteCacheOpts): void {
214
+ try {
215
+ const cachePath = getCachePath(homeDir);
216
+ const cacheDir = path.dirname(cachePath);
217
+
218
+ if (!fs.existsSync(cacheDir)) {
219
+ fs.mkdirSync(cacheDir, { recursive: true });
220
+ }
221
+
222
+ const cache: CacheFile = { data, timestamp };
223
+ if (opts?.rateLimitedCount && opts.rateLimitedCount > 0) {
224
+ cache.rateLimitedCount = opts.rateLimitedCount;
225
+ }
226
+ if (opts?.retryAfterUntil) {
227
+ cache.retryAfterUntil = opts.retryAfterUntil;
228
+ }
229
+ if (opts?.lastGoodData) {
230
+ cache.lastGoodData = opts.lastGoodData;
231
+ }
232
+ fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf8');
233
+ } catch {
234
+ // Ignore cache write failures
235
+ }
236
+ }
237
+
238
+ function readLockTimestamp(lockPath: string): number | null {
239
+ try {
240
+ if (!fs.existsSync(lockPath)) return null;
241
+ const raw = fs.readFileSync(lockPath, 'utf8').trim();
242
+ const parsed = Number.parseInt(raw, 10);
243
+ return Number.isFinite(parsed) ? parsed : null;
244
+ } catch {
245
+ return null;
246
+ }
247
+ }
248
+
249
+ function tryAcquireCacheLock(homeDir: string): CacheLockStatus {
250
+ const lockPath = getCacheLockPath(homeDir);
251
+ const cacheDir = path.dirname(lockPath);
252
+
253
+ try {
254
+ if (!fs.existsSync(cacheDir)) {
255
+ fs.mkdirSync(cacheDir, { recursive: true });
256
+ }
257
+
258
+ const fd = fs.openSync(lockPath, 'wx');
259
+ try {
260
+ fs.writeFileSync(fd, String(Date.now()), 'utf8');
261
+ } finally {
262
+ fs.closeSync(fd);
263
+ }
264
+ return 'acquired';
265
+ } catch (error) {
266
+ const maybeError = error as NodeJS.ErrnoException;
267
+ if (maybeError.code !== 'EEXIST') {
268
+ debug('Usage cache lock unavailable, continuing without coordination:', maybeError.message);
269
+ return 'unsupported';
270
+ }
271
+ }
272
+
273
+ const lockTimestamp = readLockTimestamp(lockPath);
274
+ // Unparseable timestamp — use mtime to distinguish a crash leftover from an active writer.
275
+ if (lockTimestamp === null) {
276
+ try {
277
+ const lockStat = fs.statSync(lockPath);
278
+ if (Date.now() - lockStat.mtimeMs < CACHE_LOCK_STALE_MS) {
279
+ return 'busy';
280
+ }
281
+ } catch {
282
+ return tryAcquireCacheLock(homeDir);
283
+ }
284
+ try {
285
+ fs.unlinkSync(lockPath);
286
+ } catch {
287
+ return 'busy';
288
+ }
289
+ return tryAcquireCacheLock(homeDir);
290
+ }
291
+
292
+ if (lockTimestamp != null && Date.now() - lockTimestamp > CACHE_LOCK_STALE_MS) {
293
+ try {
294
+ fs.unlinkSync(lockPath);
295
+ } catch {
296
+ return 'busy';
297
+ }
298
+ return tryAcquireCacheLock(homeDir);
299
+ }
300
+
301
+ return 'busy';
302
+ }
303
+
304
+ function releaseCacheLock(homeDir: string): void {
305
+ try {
306
+ const lockPath = getCacheLockPath(homeDir);
307
+ if (fs.existsSync(lockPath)) {
308
+ fs.unlinkSync(lockPath);
309
+ }
310
+ } catch {
311
+ // Ignore lock cleanup failures
312
+ }
313
+ }
314
+
315
+ async function waitForFreshCache(
316
+ homeDir: string,
317
+ now: () => number,
318
+ ttls: CacheTtls,
319
+ timeoutMs: number = CACHE_LOCK_WAIT_MS
320
+ ): Promise<UsageData | null> {
321
+ const deadline = Date.now() + timeoutMs;
322
+
323
+ while (Date.now() < deadline) {
324
+ await new Promise((resolve) => setTimeout(resolve, CACHE_LOCK_POLL_MS));
325
+ const cached = readUsageCache(homeDir, now(), ttls);
326
+ if (cached) {
327
+ return cached;
328
+ }
329
+
330
+ if (!fs.existsSync(getCacheLockPath(homeDir))) {
331
+ break;
332
+ }
333
+ }
334
+
335
+ return readUsageCache(homeDir, now(), ttls);
336
+ }
337
+
338
+ // Dependency injection for testing
339
+ export type UsageApiDeps = {
340
+ homeDir: () => string;
341
+ fetchApi: (accessToken: string) => Promise<UsageApiResult>;
342
+ now: () => number;
343
+ readKeychain: (now: number, homeDir: string) => { accessToken: string; subscriptionType: string } | null;
344
+ ttls: CacheTtls;
345
+ };
346
+
347
+ const defaultDeps: UsageApiDeps = {
348
+ homeDir: () => os.homedir(),
349
+ fetchApi: fetchUsageApi,
350
+ now: () => Date.now(),
351
+ readKeychain: readKeychainCredentials,
352
+ ttls: { cacheTtlMs: CACHE_TTL_MS, failureCacheTtlMs: CACHE_FAILURE_TTL_MS },
353
+ };
354
+
355
+ /**
356
+ * Get OAuth usage data from Anthropic API.
357
+ * Returns null if user is an API user (no OAuth credentials) or credentials are expired.
358
+ * Returns { apiUnavailable: true, ... } if API call fails (to show warning in HUD).
359
+ *
360
+ * Uses file-based cache since HUD runs as a new process each render (~300ms).
361
+ * Cache TTL is configurable via usage.cacheTtlSeconds / usage.failureCacheTtlSeconds in config.json
362
+ * (defaults: 60s for success, 15s for failures).
363
+ */
364
+ export async function getUsage(overrides: Partial<UsageApiDeps> = {}): Promise<UsageData | null> {
365
+ const deps = { ...defaultDeps, ...overrides };
366
+ const now = deps.now();
367
+ const homeDir = deps.homeDir();
368
+
369
+ // Skip usage API if user is using a custom provider
370
+ if (isUsingCustomApiEndpoint()) {
371
+ debug('Skipping usage API: custom API endpoint configured');
372
+ return null;
373
+ }
374
+ // Check file-based cache first
375
+ const cacheState = readUsageCacheState(homeDir, now, deps.ttls);
376
+ if (cacheState?.isFresh) {
377
+ return cacheState.data;
378
+ }
379
+
380
+ let holdsCacheLock = false;
381
+ const lockStatus = tryAcquireCacheLock(homeDir);
382
+ if (lockStatus === 'busy') {
383
+ if (cacheState) {
384
+ return cacheState.data;
385
+ }
386
+ return await waitForFreshCache(homeDir, deps.now, deps.ttls);
387
+ }
388
+ holdsCacheLock = lockStatus === 'acquired';
389
+
390
+ try {
391
+ const refreshedCache = readUsageCache(homeDir, deps.now(), deps.ttls);
392
+ if (refreshedCache) {
393
+ return refreshedCache;
394
+ }
395
+
396
+ const credentials = readCredentials(homeDir, now, deps.readKeychain);
397
+ if (!credentials) {
398
+ return null;
399
+ }
400
+
401
+ const { accessToken, subscriptionType } = credentials;
402
+
403
+ // Determine plan name from subscriptionType
404
+ const planName = getPlanName(subscriptionType);
405
+ if (!planName) {
406
+ // API user, no usage limits to show
407
+ return null;
408
+ }
409
+
410
+ // Fetch usage from API
411
+ const apiResult = await deps.fetchApi(accessToken);
412
+ if (!apiResult.data) {
413
+ const isRateLimited = apiResult.error === 'rate-limited';
414
+ const prevCount = readRateLimitedCount(homeDir);
415
+ const rateLimitedCount = isRateLimited ? prevCount + 1 : 0;
416
+ const retryAfterUntil = isRateLimited && apiResult.retryAfterSec
417
+ ? now + apiResult.retryAfterSec * 1000
418
+ : undefined;
419
+ const backoffOpts: WriteCacheOpts = {
420
+ rateLimitedCount: isRateLimited ? rateLimitedCount : undefined,
421
+ retryAfterUntil,
422
+ };
423
+
424
+ const failureResult: UsageData = {
425
+ planName,
426
+ fiveHour: null,
427
+ sevenDay: null,
428
+ fiveHourResetAt: null,
429
+ sevenDayResetAt: null,
430
+ apiUnavailable: true,
431
+ apiError: apiResult.error,
432
+ };
433
+
434
+ if (isRateLimited) {
435
+ const staleCache = readUsageCacheState(homeDir, now, deps.ttls);
436
+ const lastGood = readLastGoodData(homeDir);
437
+ const goodData = (staleCache && !staleCache.data.apiUnavailable)
438
+ ? staleCache.data
439
+ : lastGood;
440
+
441
+ if (goodData) {
442
+ // Preserve the backoff state in cache, but keep rendering the last successful values
443
+ // with a syncing hint so stale data is visible to the user.
444
+ writeUsageCache(homeDir, failureResult, now, { ...backoffOpts, lastGoodData: goodData });
445
+ return withRateLimitedSyncing(goodData);
446
+ }
447
+ }
448
+
449
+ writeUsageCache(homeDir, failureResult, now, backoffOpts);
450
+ return failureResult;
451
+ }
452
+
453
+ // Parse response - API returns 0-100 percentage directly
454
+ // Clamp to 0-100 and handle NaN/Infinity
455
+ const fiveHour = parseUtilization(apiResult.data.five_hour?.utilization);
456
+ const sevenDay = parseUtilization(apiResult.data.seven_day?.utilization);
457
+
458
+ const fiveHourResetAt = parseDate(apiResult.data.five_hour?.resets_at);
459
+ const sevenDayResetAt = parseDate(apiResult.data.seven_day?.resets_at);
460
+
461
+ const result: UsageData = {
462
+ planName,
463
+ fiveHour,
464
+ sevenDay,
465
+ fiveHourResetAt,
466
+ sevenDayResetAt,
467
+ };
468
+
469
+ // Write to file cache — also store as lastGoodData for rate-limit resilience
470
+ writeUsageCache(homeDir, result, now, { lastGoodData: result });
471
+
472
+ return result;
473
+ } catch (error) {
474
+ debug('getUsage failed:', error);
475
+ return null;
476
+ } finally {
477
+ if (holdsCacheLock) {
478
+ releaseCacheLock(homeDir);
479
+ }
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Get path for keychain failure backoff cache.
485
+ * Separate from usage cache to track keychain-specific failures.
486
+ */
487
+ function getKeychainBackoffPath(homeDir: string): string {
488
+ return path.join(getHudPluginDir(homeDir), '.keychain-backoff');
489
+ }
490
+
491
+ /**
492
+ * Check if we're in keychain backoff period (recent failure/timeout).
493
+ * Prevents re-prompting user on every render cycle.
494
+ */
495
+ function isKeychainBackoff(homeDir: string, now: number): boolean {
496
+ try {
497
+ const backoffPath = getKeychainBackoffPath(homeDir);
498
+ if (!fs.existsSync(backoffPath)) return false;
499
+ const timestamp = parseInt(fs.readFileSync(backoffPath, 'utf8'), 10);
500
+ return now - timestamp < KEYCHAIN_BACKOFF_MS;
501
+ } catch {
502
+ return false;
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Record keychain failure for backoff.
508
+ */
509
+ function recordKeychainFailure(homeDir: string, now: number): void {
510
+ try {
511
+ const backoffPath = getKeychainBackoffPath(homeDir);
512
+ const dir = path.dirname(backoffPath);
513
+ if (!fs.existsSync(dir)) {
514
+ fs.mkdirSync(dir, { recursive: true });
515
+ }
516
+ fs.writeFileSync(backoffPath, String(now), 'utf8');
517
+ } catch {
518
+ // Ignore write failures
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Determine the macOS Keychain service name for Claude Code credentials.
524
+ * Claude Code uses the default service for ~/.claude and a hashed suffix for custom config directories.
525
+ */
526
+ export function getKeychainServiceName(configDir: string, homeDir: string): string {
527
+ const normalizedConfigDir = path.normalize(path.resolve(configDir));
528
+ const normalizedDefaultDir = path.normalize(path.resolve(path.join(homeDir, '.claude')));
529
+
530
+ if (normalizedConfigDir === normalizedDefaultDir) {
531
+ return LEGACY_KEYCHAIN_SERVICE_NAME;
532
+ }
533
+
534
+ const hash = createHash('sha256').update(normalizedConfigDir).digest('hex').slice(0, 8);
535
+ return `${LEGACY_KEYCHAIN_SERVICE_NAME}-${hash}`;
536
+ }
537
+
538
+ export function getKeychainServiceNames(
539
+ configDir: string,
540
+ homeDir: string,
541
+ env: NodeJS.ProcessEnv = process.env
542
+ ): string[] {
543
+ const serviceNames: string[] = [getKeychainServiceName(configDir, homeDir)];
544
+ const envConfigDir = env.CLAUDE_CONFIG_DIR?.trim();
545
+
546
+ if (envConfigDir) {
547
+ const normalizedDefaultDir = path.normalize(path.resolve(path.join(homeDir, '.claude')));
548
+ const normalizedEnvDir = path.normalize(path.resolve(envConfigDir));
549
+ if (normalizedEnvDir === normalizedDefaultDir) {
550
+ serviceNames.push(LEGACY_KEYCHAIN_SERVICE_NAME);
551
+ } else {
552
+ const envHash = createHash('sha256').update(envConfigDir).digest('hex').slice(0, 8);
553
+ serviceNames.push(`${LEGACY_KEYCHAIN_SERVICE_NAME}-${envHash}`);
554
+ }
555
+ }
556
+
557
+ serviceNames.push(LEGACY_KEYCHAIN_SERVICE_NAME);
558
+
559
+ return [...new Set(serviceNames)];
560
+ }
561
+
562
+ function isMissingKeychainItemError(error: unknown): boolean {
563
+ if (!error || typeof error !== 'object') return false;
564
+
565
+ const maybeError = error as { status?: unknown; message?: unknown; stderr?: unknown };
566
+ if (maybeError.status === 44) return true;
567
+
568
+ const message = typeof maybeError.message === 'string' ? maybeError.message.toLowerCase() : '';
569
+ if (message.includes('could not be found in the keychain')) return true;
570
+
571
+ const stderr = typeof maybeError.stderr === 'string'
572
+ ? maybeError.stderr.toLowerCase()
573
+ : Buffer.isBuffer(maybeError.stderr)
574
+ ? maybeError.stderr.toString('utf8').toLowerCase()
575
+ : '';
576
+ return stderr.includes('could not be found in the keychain');
577
+ }
578
+
579
+ export function resolveKeychainCredentials(
580
+ serviceNames: string[],
581
+ now: number,
582
+ loadService: (serviceName: string, accountName?: string) => string,
583
+ accountName?: string | null,
584
+ ): { credentials: { accessToken: string; subscriptionType: string } | null; shouldBackoff: boolean } {
585
+ let shouldBackoff = false;
586
+ let allowGenericFallback = Boolean(accountName);
587
+
588
+ for (const serviceName of serviceNames) {
589
+ try {
590
+ const keychainData = accountName
591
+ ? loadService(serviceName, accountName)
592
+ : loadService(serviceName);
593
+ if (accountName) allowGenericFallback = false;
594
+ const trimmedKeychainData = keychainData.trim();
595
+ if (!trimmedKeychainData) continue;
596
+
597
+ const data: CredentialsFile = JSON.parse(trimmedKeychainData);
598
+ const credentials = parseCredentialsData(data, now);
599
+ if (credentials) {
600
+ return { credentials, shouldBackoff: false };
601
+ }
602
+ } catch (error) {
603
+ if (!isMissingKeychainItemError(error)) {
604
+ if (accountName) allowGenericFallback = false;
605
+ shouldBackoff = true;
606
+ }
607
+ }
608
+ }
609
+
610
+ if (!accountName || !allowGenericFallback) {
611
+ return { credentials: null, shouldBackoff };
612
+ }
613
+
614
+ for (const serviceName of serviceNames) {
615
+ try {
616
+ const keychainData = loadService(serviceName).trim();
617
+ if (!keychainData) continue;
618
+
619
+ const data: CredentialsFile = JSON.parse(keychainData);
620
+ const credentials = parseCredentialsData(data, now);
621
+ if (credentials) {
622
+ return { credentials, shouldBackoff: false };
623
+ }
624
+ } catch (error) {
625
+ if (!isMissingKeychainItemError(error)) {
626
+ shouldBackoff = true;
627
+ }
628
+ }
629
+ }
630
+
631
+ return { credentials: null, shouldBackoff };
632
+ }
633
+
634
+ function getKeychainAccountName(): string | null {
635
+ try {
636
+ const username = os.userInfo().username.trim();
637
+ return username || null;
638
+ } catch {
639
+ return null;
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Read credentials from macOS Keychain.
645
+ * Claude Code stores OAuth credentials in the macOS Keychain with profile-specific service names.
646
+ * Returns null if not on macOS or credentials not found.
647
+ *
648
+ * Security: Uses execFileSync with absolute path to avoid shell injection and PATH hijacking.
649
+ */
650
+ function readKeychainCredentials(now: number, homeDir: string): { accessToken: string; subscriptionType: string } | null {
651
+ // Only available on macOS
652
+ if (process.platform !== 'darwin') {
653
+ return null;
654
+ }
655
+
656
+ // Check backoff to avoid re-prompting on every render after a failure
657
+ if (isKeychainBackoff(homeDir, now)) {
658
+ debug('Keychain in backoff period, skipping');
659
+ return null;
660
+ }
661
+
662
+ try {
663
+ const configDir = getClaudeConfigDir(homeDir);
664
+ const serviceNames = getKeychainServiceNames(configDir, homeDir);
665
+ const accountName = getKeychainAccountName();
666
+ debug('Trying keychain service names:', serviceNames);
667
+ if (accountName) {
668
+ debug('Trying keychain account name:', accountName);
669
+ }
670
+
671
+ const resolved = resolveKeychainCredentials(
672
+ serviceNames,
673
+ now,
674
+ (serviceName, lookupAccountName) => execFileSync(
675
+ '/usr/bin/security',
676
+ lookupAccountName
677
+ ? ['find-generic-password', '-s', serviceName, '-a', lookupAccountName, '-w']
678
+ : ['find-generic-password', '-s', serviceName, '-w'],
679
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: KEYCHAIN_TIMEOUT_MS }
680
+ ),
681
+ accountName,
682
+ );
683
+
684
+ if (resolved.credentials) {
685
+ return resolved.credentials;
686
+ }
687
+
688
+ if (resolved.shouldBackoff) {
689
+ recordKeychainFailure(homeDir, now);
690
+ }
691
+ return null;
692
+ } catch (error) {
693
+ // Security: Only log error message, not full error object (may contain stdout/stderr with tokens)
694
+ const message = error instanceof Error ? error.message : 'unknown error';
695
+ debug('Failed to read from macOS Keychain:', message);
696
+ // Record failure for backoff to avoid re-prompting
697
+ recordKeychainFailure(homeDir, now);
698
+ return null;
699
+ }
700
+ }
701
+
702
+ /**
703
+ * Read credentials from file (legacy method).
704
+ * Older versions of Claude Code stored credentials in {CLAUDE_CONFIG_DIR}/.credentials.json.
705
+ */
706
+ function readFileCredentials(homeDir: string, now: number): { accessToken: string; subscriptionType: string } | null {
707
+ const credentialsPath = path.join(getClaudeConfigDir(homeDir), '.credentials.json');
708
+
709
+ if (!fs.existsSync(credentialsPath)) {
710
+ return null;
711
+ }
712
+
713
+ try {
714
+ const content = fs.readFileSync(credentialsPath, 'utf8');
715
+ const data: CredentialsFile = JSON.parse(content);
716
+ return parseCredentialsData(data, now);
717
+ } catch (error) {
718
+ debug('Failed to read credentials file:', error);
719
+ return null;
720
+ }
721
+ }
722
+
723
+ function readFileSubscriptionType(homeDir: string): string | null {
724
+ const credentialsPath = path.join(getClaudeConfigDir(homeDir), '.credentials.json');
725
+
726
+ if (!fs.existsSync(credentialsPath)) {
727
+ return null;
728
+ }
729
+
730
+ try {
731
+ const content = fs.readFileSync(credentialsPath, 'utf8');
732
+ const data: CredentialsFile = JSON.parse(content);
733
+ const subscriptionType = data.claudeAiOauth?.subscriptionType;
734
+ const normalizedSubscriptionType = typeof subscriptionType === 'string'
735
+ ? subscriptionType.trim()
736
+ : '';
737
+ if (!normalizedSubscriptionType) {
738
+ return null;
739
+ }
740
+ return normalizedSubscriptionType;
741
+ } catch (error) {
742
+ debug('Failed to read file subscriptionType:', error);
743
+ return null;
744
+ }
745
+ }
746
+
747
+ /**
748
+ * Parse and validate credentials data from either Keychain or file.
749
+ */
750
+ function parseCredentialsData(data: CredentialsFile, now: number): { accessToken: string; subscriptionType: string } | null {
751
+ const accessToken = data.claudeAiOauth?.accessToken;
752
+ const subscriptionType = data.claudeAiOauth?.subscriptionType ?? '';
753
+
754
+ if (!accessToken) {
755
+ return null;
756
+ }
757
+
758
+ // Check if token is expired (expiresAt is Unix ms timestamp)
759
+ // Use != null to handle expiresAt=0 correctly (would be expired)
760
+ const expiresAt = data.claudeAiOauth?.expiresAt;
761
+ if (expiresAt != null && expiresAt <= now) {
762
+ debug('OAuth token expired');
763
+ return null;
764
+ }
765
+
766
+ return { accessToken, subscriptionType };
767
+ }
768
+
769
+ /**
770
+ * Read OAuth credentials, trying macOS Keychain first (Claude Code 2.x),
771
+ * then falling back to file-based credentials (older versions).
772
+ *
773
+ * Token priority: Keychain token is authoritative (Claude Code 2.x stores current token there).
774
+ * SubscriptionType: Can be supplemented from file if keychain lacks it (display-only field).
775
+ */
776
+ function readCredentials(
777
+ homeDir: string,
778
+ now: number,
779
+ readKeychain: (now: number, homeDir: string) => { accessToken: string; subscriptionType: string } | null
780
+ ): { accessToken: string; subscriptionType: string } | null {
781
+ // Try macOS Keychain first (Claude Code 2.x)
782
+ const keychainCreds = readKeychain(now, homeDir);
783
+ if (keychainCreds) {
784
+ if (keychainCreds.subscriptionType) {
785
+ debug('Using credentials from macOS Keychain');
786
+ return keychainCreds;
787
+ }
788
+ // Keychain has token but no subscriptionType - try to supplement from file
789
+ const fileSubscriptionType = readFileSubscriptionType(homeDir);
790
+ if (fileSubscriptionType) {
791
+ debug('Using keychain token with file subscriptionType');
792
+ return {
793
+ accessToken: keychainCreds.accessToken,
794
+ subscriptionType: fileSubscriptionType,
795
+ };
796
+ }
797
+ // No subscriptionType available - use keychain token anyway
798
+ debug('Using keychain token without subscriptionType');
799
+ return keychainCreds;
800
+ }
801
+
802
+ // Fall back to file-based credentials (older versions or non-macOS)
803
+ const fileCreds = readFileCredentials(homeDir, now);
804
+ if (fileCreds) {
805
+ debug('Using credentials from file');
806
+ return fileCreds;
807
+ }
808
+
809
+ return null;
810
+ }
811
+
812
+ function getPlanName(subscriptionType: string): string | null {
813
+ const lower = subscriptionType.toLowerCase();
814
+ if (lower.includes('max')) return 'Max';
815
+ if (lower.includes('pro')) return 'Pro';
816
+ if (lower.includes('team')) return 'Team';
817
+ // API users don't have subscriptionType or have 'api'
818
+ if (!subscriptionType || lower.includes('api')) return null;
819
+ // Unknown subscription type - show it capitalized
820
+ return subscriptionType.charAt(0).toUpperCase() + subscriptionType.slice(1);
821
+ }
822
+
823
+ /** Parse utilization value, clamping to 0-100 and handling NaN/Infinity */
824
+ function parseUtilization(value: number | undefined): number | null {
825
+ if (value == null) return null;
826
+ if (!Number.isFinite(value)) return null; // Handles NaN and Infinity
827
+ return Math.round(Math.max(0, Math.min(100, value)));
828
+ }
829
+
830
+ /** Parse ISO date string safely, returning null for invalid dates */
831
+ function parseDate(dateStr: string | undefined): Date | null {
832
+ if (!dateStr) return null;
833
+ const date = new Date(dateStr);
834
+ // Check for Invalid Date
835
+ if (isNaN(date.getTime())) {
836
+ debug('Invalid date string:', dateStr);
837
+ return null;
838
+ }
839
+ return date;
840
+ }
841
+
842
+ export function getUsageApiTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
843
+ const raw = env.CLAUDE_HUD_USAGE_TIMEOUT_MS?.trim();
844
+ if (!raw) return USAGE_API_TIMEOUT_MS_DEFAULT;
845
+
846
+ const parsed = Number(raw);
847
+ if (!Number.isInteger(parsed) || parsed <= 0) {
848
+ debug('Invalid CLAUDE_HUD_USAGE_TIMEOUT_MS value:', raw);
849
+ return USAGE_API_TIMEOUT_MS_DEFAULT;
850
+ }
851
+ return parsed;
852
+ }
853
+
854
+ export function isNoProxy(hostname: string, env: NodeJS.ProcessEnv = process.env): boolean {
855
+ const noProxy = env.NO_PROXY ?? env.no_proxy;
856
+ if (!noProxy) return false;
857
+
858
+ const host = hostname.toLowerCase();
859
+ return noProxy.split(',').some((entry) => {
860
+ const pattern = entry.trim().toLowerCase();
861
+ if (!pattern) return false;
862
+ if (pattern === '*') return true;
863
+ if (host === pattern) return true;
864
+ const suffix = pattern.startsWith('.') ? pattern : `.${pattern}`;
865
+ return host.endsWith(suffix);
866
+ });
867
+ }
868
+
869
+ export function getProxyUrl(hostname: string, env: NodeJS.ProcessEnv = process.env): URL | null {
870
+ if (isNoProxy(hostname, env)) {
871
+ debug('Proxy bypassed by NO_PROXY for host:', hostname);
872
+ return null;
873
+ }
874
+
875
+ const proxyEnv = env.HTTPS_PROXY
876
+ ?? env.https_proxy
877
+ ?? env.ALL_PROXY
878
+ ?? env.all_proxy
879
+ ?? env.HTTP_PROXY
880
+ ?? env.http_proxy;
881
+ if (!proxyEnv) return null;
882
+
883
+ try {
884
+ const proxyUrl = new URL(proxyEnv);
885
+ if (proxyUrl.protocol !== 'http:' && proxyUrl.protocol !== 'https:') {
886
+ debug('Unsupported proxy protocol:', proxyUrl.protocol);
887
+ return null;
888
+ }
889
+ return proxyUrl;
890
+ } catch {
891
+ debug('Invalid proxy URL:', proxyEnv);
892
+ return null;
893
+ }
894
+ }
895
+
896
+ function createProxyTunnelAgent(proxyUrl: URL): https.Agent {
897
+ const proxyHost = proxyUrl.hostname;
898
+ const proxyPort = Number.parseInt(proxyUrl.port || (proxyUrl.protocol === 'https:' ? '443' : '80'), 10);
899
+ const proxyAuth = proxyUrl.username
900
+ ? `Basic ${Buffer.from(
901
+ `${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password || '')}`
902
+ ).toString('base64')}`
903
+ : null;
904
+
905
+ return new class extends https.Agent {
906
+ override createConnection(
907
+ options: https.RequestOptions,
908
+ callback?: (err: Error | null, socket: net.Socket) => void
909
+ ): undefined {
910
+ const targetHost = String(options.host ?? options.hostname ?? 'localhost');
911
+ const targetPort = Number(options.port) || 443;
912
+
913
+ let settled = false;
914
+ const settle = (err: Error | null, socket: net.Socket): void => {
915
+ if (settled) return;
916
+ settled = true;
917
+ callback?.(err, socket);
918
+ };
919
+
920
+ const proxySocket = proxyUrl.protocol === 'https:'
921
+ ? tls.connect({ host: proxyHost, port: proxyPort, servername: proxyHost })
922
+ : net.connect(proxyPort, proxyHost);
923
+
924
+ proxySocket.once('error', (error) => {
925
+ settle(error, proxySocket);
926
+ });
927
+
928
+ proxySocket.once('connect', () => {
929
+ const connectHeaders = [
930
+ `CONNECT ${targetHost}:${targetPort} HTTP/1.1`,
931
+ `Host: ${targetHost}:${targetPort}`,
932
+ ];
933
+ if (proxyAuth) {
934
+ connectHeaders.push(`Proxy-Authorization: ${proxyAuth}`);
935
+ }
936
+ connectHeaders.push('', '');
937
+
938
+ proxySocket.write(connectHeaders.join('\r\n'));
939
+
940
+ let responseBuffer = Buffer.alloc(0);
941
+ const onData = (chunk: Buffer): void => {
942
+ responseBuffer = Buffer.concat([responseBuffer, chunk]);
943
+ const headerEndIndex = responseBuffer.indexOf('\r\n\r\n');
944
+ if (headerEndIndex === -1) return;
945
+
946
+ proxySocket.removeListener('data', onData);
947
+
948
+ const headerText = responseBuffer.subarray(0, headerEndIndex).toString('utf8');
949
+ const statusLine = headerText.split('\r\n')[0] ?? '';
950
+ if (!/^HTTP\/1\.[01] 200 /.test(statusLine)) {
951
+ const error = new Error(`Proxy CONNECT rejected: ${statusLine || 'unknown status'}`);
952
+ proxySocket.destroy(error);
953
+ settle(error, proxySocket);
954
+ return;
955
+ }
956
+
957
+ const tlsSocket = tls.connect({
958
+ socket: proxySocket,
959
+ servername: String(options.servername ?? targetHost),
960
+ rejectUnauthorized: options.rejectUnauthorized !== false,
961
+ }, () => {
962
+ settle(null, tlsSocket);
963
+ });
964
+
965
+ tlsSocket.once('error', (error) => {
966
+ settle(error, tlsSocket);
967
+ });
968
+ };
969
+
970
+ proxySocket.on('data', onData);
971
+ });
972
+
973
+ // Must not return the socket here. In Node.js _http_agent.js, createSocket()
974
+ // calls: `if (newSocket) oncreate(null, newSocket)` — returning a truthy value
975
+ // causes the HTTP request to be written to the raw proxy socket immediately,
976
+ // before the CONNECT tunnel is established. Only deliver the final TLS socket
977
+ // asynchronously via the callback after the CONNECT handshake succeeds.
978
+ return undefined;
979
+ }
980
+ }();
981
+ }
982
+
983
+ function fetchUsageApi(accessToken: string): Promise<UsageApiResult> {
984
+ return new Promise((resolve) => {
985
+ const host = 'api.anthropic.com';
986
+ const timeoutMs = getUsageApiTimeoutMs();
987
+ const proxyUrl = getProxyUrl(host);
988
+ const options = {
989
+ hostname: host,
990
+ path: '/api/oauth/usage',
991
+ method: 'GET',
992
+ headers: {
993
+ 'Authorization': `Bearer ${accessToken}`,
994
+ 'anthropic-beta': 'oauth-2025-04-20',
995
+ 'User-Agent': USAGE_API_USER_AGENT,
996
+ },
997
+ timeout: timeoutMs,
998
+ agent: proxyUrl ? createProxyTunnelAgent(proxyUrl) : undefined,
999
+ };
1000
+
1001
+ if (proxyUrl) {
1002
+ debug('Using proxy for usage API:', proxyUrl.origin);
1003
+ }
1004
+
1005
+ const req = https.request(options, (res) => {
1006
+ let data = '';
1007
+
1008
+ res.on('data', (chunk: Buffer) => {
1009
+ data += chunk.toString();
1010
+ });
1011
+
1012
+ res.on('end', () => {
1013
+ if (res.statusCode !== 200) {
1014
+ debug('API returned non-200 status:', res.statusCode);
1015
+ // Use a distinct error key for 429 so cache/render can handle it specially
1016
+ const error = res.statusCode === 429
1017
+ ? 'rate-limited'
1018
+ : res.statusCode ? `http-${res.statusCode}` : 'http-error';
1019
+ const retryAfterSec = res.statusCode === 429
1020
+ ? parseRetryAfterSeconds(res.headers['retry-after'])
1021
+ : undefined;
1022
+ if (retryAfterSec) {
1023
+ debug('Retry-After:', retryAfterSec, 'seconds');
1024
+ }
1025
+ resolve({ data: null, error, retryAfterSec });
1026
+ return;
1027
+ }
1028
+
1029
+ try {
1030
+ const parsed: UsageApiResponse = JSON.parse(data);
1031
+ resolve({ data: parsed });
1032
+ } catch (error) {
1033
+ debug('Failed to parse API response:', error);
1034
+ resolve({ data: null, error: 'parse' });
1035
+ }
1036
+ });
1037
+ });
1038
+
1039
+ req.on('error', (error) => {
1040
+ debug('API request error:', error);
1041
+ resolve({ data: null, error: 'network' });
1042
+ });
1043
+ req.on('timeout', () => {
1044
+ debug('API request timeout');
1045
+ req.destroy();
1046
+ resolve({ data: null, error: 'timeout' });
1047
+ });
1048
+
1049
+ req.end();
1050
+ });
1051
+ }
1052
+
1053
+ export function parseRetryAfterSeconds(
1054
+ raw: string | string[] | undefined,
1055
+ nowMs: number = Date.now(),
1056
+ ): number | undefined {
1057
+ const value = Array.isArray(raw) ? raw[0] : raw;
1058
+ if (!value) return undefined;
1059
+
1060
+ const parsedSeconds = Number.parseInt(value, 10);
1061
+ if (Number.isFinite(parsedSeconds) && parsedSeconds > 0) {
1062
+ return parsedSeconds;
1063
+ }
1064
+
1065
+ const retryAtMs = Date.parse(value);
1066
+ if (!Number.isFinite(retryAtMs)) {
1067
+ return undefined;
1068
+ }
1069
+
1070
+ const retryAfterSeconds = Math.ceil((retryAtMs - nowMs) / 1000);
1071
+ return retryAfterSeconds > 0 ? retryAfterSeconds : undefined;
1072
+ }
1073
+
1074
+ // Export for testing
1075
+ export function clearCache(homeDir?: string): void {
1076
+ if (homeDir) {
1077
+ try {
1078
+ const cachePath = getCachePath(homeDir);
1079
+ if (fs.existsSync(cachePath)) {
1080
+ fs.unlinkSync(cachePath);
1081
+ }
1082
+ const lockPath = getCacheLockPath(homeDir);
1083
+ if (fs.existsSync(lockPath)) {
1084
+ fs.unlinkSync(lockPath);
1085
+ }
1086
+ } catch {
1087
+ // Ignore
1088
+ }
1089
+ }
1090
+ }