claude-usage-limits 1.7.1 → 1.9.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.
@@ -81,6 +81,15 @@ function planApply(settings, options, existingState) {
81
81
  if (EFFORT_LEVELS.indexOf(effort) === -1) {
82
82
  throw new Error('unknown effort "' + effort + '", expected one of ' + EFFORT_LEVELS.join(', '));
83
83
  }
84
+ // Claude Code refuses 'max' in settings.json; it only survives through
85
+ // /effort or CLAUDE_CODE_EFFORT_LEVEL. Writing it here would save a value
86
+ // the next session silently ignores, which is worse than an error.
87
+ if (effort === 'max') {
88
+ throw new Error(
89
+ "settings.json does not accept 'max'. Use --effort xhigh here, and /effort max " +
90
+ 'or CLAUDE_CODE_EFFORT_LEVEL=max for the sessions that need it.'
91
+ );
92
+ }
84
93
  wanted.effortLevel = effort;
85
94
  if (options.model) wanted.model = options.model;
86
95
 
@@ -129,10 +129,12 @@ async function run(now, hookInput) {
129
129
  const binding = data.binding;
130
130
  if (!binding) return '';
131
131
 
132
- const sessions = data.sessions || [];
133
- const share = usage.shareOf(sessions, sessionId);
132
+ // The same count and the same split as the brief, so the two lines never
133
+ // disagree about how many sessions there are or how much of the budget is
134
+ // this one's.
135
+ const { active, share } = brief.activeShare(data.sessions, brief.readCache(), now, sessionId);
134
136
  const turnsLeft = Number.isFinite(binding.turnsLeft)
135
- ? sessions.length > 1
137
+ ? active > 1
136
138
  ? Math.max(1, Math.round(binding.turnsLeft * share))
137
139
  : binding.turnsLeft
138
140
  : null;
@@ -156,7 +158,7 @@ async function run(now, hookInput) {
156
158
  Number.isFinite(binding.headroomMs) && binding.headroomMs <= runwayMs
157
159
  ? usage.formatDuration(binding.headroomMs)
158
160
  : null,
159
- sessions: sessions.length,
161
+ sessions: active,
160
162
  pressure,
161
163
  });
162
164
  }
