claude-usage-limits 1.1.1 → 1.1.3

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.
@@ -9,9 +9,14 @@
9
9
  {
10
10
  "name": "usage-limits",
11
11
  "source": "./",
12
- "description": "Reads how much of your Claude Code usage limit is left, converts it into turns of headroom, and plans the work to fit inside it.",
12
+ "description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.",
13
13
  "category": "productivity",
14
- "tags": ["usage", "limits", "cost", "planning"],
14
+ "tags": [
15
+ "usage",
16
+ "limits",
17
+ "cost",
18
+ "planning"
19
+ ],
15
20
  "license": "MIT"
16
21
  }
17
22
  ]
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "usage-limits",
3
3
  "displayName": "Usage Limits",
4
- "version": "1.1.1",
5
- "description": "Reads how much of your Claude Code usage limit is left, converts it into turns of headroom, and plans the work to fit inside it. Includes a low power switch that keeps spending down even at high effort.",
4
+ "version": "1.1.3",
5
+ "description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.",
6
6
  "author": {
7
7
  "name": "Ridelink",
8
8
  "url": "https://github.com/ridelink0"
package/README.md CHANGED
@@ -2,14 +2,46 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/claude-usage-limits)](https://www.npmjs.com/package/claude-usage-limits)
4
4
 
5
- A Claude Code plugin that reads how much of your usage limit is left and plans
6
- the work to fit inside it.
5
+ Most usage tools for Claude Code show **you** the numbers. This one shows them
6
+ to **Claude**, before every prompt, and changes what it does about them.
7
7
 
8
- Claude Code already knows how much of your 5-hour and weekly limit you have
9
- spent. It caches the numbers locally and shows them when you ask. What it does
10
- not do on its own is notice that the job in front of it is bigger than the
11
- budget behind it, and adjust. This adds that: a report you can act on, and a
12
- set of rules for what to do when the answer is "not enough".
8
+ Claude Code already knows how much of your 5-hour and weekly limit is gone. It
9
+ caches those numbers locally and will show them if you ask. What it does not do
10
+ is notice that the job in front of it is larger than the budget behind it. So
11
+ it starts anyway, and stops halfway through an edit.
12
+
13
+ This puts the budget in front of Claude before your prompt lands, so the reply
14
+ opens with the answer instead:
15
+
16
+ > The weekly window has about 22 turns left. That covers the parser change and
17
+ > its tests, but not the migration or the docs pass, so I will do the first two
18
+ > and leave the rest for after the reset at 09:00.
19
+
20
+ Nobody read a chart to get that. The numbers reached the model, not you.
21
+
22
+ ## How this differs from a usage dashboard
23
+
24
+ There are a lot of good tools that read the same local files this does and draw
25
+ you a picture: status lines, menu bar apps, terminal dashboards. They are worth
26
+ having, and this is not trying to replace them. The difference is who the
27
+ output is for.
28
+
29
+ | A usage dashboard | This |
30
+ | --- | --- |
31
+ | Renders numbers for a person to read | Puts numbers in the model's context |
32
+ | You notice, then you interrupt | Claude notices, and adjusts on its own |
33
+ | Tells you 78 percent is gone | Tells you 22 turns are left, and whether what you asked for fits in them |
34
+ | Runs beside Claude Code | Runs inside it, as a skill and a hook |
35
+ | Shows what already happened | Says what to do now, and what to drop |
36
+
37
+ A percentage is a fact about the past. The useful question is whether the thing
38
+ you just asked for is going to finish, and answering that needs the request and
39
+ the budget in the same place. That place is the model's context, which is where
40
+ this puts them.
41
+
42
+ So: if you want to watch your usage, install a status line. This ships one too.
43
+ If you want the thing spending the budget to know it is spending the budget,
44
+ that is what this is for.
13
45
 
14
46
  ## What it prints
15
47
 
@@ -206,13 +238,19 @@ Installed as a plugin, a hook measures the budget before each prompt and puts
206
238
  one line into Claude's context:
207
239
 
208
240
  ```
209
- [usage-limits] 5-hour 47%, weekly 16%, about 75 turns of headroom, resets in
210
- 3h 52m, this session 229 turns, $64.16.
241
+ [usage-limits] binding window is 5-hour 47% used, about 75 turns of headroom,
242
+ resets in 3h 52m. Other windows: weekly 16%. This session: 229 turns, $64.16.
211
243
  ```
212
244
 
245
+ It names the window that will stop the work first and hangs the figures off
246
+ that one. Two windows run at once and they are rarely in the same place, so
247
+ "weekly 16%" sitting next to "75 turns" would read as far more room than
248
+ exists.
249
+
213
250
  Claude opens with it. When there is room that is a single line and it moves on:
214
251
 
215
- > Weekly is at 16%, 5-hour at 47%, about 75 turns of headroom. This fits easily.
252
+ > The 5-hour window is the binding one: 47% used, about 75 turns of headroom.
253
+ > This fits easily.
216
254
 
217
255
  When there is not, the line becomes a plan rather than a status:
218
256
 
@@ -349,6 +387,9 @@ Good enough to plan with, not a bill. The honest caveats:
349
387
  a percentage of the limit. On a subscription plan you are not billed them.
350
388
  - Turns left assumes the next turns look like the last hour's. A debugging
351
389
  spiral breaks that assumption immediately.
390
+ - A model released after this table was written is priced at its family's
391
+ average rate, and the report marks those rows with an asterisk rather than
392
+ passing the guess off as a published price.
352
393
  - The cache only refreshes when Claude Code talks to the API, so after an idle
353
394
  spell a window can sit past its own reset time. When that happens the report
354
395
  says `stale` and the status line says `rolling` rather than reporting a
@@ -362,9 +403,13 @@ names, the formulas, and the rest of it.
362
403
  ```
363
404
  .claude-plugin/plugin.json plugin manifest
364
405
  .claude-plugin/marketplace.json lets the repo serve itself
365
- skills/usage-limits/SKILL.md what Claude reads
366
- skills/usage-limits/scripts/ the two scripts
367
- skills/usage-limits/references/ the longer notes
406
+ skills/usage-limits/SKILL.md what Claude reads
407
+ skills/usage-limits/scripts/ usage.js, lowpower.js, brief.js
408
+ skills/usage-limits/references/ the longer notes
409
+ hooks/hooks.json runs brief.js before each prompt
410
+ commands/check.md the /usage-limits:check command
411
+ bin/cli.js the npx entry point
412
+ tools/sync-version.js keeps the manifest version in step
368
413
  test/ node --test, no dependencies
369
414
  ```
370
415
 
@@ -374,7 +419,7 @@ test/ node --test, no dependencies
374
419
  node --test
375
420
  ```
376
421
 
377
- 105 tests over the pricing, the window arithmetic, plan and credit detection,
422
+ 119 tests over the pricing, the window arithmetic, plan and credit detection,
378
423
  the status line, the before-prompt line, job forecasting, per-project
379
424
  attribution, the CLI, packaging, and the settings save/restore.
380
425
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-usage-limits",
3
- "version": "1.1.1",
4
- "description": "See how much of your Claude Code usage limit is left as turns of work rather than a percentage, and plan the job to fit inside it.",
3
+ "version": "1.1.3",
4
+ "description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.",
5
5
  "keywords": [
6
6
  "claude",
7
7
  "claude-code",
@@ -100,7 +100,8 @@ there is nothing to go and look up.
100
100
 
101
101
  When there is room, one line, then get on with it:
102
102
 
103
- > Weekly is at 16%, 5-hour at 47%, about 75 turns of headroom. This fits easily.
103
+ > The 5-hour window is the binding one: 47% used, about 75 turns of headroom.
104
+ > This fits easily.
104
105
 
105
106
  When it does not fit, that line becomes the plan:
106
107
 
@@ -116,6 +117,13 @@ Keep it to one line unless the work genuinely does not fit. The budget note is
116
117
  a header, not a section, and it must never push the actual answer down the
117
118
  page.
118
119
 
120
+ One thing to get right: **quote the binding window, not the roomiest one.**
121
+ Two windows run at once and they are rarely in the same place. The turns of
122
+ headroom and the reset time belong to whichever runs out first. Putting the
123
+ other window's percentage next to those figures claims far more room than
124
+ exists, and that is how a session ends mid-edit while the weekly number still
125
+ looks comfortable.
126
+
119
127
  ### Pricing the job before starting it
120
128
 
121
129
  Size the work in turns using the table above, then price it:
@@ -198,7 +206,8 @@ a turn, which is the thing it is trying to save.
198
206
 
199
207
  | Path | What it is |
200
208
  | --- | --- |
201
- | `scripts/usage.js` | The report. `--json` for raw fields, `--status` for a one-line status line readout that skips the transcript scan. |
209
+ | `scripts/usage.js` | The report. `--json` for raw fields, `--status` for a one-line readout that skips the transcript scan, `--forecast N` for what an N turn job would cost. |
210
+ | `scripts/brief.js` | What the hook runs before each prompt. Not meant to be called by hand. |
202
211
  | `scripts/lowpower.js` | `status`, `on`, `off`. Restores what it replaced. |
203
212
  | `references/tactics.md` | Every lever that lowers cost, and why it works. |
204
213
  | `references/how-it-works.md` | Where the numbers come from and where they are soft. |
@@ -131,6 +131,18 @@ immediately. Re-run the report if the shape of the work changes.
131
131
  ## Keeping it accurate
132
132
 
133
133
  The rate table in `scripts/usage.js` is a plain object at the top of the file.
134
- When new models ship, add a row. An unknown id falls back to the family it
135
- names (`opus`, `sonnet`, `haiku`, `fable`) and then to Opus rates, so a missing
136
- row degrades to an estimate rather than a crash.
134
+ When new models ship, add a row.
135
+
136
+ Until someone does, a model this table has not seen is priced at the average of
137
+ the family its name contains: an unreleased `claude-opus-5-2` is charged at the
138
+ mean of every Opus rate on record. Averaging assumes nothing about which
139
+ direction prices moved, which is why it beats pinning to whichever release
140
+ happened to be newest when the table was written.
141
+
142
+ A name with no recognisable family falls back to Opus rates. That is a
143
+ deliberate choice rather than a neutral one: over-estimating cost understates
144
+ your headroom, and being told you have less room than you do is the safe way to
145
+ be wrong about a budget.
146
+
147
+ Rows priced this way are marked with an asterisk in the report, so an assumed
148
+ rate never quietly passes for a published one.
@@ -38,18 +38,41 @@ function cacheFile() {
38
38
  return path.join(configDir(), 'usage-limits-brief.json');
39
39
  }
40
40
 
41
+ // One slot per session. A single shared slot meant that alternating between
42
+ // two Claude Code windows invalidated the cache on every prompt, so neither
43
+ // ever got a hit and both paid for a full scan each time.
44
+ const KEEP_SESSIONS = 5;
45
+
41
46
  function readCache() {
42
47
  try {
43
- return JSON.parse(fs.readFileSync(cacheFile(), 'utf8'));
48
+ const parsed = JSON.parse(fs.readFileSync(cacheFile(), 'utf8'));
49
+ return parsed && typeof parsed === 'object' ? parsed : {};
44
50
  } catch (err) {
45
- return null;
51
+ return {};
46
52
  }
47
53
  }
48
54
 
49
- function writeCache(value) {
55
+ function pickCached(all, sessionId, now, ttlMs) {
56
+ const entry = all ? all[sessionId || '_'] : null;
57
+ if (!entry || !Number.isFinite(entry.at)) return null;
58
+ return now - entry.at < ttlMs ? entry : null;
59
+ }
60
+
61
+ // Keep the newest few so a machine with many sessions does not grow the file
62
+ // without bound.
63
+ function mergeCache(all, sessionId, entry, keep) {
64
+ const next = Object.assign({}, all || {});
65
+ next[sessionId || '_'] = entry;
66
+ const ordered = Object.keys(next).sort((a, b) => (next[b].at || 0) - (next[a].at || 0));
67
+ const trimmed = {};
68
+ for (const key of ordered.slice(0, keep || KEEP_SESSIONS)) trimmed[key] = next[key];
69
+ return trimmed;
70
+ }
71
+
72
+ function writeCache(all) {
50
73
  try {
51
74
  fs.mkdirSync(path.dirname(cacheFile()), { recursive: true });
52
- fs.writeFileSync(cacheFile(), JSON.stringify(value), 'utf8');
75
+ fs.writeFileSync(cacheFile(), JSON.stringify(all), 'utf8');
53
76
  } catch (err) {
54
77
  // A cache miss costs a scan. A crash costs the prompt. Prefer the scan.
55
78
  }
@@ -135,33 +158,47 @@ function sessionSpend(events, sessionId) {
135
158
  return turns ? { turns, cost } : null;
136
159
  }
137
160
 
138
- function summarise(windows) {
161
+ function describeWindow(window) {
162
+ if (!window) return null;
163
+ return window.stale
164
+ ? window.label + ' rolling over'
165
+ : window.label + ' ' + window.percentUsed + '%';
166
+ }
167
+
168
+ // Everything except the window that will actually stop the work.
169
+ function summariseOthers(windows, bindingKey) {
139
170
  return windows
140
- .filter((window) => window.percentUsed !== null)
141
- .map((window) =>
142
- window.stale
143
- ? window.label + ' rolling over'
144
- : window.label + ' ' + window.percentUsed + '%'
145
- )
171
+ .filter((window) => window.percentUsed !== null && window.key !== bindingKey)
172
+ .map(describeWindow)
146
173
  .join(', ');
147
174
  }
148
175
 
149
176
  function briefText(parts) {
150
- const facts = [];
151
- if (parts.windowSummary) facts.push(parts.windowSummary);
177
+ // The turns and the reset time belong to one specific window. Listing every
178
+ // window and then the numbers invites reading them against the wrong one, so
179
+ // the binding window is named and its figures are attached to it.
180
+ const bound = [];
181
+ const described = describeWindow(parts.binding);
182
+ if (described) bound.push(described + (parts.binding.stale ? '' : ' used'));
152
183
  if (Number.isFinite(parts.turnsLeft)) {
153
- facts.push('about ' + parts.turnsLeft + ' turns of headroom');
184
+ bound.push('about ' + parts.turnsLeft + ' turns of headroom');
154
185
  }
155
- if (parts.resetsIn) facts.push('resets in ' + parts.resetsIn);
186
+ if (parts.resetsIn) bound.push('resets in ' + parts.resetsIn);
187
+
188
+ const sentences = [];
189
+ sentences.push(
190
+ bound.length
191
+ ? '[usage-limits] binding window is ' + bound.join(', ') + '.'
192
+ : '[usage-limits] no usable window reading.'
193
+ );
194
+ if (parts.othersSummary) sentences.push('Other windows: ' + parts.othersSummary + '.');
156
195
  if (parts.session) {
157
- facts.push(
158
- 'this session ' + parts.session.turns + ' turns, ' +
159
- usage.formatUSD(parts.session.cost)
196
+ sentences.push(
197
+ 'This session: ' + parts.session.turns + ' turns, ' +
198
+ usage.formatUSD(parts.session.cost) + '.'
160
199
  );
161
200
  }
162
201
 
163
- const head = '[usage-limits] ' + facts.join(', ') + '.';
164
-
165
202
  const instruction =
166
203
  parts.pressure === 'tight' || parts.pressure === 'gone'
167
204
  ? 'Open your reply with one line on where this leaves the budget, then say ' +
@@ -170,7 +207,14 @@ function briefText(parts) {
170
207
  : 'Open your reply with one short line stating this and confirming the ' +
171
208
  'request fits, then get on with the work. Keep it to a single line.';
172
209
 
173
- return head + '\n' + instruction;
210
+ // The mistake this guards against: quoting the roomiest window and pinning
211
+ // the binding window figures to it.
212
+ const care =
213
+ ' Quote the binding window, not whichever one has the most left. The turns ' +
214
+ 'and reset time above belong to the binding window alone; do not read them ' +
215
+ 'against another window percentage.';
216
+
217
+ return sentences.join(' ') + '\n' + instruction + care;
174
218
  }
175
219
 
176
220
  async function run(now, hookInput) {
@@ -186,30 +230,27 @@ async function run(now, hookInput) {
186
230
  const cheap = usage.buildWindows(base.utilization, [], now);
187
231
  if (!cheap.length) return '';
188
232
 
189
- const cached = readCache();
190
- const fresh =
191
- cached &&
192
- Number.isFinite(cached.at) &&
193
- now - cached.at < config.cacheSeconds * SECOND &&
194
- cached.sessionId === sessionId;
233
+ const all = readCache();
234
+ const cached = pickCached(all, sessionId, now, config.cacheSeconds * SECOND);
195
235
 
196
- let turnsLeft = fresh ? cached.turnsLeft : null;
197
- let session = fresh ? cached.session : null;
236
+ let turnsLeft = cached ? cached.turnsLeft : null;
237
+ let session = cached ? cached.session : null;
198
238
  let windows = cheap;
199
239
 
200
- if (!fresh) {
240
+ if (!cached) {
201
241
  const events = await usage.readEvents(now - 8 * DAY);
202
242
  windows = usage.buildWindows(base.utilization, events, now);
203
243
  const binding = usage.bindingWindow(windows);
204
244
  turnsLeft = binding && Number.isFinite(binding.turnsLeft) ? binding.turnsLeft : null;
205
245
  session = sessionSpend(events, sessionId);
206
- writeCache({ at: now, turnsLeft, session, sessionId });
246
+ writeCache(mergeCache(all, sessionId, { at: now, turnsLeft, session }, KEEP_SESSIONS));
207
247
  }
208
248
 
209
249
  const binding = usage.bindingWindow(windows) || windows[0];
210
250
 
211
251
  return briefText({
212
- windowSummary: summarise(windows),
252
+ binding,
253
+ othersSummary: summariseOthers(windows, binding && binding.key),
213
254
  turnsLeft,
214
255
  resetsIn:
215
256
  binding && !binding.stale && binding.msToReset !== null
@@ -240,9 +281,13 @@ module.exports = {
240
281
  aheadOfPace,
241
282
  pressure,
242
283
  sessionSpend,
243
- summarise,
284
+ describeWindow,
285
+ summariseOthers,
244
286
  briefText,
245
287
  settings,
288
+ pickCached,
289
+ mergeCache,
290
+ KEEP_SESSIONS,
246
291
  run,
247
292
  cacheFile,
248
293
  };
@@ -37,8 +37,43 @@ const RATES = {
37
37
  'claude-sonnet-4-6': { input: 3, output: 15 },
38
38
  'claude-haiku-4-5': { input: 1, output: 5 },
39
39
  };
40
+ // A model can ship before this table knows about it. Rather than refusing to
41
+ // price it, fall back to the average of the family it names. Averaging assumes
42
+ // nothing about which direction prices moved, unlike pinning to one release.
43
+ // An unrecognised family falls back to Opus rates on purpose: over-estimating
44
+ // cost understates headroom, and that is the safe direction for a budget.
45
+ const FAMILIES = ['fable', 'mythos', 'opus', 'sonnet', 'haiku'];
40
46
  const FALLBACK_RATE = { input: 5, output: 25 };
41
47
 
48
+ function familyOf(model) {
49
+ const id = String(model || '').toLowerCase();
50
+ for (const family of FAMILIES) {
51
+ // Mythos is priced with Fable, so it counts as the same family.
52
+ if (id.indexOf(family) !== -1) return family === 'mythos' ? 'fable' : family;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ function familyAverage(family, table) {
58
+ if (!family) return null;
59
+ const rates = table || RATES;
60
+ const members = Object.keys(rates).filter((id) => familyOf(id) === family);
61
+ if (!members.length) return null;
62
+
63
+ let input = 0;
64
+ let output = 0;
65
+ for (const id of members) {
66
+ input += rates[id].input;
67
+ output += rates[id].output;
68
+ }
69
+ return { input: input / members.length, output: output / members.length };
70
+ }
71
+
72
+ // Whether the price came from the table or from an assumption.
73
+ function isKnownModel(model) {
74
+ return Object.prototype.hasOwnProperty.call(RATES, String(model || '').toLowerCase());
75
+ }
76
+
42
77
  // Cache traffic is priced as a multiple of the input rate.
43
78
  const CACHE_WRITE_5M = 1.25;
44
79
  const CACHE_WRITE_1H = 2;
@@ -129,11 +164,7 @@ function readJson(file) {
129
164
  function rateFor(model) {
130
165
  const id = String(model || '').toLowerCase();
131
166
  if (RATES[id]) return RATES[id];
132
- if (id.includes('fable') || id.includes('mythos')) return RATES['claude-fable-5'];
133
- if (id.includes('opus')) return RATES['claude-opus-5'];
134
- if (id.includes('sonnet')) return RATES['claude-sonnet-5'];
135
- if (id.includes('haiku')) return RATES['claude-haiku-4-5'];
136
- return FALLBACK_RATE;
167
+ return familyAverage(familyOf(id)) || FALLBACK_RATE;
137
168
  }
138
169
 
139
170
  // Cost of one assistant turn, in USD, from its usage record.
@@ -300,6 +331,7 @@ function byModel(events) {
300
331
  if (!rows.has(id)) {
301
332
  rows.set(id, {
302
333
  model: id,
334
+ estimated: !isKnownModel(id),
303
335
  turns: 0,
304
336
  tokens: 0,
305
337
  cost: 0,
@@ -829,12 +861,16 @@ function render(data) {
829
861
  );
830
862
  for (const row of data.models) {
831
863
  lines.push(
832
- ' ' + pad(' ' + row.model, 24) + padLeft(row.turns, 7) +
864
+ ' ' + pad(' ' + row.model + (row.estimated ? ' *' : ''), 24) +
865
+ padLeft(row.turns, 7) +
833
866
  padLeft(formatTokens(row.tokens), 10) +
834
867
  padLeft(formatTokens(row.parts.output), 9) +
835
868
  padLeft(Math.round(row.share * 100) + '%', 8)
836
869
  );
837
870
  }
871
+ if (data.models.some((row) => row.estimated)) {
872
+ lines.push(' * no published rate for this one yet, priced at the family average');
873
+ }
838
874
  if (data.tokens) {
839
875
  lines.push(
840
876
  ' Tokens input ' + formatTokens(data.tokens.input) +
@@ -1020,6 +1056,9 @@ module.exports = {
1020
1056
  RATES,
1021
1057
  WINDOWS,
1022
1058
  rateFor,
1059
+ familyOf,
1060
+ familyAverage,
1061
+ isKnownModel,
1023
1062
  costOf,
1024
1063
  tokensOf,
1025
1064
  eventFrom,