agent-tank 0.9.9 → 0.9.11

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.
@@ -102,22 +102,22 @@ function parseWeeklyAllModels(apiResponse, now) {
102
102
  }
103
103
 
104
104
  /**
105
- * Parse weekly Sonnet only limit from API response
105
+ * Parse weekly Fable allowance from API response
106
106
  * @param {Object} apiResponse - Raw API response
107
107
  * @param {Date} now - Current date/time
108
- * @returns {Object|null} Parsed weekly Sonnet data
108
+ * @returns {Object|null} Parsed weekly Fable data
109
109
  */
110
- function parseWeeklySonnet(apiResponse, now) {
111
- const ws = apiResponse.weeklySonnet || apiResponse.weeklySonnetOnly;
112
- if (!ws) return null;
110
+ function parseWeeklyFable(apiResponse, now) {
111
+ const wf = apiResponse.weeklyFable;
112
+ if (!wf) return null;
113
113
 
114
- const percent = ws.percentUsed ?? (ws.used && ws.limit ? Math.round((ws.used / ws.limit) * 100) : null);
114
+ const percent = wf.percentUsed ?? (wf.used != null && wf.limit > 0 ? Math.round((wf.used / wf.limit) * 100) : null);
115
115
 
116
116
  if (percent === null) return null;
117
117
 
118
- const resetInfo = parseResetTimestamp(ws.resetsAt, now);
119
- const weeklySonnet = { label: 'Current week (Sonnet only)', percent, ...resetInfo };
120
- return addPaceData(weeklySonnet, 'weekly');
118
+ const resetInfo = parseResetTimestamp(wf.resetsAt, now);
119
+ const weeklyFable = { label: 'Current week (Fable)', percent, ...resetInfo };
120
+ return addPaceData(weeklyFable, 'weekly');
121
121
  }
122
122
 
123
123
  /**
@@ -174,8 +174,8 @@ function parseFlatPercentageFields(apiResponse, usage, now) {
174
174
 
175
175
  /**
176
176
  * Normalize the OAuth usage API response to the parser's expected schema.
177
- * The API returns: five_hour, seven_day, seven_day_sonnet, extra_usage
178
- * The parser expects: sessionLimit, weeklyAllModels, weeklySonnet, extraUsage
177
+ * The API returns: five_hour, seven_day, seven_day_fable, extra_usage
178
+ * The parser expects: sessionLimit, weeklyAllModels, weeklyFable, extraUsage
179
179
  * @param {Object} raw - Raw API response
180
180
  * @returns {Object} Normalized response
181
181
  */
@@ -194,10 +194,10 @@ function normalizeOAuthResponse(raw) {
194
194
  resetsAt: raw.seven_day.resets_at,
195
195
  };
196
196
  }
