claude-usage-limits 1.9.1 → 1.11.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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +196 -25
- package/bin/cli.js +21 -1
- package/commands/panel.md +14 -0
- package/commands/statusline.md +15 -0
- package/package.json +1 -1
- package/skills/usage-limits/SKILL.md +36 -0
- package/skills/usage-limits/references/how-it-works.md +123 -2
- package/skills/usage-limits/scripts/activity.js +188 -0
- package/skills/usage-limits/scripts/bars.js +340 -0
- package/skills/usage-limits/scripts/brief.js +59 -6
- package/skills/usage-limits/scripts/feed.js +377 -0
- package/skills/usage-limits/scripts/host.js +21 -7
- package/skills/usage-limits/scripts/live.js +422 -0
- package/skills/usage-limits/scripts/panel.js +706 -0
- package/skills/usage-limits/scripts/pulse.js +24 -0
- package/skills/usage-limits/scripts/recommend.js +56 -5
- package/skills/usage-limits/scripts/sessionend.js +5 -1
- package/skills/usage-limits/scripts/statusline.js +305 -0
- package/skills/usage-limits/scripts/stop.js +5 -1
- package/skills/usage-limits/scripts/tally.js +4 -10
- package/skills/usage-limits/scripts/usage.js +525 -20
- package/skills/usage-limits/scripts/view.js +217 -0
|
@@ -23,6 +23,7 @@ const readline = require('readline');
|
|
|
23
23
|
|
|
24
24
|
const host = require('./host.js');
|
|
25
25
|
const codex = require('./codex.js');
|
|
26
|
+
const live = require('./live.js');
|
|
26
27
|
|
|
27
28
|
// Which agent's meter to read. Resolved once from the command line or the
|
|
28
29
|
// environment, because a process that changed its mind halfway through would
|
|
@@ -69,6 +70,11 @@ const RATES = {
|
|
|
69
70
|
// An unrecognised family falls back to Opus rates on purpose: over-estimating
|
|
70
71
|
// cost understates headroom, and that is the safe direction for a budget.
|
|
71
72
|
const FAMILIES = ['fable', 'mythos', 'opus', 'sonnet', 'haiku'];
|
|
73
|
+
|
|
74
|
+
// Settings that name a strategy rather than a model. `opusplan` plans on Opus
|
|
75
|
+
// and executes on Sonnet, so it spends into both families and neither of them
|
|
76
|
+
// is what a substring match would find on its own.
|
|
77
|
+
const MODEL_ALIASES = { opusplan: ['opus', 'sonnet'] };
|
|
72
78
|
const FALLBACK_RATE = { input: 5, output: 25 };
|
|
73
79
|
|
|
74
80
|
function familyOf(model) {
|
|
@@ -117,13 +123,85 @@ const CACHE_WRITE_5M = 1.25;
|
|
|
117
123
|
const CACHE_WRITE_1H = 2;
|
|
118
124
|
const CACHE_READ = 0.1;
|
|
119
125
|
|
|
126
|
+
// `family` marks a window that caps one model family rather than the account
|
|
127
|
+
// as a whole. It is what tells the rest of the file that a window cannot stop
|
|
128
|
+
// work which does not use that family.
|
|
120
129
|
const WINDOWS = [
|
|
121
130
|
{ key: 'five_hour', label: '5-hour', span: 5 * HOUR },
|
|
122
131
|
{ key: 'seven_day', label: 'weekly', span: 7 * DAY },
|
|
123
|
-
{ key: 'seven_day_opus', label: 'weekly (Opus)', span: 7 * DAY },
|
|
124
|
-
{ key: 'seven_day_sonnet', label: 'weekly (Sonnet)', span: 7 * DAY },
|
|
132
|
+
{ key: 'seven_day_opus', label: 'weekly (Opus)', span: 7 * DAY, family: 'opus' },
|
|
133
|
+
{ key: 'seven_day_sonnet', label: 'weekly (Sonnet)', span: 7 * DAY, family: 'sonnet' },
|
|
125
134
|
];
|
|
126
135
|
|
|
136
|
+
// Which model families this agent can actually spend into.
|
|
137
|
+
//
|
|
138
|
+
// A per-model weekly caps one family's spend and nothing else, so it can only
|
|
139
|
+
// ever stop work that uses that family. Reported without that qualification it
|
|
140
|
+
// becomes the loudest number in the brief for a limit the session cannot move:
|
|
141
|
+
// a session running Opus was told to weigh a Fable weekly at 88%, and no
|
|
142
|
+
// amount of work it did would have moved it a single point.
|
|
143
|
+
//
|
|
144
|
+
// The configured model is the floor. A session's own turns are added on top,
|
|
145
|
+
// because subagents and a mid-session /model both spend into families the
|
|
146
|
+
// setting never mentions. Other sessions' turns are deliberately not counted:
|
|
147
|
+
// what another window is burning is not this one's constraint.
|
|
148
|
+
//
|
|
149
|
+
// An empty set means the model could not be worked out at all, and nothing is
|
|
150
|
+
// suppressed on the strength of a guess.
|
|
151
|
+
function familiesInUse(events, sessionId, models) {
|
|
152
|
+
const families = new Set();
|
|
153
|
+
const hints = Array.isArray(models) ? models : [models];
|
|
154
|
+
for (const hint of hints) {
|
|
155
|
+
const name = normalizeModel(hint);
|
|
156
|
+
// Some settings name more than one model. `opusplan` plans on Opus and
|
|
157
|
+
// executes on Sonnet, so a session set to it spends into both, and reading
|
|
158
|
+
// only the first would suppress a Sonnet weekly while Sonnet is running.
|
|
159
|
+
const alias = MODEL_ALIASES[name];
|
|
160
|
+
if (alias) {
|
|
161
|
+
for (const family of alias) families.add(family);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const family = familyOf(name);
|
|
165
|
+
if (family) families.add(family);
|
|
166
|
+
}
|
|
167
|
+
// This session's own turns only. What another window is burning is not this
|
|
168
|
+
// one's constraint, and counting it is how a weekly for a model this agent
|
|
169
|
+
// never runs gets weighed against work that cannot move it. With no session
|
|
170
|
+
// to scan - the CLI report - the configured model is the whole answer, and
|
|
171
|
+
// when that says nothing usable, nothing is suppressed.
|
|
172
|
+
if (sessionId) {
|
|
173
|
+
for (const event of events || []) {
|
|
174
|
+
if (!event || event.sessionId !== sessionId) continue;
|
|
175
|
+
const family = familyOf(event.model);
|
|
176
|
+
if (family) families.add(family);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return families;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Whether a window is one this agent can spend into. Windows that cap the whole
|
|
183
|
+
// account always are; a per-model one only when that model is in use.
|
|
184
|
+
function appliesTo(window, families) {
|
|
185
|
+
if (!window || !window.family) return true;
|
|
186
|
+
if (!families || !families.size) return true;
|
|
187
|
+
// Only ever suppress on a family this file recognises on both sides. A
|
|
188
|
+
// scoped weekly names its model by display name, and one for a model
|
|
189
|
+
// released after this table was written falls back to that raw name - which
|
|
190
|
+
// familyOf() will never return for the setting either, so the window would
|
|
191
|
+
// be suppressed permanently, including while it is the thing being spent.
|
|
192
|
+
if (FAMILIES.indexOf(window.family) === -1) return true;
|
|
193
|
+
return families.has(window.family);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Stamps the answer onto each window so every reader - the binding choice, the
|
|
197
|
+
// critical warning, the report table - makes the same call from the same field.
|
|
198
|
+
function markApplicable(windows, families) {
|
|
199
|
+
for (const window of windows || []) {
|
|
200
|
+
if (window) window.applies = appliesTo(window, families);
|
|
201
|
+
}
|
|
202
|
+
return windows;
|
|
203
|
+
}
|
|
204
|
+
|
|
127
205
|
// organizationType gives the family; the rate limit tier is what separates
|
|
128
206
|
// Max 5x from Max 20x. Both come out of oauthAccount.
|
|
129
207
|
// Abbreviations for the status line, where there is no room to spell it out.
|
|
@@ -185,10 +263,35 @@ function configDir() {
|
|
|
185
263
|
|
|
186
264
|
// The CLI keeps its account state in ~/.claude.json, or next to the config
|
|
187
265
|
// directory when CLAUDE_CONFIG_DIR moves it.
|
|
188
|
-
|
|
266
|
+
//
|
|
267
|
+
// Both can exist at once, and the one in the config directory is not
|
|
268
|
+
// necessarily the one with the meter in it: a Claude Code migration writes a
|
|
269
|
+
// small ~/.claude/.claude.json holding machine ids and migration flags while
|
|
270
|
+
// the account state, including cachedUsageUtilization, stays in the home
|
|
271
|
+
// directory file. Picking on existence alone found that stub, reported no
|
|
272
|
+
// snapshot, and sent host detection off to Codex - which is how a Claude
|
|
273
|
+
// session ends up quoting another agent's meter entirely. So choose the file
|
|
274
|
+
// that actually carries a snapshot, and only fall back to existence.
|
|
275
|
+
function accountFiles() {
|
|
189
276
|
const scoped = path.join(configDir(), '.claude.json');
|
|
190
|
-
|
|
191
|
-
return
|
|
277
|
+
const home = path.join(os.homedir(), '.claude.json');
|
|
278
|
+
return scoped === home ? [home] : [scoped, home];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function hasSnapshot(file) {
|
|
282
|
+
const parsed = readJson(file);
|
|
283
|
+
return Boolean(parsed && parsed.cachedUsageUtilization);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function accountFile() {
|
|
287
|
+
const candidates = accountFiles();
|
|
288
|
+
for (const file of candidates) {
|
|
289
|
+
if (hasSnapshot(file)) return file;
|
|
290
|
+
}
|
|
291
|
+
for (const file of candidates) {
|
|
292
|
+
if (fs.existsSync(file)) return file;
|
|
293
|
+
}
|
|
294
|
+
return candidates[candidates.length - 1];
|
|
192
295
|
}
|
|
193
296
|
|
|
194
297
|
function readJson(file) {
|
|
@@ -403,6 +506,29 @@ function freshFiles(dir, since) {
|
|
|
403
506
|
.filter((file) => fresh(file, since));
|
|
404
507
|
}
|
|
405
508
|
|
|
509
|
+
// Every transcript under a session's subagents directory. Plain subagents
|
|
510
|
+
// write straight into it; the agents a Workflow runs write under
|
|
511
|
+
// subagents/workflows/<run id>/, and eight of those spending in parallel is
|
|
512
|
+
// exactly the burst that empties a window between two readings, so they must
|
|
513
|
+
// be counted. Two levels down is as deep as Claude Code goes today; a bounded
|
|
514
|
+
// walk copes if that changes.
|
|
515
|
+
function subagentTranscripts(dir, since, depth) {
|
|
516
|
+
const left = Number.isFinite(depth) ? depth : 3;
|
|
517
|
+
const files = freshFiles(dir, since);
|
|
518
|
+
if (left <= 0) return files;
|
|
519
|
+
let entries;
|
|
520
|
+
try {
|
|
521
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
522
|
+
} catch (err) {
|
|
523
|
+
return files;
|
|
524
|
+
}
|
|
525
|
+
for (const entry of entries) {
|
|
526
|
+
if (!entry.isDirectory()) continue;
|
|
527
|
+
for (const file of subagentTranscripts(path.join(dir, entry.name), since, left - 1)) files.push(file);
|
|
528
|
+
}
|
|
529
|
+
return files;
|
|
530
|
+
}
|
|
531
|
+
|
|
406
532
|
async function readClaudeEvents(since) {
|
|
407
533
|
const root = path.join(configDir(), 'projects');
|
|
408
534
|
let dirs = [];
|
|
@@ -427,7 +553,7 @@ async function readClaudeEvents(since) {
|
|
|
427
553
|
// A session's subagents write their transcripts under
|
|
428
554
|
// <project>/<session id>/subagents/. Same budget, different file, and
|
|
429
555
|
// for a long time an Explore or Plan agent's whole spend went unseen.
|
|
430
|
-
for (const file of
|
|
556
|
+
for (const file of subagentTranscripts(path.join(full, entry.name, 'subagents'), since)) {
|
|
431
557
|
files.push({ file, project: dir.name });
|
|
432
558
|
}
|
|
433
559
|
continue;
|
|
@@ -676,8 +802,8 @@ function creditsFrom(utilization) {
|
|
|
676
802
|
// Turn cost is not a single number, it is a spread: a turn that reads three
|
|
677
803
|
// files costs many times one that answers from context. A median alone
|
|
678
804
|
// under-promises on the expensive half, so carry a high end too.
|
|
679
|
-
function
|
|
680
|
-
const costs =
|
|
805
|
+
function callPercentiles(events) {
|
|
806
|
+
const costs = (events || [])
|
|
681
807
|
.map((event) => event.cost)
|
|
682
808
|
.filter((cost) => Number.isFinite(cost) && cost > 0)
|
|
683
809
|
.sort((a, b) => a - b);
|
|
@@ -687,6 +813,124 @@ function costPercentiles(events) {
|
|
|
687
813
|
return { median: at(0.5), high: at(0.8), sample: costs.length };
|
|
688
814
|
}
|
|
689
815
|
|
|
816
|
+
function costPercentiles(events) {
|
|
817
|
+
return callPercentiles(mainThread(events));
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// What a turn of each model family has actually cost on this machine.
|
|
821
|
+
//
|
|
822
|
+
// The account's snapshot has no model dimension at all. It says a window is at
|
|
823
|
+
// 88 per cent and never says whose turns put it there, so the report could say
|
|
824
|
+
// how much room was left and not what that room would buy. Every transcript
|
|
825
|
+
// line carries its model, which is the half the snapshot is missing.
|
|
826
|
+
//
|
|
827
|
+
// Cost is measured over main-thread turns, because a turn of headroom means a
|
|
828
|
+
// main-thread turn everywhere else in this file. A family that has only ever
|
|
829
|
+
// run as a subagent has none to measure - Sonnet on this machine had 114 calls
|
|
830
|
+
// and not one turn - and pricing it at nothing would hand back an unlimited
|
|
831
|
+
// budget, so it is priced per call instead and the row says so.
|
|
832
|
+
function modelSpend(events) {
|
|
833
|
+
const byFamily = new Map();
|
|
834
|
+
for (const event of events || []) {
|
|
835
|
+
const family = familyOf(event.model);
|
|
836
|
+
if (!family) continue;
|
|
837
|
+
if (!byFamily.has(family)) byFamily.set(family, []);
|
|
838
|
+
byFamily.get(family).push(event);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const rows = [];
|
|
842
|
+
for (const [family, own] of byFamily) {
|
|
843
|
+
const spent = totals(own);
|
|
844
|
+
const main = costPercentiles(own);
|
|
845
|
+
const rates = main || callPercentiles(own);
|
|
846
|
+
rows.push({
|
|
847
|
+
family,
|
|
848
|
+
calls: own.length,
|
|
849
|
+
turns: spent.turns,
|
|
850
|
+
usd: spent.cost,
|
|
851
|
+
tokens: spent.tokens,
|
|
852
|
+
usdPerTurn: rates ? rates.median : null,
|
|
853
|
+
sample: rates ? rates.sample : 0,
|
|
854
|
+
// True when the figure prices a subagent call rather than a turn.
|
|
855
|
+
perCall: !main,
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
return rows.sort((a, b) => b.usd - a.usd);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Which window a family's spend lands in: its own weekly where the account
|
|
862
|
+
// gives it one, and the shared weekly otherwise.
|
|
863
|
+
function windowForFamily(windows, family) {
|
|
864
|
+
const own = (windows || []).find((w) => w && w.family === family);
|
|
865
|
+
if (own) return own;
|
|
866
|
+
return (windows || []).find((w) => w && w.key === 'seven_day') || null;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Too few turns to price one. The figure is divided into the whole remaining
|
|
870
|
+
// budget, so an error in it is multiplied up rather than averaged away.
|
|
871
|
+
const MIN_MODEL_SAMPLE = 5;
|
|
872
|
+
|
|
873
|
+
// What the room that is left buys, counted in turns of each model.
|
|
874
|
+
//
|
|
875
|
+
// Families that share the weekly window are alternatives, not additions: each
|
|
876
|
+
// row says what the same remaining room would buy if it all went on that model.
|
|
877
|
+
// A family with a weekly of its own is the exception, and it is the case worth
|
|
878
|
+
// knowing about, because it is the one where changing model changes which wall
|
|
879
|
+
// the work is walking towards.
|
|
880
|
+
function modelHeadroom(windows, events, families, remembered) {
|
|
881
|
+
return modelSpend(events).map((row) => {
|
|
882
|
+
const window = windowForFamily(windows, row.family);
|
|
883
|
+
const learned = remembered && remembered[row.family];
|
|
884
|
+
// A handful of turns prices a turn badly. What was measured when there was
|
|
885
|
+
// a proper sample is better evidence than what this week happens to hold.
|
|
886
|
+
const thin = row.sample < MIN_MODEL_SAMPLE;
|
|
887
|
+
const canRemember =
|
|
888
|
+
thin && Boolean(learned) && Number.isFinite(learned.usdPerTurn) && learned.usdPerTurn > 0 &&
|
|
889
|
+
// A remembered per-call price is not a turn price either, whatever it is
|
|
890
|
+
// worth for delegation. See below.
|
|
891
|
+
!learned.perCall;
|
|
892
|
+
const usdPerTurn = canRemember ? learned.usdPerTurn : row.usdPerTurn;
|
|
893
|
+
const perCall = canRemember ? Boolean(learned.perCall) : row.perCall;
|
|
894
|
+
// No turn count for a model that has never taken a turn.
|
|
895
|
+
//
|
|
896
|
+
// A family that has only ever run as a subagent has errands to price, not
|
|
897
|
+
// turns: 114 Sonnet calls here averaged under two cents because they were
|
|
898
|
+
// one-shot lookups, and dividing the remaining budget by that promised
|
|
899
|
+
// twenty-two thousand Sonnet turns. Those turns would not be doing the work
|
|
900
|
+
// the Opus turns are doing, and the error is in the direction that promises
|
|
901
|
+
// room, which is the direction that gets a session cut off mid-edit. The
|
|
902
|
+
// row still says what the model has cost; it does not project from it.
|
|
903
|
+
//
|
|
904
|
+
// Nor against a window that has already rolled over. Its remaining money
|
|
905
|
+
// describes the allowance the stale reading was taken from, not the one
|
|
906
|
+
// running now, and dividing by a turn price turns that into a confident
|
|
907
|
+
// count of turns nobody has: a weekly past its reset offered five hundred.
|
|
908
|
+
const turnsLeft =
|
|
909
|
+
!perCall &&
|
|
910
|
+
window &&
|
|
911
|
+
!window.stale &&
|
|
912
|
+
Number.isFinite(window.remainingUSD) &&
|
|
913
|
+
Number.isFinite(usdPerTurn) &&
|
|
914
|
+
usdPerTurn > 0
|
|
915
|
+
? Math.max(0, Math.floor(window.remainingUSD / usdPerTurn))
|
|
916
|
+
: null;
|
|
917
|
+
return Object.assign({}, row, {
|
|
918
|
+
windowKey: window ? window.key : null,
|
|
919
|
+
windowLabel: window ? window.label : null,
|
|
920
|
+
// True when this family has a weekly of its own rather than sharing.
|
|
921
|
+
ownWindow: Boolean(window && window.family),
|
|
922
|
+
// False when the window is one this agent cannot spend into anyway.
|
|
923
|
+
windowApplies: !window || window.applies !== false,
|
|
924
|
+
inUse: !families || !families.size || families.has(row.family),
|
|
925
|
+
usdPerTurn,
|
|
926
|
+
perCall,
|
|
927
|
+
// True when the price came off the record rather than this week's turns.
|
|
928
|
+
remembered: Boolean(canRemember),
|
|
929
|
+
turnsLeft,
|
|
930
|
+
});
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
|
|
690
934
|
// What a job of this many turns would take out of one window.
|
|
691
935
|
function forecastWindow(window, turns, rates) {
|
|
692
936
|
if (!window || !rates || !window.usdPerPercent || window.stale) return null;
|
|
@@ -791,6 +1035,70 @@ function writeCalibration(all) {
|
|
|
791
1035
|
}
|
|
792
1036
|
}
|
|
793
1037
|
|
|
1038
|
+
// What each model has cost, kept so the next session does not have to have
|
|
1039
|
+
// spent anything to know.
|
|
1040
|
+
//
|
|
1041
|
+
// The transcripts only answer for as long as they are on disk and as far back
|
|
1042
|
+
// as the scan reaches, which is eight days. A session that opens on a model it
|
|
1043
|
+
// has not used this week would otherwise have no price for it at all, and what
|
|
1044
|
+
// Sonnet would buy is worth answering before the first Sonnet turn rather than
|
|
1045
|
+
// after. One entry per family, so it cannot grow.
|
|
1046
|
+
//
|
|
1047
|
+
// It is stamped with the plan and read back through the same guard as the
|
|
1048
|
+
// window calibration. What a turn costs is a fact about the model; what it buys
|
|
1049
|
+
// is a fact about the allowance, and that moves when the plan does.
|
|
1050
|
+
function modelRecordFile() {
|
|
1051
|
+
const dir = isCodex() ? codex.homeDir() : configDir();
|
|
1052
|
+
return path.join(dir, 'usage-limits-models.json');
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function readModelRecord() {
|
|
1056
|
+
try {
|
|
1057
|
+
const parsed = JSON.parse(fs.readFileSync(modelRecordFile(), 'utf8'));
|
|
1058
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
1059
|
+
} catch (err) {
|
|
1060
|
+
return {};
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function writeModelRecord(all) {
|
|
1065
|
+
try {
|
|
1066
|
+
fs.mkdirSync(path.dirname(modelRecordFile()), { recursive: true });
|
|
1067
|
+
fs.writeFileSync(modelRecordFile(), JSON.stringify(all), 'utf8');
|
|
1068
|
+
} catch (err) {
|
|
1069
|
+
// The record is a convenience for a thin week, not a source of truth.
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// The measurement to keep. Deliberately the freshest adequate one rather than
|
|
1074
|
+
// the largest: what a turn costs drifts as a session's context grows, so an old
|
|
1075
|
+
// figure with a big sample behind it is not the better answer, only the better
|
|
1076
|
+
// attested one.
|
|
1077
|
+
function modelSamples(headroom, previous) {
|
|
1078
|
+
const kept = Object.assign({}, previous || {});
|
|
1079
|
+
let changed = false;
|
|
1080
|
+
for (const row of headroom || []) {
|
|
1081
|
+
if (row.remembered) continue;
|
|
1082
|
+
if (!Number.isFinite(row.usdPerTurn) || row.usdPerTurn <= 0) continue;
|
|
1083
|
+
if (row.sample < MIN_MODEL_SAMPLE) continue;
|
|
1084
|
+
// Rounded before comparing, so a fraction of a cent of drift does not
|
|
1085
|
+
// rewrite the file on every prompt.
|
|
1086
|
+
const usdPerTurn = Math.round(row.usdPerTurn * 1e6) / 1e6;
|
|
1087
|
+
const before = kept[row.family];
|
|
1088
|
+
if (
|
|
1089
|
+
before &&
|
|
1090
|
+
before.usdPerTurn === usdPerTurn &&
|
|
1091
|
+
before.perCall === Boolean(row.perCall) &&
|
|
1092
|
+
before.sample === row.sample
|
|
1093
|
+
) {
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
kept[row.family] = { usdPerTurn, sample: row.sample, perCall: Boolean(row.perCall) };
|
|
1097
|
+
changed = true;
|
|
1098
|
+
}
|
|
1099
|
+
return { models: kept, changed };
|
|
1100
|
+
}
|
|
1101
|
+
|
|
794
1102
|
// Everything learned about a budget belongs to the plan it was learned on.
|
|
795
1103
|
//
|
|
796
1104
|
// A point of a window is a share of an allowance, so changing the allowance
|
|
@@ -921,7 +1229,14 @@ function buildWindow(spec, snapshot, events, now, options) {
|
|
|
921
1229
|
severity: null,
|
|
922
1230
|
isActive: false,
|
|
923
1231
|
scoped: false,
|
|
924
|
-
family
|
|
1232
|
+
// The model family this window caps, when it caps one. Set from the spec
|
|
1233
|
+
// here for the bucket-table weeklies, and again by the caller for the
|
|
1234
|
+
// per-model limits that only exist in the account's own `limits` list.
|
|
1235
|
+
family: spec.family || null,
|
|
1236
|
+
// Whether this session's models spend into it. Filled in by
|
|
1237
|
+
// markApplicable once the models in use are known; assume they do until
|
|
1238
|
+
// then, so a reader that never marks them behaves exactly as before.
|
|
1239
|
+
applies: true,
|
|
925
1240
|
verdict: 'unknown',
|
|
926
1241
|
};
|
|
927
1242
|
|
|
@@ -1124,6 +1439,10 @@ function criticalOthers(windows, bindingKey, threshold) {
|
|
|
1124
1439
|
w &&
|
|
1125
1440
|
w.key !== bindingKey &&
|
|
1126
1441
|
!w.stale &&
|
|
1442
|
+
// A full window that this session cannot spend into is not a warning, it
|
|
1443
|
+
// is someone else's news. Told to weigh it, the only thing an agent can
|
|
1444
|
+
// do about it is less work, against a limit its work never touches.
|
|
1445
|
+
w.applies !== false &&
|
|
1127
1446
|
w.percentUsed !== null &&
|
|
1128
1447
|
w.percentUsed >= limit
|
|
1129
1448
|
);
|
|
@@ -1131,7 +1450,18 @@ function criticalOthers(windows, bindingKey, threshold) {
|
|
|
1131
1450
|
|
|
1132
1451
|
// The window that will stop the work first.
|
|
1133
1452
|
function bindingWindow(windows) {
|
|
1134
|
-
|
|
1453
|
+
// A per-model weekly for a model that is not running cannot be the window
|
|
1454
|
+
// that stops the work, however full it is - and the account's own is_active
|
|
1455
|
+
// flag says nothing about which model this session happens to be using, so
|
|
1456
|
+
// it must not promote one either. Kept as a fallback in the impossible case
|
|
1457
|
+
// that every window is a model's, so this never returns nothing.
|
|
1458
|
+
// Ordered this way round on purpose: the fallback has to fire when there is
|
|
1459
|
+
// no readable window left after suppression, not merely no window. Testing
|
|
1460
|
+
// the unfiltered list first returned nothing at all where a suppressed
|
|
1461
|
+
// per-model weekly was the only window carrying a reading.
|
|
1462
|
+
const readable = windows.filter((w) => w && w.percentUsed !== null);
|
|
1463
|
+
const relevant = readable.filter((w) => w.applies !== false);
|
|
1464
|
+
const known = relevant.length ? relevant : readable;
|
|
1135
1465
|
// Prefer windows we can still trust; fall back only if every one is stale.
|
|
1136
1466
|
const fresh = known.filter((w) => !w.stale);
|
|
1137
1467
|
const live = fresh.length ? fresh : known;
|
|
@@ -1320,12 +1650,39 @@ function collect(now) {
|
|
|
1320
1650
|
return collectClaude(now);
|
|
1321
1651
|
}
|
|
1322
1652
|
|
|
1653
|
+
// Whether the plugin's own live reading should stand in for Claude Code's
|
|
1654
|
+
// cache. Both describe the same account; the newer one is simply the more
|
|
1655
|
+
// recent fact. A reading for a different account, or one stamped from a clock
|
|
1656
|
+
// that is ahead, is not a fresher reading of this account.
|
|
1657
|
+
function preferLive(cache, fresh, accountUuid, now) {
|
|
1658
|
+
if (!fresh || !fresh.utilization || typeof fresh.utilization !== 'object') return false;
|
|
1659
|
+
if (!Number.isFinite(fresh.fetchedAtMs)) return false;
|
|
1660
|
+
if (fresh.fetchedAtMs > now + MINUTE) return false;
|
|
1661
|
+
if (fresh.accountUuid && accountUuid && fresh.accountUuid !== accountUuid) return false;
|
|
1662
|
+
if (!cache || !Number.isFinite(cache.fetchedAtMs)) return true;
|
|
1663
|
+
return fresh.fetchedAtMs > cache.fetchedAtMs;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// The account the login belongs to, so a live reading can be stamped with it.
|
|
1667
|
+
function accountUuid() {
|
|
1668
|
+
const account = readJson(accountFile());
|
|
1669
|
+
return account && account.oauthAccount && account.oauthAccount.accountUuid
|
|
1670
|
+
? account.oauthAccount.accountUuid
|
|
1671
|
+
: null;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1323
1674
|
function collectClaude(now) {
|
|
1324
1675
|
const account = readJson(accountFile()) || {};
|
|
1325
1676
|
const settings = readJson(path.join(configDir(), 'settings.json')) || {};
|
|
1326
1677
|
const cache = account.cachedUsageUtilization || null;
|
|
1327
|
-
const utilization = cache && cache.utilization ? cache.utilization : null;
|
|
1328
1678
|
const oauth = account.oauthAccount || {};
|
|
1679
|
+
// The panel and the status line take the same reading Claude Code takes for
|
|
1680
|
+
// /usage and keep it in a file of their own. When that is newer than what
|
|
1681
|
+
// Claude Code cached, it is the better description of the same account.
|
|
1682
|
+
const fresh = live.readLive();
|
|
1683
|
+
const useLive = preferLive(cache, fresh, oauth.accountUuid, now);
|
|
1684
|
+
const snapshot = useLive ? fresh : cache;
|
|
1685
|
+
const utilization = snapshot && snapshot.utilization ? snapshot.utilization : null;
|
|
1329
1686
|
const plan = detectPlan(oauth);
|
|
1330
1687
|
|
|
1331
1688
|
return {
|
|
@@ -1337,8 +1694,9 @@ function collectClaude(now) {
|
|
|
1337
1694
|
planId: plan.id,
|
|
1338
1695
|
planTier: plan.tier,
|
|
1339
1696
|
planAdvice: plan.advice,
|
|
1340
|
-
snapshotAgeMs:
|
|
1341
|
-
snapshotFetchedAt:
|
|
1697
|
+
snapshotAgeMs: snapshot && snapshot.fetchedAtMs ? now - snapshot.fetchedAtMs : null,
|
|
1698
|
+
snapshotFetchedAt: snapshot && snapshot.fetchedAtMs ? snapshot.fetchedAtMs : null,
|
|
1699
|
+
snapshotSource: utilization ? (useLive ? 'live' : 'cache') : null,
|
|
1342
1700
|
utilization,
|
|
1343
1701
|
settings: {
|
|
1344
1702
|
model: settings.model || 'default',
|
|
@@ -1644,6 +2002,28 @@ async function report(now, options) {
|
|
|
1644
2002
|
calibrated.planChanged ? new Map() : rejections
|
|
1645
2003
|
);
|
|
1646
2004
|
|
|
2005
|
+
// Which of those windows this agent can actually spend into. Everything that
|
|
2006
|
+
// ranks or warns about a window reads the answer off the window itself.
|
|
2007
|
+
const families = familiesInUse(
|
|
2008
|
+
events,
|
|
2009
|
+
options && options.sessionId,
|
|
2010
|
+
[base.settings && base.settings.model, process.env.ANTHROPIC_MODEL]
|
|
2011
|
+
);
|
|
2012
|
+
markApplicable(windows, families);
|
|
2013
|
+
|
|
2014
|
+
// What the room left buys in turns of each model, and the record that lets a
|
|
2015
|
+
// later session answer that for a model it has not run yet. Scoped to a week
|
|
2016
|
+
// because every window a family draws on here is a weekly one.
|
|
2017
|
+
const remembered = calibrationForPlan(readModelRecord(), base.planId).learned;
|
|
2018
|
+
const headroom = modelHeadroom(
|
|
2019
|
+
windows,
|
|
2020
|
+
events.filter((event) => event.at >= now - 7 * DAY),
|
|
2021
|
+
families,
|
|
2022
|
+
remembered
|
|
2023
|
+
);
|
|
2024
|
+
const sampled = modelSamples(headroom, remembered);
|
|
2025
|
+
if (sampled.changed) writeModelRecord(stampPlan(sampled.models, base.planId));
|
|
2026
|
+
|
|
1647
2027
|
// Keep the best sample seen so far, so a thin baseline never has to guess.
|
|
1648
2028
|
const updated = Object.assign({}, learned);
|
|
1649
2029
|
for (const window of windows) {
|
|
@@ -1685,10 +2065,14 @@ async function report(now, options) {
|
|
|
1685
2065
|
credits: base.codexCredits || creditsFrom(base.utilization),
|
|
1686
2066
|
sessions: activeSessions(events, now, CONCURRENT_WINDOW_MS),
|
|
1687
2067
|
session: sessionSpend(events, options && options.sessionId),
|
|
1688
|
-
|
|
2068
|
+
// A per-model weekly for a model that is not running could be a week past
|
|
2069
|
+
// its reset without that saying anything about the numbers this agent is
|
|
2070
|
+
// working from, and it should not put "run /usage" on every prompt.
|
|
2071
|
+
staleWindows: windows.filter((w) => w.stale && w.applies !== false).length,
|
|
1689
2072
|
rates: costPercentiles(recentEvents.length >= 5 ? recentEvents : scoped),
|
|
1690
2073
|
resumeAt: binding ? binding.resetsAt : null,
|
|
1691
2074
|
models: scopedModels,
|
|
2075
|
+
modelHeadroom: headroom,
|
|
1692
2076
|
projects: byProject(scoped),
|
|
1693
2077
|
tokens: scopedTotals.parts,
|
|
1694
2078
|
reasoning: reasoningSpend(scopedModels, scopedTotals.parts),
|
|
@@ -1755,6 +2139,13 @@ function statusLine(collected) {
|
|
|
1755
2139
|
if (!utilization) return '';
|
|
1756
2140
|
|
|
1757
2141
|
const now = collected.now || Date.now();
|
|
2142
|
+
// No transcripts here on purpose, so the only thing that says which model is
|
|
2143
|
+
// running is the setting. That is enough to keep a weekly for a model this
|
|
2144
|
+
// agent is not using out of a line that is meant to read as "your room".
|
|
2145
|
+
const families = familiesInUse(null, null, [
|
|
2146
|
+
collected.settings && collected.settings.model,
|
|
2147
|
+
process.env.ANTHROPIC_MODEL,
|
|
2148
|
+
]);
|
|
1758
2149
|
const parts = [];
|
|
1759
2150
|
for (const spec of collected.windowSpecs && collected.windowSpecs.length
|
|
1760
2151
|
? collected.windowSpecs
|
|
@@ -1767,6 +2158,11 @@ function statusLine(collected) {
|
|
|
1767
2158
|
label: SHORT_LABELS[spec.key] || spec.label,
|
|
1768
2159
|
percent: snapshot.utilization,
|
|
1769
2160
|
msToReset,
|
|
2161
|
+
// A per-model weekly for a model that is not running is shown - hiding a
|
|
2162
|
+
// limit outright is the one failure worse than over-reporting one, and
|
|
2163
|
+
// the only thing telling this line which model is running is the
|
|
2164
|
+
// setting, which can be behind. It just does not raise the alarm.
|
|
2165
|
+
idle: !appliesTo(spec, families),
|
|
1770
2166
|
stale: msToReset !== null && msToReset <= 0,
|
|
1771
2167
|
// Zero with no reset time is not an empty window, it is a bucket that is
|
|
1772
2168
|
// not reporting: a real window at 0% has just reset and says when it will
|
|
@@ -1775,9 +2171,33 @@ function statusLine(collected) {
|
|
|
1775
2171
|
unreported: snapshot.utilization === 0 && !Number.isFinite(resetsAt),
|
|
1776
2172
|
});
|
|
1777
2173
|
}
|
|
2174
|
+
|
|
2175
|
+
// The per-model weeklies are not bucket keys, they are entries in the
|
|
2176
|
+
// account's own `limits` list, so a loop over the bucket table never saw
|
|
2177
|
+
// them. On a plan where the Fable weekly is the limit that actually binds,
|
|
2178
|
+
// that meant the status line quoting the shared weekly at 24% while the
|
|
2179
|
+
// window about to stop the work sat at 76, which is the wrong number in the
|
|
2180
|
+
// most convincing possible place.
|
|
2181
|
+
for (const limit of limitWindows(utilization)) {
|
|
2182
|
+
if (!limit.family) continue;
|
|
2183
|
+
const msToReset = Number.isFinite(limit.resetsAt) ? limit.resetsAt - now : null;
|
|
2184
|
+
parts.push({
|
|
2185
|
+
label: limit.family,
|
|
2186
|
+
percent: limit.percent,
|
|
2187
|
+
msToReset,
|
|
2188
|
+
idle: !appliesTo(limit, families),
|
|
2189
|
+
stale: msToReset !== null && msToReset <= 0,
|
|
2190
|
+
unreported: false,
|
|
2191
|
+
});
|
|
2192
|
+
}
|
|
2193
|
+
|
|
1778
2194
|
if (!parts.length) return '';
|
|
1779
2195
|
|
|
1780
|
-
|
|
2196
|
+
// Shown, but never the alarm. Hiding a limit outright is the one failure
|
|
2197
|
+
// worse than over-reporting one, and the only thing telling this line which
|
|
2198
|
+
// model is running is the setting, which can be behind a /model. So an idle
|
|
2199
|
+
// per-model weekly stays on the line and is left out of the worst-of.
|
|
2200
|
+
const trusted = parts.filter((part) => !part.stale && !part.unreported && !part.idle);
|
|
1781
2201
|
const worst = trusted.length
|
|
1782
2202
|
? trusted.reduce((a, b) => (b.percent > a.percent ? b : a))
|
|
1783
2203
|
: null;
|
|
@@ -1806,7 +2226,9 @@ function render(data) {
|
|
|
1806
2226
|
lines.push(' Plan ' + data.plan);
|
|
1807
2227
|
lines.push(
|
|
1808
2228
|
' Snapshot ' +
|
|
1809
|
-
(data.snapshotAgeMs === null
|
|
2229
|
+
(data.snapshotAgeMs === null
|
|
2230
|
+
? 'none on disk'
|
|
2231
|
+
: formatDuration(data.snapshotAgeMs) + ' old' + (data.snapshotSource === 'live' ? ' (live reading)' : ''))
|
|
1810
2232
|
);
|
|
1811
2233
|
lines.push(' Settings model=' + data.settings.model + ' effort=' + data.settings.effortLevel);
|
|
1812
2234
|
if (data.planChanged) {
|
|
@@ -1869,11 +2291,18 @@ function render(data) {
|
|
|
1869
2291
|
const bound = data.binding && window.key === data.binding.key;
|
|
1870
2292
|
// The account's own severity, when it says critical, is worth a word.
|
|
1871
2293
|
const critical = window.severity === 'critical';
|
|
2294
|
+
// A per-model weekly for a model that is not running still belongs in the
|
|
2295
|
+
// table - it is real, and switching to that model would make it bite - but
|
|
2296
|
+
// it is not this agent's room, and a bare percentage next to the others
|
|
2297
|
+
// reads as though it were. The row says which it is.
|
|
2298
|
+
const idle = window.applies === false;
|
|
1872
2299
|
const marker = bound
|
|
1873
2300
|
? ' <- binding' + (critical ? ', critical' : '')
|
|
1874
|
-
:
|
|
1875
|
-
? ' critical'
|
|
1876
|
-
:
|
|
2301
|
+
: idle
|
|
2302
|
+
? ' not in use' + (critical ? ', critical for that model' : '')
|
|
2303
|
+
: critical
|
|
2304
|
+
? ' critical'
|
|
2305
|
+
: '';
|
|
1877
2306
|
lines.push(
|
|
1878
2307
|
' ' + pad(window.label, 15) +
|
|
1879
2308
|
padLeft(
|
|
@@ -1890,8 +2319,62 @@ function render(data) {
|
|
|
1890
2319
|
marker
|
|
1891
2320
|
);
|
|
1892
2321
|
}
|
|
2322
|
+
if (data.windows.some((window) => window.applies === false)) {
|
|
2323
|
+
// Named from what is actually running rather than from the setting, which
|
|
2324
|
+
// can say 'default', or name a strategy like 'opusplan', or be behind a
|
|
2325
|
+
// /model - and the legend would then assert a model nothing was decided
|
|
2326
|
+
// from.
|
|
2327
|
+
const running = (data.modelHeadroom || [])
|
|
2328
|
+
.filter((row) => row.inUse)
|
|
2329
|
+
.map((row) => row.family);
|
|
2330
|
+
lines.push(
|
|
2331
|
+
' not in use caps one model, and this agent is running ' +
|
|
2332
|
+
(running.length ? running.join(' and ') : 'another model') +
|
|
2333
|
+
', so nothing here spends into it'
|
|
2334
|
+
);
|
|
2335
|
+
}
|
|
1893
2336
|
lines.push('');
|
|
1894
2337
|
|
|
2338
|
+
// How much room is left is only half the question. The other half is what
|
|
2339
|
+
// that room buys, and the answer is different for every model: the same
|
|
2340
|
+
// weekly holds a few hundred Fable turns or several thousand Sonnet ones.
|
|
2341
|
+
// The account's own figures cannot say this - they have no model in them.
|
|
2342
|
+
const headroom = money ? (data.modelHeadroom || []) : [];
|
|
2343
|
+
if (headroom.some((row) => row.turnsLeft !== null)) {
|
|
2344
|
+
lines.push(' Model headroom, what the room left buys in turns of each model');
|
|
2345
|
+
lines.push(
|
|
2346
|
+
' ' + pad(' Model', 12) + pad('Window', 17) + padLeft('Turns', 7) +
|
|
2347
|
+
padLeft('Spent', 9) + padLeft('Per turn', 10) + padLeft('Turns left', 12)
|
|
2348
|
+
);
|
|
2349
|
+
for (const row of headroom) {
|
|
2350
|
+
lines.push(
|
|
2351
|
+
' ' + pad(' ' + row.family, 12) +
|
|
2352
|
+
pad(row.windowLabel || '-', 17) +
|
|
2353
|
+
padLeft(formatCount(row.sample), 7) +
|
|
2354
|
+
padLeft(formatUSD(row.usd), 9) +
|
|
2355
|
+
padLeft(formatUSD(row.usdPerTurn) + (row.perCall ? '*' : ''), 10) +
|
|
2356
|
+
padLeft(row.turnsLeft === null ? '-' : '~' + formatCount(row.turnsLeft), 12) +
|
|
2357
|
+
(row.inUse ? ' <- running' : '')
|
|
2358
|
+
);
|
|
2359
|
+
}
|
|
2360
|
+
if (headroom.some((row) => row.perCall)) {
|
|
2361
|
+
lines.push(' * a subagent call, not a turn: this model has taken no turns of its own,');
|
|
2362
|
+
lines.push(' so there is nothing here to project a turn count from. The price is');
|
|
2363
|
+
lines.push(' still what delegating to it has cost.');
|
|
2364
|
+
}
|
|
2365
|
+
if (headroom.some((row) => row.remembered)) {
|
|
2366
|
+
lines.push(' A row with too few turns this week is priced from the record of what');
|
|
2367
|
+
lines.push(' that model cost when there were enough.');
|
|
2368
|
+
}
|
|
2369
|
+
// The two tables price the same window differently on purpose, and someone
|
|
2370
|
+
// is going to notice, so say why before it gets read as a bug.
|
|
2371
|
+
lines.push(' Turns left in the table above is a blend of every model on record;');
|
|
2372
|
+
lines.push(" these are each model's own measured cost per turn, and rows sharing a");
|
|
2373
|
+
lines.push(' window are alternatives rather than additions: the same room, spent on');
|
|
2374
|
+
lines.push(' a different model.');
|
|
2375
|
+
lines.push('');
|
|
2376
|
+
}
|
|
2377
|
+
|
|
1895
2378
|
// A bucket with no span cannot be priced or projected, but saying nothing
|
|
1896
2379
|
// about one that is nearly full would be the worse failure.
|
|
1897
2380
|
if (data.otherLimits && data.otherLimits.length) {
|
|
@@ -2097,6 +2580,9 @@ function renderForecast(data, turns) {
|
|
|
2097
2580
|
}
|
|
2098
2581
|
|
|
2099
2582
|
const rows = data.windows
|
|
2583
|
+
// A limit this agent cannot spend into is not what a job fails to fit in,
|
|
2584
|
+
// so it is never offered as a reason to cut the work down.
|
|
2585
|
+
.filter((window) => window.applies !== false)
|
|
2100
2586
|
.map((window) => forecastWindow(window, turns, data.rates))
|
|
2101
2587
|
.filter(Boolean);
|
|
2102
2588
|
|
|
@@ -2316,7 +2802,10 @@ async function main(argv) {
|
|
|
2316
2802
|
const turns = Number(argv[forecastAt + 1]);
|
|
2317
2803
|
if (wantsJson) {
|
|
2318
2804
|
const rows = data.windows
|
|
2319
|
-
|
|
2805
|
+
// A limit this agent cannot spend into is not what a job fails to fit in,
|
|
2806
|
+
// so it is never offered as a reason to cut the work down.
|
|
2807
|
+
.filter((window) => window.applies !== false)
|
|
2808
|
+
.map((window) => forecastWindow(window, turns, data.rates))
|
|
2320
2809
|
.filter(Boolean);
|
|
2321
2810
|
process.stdout.write(JSON.stringify({ turns, rates: data.rates, windows: rows }, null, 2) + '\n');
|
|
2322
2811
|
} else {
|
|
@@ -2358,12 +2847,27 @@ module.exports = {
|
|
|
2358
2847
|
isCodex,
|
|
2359
2848
|
otherLimits,
|
|
2360
2849
|
collectClaude,
|
|
2850
|
+
preferLive,
|
|
2851
|
+
accountUuid,
|
|
2852
|
+
subagentTranscripts,
|
|
2361
2853
|
readClaudeEvents,
|
|
2362
2854
|
RATES,
|
|
2363
2855
|
WINDOWS,
|
|
2364
2856
|
rateFor,
|
|
2365
2857
|
familyOf,
|
|
2366
2858
|
familyAverage,
|
|
2859
|
+
familiesInUse,
|
|
2860
|
+
appliesTo,
|
|
2861
|
+
markApplicable,
|
|
2862
|
+
callPercentiles,
|
|
2863
|
+
modelSpend,
|
|
2864
|
+
windowForFamily,
|
|
2865
|
+
modelHeadroom,
|
|
2866
|
+
modelSamples,
|
|
2867
|
+
modelRecordFile,
|
|
2868
|
+
readModelRecord,
|
|
2869
|
+
writeModelRecord,
|
|
2870
|
+
MIN_MODEL_SAMPLE,
|
|
2367
2871
|
isKnownModel,
|
|
2368
2872
|
costOf,
|
|
2369
2873
|
tokensOf,
|
|
@@ -2376,6 +2880,7 @@ module.exports = {
|
|
|
2376
2880
|
SATURATION_LIMIT,
|
|
2377
2881
|
MIN_BASELINE_TURNS,
|
|
2378
2882
|
MIN_BASELINE_PERCENT,
|
|
2883
|
+
accountFile,
|
|
2379
2884
|
buildWindows,
|
|
2380
2885
|
limitWindows,
|
|
2381
2886
|
lastRejections,
|