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.
- package/CHANGELOG.md +157 -0
- package/README.md +84 -9
- package/bin/agent-tank.js +26 -8
- package/favicon.svg +7 -0
- package/package.json +3 -1
- package/src/activity-monitor.js +6 -1
- package/src/agent-config.js +105 -0
- package/src/agents/agy.js +48 -10
- package/src/agents/api-response-parser.js +17 -17
- package/src/agents/base.js +29 -14
- package/src/agents/claude.js +149 -22
- package/src/agents/codex-rpc-helpers.js +166 -34
- package/src/agents/codex.js +42 -9
- package/src/agents/oauth-helper.js +12 -4
- package/src/agents/pty-output-parser.js +28 -10
- package/src/auto-refresh-manager.js +6 -1
- package/src/client-auto-refresh-helpers.js +13 -13
- package/src/client-auto-refresh.js +9 -7
- package/src/client-script.js +1 -1
- package/src/index.js +49 -22
- package/src/pace-attachment.js +2 -2
- package/src/snapshot-metrics.js +1 -1
- package/src/status-page.js +11 -6
- package/src/usage-formatters.js +14 -12
|
@@ -102,22 +102,22 @@ function parseWeeklyAllModels(apiResponse, now) {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
|
-
* Parse weekly
|
|
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
|
|
108
|
+
* @returns {Object|null} Parsed weekly Fable data
|
|
109
109
|
*/
|
|
110
|
-
function
|
|
111
|
-
const
|
|
112
|
-
if (!
|
|
110
|
+
function parseWeeklyFable(apiResponse, now) {
|
|
111
|
+
const wf = apiResponse.weeklyFable;
|
|
112
|
+
if (!wf) return null;
|
|
113
113
|
|
|
114
|
-
const percent =
|
|
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(
|
|
119
|
-
const
|
|
120
|
-
return addPaceData(
|
|
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,
|
|
178
|
-
* The parser expects: sessionLimit, weeklyAllModels,
|
|
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.
|
|
198
|
-
norm.
|
|
199
|
-
percentUsed: Math.round(raw.
|
|
200
|
-
resetsAt: raw.
|
|
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,
|
|
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.
|
|
239
|
+
usage.weeklyFable = parseWeeklyFable(normalized, now);
|
|
240
240
|
|
|
241
241
|
const extraUsage = parseExtraUsage(normalized, now);
|
|
242
242
|
if (extraUsage) {
|
package/src/agents/base.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/src/agents/claude.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
121
|
-
resetsAt,
|
|
200
|
+
...percentages,
|
|
201
|
+
resetsAt: resetInfo.resetsAt,
|
|
122
202
|
label,
|
|
123
|
-
|
|
124
|
-
|
|
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
|