claude-usage-limits 1.5.3 → 1.5.5

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "usage-limits",
3
3
  "displayName": "Usage Limits",
4
- "version": "1.5.3",
4
+ "version": "1.5.5",
5
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",
package/README.md CHANGED
@@ -325,6 +325,13 @@ explaining is pace: two days into a week you should be near 29 percent spent, so
325
325
  60 percent means you will not last the week, and that is worth hearing at 60
326
326
  rather than at 85.
327
327
 
328
+ One limit worth knowing: the hook fires when a prompt is submitted, so a
329
+ message sent while Claude is already working does not refresh it. Claude Code
330
+ delivers those into the running turn without re-running hooks, which no plugin
331
+ can intercept. The skill handles it by telling Claude the figures age during a
332
+ turn, and to re-read them before claiming a job fits rather than trusting a
333
+ number from several tool calls ago.
334
+
328
335
  It has to be cheap, because it runs on every prompt. The percentages come from
329
336
  one small file. The transcript scan behind "turns of headroom" is cached for a
330
337
  minute, so it costs about 400ms cold and 120ms warm.
@@ -502,7 +509,7 @@ test/ node --test, no dependencies
502
509
  node --test
503
510
  ```
504
511
 
505
- 179 tests over the pricing, the window arithmetic, plan and credit detection,
512
+ 181 tests over the pricing, the window arithmetic, plan and credit detection,
506
513
  the status line, the before-prompt line, job forecasting, per-project
507
514
  attribution, the CLI, packaging, and the settings save/restore.
508
515
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-usage-limits",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
4
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",
@@ -78,6 +78,29 @@ abandoned mid-edit.
78
78
  **The window resets first.** If the reset lands before the budget runs out,
79
79
  the limit is not the constraint. Say that and stop optimising for it.
80
80
 
81
+ ## The budget line ages during a turn
82
+
83
+ The hook runs when a prompt is submitted, so the figures you were given are
84
+ from the start of the turn. A message sent while you are already working does
85
+ not fire it again: it arrives without a budget line, and the numbers you are
86
+ holding are now older than they look.
87
+
88
+ That matters most when it is the one thing you are about to assert. Re-check
89
+ before saying a job fits, if any of these are true:
90
+
91
+ - the turn has run long, or through many tool calls
92
+ - more requests arrived while you were working
93
+ - other sessions are active, so the budget is draining without you
94
+
95
+ ```
96
+ node scripts/usage.js --status
97
+ ```
98
+
99
+ That is one cheap read of a small file, no transcript scan, and it costs far
100
+ less than promising to finish something and stopping halfway. If you have no
101
+ budget line at all this turn, you are mid-turn: use the last one you were given
102
+ and treat it as a ceiling, not a reading.
103
+
81
104
  ## When messages stack up
82
105
 
83
106
  Every message sent while work is already running starts another turn, and every
@@ -119,7 +119,6 @@ function aheadOfPace(window, now) {
119
119
  return window.percentUsed - Math.min(100, Math.max(0, elapsed));
120
120
  }
121
121
 
122
- // Not whether to speak, which is always, but how hard to lean on it.
123
122
  // Not whether to speak, which is always, but how hard to lean on it.
124
123
  function pressure(window, now, config, turnsLeft) {
125
124
  if (!window || window.percentUsed === null || window.stale) return 'unknown';
@@ -180,17 +179,8 @@ function readHookInput() {
180
179
  });
181
180
  }
182
181
 
183
- function sessionSpend(events, sessionId) {
184
- if (!sessionId) return null;
185
- let cost = 0;
186
- let turns = 0;
187
- for (const event of events) {
188
- if (event.sessionId !== sessionId) continue;
189
- cost += event.cost;
190
- turns += 1;
191
- }
192
- return turns ? { turns, cost } : null;
193
- }
182
+ // Kept as a re-export so there is exactly one implementation.
183
+ const sessionSpend = usage.sessionSpend;
194
184
 
195
185
  function describeWindow(window) {
196
186
  if (!window) return null;
@@ -294,16 +284,18 @@ async function run(now, hookInput) {
294
284
  // full scan and the binding window from somewhere cheaper is how the two
295
285
  // end up describing different windows.
296
286
  if (!view || !view.binding) {
297
- const events = await usage.readEvents(now - 8 * DAY);
298
- const windows = usage.buildWindows(base.utilization, events, now);
299
- const binding = usage.bindingWindow(windows);
287
+ // One call, shared with the report. Building the view twice is how the
288
+ // snapshot correction reached the report and never reached the hook.
289
+ const data = await usage.report(now, { sessionId });
290
+ const binding = data.binding;
300
291
  view = {
301
292
  at: now,
302
293
  turnsLeft: binding && Number.isFinite(binding.turnsLeft) ? binding.turnsLeft : null,
303
- session: sessionSpend(events, sessionId),
304
- othersSummary: summariseOthers(windows, binding && binding.key),
305
- sessions: usage.activeSessions(events, now, usage.CONCURRENT_WINDOW_MS),
306
- staleWindows: windows.filter((w) => w.stale).length,
294
+ session: data.session,
295
+ othersSummary: summariseOthers(data.windows, binding && binding.key),
296
+ sessions: data.sessions,
297
+ staleWindows: data.staleWindows,
298
+ snapshotAge: usage.formatDuration(data.snapshotAgeMs),
307
299
  binding: binding
308
300
  ? {
309
301
  key: binding.key,
@@ -313,6 +305,7 @@ async function run(now, hookInput) {
313
305
  estimated: binding.estimated,
314
306
  adjusted: binding.adjusted,
315
307
  pointsSinceSnapshot: binding.pointsSinceSnapshot,
308
+ correctionUnreliable: binding.correctionUnreliable,
316
309
  resetsAt: binding.resetsAt,
317
310
  verdict: binding.verdict,
318
311
  windowStart: binding.windowStart,
@@ -342,7 +335,7 @@ async function run(now, hookInput) {
342
335
  rebuilt: Boolean(binding && binding.estimated),
343
336
  staleWindows: view.staleWindows || 0,
344
337
  pointsSinceSnapshot: (binding && binding.pointsSinceSnapshot) || 0,
345
- snapshotAge: usage.formatDuration(base.snapshotAgeMs),
338
+ snapshotAge: view.snapshotAge,
346
339
  pressure: pressure(binding, now, config, view.turnsLeft),
347
340
  });
348
341
  }
@@ -582,6 +582,8 @@ function buildWindow(spec, snapshot, events, now, options) {
582
582
  // True when spend since the snapshot was added to its reading.
583
583
  adjusted: false,
584
584
  pointsSinceSnapshot: 0,
585
+ // Set when spend since the snapshot could not be priced sensibly.
586
+ correctionUnreliable: false,
585
587
  verdict: 'unknown',
586
588
  };
587
589
 
@@ -610,10 +612,22 @@ function buildWindow(spec, snapshot, events, now, options) {
610
612
  ) {
611
613
  const upTo = totals(inWindow.filter((e) => e.at <= extra.fetchedAt));
612
614
  const after = totals(inWindow.filter((e) => e.at > extra.fetchedAt));
613
- if (upTo.cost > 0 && after.cost > 0) {
615
+
616
+ // The baseline has to be worth something. Pricing a point off two or three
617
+ // turns makes it far too cheap, and every dollar spent since then is then
618
+ // divided by that, which is how a window truly at 55% got corrected all the
619
+ // way to a confident 100.
620
+ if (upTo.cost > 0 && after.cost > 0 && upTo.turns >= MIN_BASELINE_TURNS) {
614
621
  const pricePerPoint = upTo.cost / rawPercent;
615
622
  sinceSnapshot = after.cost / pricePerPoint;
616
- if (sinceSnapshot >= 1) {
623
+
624
+ // Same rule as a rebuild: past this it is the calibration that is full,
625
+ // not the window. Better to leave the reading uncorrected and say the
626
+ // snapshot is old than to assert a budget that is gone.
627
+ if (rawPercent + sinceSnapshot > SATURATION_LIMIT) {
628
+ sinceSnapshot = 0;
629
+ window.correctionUnreliable = true;
630
+ } else if (sinceSnapshot >= 1) {
617
631
  percent = Math.min(100, Math.round(rawPercent + sinceSnapshot));
618
632
  window.adjusted = true;
619
633
  window.pointsSinceSnapshot = Math.round(sinceSnapshot);
@@ -812,6 +826,9 @@ function collect(now) {
812
826
  // Anything above this and the calibration, not the budget, is what is full.
813
827
  const SATURATION_LIMIT = 105;
814
828
 
829
+ // Fewer turns than this before the snapshot and a point cannot be priced.
830
+ const MIN_BASELINE_TURNS = 5;
831
+
815
832
  function reconstructWindow(spec, snapshot, events, now) {
816
833
  if (!snapshot || typeof snapshot.utilization !== 'number') return null;
817
834
  if (snapshot.utilization <= 0) return null;
@@ -879,7 +896,20 @@ function buildWindows(utilization, events, now, fetchedAt) {
879
896
  }).filter(Boolean);
880
897
  }
881
898
 
882
- async function report(now) {
899
+ // What one session has spent, out of everything on record.
900
+ function sessionSpend(events, sessionId) {
901
+ if (!sessionId) return null;
902
+ let cost = 0;
903
+ let turns = 0;
904
+ for (const event of events) {
905
+ if (event.sessionId !== sessionId) continue;
906
+ cost += event.cost;
907
+ turns += 1;
908
+ }
909
+ return turns ? { turns, cost } : null;
910
+ }
911
+
912
+ async function report(now, options) {
883
913
  const base = collect(now);
884
914
  // A stale snapshot can put a window's start slightly further back than
885
915
  // seven days, so give the scan a day of slack.
@@ -901,6 +931,8 @@ async function report(now) {
901
931
  binding,
902
932
  credits: creditsFrom(base.utilization),
903
933
  sessions: activeSessions(events, now, CONCURRENT_WINDOW_MS),
934
+ session: sessionSpend(events, options && options.sessionId),
935
+ staleWindows: windows.filter((w) => w.stale).length,
904
936
  rates: costPercentiles(recentEvents.length >= 5 ? recentEvents : scoped),
905
937
  resumeAt: binding ? binding.resetsAt : null,
906
938
  models: byModel(scoped),
@@ -1182,13 +1214,18 @@ function formatPercent(value) {
1182
1214
 
1183
1215
  function renderForecast(data, turns) {
1184
1216
  const lines = [];
1185
- lines.push('Forecast for ' + turns + ' turns');
1186
- lines.push('');
1187
1217
 
1218
+ // Checked before the heading is built, or a bad argument prints straight
1219
+ // into it: "Forecast for NaN turns".
1188
1220
  if (!Number.isFinite(turns) || turns <= 0) {
1221
+ lines.push('Forecast');
1222
+ lines.push('');
1189
1223
  lines.push(' Give a number of turns, for example --forecast 15.');
1190
1224
  return lines.join('\n');
1191
1225
  }
1226
+
1227
+ lines.push('Forecast for ' + turns + ' turns');
1228
+ lines.push('');
1192
1229
  if (!data.rates) {
1193
1230
  lines.push(' Nothing recent to price this against yet. Do some work in this');
1194
1231
  lines.push(' session first, then ask again.');
@@ -1300,11 +1337,13 @@ module.exports = {
1300
1337
  buildWindow,
1301
1338
  reconstructWindow,
1302
1339
  SATURATION_LIMIT,
1340
+ MIN_BASELINE_TURNS,
1303
1341
  buildWindows,
1304
1342
  bindingWindow,
1305
1343
  dominantEffort,
1306
1344
  typicalTurnCost,
1307
1345
  activeSessions,
1346
+ sessionSpend,
1308
1347
  shareOf,
1309
1348
  CONCURRENT_WINDOW_MS,
1310
1349
  MIN_PACE_SAMPLE,