@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +1 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.js +5 -2
  5. package/dist/commands/feed.js +28 -19
  6. package/dist/commands/hooks.js +9 -45
  7. package/dist/commands/menubar.js +24 -24
  8. package/dist/commands/message.js +23 -3
  9. package/dist/commands/perf.d.ts +13 -0
  10. package/dist/commands/perf.js +80 -23
  11. package/dist/commands/projects.d.ts +11 -0
  12. package/dist/commands/projects.js +153 -21
  13. package/dist/commands/routines.js +46 -1
  14. package/dist/commands/ssh.js +69 -0
  15. package/dist/commands/trends.d.ts +2 -0
  16. package/dist/commands/trends.js +158 -0
  17. package/dist/commands/usage.d.ts +4 -4
  18. package/dist/commands/view.d.ts +6 -0
  19. package/dist/commands/view.js +90 -45
  20. package/dist/index.js +14 -1
  21. package/dist/lib/agents.js +2 -2
  22. package/dist/lib/analytics/dashboard.d.ts +11 -0
  23. package/dist/lib/analytics/dashboard.js +31 -0
  24. package/dist/lib/analytics/recipes.d.ts +32 -0
  25. package/dist/lib/analytics/recipes.js +316 -0
  26. package/dist/lib/analytics/usage-db.d.ts +84 -0
  27. package/dist/lib/analytics/usage-db.js +301 -0
  28. package/dist/lib/browser/service.js +18 -0
  29. package/dist/lib/cli-resources.d.ts +20 -0
  30. package/dist/lib/cli-resources.js +48 -1
  31. package/dist/lib/daemon.js +51 -14
  32. package/dist/lib/devices/health-report.d.ts +5 -0
  33. package/dist/lib/devices/health-report.js +3 -0
  34. package/dist/lib/feed-broadcast.d.ts +52 -7
  35. package/dist/lib/feed-broadcast.js +125 -18
  36. package/dist/lib/fleet-cache.d.ts +37 -0
  37. package/dist/lib/fleet-cache.js +40 -0
  38. package/dist/lib/fleet-status.d.ts +53 -0
  39. package/dist/lib/fleet-status.js +120 -0
  40. package/dist/lib/friction-heuristics.d.ts +32 -0
  41. package/dist/lib/friction-heuristics.js +47 -0
  42. package/dist/lib/hooks/cache.js +28 -6
  43. package/dist/lib/hooks/profile.d.ts +8 -0
  44. package/dist/lib/hooks/profile.js +14 -4
  45. package/dist/lib/hooks.js +72 -17
  46. package/dist/lib/linear-cache.d.ts +63 -0
  47. package/dist/lib/linear-cache.js +146 -0
  48. package/dist/lib/linear-project-counts.d.ts +35 -5
  49. package/dist/lib/linear-project-counts.js +61 -16
  50. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  51. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +3 -1
  52. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  53. package/dist/lib/menubar/install-menubar.d.ts +7 -0
  54. package/dist/lib/menubar/install-menubar.js +36 -6
  55. package/dist/lib/perf/db.d.ts +6 -1
  56. package/dist/lib/perf/db.js +35 -5
  57. package/dist/lib/perf/types.d.ts +10 -0
  58. package/dist/lib/project-doctor.d.ts +36 -0
  59. package/dist/lib/project-doctor.js +45 -0
  60. package/dist/lib/project-import.d.ts +11 -1
  61. package/dist/lib/project-import.js +17 -3
  62. package/dist/lib/project-status.d.ts +25 -5
  63. package/dist/lib/project-status.js +48 -6
  64. package/dist/lib/rotate.d.ts +27 -0
  65. package/dist/lib/rotate.js +44 -17
  66. package/dist/lib/routines.d.ts +16 -0
  67. package/dist/lib/routines.js +39 -0
  68. package/dist/lib/runner.js +34 -0
  69. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  70. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  71. package/dist/lib/secrets/usage-db.d.ts +3 -63
  72. package/dist/lib/secrets/usage-db.js +46 -186
  73. package/dist/lib/session/db.d.ts +2 -1
  74. package/dist/lib/session/db.js +14 -3
  75. package/dist/lib/session/discover.d.ts +3 -0
  76. package/dist/lib/session/discover.js +8 -0
  77. package/dist/lib/session/types.d.ts +1 -0
  78. package/dist/lib/startup/command-registry.d.ts +1 -0
  79. package/dist/lib/startup/command-registry.js +2 -0
  80. package/dist/lib/state.d.ts +31 -3
  81. package/dist/lib/state.js +53 -10
  82. package/dist/lib/types.d.ts +8 -4
  83. package/dist/lib/usage-refresh.d.ts +106 -0
  84. package/dist/lib/usage-refresh.js +238 -0
  85. package/dist/lib/usage.d.ts +152 -17
  86. package/dist/lib/usage.js +393 -79
  87. 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
