claude-usage-limits 1.18.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;
@@ -2160,6 +2164,85 @@ function criticalOthers(windows, bindingKey, threshold) {
2160
2164
  );
2161
2165
  }
2162
2166
 
2167
+ // What effort is ACTUALLY running, and how we know.
2168
+ //
2169
+ // This looked like a plugin bug and is not one. Ultracode is not a separate
2170
+ // effort level: Claude Code's own words are "ultracode: xhigh + dynamic
2171
+ // workflow orchestration (this session only)". So a session in ultracode has
2172
+ // effortLevel xhigh in settings.json and writes effort "xhigh" into every
2173
+ // transcript line - both correct, and both unable to say which MODE is on.
2174
+ // "This session only" means it is never written to disk at all.
2175
+ //
2176
+ // So the honest answer is a chain, and every reading says where it came from,
2177
+ // because "xhigh" from a stale settings file and "xhigh" from a live override
2178
+ // deserve different confidence:
2179
+ //
2180
+ // 1. CLAUDE_CODE_EFFORT_LEVEL - Claude Code's own session override. Hooks
2181
+ // inherit the environment, so this is live and authoritative.
2182
+ // 2. An override recorded here by hand, for the one case nothing can see:
2183
+ // /effort ultracode typed in the UI, which touches neither disk nor env.
2184
+ // 3. settings.effortLevel - the persisted default.
2185
+ // 4. The transcript - stamped at session start and never re-stamped.
2186
+ const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'ultracode'];
2187
+
2188
+ function effortOverrideFile() {
2189
+ return path.join(configDir(), 'usage-limits-effort.json');
2190
+ }
2191
+
2192
+ function readEffortOverride() {
2193
+ try {
2194
+ const parsed = JSON.parse(fs.readFileSync(effortOverrideFile(), 'utf8'));
2195
+ if (!parsed || !EFFORT_LEVELS.includes(parsed.effort)) return null;
2196
+ // A hand-set override describes one session. Left lying around it would
2197
+ // outlive the session it was true for, so it expires.
2198
+ if (!Number.isFinite(parsed.at) || Date.now() - parsed.at > 12 * 60 * 60 * 1000) return null;
2199
+ return parsed;
2200
+ } catch (err) {
2201
+ return null;
2202
+ }
2203
+ }
2204
+
2205
+ function writeEffortOverride(level, now) {
2206
+ if (level === null) {
2207
+ try {
2208
+ fs.unlinkSync(effortOverrideFile());
2209
+ } catch (err) {
2210
+ // Already gone is the outcome asked for.
2211
+ }
2212
+ return { ok: true, cleared: true };
2213
+ }
2214
+ if (!EFFORT_LEVELS.includes(level)) return { ok: false, error: 'effort must be one of: ' + EFFORT_LEVELS.join(', ') };
2215
+ try {
2216
+ fs.mkdirSync(configDir(), { recursive: true });
2217
+ writeJsonAtomic(effortOverrideFile(), { effort: level, at: Number.isFinite(now) ? now : Date.now() });
2218
+ return { ok: true, effort: level };
2219
+ } catch (err) {
2220
+ return { ok: false, error: err.message };
2221
+ }
2222
+ }
2223
+
2224
+ function effortNow(sessionId, env) {
2225
+ const environment = env || process.env;
2226
+ const fromEnv = String(environment.CLAUDE_CODE_EFFORT_LEVEL || '').trim().toLowerCase();
2227
+ if (EFFORT_LEVELS.includes(fromEnv)) return { effort: fromEnv, source: 'environment', live: true };
2228
+
2229
+ const override = readEffortOverride();
2230
+ if (override) return { effort: override.effort, source: 'set by hand', live: true };
2231
+
2232
+ let configured = null;
2233
+ try {
2234
+ configured = collect(Date.now()).settings;
2235
+ } catch (err) {
2236
+ configured = null;
2237
+ }
2238
+ const level = configured && configured.effortLevel;
2239
+ if (EFFORT_LEVELS.includes(level)) return { effort: level, source: 'settings', live: false };
2240
+
2241
+ const seen = liveEffort(sessionId);
2242
+ if (seen && EFFORT_LEVELS.includes(seen.effort)) return { effort: seen.effort, source: 'transcript', live: false };
2243
+ return null;
2244
+ }
2245
+
2163
2246
  // Is the setting bigger than the work needs?
2164
2247
  //
2165
2248
  // Everything else here is budget-triggered: it speaks when a window is filling.
