@phnx-labs/agents-cli 1.21.1 → 1.21.2
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 +172 -0
- package/README.md +1 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.js +5 -2
- package/dist/commands/feed.js +28 -19
- package/dist/commands/hooks.js +9 -45
- package/dist/commands/menubar.js +24 -24
- package/dist/commands/message.js +23 -3
- package/dist/commands/perf.d.ts +13 -0
- package/dist/commands/perf.js +80 -23
- package/dist/commands/projects.d.ts +11 -0
- package/dist/commands/projects.js +153 -21
- package/dist/commands/routines.js +46 -1
- package/dist/commands/ssh.js +69 -0
- package/dist/commands/trends.d.ts +2 -0
- package/dist/commands/trends.js +158 -0
- package/dist/commands/usage.d.ts +4 -4
- package/dist/commands/view.d.ts +6 -0
- package/dist/commands/view.js +90 -45
- package/dist/index.js +14 -1
- package/dist/lib/agents.js +2 -2
- package/dist/lib/analytics/dashboard.d.ts +11 -0
- package/dist/lib/analytics/dashboard.js +31 -0
- package/dist/lib/analytics/recipes.d.ts +32 -0
- package/dist/lib/analytics/recipes.js +316 -0
- package/dist/lib/analytics/usage-db.d.ts +84 -0
- package/dist/lib/analytics/usage-db.js +301 -0
- package/dist/lib/browser/service.js +18 -0
- package/dist/lib/cli-resources.d.ts +20 -0
- package/dist/lib/cli-resources.js +48 -1
- package/dist/lib/daemon.js +51 -14
- package/dist/lib/devices/health-report.d.ts +5 -0
- package/dist/lib/devices/health-report.js +3 -0
- package/dist/lib/feed-broadcast.d.ts +52 -7
- package/dist/lib/feed-broadcast.js +125 -18
- package/dist/lib/fleet-cache.d.ts +37 -0
- package/dist/lib/fleet-cache.js +40 -0
- package/dist/lib/fleet-status.d.ts +53 -0
- package/dist/lib/fleet-status.js +120 -0
- package/dist/lib/friction-heuristics.d.ts +32 -0
- package/dist/lib/friction-heuristics.js +47 -0
- package/dist/lib/hooks/cache.js +28 -6
- package/dist/lib/hooks/profile.d.ts +8 -0
- package/dist/lib/hooks/profile.js +14 -4
- package/dist/lib/hooks.js +72 -17
- package/dist/lib/linear-cache.d.ts +63 -0
- package/dist/lib/linear-cache.js +146 -0
- package/dist/lib/linear-project-counts.d.ts +35 -5
- package/dist/lib/linear-project-counts.js +61 -16
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +3 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +7 -0
- package/dist/lib/menubar/install-menubar.js +36 -6
- package/dist/lib/perf/db.d.ts +6 -1
- package/dist/lib/perf/db.js +35 -5
- package/dist/lib/perf/types.d.ts +10 -0
- package/dist/lib/project-doctor.d.ts +36 -0
- package/dist/lib/project-doctor.js +45 -0
- package/dist/lib/project-import.d.ts +11 -1
- package/dist/lib/project-import.js +17 -3
- package/dist/lib/project-status.d.ts +25 -5
- package/dist/lib/project-status.js +48 -6
- package/dist/lib/rotate.d.ts +27 -0
- package/dist/lib/rotate.js +44 -17
- package/dist/lib/routines.d.ts +16 -0
- package/dist/lib/routines.js +39 -0
- package/dist/lib/runner.js +34 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/usage-db.d.ts +3 -63
- package/dist/lib/secrets/usage-db.js +46 -186
- package/dist/lib/session/db.d.ts +2 -1
- package/dist/lib/session/db.js +14 -3
- package/dist/lib/session/discover.d.ts +3 -0
- package/dist/lib/session/discover.js +8 -0
- package/dist/lib/session/types.d.ts +1 -0
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/state.d.ts +31 -3
- package/dist/lib/state.js +53 -10
- package/dist/lib/types.d.ts +8 -4
- package/dist/lib/usage-refresh.d.ts +106 -0
- package/dist/lib/usage-refresh.js +238 -0
- package/dist/lib/usage.d.ts +152 -17
- package/dist/lib/usage.js +393 -79
- package/package.json +1 -1
package/dist/lib/usage.js
CHANGED
|
@@ -23,6 +23,7 @@ import { getKeychainToken, setKeychainToken, deleteKeychainToken, isKeychainBack
|
|
|
23
23
|
import { resolveClaudeSetupToken } from './claude-account-token.js';
|
|
24
24
|
import { formatBackoffRemaining, noteUsageRateLimited, usageRateLimitedUntil, } from './usage-backoff.js';
|
|
25
25
|
import { getCacheDir } from './state.js';
|
|
26
|
+
import { mapBounded } from './concurrency.js';
|
|
26
27
|
const execFileAsync = promisify(execFile);
|
|
27
28
|
const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
|
|
28
29
|
const CLAUDE_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
|
|
@@ -105,11 +106,25 @@ const CLAUDE_SCOPES = [
|
|
|
105
106
|
'user:file_upload',
|
|
106
107
|
];
|
|
107
108
|
const CLAUDE_KEYCHAIN_SERVICE = 'Claude Code-credentials';
|
|
108
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Test seam for the usage cache path, mirroring `setUsageBackoffDirForTest`.
|
|
111
|
+
* `getCacheDir()` resolves from a module-level constant captured at import, so
|
|
112
|
+
* overriding `HOME` in a test does NOT redirect this cache — it would write into
|
|
113
|
+
* the developer's real `~/.agents/.cache/`. Point it at a tmpdir instead.
|
|
114
|
+
*/
|
|
115
|
+
let claudeUsageCachePathOverride = null;
|
|
116
|
+
export function setClaudeUsageCachePathForTest(cachePath) {
|
|
117
|
+
const prev = claudeUsageCachePathOverride;
|
|
118
|
+
claudeUsageCachePathOverride = cachePath;
|
|
119
|
+
return prev;
|
|
120
|
+
}
|
|
121
|
+
const getClaudeUsageCachePath = () => claudeUsageCachePathOverride ?? path.join(getCacheDir(), 'claude-usage.json');
|
|
109
122
|
const CACHED_CLAUDE_USAGE_SOURCE_LABEL = 'last seen live account data';
|
|
110
123
|
const KIMI_USAGES_URL = 'https://api.kimi.com/coding/v1/usages';
|
|
111
124
|
const DROID_USAGE_URL = 'https://api.factory.ai/api/billing/limits';
|
|
112
125
|
const CURSOR_USAGE_URL = 'https://cursor.com/api/usage';
|
|
126
|
+
const CURSOR_PERIOD_USAGE_URL = 'https://cursor.com/api/dashboard/get-current-period-usage';
|
|
127
|
+
const CURSOR_USAGE_SUMMARY_URL = 'https://cursor.com/api/usage-summary';
|
|
113
128
|
const COMPACT_BAR_LEN = 5;
|
|
114
129
|
const USAGE_BAR_LEN = 10;
|
|
115
130
|
const FULL = '\u2588';
|
|
@@ -174,10 +189,47 @@ export function buildCanonicalUsageContext(inputs) {
|
|
|
174
189
|
export function agentReportsUsage(agentId) {
|
|
175
190
|
return getUsageSource(agentId) !== undefined;
|
|
176
191
|
}
|
|
177
|
-
/**
|
|
192
|
+
/**
|
|
193
|
+
* Whether an agent's usage source makes a live NETWORK call (Claude/Kimi/Droid/
|
|
194
|
+
* Cursor/Antigravity) versus reading local session logs (Codex/Grok). Only the
|
|
195
|
+
* networked ones go through the on-disk cache, and only they need the daemon's
|
|
196
|
+
* background refresher to keep that cache warm for the routing hot path.
|
|
197
|
+
*/
|
|
198
|
+
export function agentUsesNetworkUsage(agentId) {
|
|
199
|
+
return getUsageSource(agentId)?.network === true;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Concurrent live usage fetches for a single `agents view` / rotation pass.
|
|
203
|
+
* High enough to finish a multi-account refresh in one round-trip window; low
|
|
204
|
+
* enough that a cold cache of 10+ accounts cannot open 10+ HTTP calls at once
|
|
205
|
+
* (and cannot stack behind delayed responses until the process is pegged).
|
|
206
|
+
*/
|
|
207
|
+
export const USAGE_FETCH_CONCURRENCY = 3;
|
|
208
|
+
/**
|
|
209
|
+
* Concurrent background SWR refreshes. Kept below the blocking concurrency so
|
|
210
|
+
* a display path that returns cached data immediately does not still flood the
|
|
211
|
+
* network with N silent refreshes that finish long after the command exits and
|
|
212
|
+
* pile onto the next invocation.
|
|
213
|
+
*/
|
|
214
|
+
const USAGE_BG_REFRESH_CONCURRENCY = 2;
|
|
215
|
+
/**
|
|
216
|
+
* How long a cached snapshot is treated as fresh enough that we skip the network
|
|
217
|
+
* entirely. Five minutes balances "still accurate enough to glance at" against
|
|
218
|
+
* "don't re-hit every account on every `agents view` in a tight loop". Was 2
|
|
219
|
+
* minutes; that re-fired too often when delayed responses stacked.
|
|
220
|
+
*/
|
|
221
|
+
export const USAGE_CACHE_FRESH_MS = 5 * 60 * 1000;
|
|
222
|
+
const USAGE_CACHE_SWR_MS = 24 * 60 * 60 * 1000; // 24 hours — beyond this, block on live fetch.
|
|
223
|
+
/**
|
|
224
|
+
* Unified entry for every multi-account usage lookup (`agents view`, rotation,
|
|
225
|
+
* JSON export). Deduplicates by usage identity, then fans out through the
|
|
226
|
+
* shared SWR + timeout path with a hard concurrency cap so delayed calls
|
|
227
|
+
* cannot pile up.
|
|
228
|
+
*/
|
|
178
229
|
export async function getUsageInfoByIdentity(inputs, opts) {
|
|
179
230
|
const { canonicalByUsageKey, usageFetchInputs } = buildCanonicalUsageContext(inputs);
|
|
180
|
-
const
|
|
231
|
+
const entries = [...usageFetchInputs.entries()];
|
|
232
|
+
const usageResults = await mapBounded(entries, async ([key, input]) => ({
|
|
181
233
|
key,
|
|
182
234
|
usage: await getUsageInfoForIdentity({
|
|
183
235
|
agentId: input.agentId,
|
|
@@ -185,14 +237,12 @@ export async function getUsageInfoByIdentity(inputs, opts) {
|
|
|
185
237
|
cliVersion: input.cliVersion,
|
|
186
238
|
info: canonicalByUsageKey.get(key),
|
|
187
239
|
}, opts),
|
|
188
|
-
}))
|
|
240
|
+
}), { concurrency: USAGE_FETCH_CONCURRENCY });
|
|
189
241
|
return {
|
|
190
242
|
canonicalByUsageKey,
|
|
191
243
|
usageByKey: new Map(usageResults.map(({ key, usage }) => [key, usage])),
|
|
192
244
|
};
|
|
193
245
|
}
|
|
194
|
-
const USAGE_CACHE_FRESH_MS = 2 * 60 * 1000; // 2 minutes — fresh window: don't refresh.
|
|
195
|
-
const USAGE_CACHE_SWR_MS = 24 * 60 * 60 * 1000; // 24 hours — beyond this, block on live fetch.
|
|
196
246
|
/**
|
|
197
247
|
* How stale a cached snapshot may be before the read stops serving it and blocks
|
|
198
248
|
* on the network. Defaults to the full 24h stale-while-revalidate window; a
|
|
@@ -205,23 +255,35 @@ export function swrWindowMsFor(maxAgeMs) {
|
|
|
205
255
|
return USAGE_CACHE_SWR_MS;
|
|
206
256
|
return Math.min(USAGE_CACHE_SWR_MS, Math.max(0, maxAgeMs));
|
|
207
257
|
}
|
|
208
|
-
/**
|
|
209
|
-
|
|
258
|
+
/**
|
|
259
|
+
* In-process dedup for live + background usage work on the same identity.
|
|
260
|
+
* Covers both the blocking cold-cache path and SWR background refreshes so a
|
|
261
|
+
* delayed HTTP response cannot be stacked under a second request for the same
|
|
262
|
+
* key (the pile-up that made consecutive `agents view` runs peg CPU/network).
|
|
263
|
+
*/
|
|
264
|
+
const inFlightLiveFetches = new Map();
|
|
265
|
+
const inFlightBgKeys = new Set();
|
|
266
|
+
const bgRefreshQueue = [];
|
|
267
|
+
let bgRefreshActive = 0;
|
|
210
268
|
/**
|
|
211
269
|
* Fetch usage for a single identity using stale-while-revalidate.
|
|
212
270
|
*
|
|
213
|
-
* - Cache fresh (<
|
|
214
|
-
* - Cache stale but < 24h: return cached snapshot instantly,
|
|
215
|
-
*
|
|
271
|
+
* - Cache fresh (< 5 min): return cached snapshot, NO network.
|
|
272
|
+
* - Cache stale but < 24h: return cached snapshot instantly, enqueue a
|
|
273
|
+
* concurrency-capped background refresh.
|
|
274
|
+
* - Cache too stale or absent: block on live fetch (shared in-flight promise),
|
|
275
|
+
* fall back to cache on error.
|
|
216
276
|
*
|
|
217
|
-
* This keeps `agents run`
|
|
218
|
-
* invocation after a cold install or 24h gap still blocks once to seed
|
|
219
|
-
* cache; every run after that returns instantly while the cache silently
|
|
220
|
-
* refreshes in the background
|
|
277
|
+
* This keeps `agents run` / `agents view` off the network on the hot path. The
|
|
278
|
+
* first invocation after a cold install or 24h gap still blocks once to seed
|
|
279
|
+
* the cache; every run after that returns instantly while the cache silently
|
|
280
|
+
* refreshes in the background — never more than {@link USAGE_BG_REFRESH_CONCURRENCY}
|
|
281
|
+
* at a time.
|
|
221
282
|
*/
|
|
222
283
|
export async function getUsageInfoForIdentity(input, opts) {
|
|
223
284
|
const usageKey = getUsageLookupKey(input.info);
|
|
224
285
|
const forceRefresh = opts?.forceRefresh === true;
|
|
286
|
+
const readOnly = opts?.readOnly === true;
|
|
225
287
|
// Agents whose registered usage source makes a live network call go
|
|
226
288
|
// through the stale-while-revalidate cache below so `agents run`/`agents view`
|
|
227
289
|
// stay off the network on the hot path. Everything else (Codex reads local
|
|
@@ -239,6 +301,22 @@ export async function getUsageInfoForIdentity(input, opts) {
|
|
|
239
301
|
}
|
|
240
302
|
const cached = readClaudeUsageCache(usageKey);
|
|
241
303
|
const ageMs = cached?.capturedAt ? Date.now() - cached.capturedAt.getTime() : Infinity;
|
|
304
|
+
// `readOnly` (the `agents run` routing hot path): serve the cache and NEVER
|
|
305
|
+
// touch the network — not even a background refresh. `collectRunCandidates`
|
|
306
|
+
// used to pass a 5-minute `maxAgeMs`, which made a snapshot older than that
|
|
307
|
+
// fall through to the blocking live fetch below (getUsageInfo → provider HTTP),
|
|
308
|
+
// adding one round trip per account to `agents run` cold-start on a box whose
|
|
309
|
+
// cache had gone stale. The daemon now owns keeping this cache fresh
|
|
310
|
+
// (`runUsageRefresh`, adaptive + rate-capped), so the router only ever reads
|
|
311
|
+
// it. A stale-or-absent snapshot is handled downstream by the router's own
|
|
312
|
+
// freshness guard (`isUsageVerified` in rotate.ts), which routes around a
|
|
313
|
+
// number it can't confirm rather than trusting an old one — so returning a
|
|
314
|
+
// stale snapshot here is safe, and an absent one reports `'stale'`.
|
|
315
|
+
if (readOnly) {
|
|
316
|
+
if (cached)
|
|
317
|
+
return { snapshot: cached, error: null };
|
|
318
|
+
return { snapshot: null, error: 'stale' };
|
|
319
|
+
}
|
|
242
320
|
// `--refresh` (forceRefresh) skips both cache short-circuits and blocks on a
|
|
243
321
|
// live fetch below, so `agents view --refresh` repopulates every account we can
|
|
244
322
|
// actually reach a token for.
|
|
@@ -258,60 +336,121 @@ export async function getUsageInfoForIdentity(input, opts) {
|
|
|
258
336
|
// full 24h window and stay off the hot path.
|
|
259
337
|
const swrWindowMs = swrWindowMsFor(opts?.maxAgeMs);
|
|
260
338
|
if (cached && ageMs < swrWindowMs) {
|
|
261
|
-
|
|
339
|
+
enqueueBackgroundUsageRefresh(input, usageKey);
|
|
262
340
|
return { snapshot: cached, error: null };
|
|
263
341
|
}
|
|
264
342
|
}
|
|
265
|
-
// Cold cache or > 24h old: block on live fetch
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
343
|
+
// Cold cache or > 24h old (or forceRefresh): block on a shared live fetch so
|
|
344
|
+
// concurrent callers for the same identity share one in-flight HTTP call.
|
|
345
|
+
return fetchLiveUsageDeduped(input, usageKey, cached);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Single-flight live usage fetch per usage key. Concurrent callers (view +
|
|
349
|
+
* rotation, or two rows sharing an account) await the same promise rather than
|
|
350
|
+
* opening duplicate HTTP requests that then time out and pile up.
|
|
351
|
+
*/
|
|
352
|
+
async function fetchLiveUsageDeduped(input, usageKey, cached) {
|
|
353
|
+
const existing = inFlightLiveFetches.get(usageKey);
|
|
354
|
+
if (existing)
|
|
355
|
+
return existing;
|
|
356
|
+
const promise = (async () => {
|
|
357
|
+
const usage = await getUsageInfo(input.agentId, {
|
|
358
|
+
home: input.home,
|
|
359
|
+
cliVersion: input.cliVersion,
|
|
360
|
+
organizationId: input.info.organizationId,
|
|
361
|
+
});
|
|
362
|
+
if (usage.snapshot?.source === 'live') {
|
|
363
|
+
writeClaudeUsageCache(usageKey, usage.snapshot);
|
|
364
|
+
return usage;
|
|
365
|
+
}
|
|
366
|
+
// Live fetch failed — last-resort fallback to whatever cache we had.
|
|
367
|
+
if (cached) {
|
|
368
|
+
return { snapshot: cached, error: usage.error };
|
|
369
|
+
}
|
|
273
370
|
return usage;
|
|
371
|
+
})();
|
|
372
|
+
inFlightLiveFetches.set(usageKey, promise);
|
|
373
|
+
try {
|
|
374
|
+
return await promise;
|
|
274
375
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
return { snapshot: cached, error: usage.error };
|
|
376
|
+
finally {
|
|
377
|
+
inFlightLiveFetches.delete(usageKey);
|
|
278
378
|
}
|
|
279
|
-
return usage;
|
|
280
379
|
}
|
|
281
380
|
/**
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
* getKeychainToken → execFileSync) does synchronous I/O even though the
|
|
287
|
-
* functions are declared `async`. Without the defer, that sync I/O blocks
|
|
288
|
-
* the SWR caller and defeats the whole point of returning the cache instantly.
|
|
381
|
+
* Enqueue a background refresh of the usage cache. Errors are swallowed — a
|
|
382
|
+
* failed refresh leaves the existing cache in place. Work is deferred via
|
|
383
|
+
* `setImmediate` (keychain reads do sync I/O) and drained with a hard
|
|
384
|
+
* concurrency cap so N stale accounts do not open N HTTP calls at once.
|
|
289
385
|
*/
|
|
290
|
-
function
|
|
291
|
-
if (
|
|
386
|
+
function enqueueBackgroundUsageRefresh(input, usageKey) {
|
|
387
|
+
if (inFlightBgKeys.has(usageKey) || inFlightLiveFetches.has(usageKey))
|
|
292
388
|
return;
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
308
|
-
finally {
|
|
309
|
-
inFlightRefreshes.delete(usageKey);
|
|
310
|
-
resolve();
|
|
311
|
-
}
|
|
312
|
-
});
|
|
389
|
+
inFlightBgKeys.add(usageKey);
|
|
390
|
+
bgRefreshQueue.push(async () => {
|
|
391
|
+
try {
|
|
392
|
+
// Reuse the single-flight live path so a blocking caller that arrives
|
|
393
|
+
// mid-refresh shares the same HTTP call instead of racing it.
|
|
394
|
+
const cached = readClaudeUsageCache(usageKey);
|
|
395
|
+
await fetchLiveUsageDeduped(input, usageKey, cached);
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
/* background refresh failed — leave existing cache in place */
|
|
399
|
+
}
|
|
400
|
+
finally {
|
|
401
|
+
inFlightBgKeys.delete(usageKey);
|
|
402
|
+
}
|
|
313
403
|
});
|
|
314
|
-
|
|
404
|
+
pumpBackgroundUsageRefreshes();
|
|
405
|
+
}
|
|
406
|
+
function pumpBackgroundUsageRefreshes() {
|
|
407
|
+
while (bgRefreshActive < USAGE_BG_REFRESH_CONCURRENCY && bgRefreshQueue.length > 0) {
|
|
408
|
+
const work = bgRefreshQueue.shift();
|
|
409
|
+
bgRefreshActive++;
|
|
410
|
+
// setImmediate so the SWR caller returns the cached snapshot before any
|
|
411
|
+
// keychain/network work starts on this tick.
|
|
412
|
+
setImmediate(() => {
|
|
413
|
+
work().finally(() => {
|
|
414
|
+
bgRefreshActive--;
|
|
415
|
+
pumpBackgroundUsageRefreshes();
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Pick which usage windows to render in a compact one-line summary.
|
|
422
|
+
*
|
|
423
|
+
* Overview rows (`agents view` all agents) must stay narrow enough that one
|
|
424
|
+
* multi-window agent (Antigravity's four model quotas, Droid's three buckets)
|
|
425
|
+
* does not force every other row to pad to ~200 columns and wrap. Prefer the
|
|
426
|
+
* canonical session + week windows when present; otherwise take the highest
|
|
427
|
+
* utilization remaining. Returns the full set when `maxWindows` is unset.
|
|
428
|
+
*/
|
|
429
|
+
export function pickCompactUsageWindows(windows, maxWindows) {
|
|
430
|
+
const filtered = windows.filter((window) => window.key !== 'sonnet_week');
|
|
431
|
+
if (maxWindows === undefined || maxWindows <= 0 || filtered.length <= maxWindows) {
|
|
432
|
+
return filtered;
|
|
433
|
+
}
|
|
434
|
+
// Pick by object identity, not by key. Antigravity normalizes every model
|
|
435
|
+
// quota as key: 'session', so a key-set filter would keep only the first and
|
|
436
|
+
// drop the rest even when maxWindows > 1.
|
|
437
|
+
const chosen = [];
|
|
438
|
+
const take = (w) => {
|
|
439
|
+
if (!w || chosen.includes(w) || chosen.length >= maxWindows)
|
|
440
|
+
return;
|
|
441
|
+
chosen.push(w);
|
|
442
|
+
};
|
|
443
|
+
take(filtered.find((w) => w.key === 'session'));
|
|
444
|
+
take(filtered.find((w) => w.key === 'week'));
|
|
445
|
+
const rest = filtered
|
|
446
|
+
.filter((w) => !chosen.includes(w))
|
|
447
|
+
.sort((a, b) => b.usedPercent - a.usedPercent);
|
|
448
|
+
for (const w of rest) {
|
|
449
|
+
if (chosen.length >= maxWindows)
|
|
450
|
+
break;
|
|
451
|
+
chosen.push(w);
|
|
452
|
+
}
|
|
453
|
+
return chosen;
|
|
315
454
|
}
|
|
316
455
|
/** Format a one-line usage summary with compact bars for inline display. */
|
|
317
456
|
export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
|
|
@@ -320,23 +459,29 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
|
|
|
320
459
|
parts.push(chalk.gray(plan.padEnd(planWidth)));
|
|
321
460
|
}
|
|
322
461
|
if (snapshot) {
|
|
323
|
-
// Compact rows show
|
|
462
|
+
// Compact rows show BLOCKING windows — the same set
|
|
324
463
|
// deriveUsageStatusFromSnapshot uses for the rate-limited badge — so an
|
|
325
464
|
// account throttled by its month window (Droid meters on 5h/week/month)
|
|
326
465
|
// shows the bar that explains why. Claude's Sonnet week is a per-model
|
|
327
466
|
// sub-limit, not a blocking window; it renders only in the full
|
|
328
467
|
// per-version usage section. Each window reads "S: ███░░ 58% (3d)" — the
|
|
329
468
|
// gauge, the exact percentage, and a compact hint of when it resets.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
469
|
+
//
|
|
470
|
+
// Overview caps the window count (see pickCompactUsageWindows) so one
|
|
471
|
+
// multi-meter agent cannot force the whole table to wrap.
|
|
472
|
+
const selected = pickCompactUsageWindows(snapshot.windows, opts?.maxWindows);
|
|
473
|
+
const hidden = Math.max(0, snapshot.windows.filter((w) => w.key !== 'sonnet_week').length - selected.length);
|
|
474
|
+
const windowParts = selected.map((window) => {
|
|
333
475
|
const bar = renderCompactUsageBar(window.usedPercent);
|
|
334
476
|
const pct = colorUsage(`${Math.round(window.usedPercent)}%`, window.usedPercent);
|
|
335
477
|
const reset = window.resetsAt ? chalk.dim(` (${formatResetHint(window.resetsAt)})`) : '';
|
|
336
478
|
return `${chalk.gray(`${window.shortLabel}:`)} ${bar} ${pct}${reset}`;
|
|
337
479
|
});
|
|
338
|
-
if (
|
|
339
|
-
|
|
480
|
+
if (hidden > 0) {
|
|
481
|
+
windowParts.push(chalk.dim(`+${hidden}`));
|
|
482
|
+
}
|
|
483
|
+
if (windowParts.length > 0) {
|
|
484
|
+
parts.push(windowParts.join(' '));
|
|
340
485
|
}
|
|
341
486
|
// The bars came from the cache and the live read that should have confirmed
|
|
342
487
|
// them failed, so they are the last thing we saw — not the current state.
|
|
@@ -380,6 +525,27 @@ export function deriveUsageStatusFromSnapshot(snapshot) {
|
|
|
380
525
|
const maxUsed = Math.max(...windows.map((window) => window.usedPercent));
|
|
381
526
|
return maxUsed >= 100 ? 'rate_limited' : 'available';
|
|
382
527
|
}
|
|
528
|
+
export function deriveUsageHeadroom(snapshot, prev) {
|
|
529
|
+
const status = deriveUsageStatusFromSnapshot(snapshot);
|
|
530
|
+
if (!snapshot || status === null)
|
|
531
|
+
return { status, minutesToLimit: null };
|
|
532
|
+
if (status === 'rate_limited')
|
|
533
|
+
return { status, minutesToLimit: 0 };
|
|
534
|
+
const session = snapshot.windows.find((window) => window.key === 'session');
|
|
535
|
+
const capturedAt = snapshot.capturedAt?.getTime();
|
|
536
|
+
if (!session || capturedAt === undefined || !prev) {
|
|
537
|
+
return { status, minutesToLimit: null };
|
|
538
|
+
}
|
|
539
|
+
const deltaPercent = session.usedPercent - prev.usedPercent;
|
|
540
|
+
const deltaMinutes = (capturedAt - prev.capturedAt) / 60_000;
|
|
541
|
+
// Flat, falling (a window reset), or a zero/negative time delta: no live burn
|
|
542
|
+
// to project from, so this account is not "projected to cap".
|
|
543
|
+
if (deltaPercent <= 0 || deltaMinutes <= 0)
|
|
544
|
+
return { status, minutesToLimit: null };
|
|
545
|
+
const burnPerMinute = deltaPercent / deltaMinutes;
|
|
546
|
+
const remaining = Math.max(0, 100 - session.usedPercent);
|
|
547
|
+
return { status, minutesToLimit: remaining / burnPerMinute };
|
|
548
|
+
}
|
|
383
549
|
/**
|
|
384
550
|
* Compact colored badge for the account's overall usage status. Renders only
|
|
385
551
|
* when the account is throttled — `available` and `null` return ''.
|
|
@@ -1049,9 +1215,9 @@ function parseClaudeOauthPayload(raw) {
|
|
|
1049
1215
|
// The source item `Claude Code-credentials-<hash>` is ACL-bound to Claude Code's
|
|
1050
1216
|
// own process, so every read agents-cli makes (via `/usr/bin/security`) pops
|
|
1051
1217
|
// Touch ID. The Factory watchdog polls `agents view --json` every 60s per agent,
|
|
1052
|
-
// and each poll that crosses the
|
|
1218
|
+
// and each poll that crosses the 5-minute usage cache fires a background refresh
|
|
1053
1219
|
// -> loadClaudeOauth -> keychain read -> a biometric prompt. With many agents and
|
|
1054
|
-
// accounts that is a prompt every
|
|
1220
|
+
// accounts that is a prompt every few minutes, per account.
|
|
1055
1221
|
//
|
|
1056
1222
|
// Fix: after one real (prompting) read, cache the ACCESS token in a device-local
|
|
1057
1223
|
// NO-ACL keychain item — the same `set-no-acl` mechanism secrets/session-store.ts
|
|
@@ -1720,7 +1886,54 @@ export function normalizeCursorUsage(data) {
|
|
|
1720
1886
|
},
|
|
1721
1887
|
];
|
|
1722
1888
|
}
|
|
1723
|
-
/**
|
|
1889
|
+
/**
|
|
1890
|
+
* Normalize a single Cursor percent-based window (auto/api/total), or null when
|
|
1891
|
+
* the percent is not a finite number — the "no empty gauges" rule.
|
|
1892
|
+
* `windowMinutes` stays null: every window shares one billing-cycle reset
|
|
1893
|
+
* (`resetsAt`, from the explicit `billingCycleEnd`), not an inferred cadence, so
|
|
1894
|
+
* inferring one from the (repurposed) `session`/`week`/`month` key would let the
|
|
1895
|
+
* SWR cache zero the bar out long before the real reset.
|
|
1896
|
+
*/
|
|
1897
|
+
function normalizeCursorPercentWindow(percent, key, label, shortLabel, resetsAt) {
|
|
1898
|
+
const usedPercent = normalizePercent(percent);
|
|
1899
|
+
if (usedPercent === null)
|
|
1900
|
+
return null;
|
|
1901
|
+
return { key, label, shortLabel, usedPercent, resetsAt, windowMinutes: null };
|
|
1902
|
+
}
|
|
1903
|
+
/**
|
|
1904
|
+
* Normalize Cursor's dashboard `get-current-period-usage` payload — the
|
|
1905
|
+
* primary usage source, giving the same Auto+Composer / API / Total breakdown
|
|
1906
|
+
* the web dashboard shows.
|
|
1907
|
+
*/
|
|
1908
|
+
export function normalizeCursorPeriodUsage(data) {
|
|
1909
|
+
const resetsAt = parseDateValue(data.billingCycleEnd);
|
|
1910
|
+
const plan = data.planUsage;
|
|
1911
|
+
const windows = [
|
|
1912
|
+
normalizeCursorPercentWindow(plan?.autoPercentUsed, 'session', 'Auto + Composer', 'A', resetsAt),
|
|
1913
|
+
normalizeCursorPercentWindow(plan?.apiPercentUsed, 'week', 'API', 'API', resetsAt),
|
|
1914
|
+
normalizeCursorPercentWindow(plan?.totalPercentUsed, 'month', 'Total', 'T', resetsAt),
|
|
1915
|
+
];
|
|
1916
|
+
return windows.filter((window) => window !== null);
|
|
1917
|
+
}
|
|
1918
|
+
/**
|
|
1919
|
+
* Normalize Cursor's `usage-summary` fallback payload — the same Auto/API/Total
|
|
1920
|
+
* breakdown nested under `individualUsage.plan`, used when the primary
|
|
1921
|
+
* dashboard endpoint returns no usable `planUsage` (seen on some
|
|
1922
|
+
* enterprise/team accounts). An unlimited plan (`isUnlimited: true`) with no
|
|
1923
|
+
* usable percent has nothing to draw and returns no windows, rather than a
|
|
1924
|
+
* misleading empty gauge.
|
|
1925
|
+
*/
|
|
1926
|
+
export function normalizeCursorUsageSummary(data) {
|
|
1927
|
+
const resetsAt = parseDateValue(data.billingCycleEnd);
|
|
1928
|
+
const plan = data.individualUsage?.plan;
|
|
1929
|
+
const windows = [
|
|
1930
|
+
normalizeCursorPercentWindow(plan?.autoPercentUsed, 'session', 'Auto + Composer', 'A', resetsAt),
|
|
1931
|
+
normalizeCursorPercentWindow(plan?.apiPercentUsed, 'week', 'API', 'API', resetsAt),
|
|
1932
|
+
normalizeCursorPercentWindow(plan?.totalPercentUsed, 'month', 'Total', 'T', resetsAt),
|
|
1933
|
+
];
|
|
1934
|
+
return windows.filter((window) => window !== null);
|
|
1935
|
+
}
|
|
1936
|
+
/** Read Cursor's OAuth access token + config-file subject from the local CLI config/auth files. */
|
|
1724
1937
|
function readCursorCredentials(base) {
|
|
1725
1938
|
try {
|
|
1726
1939
|
const cfgPath = path.join(base, '.cursor', 'cli-config.json');
|
|
@@ -1728,26 +1941,109 @@ function readCursorCredentials(base) {
|
|
|
1728
1941
|
if (!fs.existsSync(cfgPath) || !fs.existsSync(authPath))
|
|
1729
1942
|
return null;
|
|
1730
1943
|
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
|
|
1731
|
-
const
|
|
1944
|
+
const cfgSub = typeof cfg?.authInfo?.authId === 'string' ? cfg.authInfo.authId : null;
|
|
1732
1945
|
const auth = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
|
|
1733
1946
|
const accessToken = auth?.accessToken;
|
|
1734
|
-
if (typeof sub !== 'string' || !sub)
|
|
1735
|
-
return null;
|
|
1736
1947
|
if (typeof accessToken !== 'string' || !accessToken)
|
|
1737
1948
|
return null;
|
|
1738
|
-
return {
|
|
1949
|
+
return { cfgSub, accessToken };
|
|
1739
1950
|
}
|
|
1740
1951
|
catch {
|
|
1741
1952
|
return null;
|
|
1742
1953
|
}
|
|
1743
1954
|
}
|
|
1744
1955
|
/**
|
|
1745
|
-
*
|
|
1746
|
-
*
|
|
1747
|
-
*
|
|
1748
|
-
*
|
|
1749
|
-
|
|
1750
|
-
|
|
1956
|
+
* Resolve the OAuth subject Cursor expects in the `WorkosCursorSessionToken`
|
|
1957
|
+
* cookie: the access token's own JWT `sub` claim first (the subject that
|
|
1958
|
+
* actually signed the token in hand), falling back to the subject
|
|
1959
|
+
* `cli-config.json` recorded at login when the token carries no usable `sub`.
|
|
1960
|
+
*/
|
|
1961
|
+
function resolveCursorSubject(accessToken, cfgSub) {
|
|
1962
|
+
const jwtSub = normalizeString(decodeJwtPayload(accessToken)?.sub);
|
|
1963
|
+
return jwtSub || cfgSub;
|
|
1964
|
+
}
|
|
1965
|
+
/**
|
|
1966
|
+
* POST the dashboard current-period-usage endpoint and normalize its windows.
|
|
1967
|
+
* Returns null on any network/auth failure so the caller falls through to the
|
|
1968
|
+
* next source — only a genuine empty-windows response distinguishes "no usage
|
|
1969
|
+
* to report" from "couldn't reach this source".
|
|
1970
|
+
*/
|
|
1971
|
+
async function fetchCursorPeriodWindows(cookie) {
|
|
1972
|
+
try {
|
|
1973
|
+
const response = await fetch(CURSOR_PERIOD_USAGE_URL, {
|
|
1974
|
+
method: 'POST',
|
|
1975
|
+
headers: {
|
|
1976
|
+
Cookie: cookie,
|
|
1977
|
+
Origin: 'https://cursor.com',
|
|
1978
|
+
'Content-Type': 'application/json',
|
|
1979
|
+
Accept: 'application/json',
|
|
1980
|
+
},
|
|
1981
|
+
body: '{}',
|
|
1982
|
+
signal: AbortSignal.timeout(5000),
|
|
1983
|
+
});
|
|
1984
|
+
if (!response.ok) {
|
|
1985
|
+
if (response.status === 429) {
|
|
1986
|
+
noteUsageRateLimited('cursor', response.headers.get('retry-after'));
|
|
1987
|
+
}
|
|
1988
|
+
return null;
|
|
1989
|
+
}
|
|
1990
|
+
const data = (await response.json());
|
|
1991
|
+
return normalizeCursorPeriodUsage(data);
|
|
1992
|
+
}
|
|
1993
|
+
catch {
|
|
1994
|
+
return null;
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
/**
|
|
1998
|
+
* GET the usage-summary fallback endpoint and normalize its windows. Same
|
|
1999
|
+
* null-on-failure contract as {@link fetchCursorPeriodWindows}.
|
|
2000
|
+
*/
|
|
2001
|
+
async function fetchCursorUsageSummaryWindows(cookie) {
|
|
2002
|
+
try {
|
|
2003
|
+
const response = await fetch(CURSOR_USAGE_SUMMARY_URL, {
|
|
2004
|
+
method: 'GET',
|
|
2005
|
+
headers: {
|
|
2006
|
+
Cookie: cookie,
|
|
2007
|
+
Accept: 'application/json',
|
|
2008
|
+
},
|
|
2009
|
+
signal: AbortSignal.timeout(5000),
|
|
2010
|
+
});
|
|
2011
|
+
if (!response.ok) {
|
|
2012
|
+
if (response.status === 429) {
|
|
2013
|
+
noteUsageRateLimited('cursor', response.headers.get('retry-after'));
|
|
2014
|
+
}
|
|
2015
|
+
return null;
|
|
2016
|
+
}
|
|
2017
|
+
const data = (await response.json());
|
|
2018
|
+
return normalizeCursorUsageSummary(data);
|
|
2019
|
+
}
|
|
2020
|
+
catch {
|
|
2021
|
+
return null;
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
/**
|
|
2025
|
+
* Fetch Cursor usage. Cursor authenticates every one of these requests with a
|
|
2026
|
+
* `WorkosCursorSessionToken` cookie of the form `<oauth-subject>::<access-token>`
|
|
2027
|
+
* (the same pair the web dashboard sends), not a bearer header, so all three
|
|
2028
|
+
* sources below share one resolved cookie.
|
|
2029
|
+
*
|
|
2030
|
+
* Three sources, tried in order, because no single endpoint carries usable data
|
|
2031
|
+
* for every plan shape:
|
|
2032
|
+
*
|
|
2033
|
+
* 1. `get-current-period-usage` — the primary source, and the richest: the
|
|
2034
|
+
* Auto+Composer / API / Total percent breakdown the dashboard itself shows.
|
|
2035
|
+
* 2. `usage-summary` — some enterprise/team accounts return no usable
|
|
2036
|
+
* `planUsage` from (1); this nests the same three percentages under
|
|
2037
|
+
* `individualUsage.plan` instead.
|
|
2038
|
+
* 3. The legacy `/api/usage` request-cap endpoint — the original source,
|
|
2039
|
+
* kept as the final fallback for free/legacy plans that predate the
|
|
2040
|
+
* percent-based breakdown above and only ever exposed a monthly request cap.
|
|
2041
|
+
*
|
|
2042
|
+
* The first source to yield a non-empty window list wins; a source that errors
|
|
2043
|
+
* or returns no usable numbers falls through to the next rather than surfacing
|
|
2044
|
+
* an error — only the last resort's own response/error is surfaced when every
|
|
2045
|
+
* source comes up empty, so a plan enrolled in exactly one billing model still
|
|
2046
|
+
* renders instead of reporting three swallowed failures.
|
|
1751
2047
|
*/
|
|
1752
2048
|
async function getCursorUsageInfo(options) {
|
|
1753
2049
|
try {
|
|
@@ -1759,15 +2055,33 @@ async function getCursorUsageInfo(options) {
|
|
|
1759
2055
|
if (typeof exp === 'number' && Date.now() / 1000 >= exp) {
|
|
1760
2056
|
return { snapshot: null, error: usageExpiredCredentialError('Cursor') };
|
|
1761
2057
|
}
|
|
1762
|
-
const
|
|
2058
|
+
const sub = resolveCursorSubject(creds.accessToken, creds.cfgSub);
|
|
2059
|
+
if (!sub)
|
|
2060
|
+
return { snapshot: null, error: usageNoCredentialError('Cursor') };
|
|
1763
2061
|
const throttledUntil = usageRateLimitedUntil('cursor');
|
|
1764
2062
|
if (throttledUntil) {
|
|
1765
2063
|
return { snapshot: null, error: usageThrottledError('Cursor', throttledUntil) };
|
|
1766
2064
|
}
|
|
2065
|
+
const cookie = `WorkosCursorSessionToken=${sub}%3A%3A${creds.accessToken}`;
|
|
2066
|
+
const periodWindows = await fetchCursorPeriodWindows(cookie);
|
|
2067
|
+
if (periodWindows && periodWindows.length > 0) {
|
|
2068
|
+
return {
|
|
2069
|
+
snapshot: { source: 'live', sourceLabel: 'live account data', capturedAt: new Date(), windows: periodWindows },
|
|
2070
|
+
error: null,
|
|
2071
|
+
};
|
|
2072
|
+
}
|
|
2073
|
+
const summaryWindows = await fetchCursorUsageSummaryWindows(cookie);
|
|
2074
|
+
if (summaryWindows && summaryWindows.length > 0) {
|
|
2075
|
+
return {
|
|
2076
|
+
snapshot: { source: 'live', sourceLabel: 'live account data', capturedAt: new Date(), windows: summaryWindows },
|
|
2077
|
+
error: null,
|
|
2078
|
+
};
|
|
2079
|
+
}
|
|
2080
|
+
const url = `${CURSOR_USAGE_URL}?user=${encodeURIComponent(sub)}`;
|
|
1767
2081
|
const response = await fetch(url, {
|
|
1768
2082
|
method: 'GET',
|
|
1769
2083
|
headers: {
|
|
1770
|
-
Cookie:
|
|
2084
|
+
Cookie: cookie,
|
|
1771
2085
|
Accept: 'application/json',
|
|
1772
2086
|
},
|
|
1773
2087
|
signal: AbortSignal.timeout(5000),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.2",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|