- const getClaudeUsageCachePath = () => path.join(getCacheDir(), 'claude-usage.json');
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
- /** Fetch usage info for all unique accounts in parallel, keyed by usage key. */
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 usageResults = await Promise.all([...usageFetchInputs.entries()].map(async ([key, input]) => ({
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
- /** In-process dedup: don't fire concurrent background refreshes for the same identity. */
209
- const inFlightRefreshes = new Map();
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 (< 2 min): return cached snapshot, NO network.
214
- * - Cache stale but < 24h: return cached snapshot instantly, fire background refresh.
215
- * - Cache too stale or absent: block on live fetch, fall back to cache on error.
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` startup off the network on the hot path. The first
218
- * invocation after a cold install or 24h gap still blocks once to seed the
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
- triggerBackgroundUsageRefresh(input, usageKey);
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
- const usage = await getUsageInfo(input.agentId, {
267
- home: input.home,
268
- cliVersion: input.cliVersion,
269
- organizationId: input.info.organizationId,
270
- });
271
- if (usage.snapshot?.source === 'live') {
272
- writeClaudeUsageCache(usageKey, usage.snapshot);
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
- // Live fetch failed — last-resort fallback to whatever cache we had.
276
- if (cached) {
277
- return { snapshot: cached, error: usage.error };
376
+ finally {
377
+ inFlightLiveFetches.delete(usageKey);
278
378
  }
279
- return usage;
280
379
  }
281
380
  /**
282
- * Kick off a background refresh of the usage cache. Errors are swallowed —
283
- * a failed background refresh leaves the existing cache in place for the
284
- * next invocation. The work is deferred to a future event-loop tick via
285
- * `setImmediate` because some of the call chain (loadClaudeOauth
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 triggerBackgroundUsageRefresh(input, usageKey) {
291
- if (inFlightRefreshes.has(usageKey))
386
+ function enqueueBackgroundUsageRefresh(input, usageKey) {
387
+ if (inFlightBgKeys.has(usageKey) || inFlightLiveFetches.has(usageKey))
292
388
  return;
293
- const promise = new Promise((resolve) => {
294
- setImmediate(async () => {
295
- try {
296
- const usage = await getUsageInfo(input.agentId, {
297
- home: input.home,
298
- cliVersion: input.cliVersion,
299
- organizationId: input.info.organizationId,
300
- });
301
- if (usage.snapshot?.source === 'live') {
302
- writeClaudeUsageCache(usageKey, usage.snapshot);
303
- }
304
- }
305
- catch {
306
- /* background refresh failed — leave existing cache in place */
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
- inFlightRefreshes.set(usageKey, promise);
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 every BLOCKING window — the same set
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
- const windows = snapshot.windows
331
- .filter((window) => window.key !== 'sonnet_week')
332
- .map((window) => {
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 (windows.length > 0) {
339
- parts.push(windows.join(' '));
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 2-minute usage cache fires a background refresh
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 couple of minutes, per account.
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
- /** Read Cursor's OAuth subject + access token from the local CLI config/auth files. */
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 sub = cfg?.authInfo?.authId;
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 { sub, accessToken };
1949
+ return { cfgSub, accessToken };
1739
1950
  }
1740
1951
  catch {
1741
1952
  return null;
1742
1953
  }
1743
1954
  }
1744
1955
  /**
1745
- * Fetch Cursor usage from the dashboard usage endpoint. Cursor authenticates the
1746
- * request with a `WorkosCursorSessionToken` cookie of the form
1747
- * `<oauth-subject>::<access-token>` (the same pair the web dashboard sends), not a
1748
- * bearer header. Free/legacy plans return a monthly request bar; usage-based plans
1749
- * have no request cap, so they return a live-but-window-less snapshot (the account
1750
- * row still renders, without a misleading empty gauge).
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 url = `${CURSOR_USAGE_URL}?user=${encodeURIComponent(creds.sub)}`;
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: `WorkosCursorSessionToken=${creds.sub}%3A%3A${creds.accessToken}`,
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.1",
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",