@@ -2180,16 +2263,30 @@ function settingFit(events, effortNow, which) {
2180
2263
  // Guarded here rather than trusted from the caller: a cache hit has no event
2181
2264
  // list to hand over, and dominantEffort iterates without checking.
2182
2265
  const list = Array.isArray(events) ? events : [];
2183
- 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;
2184
2281
  if (!current) return null;
2185
- const rates = effortRates(list);
2186
- const here = rates.find((row) => row.effort === current);
2282
+ const table = Array.isArray(rates) ? rates : [];
2283
+ const here = table.find((row) => row.effort === current);
2187
2284
  if (!here || here.turns < MIN_EFFORT_SAMPLE) return null;
2188
2285
  const measure = (row) => (Number.isFinite(row.outputPerTurn) && row.outputPerTurn > 0 ? row.outputPerTurn : row.perTurn);
2189
2286
  const mine = measure(here);
2190
2287
  if (!Number.isFinite(mine) || mine <= 0) return null;
2191
2288
  let best = null;
2192
- for (const row of rates) {
2289
+ for (const row of table) {
2193
2290
  if (row.effort === current || row.turns < MIN_EFFORT_SAMPLE) continue;
2194
2291
  const theirs = measure(row);
2195
2292
  if (!Number.isFinite(theirs) || theirs <= 0) continue;
@@ -2230,23 +2327,46 @@ function levers(which) {
2230
2327
  // `host` is the required module here, not a parameter - shadowing it was a
2231
2328
  // ReferenceError on the constant it owns.
2232
2329
  const codex = which === host.CODEX;
2330
+ const gemini = which === host.GEMINI;
2233
2331
  return {
2234
2332
  model: (family) =>
2235
- codex
2236
- ? "Codex's own model control (the script only saves defaults for new sessions: " +
2237
- 'node scripts/lowpower.js on --host codex --model <id>)'
2238
- : '/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>)',
2239
2339
  effort: (level) =>
2240
- codex
2241
- ? "Codex's own effort control for this task (node scripts/lowpower.js on --host codex --effort " +
2242
- (level || '<level>') + ' saves it for new sessions and cannot change one already running)'
2243
- : '/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>'),
2244
2346
  };
2245
2347
  }
2246
2348
 
2247
- 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) {
2248
2366
  if (!binding || binding.percentUsed === null || binding.percentUsed === undefined) return null;
2249
2367
  const lever = levers(host || currentHost());
2368
+ const gapPoints = policy && Number.isFinite(policy.switchGapPoints) ? policy.switchGapPoints : 10;
2369
+ const pinned = Boolean(bounds && bounds.pin);
2250
2370
  const live = (windows || []).filter(
2251
2371
  (w) => w && w.percentUsed !== null && w.percentUsed !== undefined && !w.stale && w.key !== binding.key
2252
2372
  );
@@ -2263,12 +2383,13 @@ function escapeRoute(windows, binding, effortNote, host) {
2263
2383
  // no other readable window there is no evidence a switch helps at all: it
2264
2384
  // is true that the scoped window would retire, but "you have room" is a
2265
2385
  // claim, and a claim with nothing behind it is the thing not to make.
2266
- if (!next || next.percentUsed >= binding.percentUsed - 10) return null;
2386
+ if (!next || next.percentUsed >= binding.percentUsed - gapPoints) return null;
2267
2387
  const roomier = after
2268
2388
  .filter((w) => w.family && w.family !== binding.family)
2269
2389
  .sort((a, b) => a.percentUsed - b.percentUsed)[0] || null;
2270
2390
  return {
2271
2391
  kind: 'model',
2392
+ report: pinned,
2272
2393
  frees: binding.label || binding.key,
2273
2394
  family: binding.family,
2274
2395
  nextLabel: next ? next.label || next.key : null,
@@ -2283,6 +2404,7 @@ function escapeRoute(windows, binding, effortNote, host) {
2283
2404
  if (effortNote && effortNote.cheaper && effortNote.cheaper.effort) {
2284
2405
  return {
2285
2406
  kind: 'effort',
2407
+ report: pinned,
2286
2408
  from: effortNote.effort || null,
2287
2409
  to: effortNote.cheaper.effort,
2288
2410
  multiple: Number.isFinite(effortNote.cheaper.multiple) ? effortNote.cheaper.multiple : null,
@@ -2494,7 +2616,142 @@ function limitWindows(utilization) {
2494
2616
  return rows;
2495
2617
  }
2496
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
+
2497
2753
  function collect(now) {
2754
+ if (isGemini()) return collectGemini(now);
2498
2755
  if (isCodex()) return codex.collect(now);
2499
2756
  return collectClaude(now);
2500
2757
  }
@@ -2950,6 +3207,11 @@ async function report(now, options) {
2950
3207
  return Object.assign({}, base, {
2951
3208
  windows,
2952
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,
2953
3215
  otherLimits: otherLimits(base.utilization),
2954
3216
  // The plan moved since anything was last learned about it, so the cached
2955
3217
  // percentage was measured against a different allowance and everything
@@ -3136,7 +3398,7 @@ function render(data) {
3136
3398
  // honest money column. Everything else in the table means the same thing on
3137
3399
  // both hosts.
3138
3400
  const money = data.money !== false;
3139
- 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'));
3140
3402
  lines.push('');
3141
3403
  lines.push(' Plan ' + data.plan);
3142
3404
  lines.push(
@@ -3146,6 +3408,16 @@ function render(data) {
3146
3408
  : formatDuration(data.snapshotAgeMs) + ' old' + (data.snapshotSource === 'live' ? ' (live reading)' : ''))
3147
3409
  );
3148
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
+ }
3149
3421
  if (data.planChanged) {
3150
3422
  lines.push(' Plan change this is a different plan from the one the figures below');
3151
3423
  lines.push(' were learned on, so what a point of a window is worth has');
@@ -3540,6 +3812,23 @@ function formatPercent(value) {
3540
3812
  return value.toFixed(1) + '%';
3541
3813
  }
3542
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
+
3543
3832
  function renderForecast(data, turns) {
3544
3833
  const lines = [];
3545
3834
 
@@ -3586,8 +3875,17 @@ function renderForecast(data, turns) {
3586
3875
  }
3587
3876
  lines.push('');
3588
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.
3589
3884
  data.money === false
3590
- ? ' 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.'
3591
3889
  : ' Priced from ' + data.rates.sample + ' recent turns: ' +
3592
3890
  formatUSD(data.rates.median) + ' typical, ' + formatUSD(data.rates.high) +
3593
3891
  ' at the expensive end.'
@@ -3788,12 +4086,53 @@ async function main(argv) {
3788
4086
  .filter((window) => window.applies !== false)
3789
4087
  .map((window) => forecastWindow(window, turns, data.rates))
3790
4088
  .filter(Boolean);
3791
- 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
+ );
3792
4095
  } else {
3793
4096
  process.stdout.write(renderForecast(data, turns) + '\n');
3794
4097
  }
3795
4098
  return 0;
3796
4099
  }
4100
+ // `--effort ultracode` records what nothing on the machine can see: the
4101
+ // picker's choice is session-only and touches neither disk nor environment.
4102
+ // `--effort clear` forgets it again.
4103
+ const effortAt = argv.indexOf('--effort');
4104
+ if (effortAt !== -1) {
4105
+ const wanted = String(argv[effortAt + 1] || '').trim().toLowerCase();
4106
+ const say = (text) => {
4107
+ process.stdout.write(text + '\n');
4108
+ return 0;
4109
+ };
4110
+ if (!wanted) {
4111
+ const found = effortNow();
4112
+ if (!found) return say('No effort reading available.');
4113
+ const guess = found.source === 'settings' || found.source === 'transcript';
4114
+ return say(
4115
+ 'Effort reads as ' + found.effort + ' (' + found.source + ').' +
4116
+ (guess
4117
+ ? '\nIf you picked ultracode in the session, nothing on disk says so - Claude Code calls it' +
4118
+ ' "xhigh + dynamic workflow orchestration (this session only)".' +
4119
+ ' Tell this: usage.js --effort ultracode'
4120
+ : '')
4121
+ );
4122
+ }
4123
+ if (wanted === 'clear' || wanted === 'off') {
4124
+ writeEffortOverride(null);
4125
+ return say('Effort override cleared; back to the environment, then settings.');
4126
+ }
4127
+ const done = writeEffortOverride(wanted, Date.now());
4128
+ return say(
4129
+ done.ok
4130
+ ? 'Effort recorded as ' + done.effort +
4131
+ '. It expires after twelve hours, and CLAUDE_CODE_EFFORT_LEVEL still wins.'
4132
+ : done.error
4133
+ );
4134
+ }
4135
+
3797
4136
  const recommendAt = argv.indexOf('--recommend');
3798
4137
  if (recommendAt !== -1) {
3799
4138
  // The turn count is optional: with one the verdict is about that job,
@@ -3881,11 +4220,19 @@ module.exports = {
3881
4220
  accountFile,
3882
4221
  buildWindows,
3883
4222
  limitWindows,
4223
+ snapshotWindows,
3884
4224
  lastRejections,
3885
4225
  bindingWindow,
3886
4226
  criticalOthers,
3887
4227
  escapeRoute,
3888
4228
  settingFit,
4229
+ fitFromRates,
4230
+ formatRatePercent,
4231
+ effortNow,
4232
+ readEffortOverride,
4233
+ writeEffortOverride,
4234
+ effortOverrideFile,
4235
+ EFFORT_LEVELS,
3889
4236
  levers,
3890
4237
  betterCalibration,
3891
4238
  calibrationForPlan,
@@ -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() {