197
- if (raw.seven_day_sonnet) {
198
- norm.weeklySonnet = {
199
- percentUsed: Math.round(raw.seven_day_sonnet.utilization),
200
- resetsAt: raw.seven_day_sonnet.resets_at,
197
+ if (raw.seven_day_fable) {
198
+ norm.weeklyFable = {
199
+ percentUsed: Math.round(raw.seven_day_fable.utilization),
200
+ resetsAt: raw.seven_day_fable.resets_at,
201
201
  };
202
202
  }
203
203
  if (raw.extra_usage?.is_enabled) {
@@ -219,7 +219,7 @@ function normalizeOAuthResponse(raw) {
219
219
  * @returns {Object} The parsed usage object matching the PTY format
220
220
  */
221
221
  function parseApiResponse(apiResponse) {
222
- const usage = { session: null, weeklyAll: null, weeklySonnet: null };
222
+ const usage = { session: null, weeklyAll: null, weeklyFable: null };
223
223
 
224
224
  if (!apiResponse) return usage;
225
225
 
@@ -236,7 +236,7 @@ function parseApiResponse(apiResponse) {
236
236
  // Parse each section
237
237
  usage.session = parseSessionLimit(normalized, now);
238
238
  usage.weeklyAll = parseWeeklyAllModels(normalized, now);
239
- usage.weeklySonnet = parseWeeklySonnet(normalized, now);
239
+ usage.weeklyFable = parseWeeklyFable(normalized, now);
240
240
 
241
241
  const extraUsage = parseExtraUsage(normalized, now);
242
242
  if (extraUsage) {
@@ -134,14 +134,6 @@ class BaseAgent {
134
134
  return;
135
135
  }
136
136
 
137
- if (/rate.?limited|rate_limit_error/i.test(cleanOutput)) {
138
- logger.agent(this.name, 'Rate limited, preserving last known usage data');
139
- this.error = 'Rate limited — using cached data';
140
- this.lastUpdated = new Date().toISOString();
141
- // Don't overwrite this.usage — keep the last known good data
142
- return;
143
- }
144
-
145
137
  // Check for session/auth errors — preserve cached data if available
146
138
  const sessionErrorMatch = cleanOutput.match(/session.?expired|session.?error|invalid.?session|authentication.?error|auth.?failed|Unable to (?:load|fetch)|Error loading|could not (?:load|fetch)|Failed to load usage|not authenticated|login required|sign.?in required/i);
147
139
  if (sessionErrorMatch) {
@@ -152,12 +144,7 @@ class BaseAgent {
152
144
  return;
153
145
  }
154
146
 
155
- const parsed = this.parseOutput(output);
156
-
157
- // Only update usage if we got meaningful data (not all-null)
158
- const hasData = parsed && Object.values(parsed).some(v => v !== null && v !== undefined && (typeof v !== 'object' || (Array.isArray(v) ? v.length > 0 : Object.keys(v).length > 0)));
159
- if (hasData) { this.usage = parsed; this.lastUpdated = new Date().toISOString(); } else if (this.usage) { logger.agent(this.name, 'Parse returned no data, preserving last known usage'); this.error = 'Failed to parse — using cached data'; } else { this.usage = parsed; this.lastUpdated = new Date().toISOString(); }
160
- logger.agent(this.name, 'Parsed usage:', logger.json(this.usage));
147
+ this._applyParsedUsage(this.parseOutput(output), cleanOutput);
161
148
  } catch (err) {
162
149
  if (this.isStopping() && err.message === 'Agent stopping') {
163
150
  logger.agent(this.name, 'Refresh cancelled during shutdown');
@@ -171,6 +158,34 @@ class BaseAgent {
171
158
  }
172
159
  }
173
160
 
161
+ // Decide what to do with freshly parsed usage, given the raw (cleaned) output.
162
+ // Prefers fresh data; treats rate-limit notices as an error only when no
163
+ // usable data came back (a partial rate limit — e.g. Claude's per-model /
164
+ // Fable breakdown — still yields session/weekly data, and the rate-limited
165
+ // section simply parses to null and is omitted by the formatter).
166
+ _applyParsedUsage(parsed, cleanOutput) {
167
+ const hasData = parsed && Object.values(parsed).some(v =>
168
+ v !== null && v !== undefined &&
169
+ (typeof v !== 'object' || (Array.isArray(v) ? v.length > 0 : Object.keys(v).length > 0)));
170
+
171
+ if (hasData) {
172
+ this.usage = parsed;
173
+ this.lastUpdated = new Date().toISOString();
174
+ } else if (/rate.?limited|rate_limit_error/i.test(cleanOutput)) {
175
+ logger.agent(this.name, 'Rate limited with no usable data, preserving last known usage');
176
+ this.error = this.usage ? 'Rate limited — using cached data' : 'Rate limited';
177
+ this.lastUpdated = new Date().toISOString();
178
+ return;
179
+ } else if (this.usage) {
180
+ logger.agent(this.name, 'Parse returned no data, preserving last known usage');
181
+ this.error = 'Failed to parse — using cached data';
182
+ } else {
183
+ this.usage = parsed;
184
+ this.lastUpdated = new Date().toISOString();
185
+ }
186
+ logger.agent(this.name, 'Parsed usage:', logger.json(this.usage));
187
+ }
188
+
174
189
  async runCommand() {
175
190
  if (this.isStopping()) {
176
191
  throw this._createStopError();
@@ -1,8 +1,21 @@
1
+ /* eslint-disable max-lines -- Claude PTY and API integrations share one provider implementation. */
2
+
1
3
  const { BaseAgent } = require('./base.js');
2
4
  const logger = require('../logger.js');
3
5
  const { pingKeepalive } = require('./keepalive-helper.js');
4
6
  const { parseApiResponse } = require('./api-response-parser.js');
5
- const { parsePtyOutput } = require('./pty-output-parser.js');
7
+ const { parsePtyOutput, FABLE_SECTION_START } = require('./pty-output-parser.js');
8
+
9
+ // Matches the Fable allowance header alone, before its "N% used" row has rendered.
10
+ const FABLE_SECTION_HEADER = new RegExp(FABLE_SECTION_START, 'i');
11
+ const USAGE_SETTLE_TIMEOUT_MS = 2500;
12
+ const USAGE_SETTLE_POLL_MS = 50;
13
+ // A healthy Claude /usage dialog starts returning its core rows almost
14
+ // immediately. Persistent PTYs occasionally swallow the slash command and emit
15
+ // only terminal-mode control bytes; retry once instead of waiting 30 seconds
16
+ // for the generic command timeout to tear down the process.
17
+ const USAGE_COMMAND_RETRY_MS = 2500;
18
+ const USAGE_COMMAND_RESET_MS = 200;
6
19
  const https = require('https');
7
20
  const {
8
21
  readCredentials, isTokenExpired, refreshOAuthToken, persistRefreshedTokens,
@@ -14,6 +27,7 @@ const API_RESPONSE_SENTINEL = '__API_RESPONSE__';
14
27
  class ClaudeAgent extends BaseAgent {
15
28
  constructor(options = {}) {
16
29
  super('claude', 'claude');
30
+ this.configPath = options.configPath || null;
17
31
  this._statusSent = false;
18
32
  this.useApi = options.useApi || false;
19
33
  this._apiResponse = null; // Stores the API response when using direct API
@@ -30,7 +44,7 @@ class ClaudeAgent extends BaseAgent {
30
44
  * @returns {Promise<string|null>} The OAuth token or null
31
45
  */
32
46
  async _getAuthToken() {
33
- const creds = readCredentials();
47
+ const creds = readCredentials(this.configPath);
34
48
  if (!creds) {
35
49
  logger.agent(this.name, 'No OAuth token found in any credential source');
36
50
  return null;
@@ -133,7 +147,7 @@ class ClaudeAgent extends BaseAgent {
133
147
  * @returns {Promise<string|null>}
134
148
  */
135
149
  async _refreshAndGetToken() {
136
- const creds = readCredentials();
150
+ const creds = readCredentials(this.configPath);
137
151
  if (!creds?.refreshToken) {
138
152
  logger.agent(this.name, 'No refresh token available');
139
153
  return null;
@@ -148,7 +162,7 @@ class ClaudeAgent extends BaseAgent {
148
162
  logger.agent(this.name, 'OAuth token refreshed successfully');
149
163
  if (creds.source === 'credentials_file') {
150
164
  try {
151
- persistRefreshedTokens(refreshed);
165
+ persistRefreshedTokens(refreshed, this.configPath);
152
166
  logger.agent(this.name, 'Refreshed tokens persisted to credentials file');
153
167
  } catch (err) {
154
168
  logger.agent(this.name, 'Failed to persist refreshed tokens:', err.message);
@@ -182,6 +196,7 @@ class ClaudeAgent extends BaseAgent {
182
196
  getEnv() {
183
197
  const env = { ...process.env, TERM: 'dumb', NO_COLOR: '1' };
184
198
  delete env.CLAUDECODE; // Allow spawning inside a Claude Code session
199
+ if (this.configPath) env.CLAUDE_CONFIG_DIR = this.configPath;
185
200
  return env;
186
201
  }
187
202
 
@@ -209,19 +224,23 @@ class ClaudeAgent extends BaseAgent {
209
224
  const hasSessionData = parsed.session && typeof parsed.session.percent === 'number';
210
225
  const hasLegacyWeekly = parsed.weekly && typeof parsed.weekly.percent === 'number';
211
226
  const hasAllModelsWeekly = parsed.weeklyAll && typeof parsed.weeklyAll.percent === 'number';
212
- const hasSonnetWeekly = parsed.weeklySonnet && typeof parsed.weeklySonnet.percent === 'number';
227
+ const hasFableWeekly = parsed.weeklyFable && typeof parsed.weeklyFable.percent === 'number';
228
+
229
+ // Only Max accounts get a Fable allowance row, so never block on it unless the
230
+ // dialog has actually started drawing one — otherwise Pro accounts would wait
231
+ // out the full command timeout for a section that is never coming.
232
+ if (hasAllModelsWeekly && FABLE_SECTION_HEADER.test(clean) && !hasFableWeekly) return false;
213
233
 
214
234
  // Newer Claude builds often emit usable session/weekly data before the UI fully settles.
215
- // For the all-model weekly format, wait for the separate Sonnet section too.
216
- return Boolean(hasSessionData && (hasLegacyWeekly || (hasAllModelsWeekly && hasSonnetWeekly)));
235
+ return Boolean(hasSessionData && (hasLegacyWeekly || hasAllModelsWeekly));
217
236
  }
218
237
 
219
- sendCommands(shell, _output) {
238
+ sendCommands(shell, _output, canWrite = () => true) {
220
239
  logger.agent(this.name, 'Sending /usage command...');
221
240
  // Claude keeps slash-command suggestions open for /usage on newer builds.
222
241
  // Confirm the command selection, then submit the actual command execution.
223
242
  const writeIfActive = (value) => {
224
- if (!shell) return;
243
+ if (!shell || !canWrite()) return;
225
244
  try {
226
245
  shell.write(value);
227
246
  } catch (_err) {
@@ -233,9 +252,76 @@ class ClaudeAgent extends BaseAgent {
233
252
  setTimeout(() => writeIfActive('\r'), 900);
234
253
  }
235
254
 
236
- // After getting /usage output, dismiss dialog so next refresh starts with clean prompt
255
+ _isUsageDialogSettled(output) {
256
+ const clean = this.stripAnsi(output);
257
+ const usage = parsePtyOutput(clean);
258
+ if (!usage.weeklyAll || usage.weeklyFable) return true;
259
+
260
+ // A visible Fable header without its percentage is still mid-render.
261
+ if (FABLE_SECTION_HEADER.test(clean)) return false;
262
+
263
+ // Claude renders the usage footer after its asynchronous local-session scan.
264
+ // Reaching it without a Fable row means this account has no Fable allowance.
265
+ const weeklyIndex = clean.search(/Current\s*week\s*\(?\s*all\s*models/i);
266
+ const afterWeekly = weeklyIndex === -1 ? clean : clean.slice(weeklyIndex);
267
+ return /Usage\s*credits\s+are|Extra\s+usage/i.test(afterWeekly);
268
+ }
269
+
270
+ async _waitForUsageDialogSettlement(initialOutput) {
271
+ const deadline = Date.now() + USAGE_SETTLE_TIMEOUT_MS;
272
+ let latestOutput = this.output || initialOutput;
273
+
274
+ while (!this._isUsageDialogSettled(latestOutput) && Date.now() < deadline) {
275
+ await new Promise(resolve => setTimeout(resolve, USAGE_SETTLE_POLL_MS));
276
+ latestOutput = this.output || latestOutput;
277
+ }
278
+
279
+ return latestOutput;
280
+ }
281
+
282
+ _hasParseableUsageRows(output) {
283
+ const parsed = parsePtyOutput(this.stripAnsi(output));
284
+ return [parsed.session, parsed.weekly, parsed.weeklyAll, parsed.weeklyFable]
285
+ .some(row => row && typeof row.percent === 'number');
286
+ }
287
+
288
+ _retryStalledUsageCommand() {
289
+ const shell = this.shell;
290
+ const commandCallback = this._onDataCallback;
291
+ // Rows can arrive during either the reset delay or delayed submission while
292
+ // the Fable allowance is still rendering. Retry only the original waiter
293
+ // with no parseable rows, and continue to respect completed error responses.
294
+ const canRetry = () => shell && this.shell === shell && this._commandInFlight &&
295
+ this._onDataCallback === commandCallback && !this._hasParseableUsageRows(this.output) &&
296
+ !this.hasCompleteOutput(this.output);
297
+ if (!canRetry()) return;
298
+
299
+ logger.agent(this.name, 'No usage rows after 2.5s; resetting prompt and retrying /usage...');
300
+ try {
301
+ // Dismiss a half-open dialog or suggestion menu before resubmitting.
302
+ shell.write('\x1b');
303
+ } catch (_err) {
304
+ return;
305
+ }
306
+
307
+ setTimeout(() => {
308
+ if (!canRetry()) return;
309
+ this.output = '';
310
+ this.sendCommands(shell, '', canRetry);
311
+ }, USAGE_COMMAND_RESET_MS);
312
+ }
313
+
314
+ // After getting /usage output, let Claude finish its asynchronous allowance
315
+ // rows, then dismiss the dialog so the next refresh starts with a clean prompt.
237
316
  async sendCommandAndWait() {
238
- const result = await super.sendCommandAndWait();
317
+ const retryTimer = setTimeout(() => this._retryStalledUsageCommand(), USAGE_COMMAND_RETRY_MS);
318
+ let result;
319
+ try {
320
+ result = await super.sendCommandAndWait();
321
+ } finally {
322
+ clearTimeout(retryTimer);
323
+ }
324
+ result = await this._waitForUsageDialogSettlement(result);
239
325
  if (this.shell) { this.shell.write('\x1b'); await new Promise(r => setTimeout(r, 1000)); this.output = ''; }
240
326
  return result;
241
327
  }
@@ -352,17 +438,54 @@ class ClaudeAgent extends BaseAgent {
352
438
  _parseStatusOutput(output) {
353
439
  const clean = this.stripAnsi(output);
354
440
  const metadata = {};
355
- const patterns = [
356
- ['sessionId', /Session(?:\s+ID)?:\s*([a-f0-9-]+)/i],
357
- ['cwd', /(?:Working directory|Cwd|Current directory|Directory):\s*([^\n│]+)/i],
358
- ['organization', /(?:Organization|Org):\s*([^\n│]+)/i],
359
- ['email', /(?:Email|Account|User|Logged in as):\s*(\S+@\S+)/i],
360
- ['model', /(?:Model|Using model):\s*(claude[-\w.]+)/i],
361
- ['version', /(?:Version|Claude Code):\s*v?([\d.]+)/i],
362
- ];
363
- for (const [key, regex] of patterns) {
364
- const match = clean.match(regex);
365
- if (match) metadata[key] = this.stripBoxChars(match[1]);
441
+
442
+ // Claude 2.1.284 redraws the /status table in-place. Once terminal control
443
+ // sequences are removed, several fields can share one logical line, so a
444
+ // newline-only value boundary consumes every field that follows it. Keep
445
+ // line and box boundaries too, including before prompts or unknown labels.
446
+ const fieldStart = [
447
+ 'Version', 'Claude\\s*Code', 'Session\\s*name', 'Session\\s*ID', 'Session\\s*kind',
448
+ 'Peer\\s*address', 'Working\\s*directory', 'Cwd', 'Current\\s*directory', 'Directory',
449
+ 'Login\\s*method', 'Organization', 'Org', 'Email', 'Account', 'User', 'Logged\\s*in\\s*as',
450
+ 'Cloud\\s*sessions', 'Model', 'Using\\s*model', 'MCP\\s*servers', 'Setting\\s*sources',
451
+ 'Auto\\s*mode\\s*server',
452
+ ].join('|');
453
+ const readField = (labels) => {
454
+ const match = clean.match(new RegExp(
455
+ `(?:${labels})\\s*:\\s*(.*?)(?=\\s*(?:(?:${fieldStart})\\s*:|Esc\\s*to\\s*cancel)|[\\r\\n│]|$)`,
456
+ 'i'
457
+ ));
458
+ return match ? this.stripBoxChars(match[1]).trim() : null;
459
+ };
460
+
461
+ const sessionId = readField('Session(?:\\s*ID)?');
462
+ if (sessionId) {
463
+ const match = sessionId.match(/[a-f0-9-]+/i);
464
+ if (match) metadata.sessionId = match[0];
465
+ }
466
+
467
+ const cwd = readField('Working\\s*directory|Cwd|Current\\s*directory|Directory');
468
+ if (cwd) metadata.cwd = cwd;
469
+
470
+ const organization = readField('Organization|Org');
471
+ if (organization) metadata.organization = organization;
472
+
473
+ const email = readField('Email|Account|User|Logged\\s*in\\s*as');
474
+ if (email) {
475
+ const match = email.match(/\S+@\S+/);
476
+ if (match) metadata.email = match[0];
477
+ }
478
+
479
+ const model = readField('Model|Using\\s*model');
480
+ if (model) {
481
+ const claudeModel = model.match(/claude[-\w.]+/i);
482
+ metadata.model = claudeModel ? claudeModel[0] : model.replace(/\[\d+m/g, '').trim();
483
+ }
484
+
485
+ const version = readField('Version|Claude\\s*Code');
486
+ if (version) {
487
+ const match = version.match(/v?([\d.]+)/i);
488
+ if (match) metadata.version = match[1];
366
489
  }
367
490
  return Object.keys(metadata).length > 0 ? metadata : null;
368
491
  }
@@ -370,6 +493,10 @@ class ClaudeAgent extends BaseAgent {
370
493
  /** Lightweight keepalive to prevent session expiration. @returns {Promise<boolean>} True if keepalive succeeded */
371
494
  async keepalive() {
372
495
  if (this.freshProcess) { console.log(`[${this.name}] Keepalive skipped (fresh process mode)`); return true; }
496
+ if (this.isRefreshing || this._commandInFlight) {
497
+ console.log(`[${this.name}] Keepalive skipped (command in flight)`);
498
+ return true;
499
+ }
373
500
  if (!this.shell || !this.processReady) { console.log(`[${this.name}] Keepalive: spawning process...`); await this.spawnProcess(); }
374
501
  if (this.shell) { console.log(`[${this.name}] Keepalive: sending ping...`); this.shell.write('\x1b'); return true; }
375
502
  return false;
@@ -13,12 +13,20 @@ function formatRpcResponseAsOutput(rateLimits) {
13
13
  throw new Error('Empty rate limits response');
14
14
  }
15
15
 
16
- // Check if we got a structured response we can use directly
16
+ // Current app-server responses include a backwards-compatible single bucket
17
+ // plus an optional map of model-specific buckets. Keep the entire response so
18
+ // the parser can use both views.
19
+ if (isRateLimitBucket(rateLimits) || isRateLimitBucket(rateLimits.rateLimits) ||
20
+ isObject(rateLimits.rateLimitsByLimitId)) {
21
+ return { marker: '__RPC_RESPONSE__', data: rateLimits };
22
+ }
23
+
24
+ // Legacy structured response.
17
25
  if (rateLimits.fiveHour || rateLimits.weekly || rateLimits.limits) {
18
26
  return { marker: '__RPC_RESPONSE__', data: rateLimits };
19
27
  }
20
28
 
21
- // Handle different response formats
29
+ // Legacy nested response.
22
30
  if (rateLimits.rateLimits) {
23
31
  return { marker: '__RPC_RESPONSE__', data: rateLimits.rateLimits };
24
32
  }
@@ -39,13 +47,19 @@ function parseRpcRateLimits(rateLimits, context = {}) {
39
47
  weekly: null,
40
48
  version: context.versionInfo || null,
41
49
  };
50
+ const metadataUpdates = {};
42
51
 
43
- // Parse five hour limit
52
+ if (isCurrentRateLimitsResponse(rateLimits)) {
53
+ parseCurrentRateLimits(rateLimits, usage, metadataUpdates, context);
54
+ return { usage, metadataUpdates };
55
+ }
56
+
57
+ // Parse legacy five hour limit.
44
58
  if (rateLimits.fiveHour) {
45
59
  usage.fiveHour = parseRpcLimitEntry(rateLimits.fiveHour, '5h limit', 'fiveHour', context);
46
60
  }
47
61
 
48
- // Parse weekly limit
62
+ // Parse legacy weekly limit.
49
63
  if (rateLimits.weekly) {
50
64
  usage.weekly = parseRpcLimitEntry(rateLimits.weekly, 'Weekly limit', 'weekly', context);
51
65
  }
@@ -71,8 +85,6 @@ function parseRpcRateLimits(rateLimits, context = {}) {
71
85
  usage.account = rateLimits.account || rateLimits.email;
72
86
  }
73
87
 
74
- // Build metadata updates
75
- const metadataUpdates = {};
76
88
  if (rateLimits.model) {
77
89
  metadataUpdates.model = rateLimits.model;
78
90
  }
@@ -86,6 +98,89 @@ function parseRpcRateLimits(rateLimits, context = {}) {
86
98
  return { usage, metadataUpdates };
87
99
  }
88
100
 
101
+ function parseCurrentRateLimits(rateLimits, usage, metadataUpdates, context) {
102
+ const bucketsById = isObject(rateLimits.rateLimitsByLimitId)
103
+ ? rateLimits.rateLimitsByLimitId
104
+ : {};
105
+ const mainBucket = findMainBucket(rateLimits, bucketsById);
106
+
107
+ if (mainBucket) {
108
+ Object.assign(usage, parseRateLimitBucket(mainBucket, context));
109
+ if (mainBucket.planType) metadataUpdates.planType = mainBucket.planType;
110
+ }
111
+
112
+ const modelLimits = parseModelLimitBuckets(bucketsById, mainBucket, context);
113
+ if (modelLimits.length > 0) usage.modelLimits = modelLimits;
114
+ }
115
+
116
+ function findMainBucket(rateLimits, bucketsById) {
117
+ if (isRateLimitBucket(rateLimits.rateLimits)) return rateLimits.rateLimits;
118
+ if (isRateLimitBucket(rateLimits)) return rateLimits;
119
+ if (isRateLimitBucket(bucketsById.codex)) return bucketsById.codex;
120
+ return Object.values(bucketsById).find(bucket =>
121
+ isRateLimitBucket(bucket) && !bucket.limitName
122
+ ) || null;
123
+ }
124
+
125
+ function parseModelLimitBuckets(bucketsById, mainBucket, context) {
126
+ const modelLimits = [];
127
+ for (const [limitId, bucket] of Object.entries(bucketsById)) {
128
+ if (isMainBucketEntry(bucket, limitId, mainBucket)) continue;
129
+
130
+ const parsed = parseRateLimitBucket(bucket, context);
131
+ if (!parsed.fiveHour && !parsed.weekly) continue;
132
+ modelLimits.push({
133
+ name: bucket.limitName || bucket.limitId || limitId,
134
+ ...parsed,
135
+ });
136
+ }
137
+ return modelLimits;
138
+ }
139
+
140
+ function isMainBucketEntry(bucket, limitId, mainBucket) {
141
+ if (!isRateLimitBucket(bucket) || bucket === mainBucket) return true;
142
+ return Boolean(mainBucket?.limitId && (bucket.limitId || limitId) === mainBucket.limitId);
143
+ }
144
+
145
+ function parseRateLimitBucket(bucket, context) {
146
+ const parsed = { fiveHour: null, weekly: null };
147
+ const windows = [
148
+ { data: bucket.primary, fallbackCycle: 'fiveHour' },
149
+ { data: bucket.secondary, fallbackCycle: 'weekly' },
150
+ ];
151
+
152
+ for (const { data, fallbackCycle } of windows) {
153
+ if (!isObject(data)) continue;
154
+ const cycle = classifyWindow(data.windowDurationMins) || fallbackCycle;
155
+ if (parsed[cycle]) continue;
156
+ const label = cycle === 'weekly' ? 'Weekly limit' : '5h limit';
157
+ parsed[cycle] = parseRpcLimitEntry(data, label, cycle, context);
158
+ }
159
+
160
+ return parsed;
161
+ }
162
+
163
+ function classifyWindow(windowDurationMins) {
164
+ const duration = Number(windowDurationMins);
165
+ if (!Number.isFinite(duration)) return null;
166
+ if (duration === CYCLE_DURATIONS.fiveHour / 60) return 'fiveHour';
167
+ if (duration === CYCLE_DURATIONS.weekly / 60) return 'weekly';
168
+ return null;
169
+ }
170
+
171
+ function isCurrentRateLimitsResponse(rateLimits) {
172
+ return isRateLimitBucket(rateLimits) || isRateLimitBucket(rateLimits?.rateLimits) ||
173
+ isObject(rateLimits?.rateLimitsByLimitId);
174
+ }
175
+
176
+ function isRateLimitBucket(value) {
177
+ return isObject(value) && ('primary' in value || 'secondary' in value);
178
+ }
179
+
180
+ function isObject(value) {
181
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
182
+ }
183
+
89
184
  /**
90
185
  * Parse a single limit entry from RPC response
91
186
  * @param {Object} limit - Limit data from RPC
@@ -95,42 +190,29 @@ function parseRpcRateLimits(rateLimits, context = {}) {
95
190
  * @returns {Object} - Parsed limit entry
96
191
  */
97
192
  function parseRpcLimitEntry(limit, label, cycleType, context = {}) {
98
- // Handle different possible structures
99
- const percentLeft = limit.percentLeft ?? limit.remaining ?? limit.percent ?? 100;
100
- const percentUsed = 100 - percentLeft;
101
-
102
- // Handle reset time
103
- let resetsAt = limit.resetsAt || limit.resetAt || limit.reset || null;
104
- let resetsInSeconds = limit.resetsInSeconds || limit.secondsUntilReset || null;
105
- let resetsIn = null;
106
-
107
- // If we have seconds, calculate text
108
- if (resetsInSeconds !== null) {
109
- resetsIn = formatDuration(resetsInSeconds);
110
- } else if (resetsAt && context.parseResetTime) {
111
- // Try to parse the reset time
112
- const resetData = context.parseResetTime(resetsAt);
113
- if (resetData) {
114
- resetsIn = resetData.text;
115
- resetsInSeconds = resetData.seconds;
116
- }
117
- }
193
+ if (!isObject(limit)) return null;
194
+
195
+ const percentages = parsePercentages(limit);
196
+ if (!percentages) return null;
197
+ const resetInfo = parseRpcResetInfo(limit, context);
118
198
 
119
199
  const entry = {
120
- percentLeft,
121
- resetsAt,
200
+ ...percentages,
201
+ resetsAt: resetInfo.resetsAt,
122
202
  label,
123
- percentUsed,
124
- resetsIn,
125
- resetsInSeconds,
203
+ resetsIn: resetInfo.resetsIn,
204
+ resetsInSeconds: resetInfo.resetsInSeconds,
126
205
  };
206
+ if (limit.windowDurationMins != null) {
207
+ entry.windowDurationMins = limit.windowDurationMins;
208
+ }
127
209
 
128
210
  // Calculate pace if we have the necessary data
129
211
  const cycleDuration = CYCLE_DURATIONS[cycleType];
130
- if (cycleDuration && resetsInSeconds != null) {
212
+ if (cycleDuration && resetInfo.resetsInSeconds != null) {
131
213
  const paceData = calculatePace({
132
- usagePercent: percentUsed,
133
- resetsInSeconds,
214
+ usagePercent: percentages.percentUsed,
215
+ resetsInSeconds: resetInfo.resetsInSeconds,
134
216
  cycleDurationSeconds: cycleDuration,
135
217
  });
136
218
  if (paceData) entry.pace = paceData;
@@ -139,6 +221,56 @@ function parseRpcLimitEntry(limit, label, cycleType, context = {}) {
139
221
  return entry;
140
222
  }
141
223
 
224
+ function parsePercentages(limit) {
225
+ const usedValue = toFiniteNumber(limit.usedPercent ?? limit.percentUsed);
226
+ const leftValue = toFiniteNumber(limit.percentLeft ?? limit.remaining ?? limit.percent);
227
+ if (usedValue === null && leftValue === null) return null;
228
+
229
+ const percentUsed = usedValue ?? (100 - leftValue);
230
+ return {
231
+ percentUsed,
232
+ percentLeft: leftValue ?? (100 - percentUsed),
233
+ };
234
+ }
235
+
236
+ function parseRpcResetInfo(limit, context) {
237
+ const resetsAt = limit.resetsAt || limit.resetAt || limit.reset || null;
238
+ const suppliedSeconds = toFiniteNumber(limit.resetsInSeconds ?? limit.secondsUntilReset);
239
+ if (suppliedSeconds !== null) {
240
+ return { resetsAt, resetsIn: formatDuration(suppliedSeconds), resetsInSeconds: suppliedSeconds };
241
+ }
242
+ if (isUnixTimestamp(resetsAt)) return parseUnixResetTime(resetsAt);
243
+
244
+ const parsed = resetsAt && context.parseResetTime ? context.parseResetTime(resetsAt) : null;
245
+ return {
246
+ resetsAt,
247
+ resetsIn: parsed?.text || null,
248
+ resetsInSeconds: parsed?.seconds ?? null,
249
+ };
250
+ }
251
+
252
+ function parseUnixResetTime(resetsAt) {
253
+ const timestamp = Number(resetsAt);
254
+ const resetTimeMs = timestamp < 1e12 ? timestamp * 1000 : timestamp;
255
+ const resetsInSeconds = Math.max(0, Math.floor((resetTimeMs - Date.now()) / 1000));
256
+ return {
257
+ resetsAt,
258
+ resetsIn: resetsInSeconds === 0 ? 'soon' : formatDuration(resetsInSeconds),
259
+ resetsInSeconds,
260
+ };
261
+ }
262
+
263
+ function isUnixTimestamp(value) {
264
+ if (typeof value === 'number') return Number.isFinite(value);
265
+ return typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value.trim());
266
+ }
267
+
268
+ function toFiniteNumber(value) {
269
+ if (value === null || value === undefined || value === '') return null;
270
+ const number = Number(value);
271
+ return Number.isFinite(number) ? number : null;
272
+ }
273
+
142
274
  /**
143
275
  * Format seconds as duration string
144
276
  * @param {number} seconds - Seconds to format