@@ -0,0 +1,295 @@
1
+ 'use strict';
2
+
3
+ // Turns the budget figures into a choice of effort and model, and says where
4
+ // each half of that choice can actually be applied. There are three levers and
5
+ // they belong to different hands:
6
+ //
7
+ // - The running session's effort and model are the user's: only /effort and
8
+ // /model change them, and they change them immediately.
9
+ // - New sessions are the script's: lowpower.js writes effortLevel and model
10
+ // into settings.json, which Claude Code reads at launch.
11
+ // - Delegated work is Claude's alone: a subagent can be dispatched on any
12
+ // model at any effort, mid-session, with no one asked.
13
+ //
14
+ // Nothing here touches disk. decide() is a pure function over the same report
15
+ // data usage.js already gathers, so the reasoning can be tested without a
16
+ // transcript in sight.
17
+
18
+ // One notch down, not a cliff. Dropping xhigh to low on work that still has
19
+ // judgement in it costs more in rework than it saves; the ladder loses height
20
+ // a step at a time and 'critical' is the only posture that goes straight to
21
+ // the floor.
22
+ const NEXT_LOWER = { max: 'high', xhigh: 'medium', high: 'medium', medium: 'low', low: 'low' };
23
+
24
+ // Below this share of output, reasoning is not where the money is going, and
25
+ // turning effort down would trade quality for a saving that is not there.
26
+ const REASONING_FLOOR = 0.1;
27
+
28
+ // Turns-left walls used when no job size is given. Ten turns is barely a
29
+ // feature; twenty-five is room for one, carefully.
30
+ const CRITICAL_TURNS = 10;
31
+ const TIGHT_TURNS = 25;
32
+
33
+ const HOUR = 60 * 60 * 1000;
34
+
35
+ // Where the mechanical bulk should go when it is delegated. One tier down
36
+ // from whatever is doing the judgement; haiku is already the floor.
37
+ function delegateModel(model) {
38
+ const name = String(model || '').toLowerCase();
39
+ if (name.indexOf('haiku') !== -1) return 'haiku';
40
+ if (name.indexOf('sonnet') !== -1) return 'haiku';
41
+ return 'sonnet';
42
+ }
43
+
44
+ // The effort actually in force. settings.json says 'default' when nothing is
45
+ // set, and the measured dominant effort of recent turns is better evidence
46
+ // than a guess; xhigh is what Claude Code defaults to when neither knows.
47
+ function currentEffort(settings, recentEffort) {
48
+ const set = settings && settings.effortLevel;
49
+ if (set && set !== 'default') return set;
50
+ if (recentEffort) return recentEffort;
51
+ return 'xhigh';
52
+ }
53
+
54
+ function decide(inputs) {
55
+ const binding = inputs.binding;
56
+ const rates = inputs.rates;
57
+ const settings = inputs.settings || {};
58
+ const effortNow = currentEffort(settings, inputs.recentEffort);
59
+ const modelNow = (settings.model && settings.model !== 'default' && settings.model) || 'default';
60
+
61
+ const base = {
62
+ posture: 'unknown',
63
+ reason: null,
64
+ turnsLeft: null,
65
+ effort: { current: effortNow, target: effortNow, changes: false, why: null },
66
+ model: { current: modelNow, delegate: null, nextSession: null, why: null },
67
+ apply: { now: null, next: null, delegate: null },
68
+ notes: [],
69
+ };
70
+
71
+ if (!binding || binding.stale) {
72
+ base.reason = 'no fresh reading of the binding window';
73
+ return base;
74
+ }
75
+ if (!rates || !Number.isFinite(rates.median) || rates.median <= 0) {
76
+ base.reason = 'no measured turn cost to price the budget in turns';
77
+ return base;
78
+ }
79
+ if (!Number.isFinite(binding.usdPerPercent) || binding.usdPerPercent <= 0) {
80
+ base.reason = 'the binding window has no calibrated price per point yet';
81
+ return base;
82
+ }
83
+
84
+ const percentLeft = Number.isFinite(binding.percentLeft) ? binding.percentLeft : 0;
85
+ const turnsLeft = Math.floor((percentLeft * binding.usdPerPercent) / rates.median);
86
+ base.turnsLeft = turnsLeft;
87
+
88
+ // When the clock wins the race, the limit is not the constraint and there
89
+ // is nothing to buy by economising: whatever is left at the reset is lost.
90
+ const pace = inputs.recentTurnsPerHour;
91
+ if (
92
+ Number.isFinite(binding.msToReset) &&
93
+ binding.msToReset > 0 &&
94
+ Number.isFinite(pace) &&
95
+ pace > 0 &&
96
+ (binding.msToReset / HOUR) * pace < turnsLeft * 0.8
97
+ ) {
98
+ base.posture = 'reset-first';
99
+ base.reason = 'the window resets before this pace can spend it';
100
+ base.effort.why = 'the budget is not the constraint';
101
+ base.model.why = 'the budget is not the constraint';
102
+ return base;
103
+ }
104
+
105
+ // With a job size, the forecast arithmetic decides. Without one, the raw
106
+ // turns of headroom do. Both use the expensive end of the measured spread,
107
+ // because that is the honest number for a long run.
108
+ if (Number.isFinite(inputs.turns) && inputs.turns > 0) {
109
+ const percentHigh = (inputs.turns * rates.high) / binding.usdPerPercent;
110
+ if (percentHigh > percentLeft) {
111
+ base.posture = 'critical';
112
+ base.reason = 'a ' + inputs.turns + ' turn job does not fit in what is left';
113
+ } else if (percentHigh > percentLeft * 0.75) {
114
+ base.posture = 'tight';
115
+ base.reason = 'a ' + inputs.turns + ' turn job fits, but only just';
116
+ } else {
117
+ base.posture = 'roomy';
118
+ base.reason = 'a ' + inputs.turns + ' turn job fits with room to spare';
119
+ }
120
+ } else if (percentLeft <= 0 || turnsLeft <= 0) {
121
+ base.posture = 'critical';
122
+ base.reason = 'the binding window is spent';
123
+ } else if (turnsLeft <= CRITICAL_TURNS) {
124
+ base.posture = 'critical';
125
+ base.reason = 'about ' + turnsLeft + ' turns of headroom';
126
+ } else if (turnsLeft <= TIGHT_TURNS) {
127
+ base.posture = 'tight';
128
+ base.reason = 'about ' + turnsLeft + ' turns of headroom';
129
+ } else {
130
+ base.posture = 'roomy';
131
+ base.reason = 'about ' + turnsLeft + ' turns of headroom';
132
+ }
133
+
134
+ if (base.posture === 'roomy') {
135
+ base.effort.why = 'cheapness is not a virtue when the budget is not tight';
136
+ base.model.why = 'cheapness is not a virtue when the budget is not tight';
137
+ return base;
138
+ }
139
+
140
+ // Effort is the biggest per-turn lever, but only when reasoning is actually
141
+ // where the money goes. The reasoning share is the ceiling on the saving,
142
+ // so a small share means the honest advice is to leave effort alone.
143
+ const share = inputs.reasoningShare;
144
+ if (Number.isFinite(share) && share < REASONING_FLOOR) {
145
+ base.effort.why =
146
+ 'reasoning is only ' + Math.round(share * 100) +
147
+ '% of output, so effort is not where the money is going';
148
+ } else {
149
+ const target = base.posture === 'critical' ? 'low' : NEXT_LOWER[effortNow] || 'medium';
150
+ if (target !== effortNow) {
151
+ base.effort.target = target;
152
+ base.effort.changes = true;
153
+ base.effort.why =
154
+ base.posture === 'critical'
155
+ ? 'reasoning is billed as output, and low is the largest saving that changes nothing else'
156
+ : 'one notch covers the mechanical stretches; keep judgement calls at full effort';
157
+ } else {
158
+ base.effort.why = 'already at the floor for this posture';
159
+ }
160
+ }
161
+
162
+ // The main model is only worth flipping when things are critical, and even
163
+ // then it lands in settings.json for the next session: switching the running
164
+ // session's model mid-task invalidates the prompt cache, so the change
165
+ // belongs at a session boundary.
166
+ base.model.delegate = delegateModel(modelNow);
167
+ base.model.why =
168
+ 'keep ' + (modelNow === 'default' ? 'the current model' : modelNow) +
169
+ ' for the judgement; the saving is in where the mechanical bulk runs';
170
+ if (base.posture === 'critical' && base.model.delegate !== 'haiku') {
171
+ base.model.nextSession = 'sonnet';
172
+ }
173
+
174
+ // The commands, spelled out, because the point of a recommendation is that
175
+ // it can be acted on without working anything out.
176
+ if (base.effort.changes) {
177
+ base.apply.now = '/effort ' + base.effort.target;
178
+ }
179
+ if (inputs.codex) {
180
+ base.notes.push(
181
+ 'Under Codex, settings.json is not in play: change model or effort through ' +
182
+ "Codex's own controls."
183
+ );
184
+ } else if (base.effort.changes || base.model.nextSession) {
185
+ const settingsEffort = base.effort.changes ? base.effort.target : effortNow;
186
+ base.apply.next =
187
+ 'node scripts/lowpower.js on --effort ' +
188
+ (settingsEffort === 'max' ? 'xhigh' : settingsEffort) +
189
+ (base.model.nextSession ? ' --model ' + base.model.nextSession : '');
190
+ }
191
+ base.apply.delegate =
192
+ 'dispatch self-contained mechanical work to a subagent on ' +
193
+ base.model.delegate +
194
+ ' at low effort, and keep the judgement here';
195
+
196
+ if (Number.isFinite(inputs.sessions) && inputs.sessions > 1) {
197
+ base.notes.push(
198
+ inputs.sessions + ' sessions are spending this budget at once, so the headroom ' +
199
+ 'drains faster than these figures alone suggest.'
200
+ );
201
+ }
202
+ if (effortNow === 'max') {
203
+ base.notes.push(
204
+ "settings.json does not accept 'max', so a saved level can only go up to " +
205
+ 'xhigh; max survives only through /effort or CLAUDE_CODE_EFFORT_LEVEL.'
206
+ );
207
+ }
208
+
209
+ return base;
210
+ }
211
+
212
+ // The report data usage.js gathers, reduced to what decide() reads.
213
+ function fromReport(data, turns) {
214
+ return {
215
+ binding: (data && data.binding) || null,
216
+ rates: (data && data.rates) || null,
217
+ settings: (data && data.settings) || {},
218
+ recentEffort: data && data.recent ? data.recent.effort : null,
219
+ recentTurnsPerHour: data && data.recent ? data.recent.turns : null,
220
+ reasoningShare: data && data.reasoning ? data.reasoning.shareOfOutput : null,
221
+ sessions: data && data.sessions ? data.sessions.length : 1,
222
+ codex: Boolean(data && data.money === false),
223
+ turns: Number.isFinite(turns) ? turns : null,
224
+ };
225
+ }
226
+
227
+ // Local and small on purpose: requiring usage.js back for its formatters
228
+ // would make the two modules a cycle.
229
+ function fmtDuration(ms) {
230
+ if (!Number.isFinite(ms) || ms <= 0) return 'now';
231
+ const minutes = Math.round(ms / 60000);
232
+ if (minutes < 60) return minutes + 'm';
233
+ const hours = Math.floor(minutes / 60);
234
+ if (hours < 48) return hours + 'h ' + (minutes % 60) + 'm';
235
+ return Math.floor(hours / 24) + 'd ' + (hours % 24) + 'h';
236
+ }
237
+
238
+ function renderRecommend(data, turns) {
239
+ const decision = decide(fromReport(data, turns));
240
+ const lines = [];
241
+ lines.push('Recommendation' + (Number.isFinite(turns) && turns > 0 ? ' for ' + turns + ' turns' : ''));
242
+ lines.push('');
243
+
244
+ if (decision.posture === 'unknown') {
245
+ lines.push(' Nothing to recommend yet: ' + decision.reason + '.');
246
+ lines.push(' Run /usage once, do a little work, then ask again.');
247
+ return lines.join('\n');
248
+ }
249
+
250
+ const binding = data.binding;
251
+ const where =
252
+ binding.label +
253
+ ' window, ' +
254
+ (Number.isFinite(binding.percentLeft) ? Math.max(0, Math.round(binding.percentLeft)) : '?') +
255
+ '% left' +
256
+ (Number.isFinite(binding.msToReset) ? ', resets in ' + fmtDuration(binding.msToReset) : '');
257
+ lines.push(' Posture ' + decision.posture + ' - ' + decision.reason + ' (' + where + ')');
258
+
259
+ if (decision.posture === 'roomy' || decision.posture === 'reset-first') {
260
+ lines.push(' Effort keep ' + decision.effort.current + '; ' + decision.effort.why);
261
+ lines.push(' Model keep ' + decision.model.current + '; do not economise');
262
+ return lines.join('\n');
263
+ }
264
+
265
+ if (decision.effort.changes) {
266
+ lines.push(' Effort ' + decision.effort.current + ' -> ' + decision.effort.target + '; ' + decision.effort.why);
267
+ lines.push(' this session: ' + decision.apply.now + ' (only the user can run it)');
268
+ } else {
269
+ lines.push(' Effort keep ' + decision.effort.current + '; ' + decision.effort.why);
270
+ }
271
+ if (decision.apply.next) {
272
+ lines.push(' new sessions: ' + decision.apply.next);
273
+ }
274
+ lines.push(' Model ' + decision.model.why);
275
+ lines.push(' ' + decision.apply.delegate);
276
+ if (decision.model.nextSession) {
277
+ lines.push(' new sessions: main model to ' + decision.model.nextSession + ' until the window resets');
278
+ }
279
+ for (const note of decision.notes) {
280
+ lines.push(' Note ' + note);
281
+ }
282
+ return lines.join('\n');
283
+ }
284
+
285
+ module.exports = {
286
+ decide,
287
+ fromReport,
288
+ renderRecommend,
289
+ delegateModel,
290
+ currentEffort,
291
+ NEXT_LOWER,
292
+ REASONING_FLOOR,
293
+ CRITICAL_TURNS,
294
+ TIGHT_TURNS,
295
+ };
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // The closing line.
5
+ //
6
+ // Runs as the SessionEnd hook. Brings the session's total up to date one last
7
+ // time, marks it closed, and prints one line saying how long it ran and what
8
+ // it cost. SessionEnd shows plain stdout to the user and gives hooks a short
9
+ // shared budget, which the incremental read fits inside comfortably.
10
+
11
+ const usage = require('./usage.js');
12
+ const host = require('./host.js');
13
+ const tally = require('./tally.js');
14
+
15
+ async function run(now, hookInput) {
16
+ if (String(process.env.USAGE_LIMITS_TALLY || '').toLowerCase() === 'off') return '';
17
+ usage.setHost(host.detect(process.argv.slice(2), process.env));
18
+
19
+ const sessionId = hookInput && hookInput.session_id ? hookInput.session_id : null;
20
+ const transcript = hookInput && hookInput.transcript_path ? hookInput.transcript_path : null;
21
+ if (!sessionId || !transcript) return '';
22
+
23
+ const all = tally.readState();
24
+ const { session } = tally.update(all, sessionId, transcript, now, { cwd: hookInput.cwd || null });
25
+ session.endedAt = now;
26
+ session.reason = typeof hookInput.reason === 'string' ? hookInput.reason : null;
27
+ tally.writeState(tally.trim(all));
28
+
29
+ return tally.formatClosed(session, now);
30
+ }
31
+
32
+ if (require.main === module) {
33
+ tally
34
+ .readHookInput()
35
+ .then((input) => run(Date.now(), input))
36
+ .then(
37
+ (text) => {
38
+ if (text) process.stdout.write(text + '\n');
39
+ process.exit(0);
40
+ },
41
+ () => {
42
+ process.exit(0);
43
+ }
44
+ );
45
+ }
46
+
47
+ module.exports = { run };
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // The end-of-reply tally.
5
+ //
6
+ // Runs as the Stop hook, after Claude has finished a reply, and puts one line
7
+ // in front of the user saying what that reply cost and what the session has
8
+ // cost so far. It costs the model nothing: the line goes to the person, not
9
+ // into the context, and the numbers come from bytes of the transcript that
10
+ // have already been written.
11
+ //
12
+ // It must never exit with code 2. On this event that would stop Claude from
13
+ // stopping.
14
+
15
+ const usage = require('./usage.js');
16
+ const host = require('./host.js');
17
+ const tally = require('./tally.js');
18
+
19
+ async function run(now, hookInput) {
20
+ if (String(process.env.USAGE_LIMITS_TALLY || '').toLowerCase() === 'off') return '';
21
+ usage.setHost(host.detect(process.argv.slice(2), process.env));
22
+
23
+ const sessionId = hookInput && hookInput.session_id ? hookInput.session_id : null;
24
+ const transcript = hookInput && hookInput.transcript_path ? hookInput.transcript_path : null;
25
+ if (!sessionId || !transcript) return '';
26
+
27
+ const all = tally.readState();
28
+ const { session, delta, created } = tally.update(all, sessionId, transcript, now, {
29
+ cwd: hookInput.cwd || null,
30
+ });
31
+ tally.writeState(tally.trim(all));
32
+
33
+ // The first time a session is seen, everything read is history rather than
34
+ // the reply that just finished, so only the total is shown.
35
+ return JSON.stringify({
36
+ systemMessage: tally.formatTally(session, created ? null : delta, tally.pricing(now)),
37
+ });
38
+ }
39
+
40
+ if (require.main === module) {
41
+ tally
42
+ .readHookInput()
43
+ .then((input) => run(Date.now(), input))
44
+ .then(
45
+ (text) => {
46
+ if (text) process.stdout.write(text + '\n');
47
+ process.exit(0);
48
+ },
49
+ () => {
50
+ process.exit(0);
51
+ }
52
+ );
53
+ }
54
+
55
+ module.exports = { run };