claude-usage-limits 1.19.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -45,6 +45,10 @@ function isCodex() {
45
45
  return currentHost() === host.CODEX;
46
46
  }
47
47
 
48
+ function isGemini() {
49
+ return currentHost() === host.GEMINI;
50
+ }
51
+
48
52
  const MINUTE = 60 * 1000;
49
53
  const HOUR = 60 * MINUTE;
50
54
  const DAY = 24 * HOUR;
@@ -2259,16 +2263,30 @@ function settingFit(events, effortNow, which) {
2259
2263
  // Guarded here rather than trusted from the caller: a cache hit has no event
2260
2264
  // list to hand over, and dominantEffort iterates without checking.
2261
2265
  const list = Array.isArray(events) ? events : [];
2262
- const current = effortNow || dominantEffort(list);
2266
+ return fitFromRates(effortRates(list), effortNow || dominantEffort(list), which);
2267
+ }
2268
+
2269
+ // The same judgement, from the per-effort table rather than from the events.
2270
+ //
2271
+ // This is the form the callers actually have. report() builds the event list,
2272
+ // derives effortRates from it and returns the TABLE - the events themselves
2273
+ // never leave the function - so every production caller of settingFit was
2274
+ // handing it `data.events`, which is undefined, and getting null back on every
2275
+ // call. That silently emptied the whole recommendation channel: no fit
2276
+ // sentence in the brief, no advice to offer or decline, and `high`'s mid-turn
2277
+ // re-cost reduced to its escape clause. Taking the table means the caller
2278
+ // passes the thing it has.
2279
+ function fitFromRates(rates, effortNow, which) {
2280
+ const current = effortNow || null;
2263
2281
  if (!current) return null;
2264
- const rates = effortRates(list);
2265
- const here = rates.find((row) => row.effort === current);
2282
+ const table = Array.isArray(rates) ? rates : [];
2283
+ const here = table.find((row) => row.effort === current);
2266
2284
  if (!here || here.turns < MIN_EFFORT_SAMPLE) return null;
2267
2285
  const measure = (row) => (Number.isFinite(row.outputPerTurn) && row.outputPerTurn > 0 ? row.outputPerTurn : row.perTurn);
2268
2286
  const mine = measure(here);
2269
2287
  if (!Number.isFinite(mine) || mine <= 0) return null;
2270
2288
  let best = null;
2271
- for (const row of rates) {
2289
+ for (const row of table) {
2272
2290
  if (row.effort === current || row.turns < MIN_EFFORT_SAMPLE) continue;
2273
2291
  const theirs = measure(row);
2274
2292
  if (!Number.isFinite(theirs) || theirs <= 0) continue;
@@ -2309,23 +2327,46 @@ function levers(which) {
2309
2327
  // `host` is the required module here, not a parameter - shadowing it was a
2310
2328
  // ReferenceError on the constant it owns.
2311
2329
  const codex = which === host.CODEX;
2330
+ const gemini = which === host.GEMINI;
2312
2331
  return {
2313
2332
  model: (family) =>
2314
- codex
2315
- ? "Codex's own model control (the script only saves defaults for new sessions: " +
2316
- 'node scripts/lowpower.js on --host codex --model <id>)'
2317
- : '/model ' + (family || '<another model>') + ' (or node scripts/lowpower.js on --model <id>)',
2333
+ gemini
2334
+ ? 'Gemini CLI model flag (agy --model ' + (family || '<model>') + ')'
2335
+ : codex
2336
+ ? "Codex's own model control (the script only saves defaults for new sessions: " +
2337
+ 'node scripts/lowpower.js on --host codex --model <id>)'
2338
+ : '/model ' + (family || '<another model>') + ' (or node scripts/lowpower.js on --model <id>)',
2318
2339
  effort: (level) =>
2319
- codex
2320
- ? "Codex's own effort control for this task (node scripts/lowpower.js on --host codex --effort " +
2321
- (level || '<level>') + ' saves it for new sessions and cannot change one already running)'
2322
- : '/effort ' + (level || '<level>'),
2340
+ gemini
2341
+ ? 'Gemini CLI effort flag (agy --effort ' + (level || 'low|medium|high') + ')'
2342
+ : codex
2343
+ ? "Codex's own effort control for this task (node scripts/lowpower.js on --host codex --effort " +
2344
+ (level || '<level>') + ' saves it for new sessions and cannot change one already running)'
2345
+ : '/effort ' + (level || '<level>'),
2323
2346
  };
2324
2347
  }
2325
2348
 
2326
- function escapeRoute(windows, binding, effortNote, host) {
2349
+ // `policy` is the budget mode's own record, when there is one, and `bounds` is
2350
+ // the user's own limits. One field of each reaches here, and they come from
2351
+ // different objects on purpose because that is where they live.
2352
+ //
2353
+ // `switchGapPoints` is the mode's: how much emptier another window has to be
2354
+ // before a switch is worth naming - the efficient modes act on a smaller
2355
+ // improvement because taking it is the whole point of being in them, while the
2356
+ // standard mode keeps the wider margin that stops a lateral move being sold as
2357
+ // a way out.
2358
+ //
2359
+ // `pin` is the user saying "report it, do not do it", and it is a BOUND, not a
2360
+ // mode field: mode.resolve() surfaces it as `bounds.pin` and no MODES record
2361
+ // has ever had a `pin` key. Reading it off the policy made `route.report`
2362
+ // unconditionally false, so the flag documented as carrying the pin carried
2363
+ // nothing, and the only reason no user ever saw a pinned suggestion phrased as
2364
+ // an instruction is that both renderers re-derived the pin for themselves.
2365
+ function escapeRoute(windows, binding, effortNote, host, policy, bounds) {
2327
2366
  if (!binding || binding.percentUsed === null || binding.percentUsed === undefined) return null;
2328
2367
  const lever = levers(host || currentHost());
2368
+ const gapPoints = policy && Number.isFinite(policy.switchGapPoints) ? policy.switchGapPoints : 10;
2369
+ const pinned = Boolean(bounds && bounds.pin);
2329
2370
  const live = (windows || []).filter(
2330
2371
  (w) => w && w.percentUsed !== null && w.percentUsed !== undefined && !w.stale && w.key !== binding.key
2331
2372
  );
@@ -2342,12 +2383,13 @@ function escapeRoute(windows, binding, effortNote, host) {
2342
2383
  // no other readable window there is no evidence a switch helps at all: it
2343
2384
  // is true that the scoped window would retire, but "you have room" is a
2344
2385
  // claim, and a claim with nothing behind it is the thing not to make.
2345
- if (!next || next.percentUsed >= binding.percentUsed - 10) return null;
2386
+ if (!next || next.percentUsed >= binding.percentUsed - gapPoints) return null;
2346
2387
  const roomier = after
2347
2388
  .filter((w) => w.family && w.family !== binding.family)
2348
2389
  .sort((a, b) => a.percentUsed - b.percentUsed)[0] || null;
2349
2390
  return {
2350
2391
  kind: 'model',
2392
+ report: pinned,
2351
2393
  frees: binding.label || binding.key,
2352
2394
  family: binding.family,
2353
2395
  nextLabel: next ? next.label || next.key : null,
@@ -2362,6 +2404,7 @@ function escapeRoute(windows, binding, effortNote, host) {
2362
2404
  if (effortNote && effortNote.cheaper && effortNote.cheaper.effort) {
2363
2405
  return {
2364
2406
  kind: 'effort',
2407
+ report: pinned,
2365
2408
  from: effortNote.effort || null,
2366
2409
  to: effortNote.cheaper.effort,
2367
2410
  multiple: Number.isFinite(effortNote.cheaper.multiple) ? effortNote.cheaper.multiple : null,
@@ -2573,7 +2616,142 @@ function limitWindows(utilization) {
2573
2616
  return rows;
2574
2617
  }
2575
2618
 
2619
+ // Every window the snapshot knows about, without a scan.
2620
+ //
2621
+ // buildWindows() is the full answer and it costs a transcript read. Two
2622
+ // callers cannot afford that and still have to reason about windows rather
2623
+ // than about one field of the snapshot: the `off` guard, whose whole promise
2624
+ // is that the mode costs nothing, and `auto`, which has to pick a mode inside
2625
+ // a hook before anything expensive runs.
2626
+ //
2627
+ // Both used to read `utilization.limits` alone. That array is the account's
2628
+ // own description of its limits and it is genuinely useful, but it is also
2629
+ // optional - nothing else in the plugin requires it, and every fixture here
2630
+ // carries the top-level `five_hour`/`seven_day` keys instead. A guard wired to
2631
+ // the one field nothing else needs is a guard that is silent on a snapshot
2632
+ // every other reader handles, which is exactly what happened: at 97 per cent
2633
+ // used with no `limits` array, the only line `off` is allowed to emit was
2634
+ // never built.
2635
+ //
2636
+ // So: the bucket keys first, the `limits` array for anything they did not
2637
+ // cover, real labels on both, corrections applied, and the same applies-flag
2638
+ // every other reader makes its decisions from.
2639
+ function snapshotWindows(collected, now, codexHome) {
2640
+ const utilization = collected && collected.utilization;
2641
+ if (!utilization) return [];
2642
+ const at = Number.isFinite(now) ? now : Date.now();
2643
+ // No transcripts here on purpose - that is the cost being avoided - so the
2644
+ // setting is the only thing that says which model is running. It is enough
2645
+ // to keep a weekly for a model this agent is not using out of a worst-of.
2646
+ const families = familiesInUse(null, null, [
2647
+ collected.settings && collected.settings.model,
2648
+ process.env.ANTHROPIC_MODEL,
2649
+ ]);
2650
+ const specs = collected.windowSpecs && collected.windowSpecs.length ? collected.windowSpecs : WINDOWS;
2651
+ const byKey = new Map();
2652
+ for (const spec of specs) {
2653
+ const snapshot = utilization[spec.key];
2654
+ if (!snapshot || typeof snapshot.utilization !== 'number') continue;
2655
+ const resetsAt = snapshot.resets_at ? Date.parse(snapshot.resets_at) : null;
2656
+ const corrected = reading.correctedFor(spec.key, at, collected.snapshotFetchedAt, codexHome);
2657
+ byKey.set(spec.key, {
2658
+ key: spec.key,
2659
+ label: spec.label,
2660
+ percentUsed: corrected && Number.isFinite(corrected.percentUsed) ? corrected.percentUsed : snapshot.utilization,
2661
+ resetsAt: Number.isFinite(resetsAt) ? resetsAt : null,
2662
+ stale: Number.isFinite(resetsAt) && resetsAt <= at,
2663
+ applies: appliesTo(spec, families),
2664
+ family: spec.family || null,
2665
+ });
2666
+ }
2667
+ for (const limit of limitWindows(utilization)) {
2668
+ if (byKey.has(limit.key)) continue;
2669
+ const spec = WINDOWS.find((w) => w.key === limit.key) || null;
2670
+ const corrected = reading.correctedFor(limit.key, at, collected.snapshotFetchedAt, codexHome);
2671
+ const family = limit.family || (spec && spec.family) || null;
2672
+ byKey.set(limit.key, {
2673
+ key: limit.key,
2674
+ // A raw key is an internal name, not a window: "five_hour is 97% used"
2675
+ // in the one line `off` gets to say is the plugin talking to itself.
2676
+ label: limit.label || (spec && spec.label) || limit.key.replace(/_/g, ' '),
2677
+ percentUsed: corrected && Number.isFinite(corrected.percentUsed) ? corrected.percentUsed : limit.percent,
2678
+ resetsAt: limit.resetsAt,
2679
+ stale: Number.isFinite(limit.resetsAt) && limit.resetsAt <= at,
2680
+ applies: appliesTo({ family }, families),
2681
+ family,
2682
+ });
2683
+ }
2684
+ return [...byKey.values()];
2685
+ }
2686
+
2687
+ // Antigravity, and the `agy` CLI behind it.
2688
+ //
2689
+ // This one is mostly a list of things that are NOT known, and that is the
2690
+ // honest shape of it. Antigravity refreshes its quota - its own log says
2691
+ // `quota_manager.go: doRefreshQuota: starting reload` - but it writes the
2692
+ // answer nowhere this can read. Its on-disk state is a protobuf text state
2693
+ // file, per-conversation SQLite databases and a settings file holding
2694
+ // agentMode, artifactReviewPolicy, colorScheme and trustedWorkspaces. There is
2695
+ // no percentage, no window and no reset time anywhere in it.
2696
+ //
2697
+ // An earlier version of this function filled that gap by reading CLAUDE's
2698
+ // meter and reporting it under a `cross-agent-claude` source, alongside a
2699
+ // hardcoded "Google AI Plus" plan and a made-up model of "Gemini 3.8 Flash
2700
+ // (High)" for a settings file that has no model key in it at all. Every one of
2701
+ // those numbers was about a different account, a different product and a
2702
+ // different budget. A plugin whose entire purpose is to be right about a
2703
+ // number must not invent one, so all of it is gone: where there is nothing to
2704
+ // read this returns null and the report says the quota is unreadable.
2705
+ //
2706
+ // What IS readable is worth having, because it is what a person can act on:
2707
+ // the agent mode, and whether the artifact review policy is set to wave
2708
+ // everything through. Both come from the real file.
2709
+ function collectGemini(now) {
2710
+ const settingsFile = path.join(host.geminiConfigDir(), 'antigravity-cli', 'settings.json');
2711
+ const settings = readJson(settingsFile) || {};
2712
+ const configFile = path.join(host.geminiConfigDir(), 'config', 'config.json');
2713
+ const config = readJson(configFile) || {};
2714
+ const userSettings = (config && config.userSettings) || {};
2715
+
2716
+ return {
2717
+ now,
2718
+ host: host.GEMINI,
2719
+ money: false,
2720
+ accountFile: settingsFile,
2721
+ // The plan is not on disk either. Naming one would be the same mistake in
2722
+ // a smaller font.
2723
+ plan: 'unknown',
2724
+ planId: 'gemini_unknown',
2725
+ planTier: null,
2726
+ planAdvice:
2727
+ 'Antigravity does not publish remaining quota anywhere readable on disk, so this ' +
2728
+ 'plugin cannot report a percentage for it. Its own /usage command is the only place ' +
2729
+ 'the figure appears. What is enforced here instead is the ceiling: past it, fan-out ' +
2730
+ 'calls are refused, which is the largest single saving available without a meter.',
2731
+ snapshotAgeMs: null,
2732
+ snapshotFetchedAt: null,
2733
+ snapshotSource: null,
2734
+ // Not "zero used". Unknown.
2735
+ utilization: null,
2736
+ // Said outright so the report can distinguish "no quota system" from
2737
+ // "quota system this cannot read". It is the second one.
2738
+ quotaUnreadable: true,
2739
+ settings: {
2740
+ // Only keys that exist in the real file. Antigravity chooses its model
2741
+ // per conversation and records it in a protobuf state file as an opaque
2742
+ // placeholder id, so there is no model name to report.
2743
+ model: 'unknown',
2744
+ effortLevel: 'unknown',
2745
+ agentMode: settings.agentMode || 'unknown',
2746
+ artifactReviewPolicy: settings.artifactReviewPolicy || null,
2747
+ autoExecutionPolicy: userSettings.autoExecutionPolicy || null,
2748
+ },
2749
+ extraUsage: null,
2750
+ };
2751
+ }
2752
+
2576
2753
  function collect(now) {
2754
+ if (isGemini()) return collectGemini(now);
2577
2755
  if (isCodex()) return codex.collect(now);
2578
2756
  return collectClaude(now);
2579
2757
  }
@@ -3029,6 +3207,11 @@ async function report(now, options) {
3029
3207
  return Object.assign({}, base, {
3030
3208
  windows,
3031
3209
  binding,
3210
+ // Carried so a renderer can ask which mode THIS session is in. Without it
3211
+ // render() asked mode.forSession({}) with no id, which skips the session
3212
+ // override entirely and reported the persisted mode while a `--session`
3213
+ // override was the one actually in force.
3214
+ sessionId: (options && options.sessionId) || null,
3032
3215
  otherLimits: otherLimits(base.utilization),
3033
3216
  // The plan moved since anything was last learned about it, so the cached
3034
3217
  // percentage was measured against a different allowance and everything
@@ -3215,7 +3398,7 @@ function render(data) {
3215
3398
  // honest money column. Everything else in the table means the same thing on
3216
3399
  // both hosts.
3217
3400
  const money = data.money !== false;
3218
- lines.push((data.host === host.CODEX ? 'Codex usage' : 'Claude Code usage'));
3401
+ lines.push((data.host === host.CODEX ? 'Codex usage' : data.host === host.GEMINI ? 'Gemini usage' : 'Claude Code usage'));
3219
3402
  lines.push('');
3220
3403
  lines.push(' Plan ' + data.plan);
3221
3404
  lines.push(
@@ -3225,6 +3408,16 @@ function render(data) {
3225
3408
  : formatDuration(data.snapshotAgeMs) + ' old' + (data.snapshotSource === 'live' ? ' (live reading)' : ''))
3226
3409
  );
3227
3410
  lines.push(' Settings model=' + data.settings.model + ' effort=' + data.settings.effortLevel);
3411
+ // Which budget mode the hooks are in, and who said so. Printed here because
3412
+ // the commonest confusion about this plugin is a line that did not appear:
3413
+ // in `off` nothing is injected at all, and the report is the one place that
3414
+ // can say why without injecting anything itself.
3415
+ try {
3416
+ const decided = require('./mode.js').forSession({ sessionId: data.sessionId || null });
3417
+ lines.push(' Mode ' + decided.label + ' (from ' + decided.source + ') - ' + decided.policy.summary);
3418
+ } catch (err) {
3419
+ // A report that fails over an optional row is worse than a missing row.
3420
+ }
3228
3421
  if (data.planChanged) {
3229
3422
  lines.push(' Plan change this is a different plan from the one the figures below');
3230
3423
  lines.push(' were learned on, so what a point of a window is worth has');
@@ -3619,6 +3812,23 @@ function formatPercent(value) {
3619
3812
  return value.toFixed(1) + '%';
3620
3813
  }
3621
3814
 
3815
+ // The same figure divided by a turn count, which is a different scale.
3816
+ //
3817
+ // formatPercent is built for window totals, where one decimal is plenty. A
3818
+ // per-turn share of a window is one or two orders of magnitude smaller: a
3819
+ // 15-turn job costing 0.6 points of the window is 0.04 a turn, and one decimal
3820
+ // prints that as "0.0%", which reads as free. The sentence exists to make the
3821
+ // table above checkable, so it has to carry enough precision to multiply back.
3822
+ function formatRatePercent(value) {
3823
+ if (!Number.isFinite(value)) return '-';
3824
+ if (value >= 10) return Math.round(value) + '%';
3825
+ if (value >= 0.1) return value.toFixed(1) + '%';
3826
+ if (value <= 0) return '0%';
3827
+ // Two decimals down to 0.01, and an honest bound below that rather than a
3828
+ // string of zeroes pretending to be a measurement.
3829
+ return value >= 0.005 ? value.toFixed(2) + '%' : 'under 0.01%';
3830
+ }
3831
+
3622
3832
  function renderForecast(data, turns) {
3623
3833
  const lines = [];
3624
3834
 
@@ -3665,8 +3875,17 @@ function renderForecast(data, turns) {
3665
3875
  }
3666
3876
  lines.push('');
3667
3877
  lines.push(
3878
+ // Codex meters a share of an allowance and never quotes a price, so a
3879
+ // dollar figure here would be invented. The rate is still worth stating -
3880
+ // it is what makes the table above checkable and what a person carries to
3881
+ // the next job - so it is quoted in the unit Codex actually has: points of
3882
+ // the window a turn costs. The table's own figures divided by the turns
3883
+ // they were priced for, so the two can never disagree.
3668
3884
  data.money === false
3669
- ? ' Priced from ' + data.rates.sample + ' recent turns, cheapest to dearest.'
3885
+ ? ' Priced from ' + data.rates.sample + ' recent turns: about ' +
3886
+ formatRatePercent(rows[0].percentLow / turns) + ' of the ' + rows[0].label +
3887
+ ' window per turn typical, ' + formatRatePercent(rows[0].percentHigh / turns) +
3888
+ ' at the expensive end.'
3670
3889
  : ' Priced from ' + data.rates.sample + ' recent turns: ' +
3671
3890
  formatUSD(data.rates.median) + ' typical, ' + formatUSD(data.rates.high) +
3672
3891
  ' at the expensive end.'
@@ -3867,7 +4086,12 @@ async function main(argv) {
3867
4086
  .filter((window) => window.applies !== false)
3868
4087
  .map((window) => forecastWindow(window, turns, data.rates))
3869
4088
  .filter(Boolean);
3870
- process.stdout.write(JSON.stringify({ turns, rates: data.rates, windows: rows }, null, 2) + '\n');
4089
+ // Same host split as the text renderer: without this a Codex reading's
4090
+ // rates would print as bare numbers and a consumer could not tell they
4091
+ // are window points rather than dollars.
4092
+ process.stdout.write(
4093
+ JSON.stringify({ turns, money: data.money !== false, rates: data.rates, windows: rows }, null, 2) + '\n'
4094
+ );
3871
4095
  } else {
3872
4096
  process.stdout.write(renderForecast(data, turns) + '\n');
3873
4097
  }
@@ -3996,11 +4220,14 @@ module.exports = {
3996
4220
  accountFile,
3997
4221
  buildWindows,
3998
4222
  limitWindows,
4223
+ snapshotWindows,
3999
4224
  lastRejections,
4000
4225
  bindingWindow,
4001
4226
  criticalOthers,
4002
4227
  escapeRoute,
4003
4228
  settingFit,
4229
+ fitFromRates,
4230
+ formatRatePercent,
4004
4231
  effortNow,
4005
4232
  readEffortOverride,
4006
4233
  writeEffortOverride,
@@ -352,6 +352,10 @@ function build(input) {
352
352
  rows,
353
353
  fable,
354
354
  hidden,
355
+ // Which budget mode the hooks are in, when it is not the one they have
356
+ // always been in. Passed in rather than read here: this module builds a
357
+ // display out of what it is given and touches no files.
358
+ budget: opts.budget && opts.budget.name ? { name: opts.budget.name, label: opts.budget.label || opts.budget.name } : null,
355
359
  // The agents underneath this session. They spend the same window and had
356
360
  // no voice on any display until now.
357
361
  agents: opts.agents && Number.isFinite(opts.agents.running) ? opts.agents : { running: 0, runs: 0 },
@@ -31,6 +31,9 @@ const fs = require('fs');
31
31
  const os = require('os');
32
32
  const path = require('path');
33
33
 
34
+ const host = require('./host.js');
35
+ const codex = require('./codex.js');
36
+
34
37
  // Enough for the stable signals - message length, punctuation, openers - to
35
38
  // stop moving. Short-message authorship work stops gaining accuracy at around
36
39
  // a hundred and twenty messages, and says very little below a dozen.
@@ -48,8 +51,14 @@ const PROMPT_MAX_WORDS = 400;
48
51
 
49
52
  const OPENERS_KEPT = 6;
50
53
 
54
+ // Every other state-writing script splits on the host so a Codex session
55
+ // learns into ~/.codex and a Claude Code one into ~/.claude; this one used to
56
+ // hardcode the Claude side, so under Codex the voice profile was written
57
+ // somewhere Codex never reads and learned nothing across sessions.
51
58
  function configDir() {
52
- return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
59
+ return host.detect(process.argv.slice(2), process.env) === host.CODEX
60
+ ? codex.homeDir()
61
+ : process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
53
62
  }
54
63
 
55
64
  function voiceFile() {