claude-usage-limits 1.9.2 → 1.11.2

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.
@@ -24,6 +24,8 @@ const path = require('path');
24
24
  const usage = require('./usage.js');
25
25
  const brief = require('./brief.js');
26
26
  const host = require('./host.js');
27
+ const activity = require('./activity.js');
28
+ const live = require('./live.js');
27
29
 
28
30
  const SECOND = 1000;
29
31
  const DEFAULT_INTERVAL_SECONDS = 120;
@@ -116,6 +118,10 @@ async function run(now, hookInput) {
116
118
  usage.setHost(host.detect(process.argv.slice(2), process.env));
117
119
 
118
120
  const sessionId = hookInput && hookInput.session_id ? hookInput.session_id : null;
121
+ // A tool call just finished, so the turn is still running. A few bytes, so
122
+ // the panel beside the chat keeps animating through a long turn.
123
+ activity.mark('working', sessionId, null, now);
124
+
119
125
  const all = readState();
120
126
  const every = intervalMs();
121
127
  // The cheap path, and the one taken almost every time.
@@ -125,6 +131,24 @@ async function run(now, hookInput) {
125
131
  // second tool call start another one.
126
132
  writeState(trim(all, sessionId, now));
127
133
 
134
+ // A reading as old as the interval is replaced with the one Claude Code
135
+ // would take for /usage, so a turn that runs for an hour is measured
136
+ // against the account rather than against a guess from its own transcript.
137
+ if (!usage.isCodex()) {
138
+ try {
139
+ const cached = usage.collect(now);
140
+ await live.refreshIfStale({
141
+ now,
142
+ maxAgeMs: every,
143
+ cacheFetchedAtMs: cached.snapshotFetchedAt,
144
+ accountUuid: usage.accountUuid(),
145
+ timeoutMs: 4000,
146
+ });
147
+ } catch (err) {
148
+ // The reading on disk is still there.
149
+ }
150
+ }
151
+
128
152
  const data = await usage.report(now, { sessionId });
129
153
  const binding = data.binding;
130
154
  if (!binding) return '';
@@ -32,15 +32,56 @@ const TIGHT_TURNS = 25;
32
32
 
33
33
  const HOUR = 60 * 60 * 1000;
34
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) {
35
+ // Where the mechanical bulk should go when it is delegated.
36
+ //
37
+ // The ladder below is an assumption: one tier down from whatever is doing the
38
+ // judgement, haiku being the floor. It is a reasonable assumption and it is
39
+ // still the fallback, but this account has measured the answer - what a call to
40
+ // each model has really cost here - and a measurement beats a ladder. On one
41
+ // machine the ladder said sonnet while the record said sonnet calls had cost
42
+ // $0.012 against Opus turns at $0.211, which is the same advice with a number
43
+ // behind it, and would have said haiku had haiku been the cheaper one.
44
+ function ladderModel(model) {
38
45
  const name = String(model || '').toLowerCase();
39
46
  if (name.indexOf('haiku') !== -1) return 'haiku';
40
47
  if (name.indexOf('sonnet') !== -1) return 'haiku';
41
48
  return 'sonnet';
42
49
  }
43
50
 
51
+ // Whose family a model setting names, without requiring usage.js back: that
52
+ // would make these two modules a cycle.
53
+ function familyName(model) {
54
+ const name = String(model || '').toLowerCase();
55
+ for (const family of ['fable', 'mythos', 'opus', 'sonnet', 'haiku']) {
56
+ if (name.indexOf(family) !== -1) return family === 'mythos' ? 'fable' : family;
57
+ }
58
+ return null;
59
+ }
60
+
61
+ function delegateModel(model, headroom) {
62
+ const mine = familyName(model);
63
+ const measured = (headroom || []).filter(
64
+ (row) =>
65
+ row &&
66
+ row.family &&
67
+ row.family !== mine &&
68
+ Number.isFinite(row.usdPerTurn) &&
69
+ row.usdPerTurn > 0 &&
70
+ // A model whose own weekly is spent is not somewhere to send work.
71
+ !(row.ownWindow && row.turnsLeft === 0)
72
+ );
73
+ if (!measured.length) return ladderModel(model);
74
+
75
+ const cheapest = measured.reduce((best, row) => (row.usdPerTurn < best.usdPerTurn ? row : best));
76
+ // Never up the ladder. If everything measured is dearer than what is already
77
+ // running, delegating buys nothing and the assumption is the better answer.
78
+ const here = (headroom || []).find((row) => row && row.family === mine);
79
+ if (here && Number.isFinite(here.usdPerTurn) && cheapest.usdPerTurn >= here.usdPerTurn) {
80
+ return ladderModel(model);
81
+ }
82
+ return cheapest.family;
83
+ }
84
+
44
85
  // The effort actually in force. settings.json says 'default' when nothing is
45
86
  // set, and the measured dominant effort of recent turns is better evidence
46
87
  // than a guess; xhigh is what Claude Code defaults to when neither knows.
@@ -163,10 +204,17 @@ function decide(inputs) {
163
204
  // then it lands in settings.json for the next session: switching the running
164
205
  // session's model mid-task invalidates the prompt cache, so the change
165
206
  // belongs at a session boundary.
166
- base.model.delegate = delegateModel(modelNow);
207
+ base.model.delegate = delegateModel(modelNow, inputs.headroom);
208
+ const mine = (inputs.headroom || []).find((row) => row && row.family === familyName(modelNow));
209
+ const there = (inputs.headroom || []).find((row) => row && row.family === base.model.delegate);
210
+ // With both prices measured, say the saving rather than asserting one.
211
+ const saving =
212
+ mine && there && Number.isFinite(mine.usdPerTurn) && Number.isFinite(there.usdPerTurn) && there.usdPerTurn > 0
213
+ ? ', measured here at ' + Math.round(mine.usdPerTurn / there.usdPerTurn) + 'x cheaper'
214
+ : '';
167
215
  base.model.why =
168
216
  'keep ' + (modelNow === 'default' ? 'the current model' : modelNow) +
169
- ' for the judgement; the saving is in where the mechanical bulk runs';
217
+ ' for the judgement; the saving is in where the mechanical bulk runs' + saving;
170
218
  if (base.posture === 'critical' && base.model.delegate !== 'haiku') {
171
219
  base.model.nextSession = 'sonnet';
172
220
  }
@@ -213,6 +261,7 @@ function decide(inputs) {
213
261
  function fromReport(data, turns) {
214
262
  return {
215
263
  binding: (data && data.binding) || null,
264
+ headroom: (data && data.modelHeadroom) || null,
216
265
  rates: (data && data.rates) || null,
217
266
  settings: (data && data.settings) || {},
218
267
  recentEffort: data && data.recent ? data.recent.effort : null,
@@ -285,6 +334,8 @@ function renderRecommend(data, turns) {
285
334
  module.exports = {
286
335
  decide,
287
336
  fromReport,
337
+ familyName,
338
+ ladderModel,
288
339
  renderRecommend,
289
340
  delegateModel,
290
341
  currentEffort,
@@ -11,12 +11,16 @@
11
11
  const usage = require('./usage.js');
12
12
  const host = require('./host.js');
13
13
  const tally = require('./tally.js');
14
+ const activity = require('./activity.js');
14
15
 
15
16
  async function run(now, hookInput) {
17
+ const sessionId = hookInput && hookInput.session_id ? hookInput.session_id : null;
18
+ // The session is over, so as far as the panel is concerned it is idle.
19
+ activity.mark('idle', sessionId, null, now);
20
+
16
21
  if (String(process.env.USAGE_LIMITS_TALLY || '').toLowerCase() === 'off') return '';
17
22
  usage.setHost(host.detect(process.argv.slice(2), process.env));
18
23
 
19
- const sessionId = hookInput && hookInput.session_id ? hookInput.session_id : null;
20
24
  const transcript = hookInput && hookInput.transcript_path ? hookInput.transcript_path : null;
21
25
  if (!sessionId || !transcript) return '';
22
26
 
@@ -0,0 +1,305 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Puts the bars under the Claude Code prompt, and takes them out again.
5
+ //
6
+ // node scripts/statusline.js status
7
+ // node scripts/statusline.js on [--refresh N] [--no-chain] [--dry-run]
8
+ // node scripts/statusline.js off
9
+ //
10
+ // `on` points Claude Code's statusLine setting at a small launcher written into
11
+ // the config directory. The launcher finds wherever this plugin is currently
12
+ // installed and runs feed.js from there, so a plugin update that moves the
13
+ // install directory does not leave the status line pointing at a folder that
14
+ // no longer exists.
15
+ //
16
+ // A status line that was already there is kept: it is recorded, and feed.js
17
+ // runs it first and prints its output above ours, unless --no-chain. `off`
18
+ // puts back exactly what was there, including nothing.
19
+
20
+ const fs = require('fs');
21
+ const os = require('os');
22
+ const path = require('path');
23
+
24
+ function configDir() {
25
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
26
+ }
27
+
28
+ function settingsFile() {
29
+ return path.join(configDir(), 'settings.json');
30
+ }
31
+
32
+ function stateFile() {
33
+ return path.join(configDir(), 'usage-limits-statusline.json');
34
+ }
35
+
36
+ function launcherFile() {
37
+ return path.join(configDir(), 'usage-limits-statusline.js');
38
+ }
39
+
40
+ function feedFile() {
41
+ return path.join(__dirname, 'feed.js');
42
+ }
43
+
44
+ function readJson(file) {
45
+ try {
46
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
47
+ } catch (err) {
48
+ if (err.code === 'ENOENT') return null;
49
+ throw err;
50
+ }
51
+ }
52
+
53
+ function readState() {
54
+ try {
55
+ const parsed = JSON.parse(fs.readFileSync(stateFile(), 'utf8'));
56
+ return parsed && typeof parsed === 'object' ? parsed : null;
57
+ } catch (err) {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ // Replace through a temporary file so an interrupted run cannot leave
63
+ // settings.json half written.
64
+ function writeJson(file, value) {
65
+ fs.mkdirSync(path.dirname(file), { recursive: true });
66
+ const temp = file + '.usage-limits-tmp';
67
+ fs.writeFileSync(temp, JSON.stringify(value, null, 2) + '\n', 'utf8');
68
+ fs.renameSync(temp, file);
69
+ }
70
+
71
+ // The launcher. It is deliberately dumb: find feed.js, hand over, and if that
72
+ // is not possible print nothing and exit 0, because a status line that shows
73
+ // an error every 300ms is worse than one that is blank.
74
+ function launcherSource(recordedFeed) {
75
+ return [
76
+ '#!/usr/bin/env node',
77
+ "'use strict';",
78
+ '// Written by claude-usage-limits (statusline on). Finds the current install and',
79
+ '// runs its status line. Remove with: claude-usage-limits statusline off',
80
+ "const fs = require('fs');",
81
+ "const os = require('os');",
82
+ "const path = require('path');",
83
+ "const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');",
84
+ 'const candidates = [];',
85
+ 'try {',
86
+ " const installed = JSON.parse(fs.readFileSync(path.join(configDir, 'plugins', 'installed_plugins.json'), 'utf8'));",
87
+ ' for (const key of Object.keys((installed && installed.plugins) || {})) {',
88
+ " if (key.indexOf('usage-limits@') !== 0) continue;",
89
+ ' for (const entry of installed.plugins[key] || []) {',
90
+ " if (entry && entry.installPath) candidates.push(path.join(entry.installPath, 'skills', 'usage-limits', 'scripts', 'feed.js'));",
91
+ ' }',
92
+ ' }',
93
+ '} catch (err) {}',
94
+ "candidates.push(path.join(configDir, 'skills', 'usage-limits', 'scripts', 'feed.js'));",
95
+ 'candidates.push(' + JSON.stringify(recordedFeed) + ');',
96
+ 'let feed = null;',
97
+ 'for (const file of candidates) {',
98
+ ' try {',
99
+ ' if (fs.statSync(file).isFile()) {',
100
+ ' feed = file;',
101
+ ' break;',
102
+ ' }',
103
+ ' } catch (err) {}',
104
+ '}',
105
+ 'if (!feed) process.exit(0);',
106
+ 'try {',
107
+ ' Promise.resolve(require(feed).main(process.argv.slice(2))).then(',
108
+ ' (code) => process.exit(code || 0),',
109
+ ' () => process.exit(0)',
110
+ ' );',
111
+ '} catch (err) {',
112
+ ' process.exit(0);',
113
+ '}',
114
+ '',
115
+ ].join('\n');
116
+ }
117
+
118
+ function parseArgs(argv) {
119
+ const args = { command: null, refresh: null, chain: true, dryRun: false };
120
+ for (let i = 0; i < argv.length; i += 1) {
121
+ const arg = argv[i];
122
+ if (arg === '--dry-run') args.dryRun = true;
123
+ else if (arg === '--no-chain') args.chain = false;
124
+ else if (arg === '--refresh') args.refresh = Number(argv[++i]);
125
+ else if (arg.indexOf('--refresh=') === 0) args.refresh = Number(arg.slice('--refresh='.length));
126
+ else if (!args.command) args.command = arg;
127
+ }
128
+ if (!args.command) args.command = 'status';
129
+ return args;
130
+ }
131
+
132
+ function isOurs(statusLine, launcher) {
133
+ return Boolean(
134
+ statusLine &&
135
+ statusLine.type === 'command' &&
136
+ typeof statusLine.command === 'string' &&
137
+ statusLine.command.indexOf(path.basename(launcher)) !== -1
138
+ );
139
+ }
140
+
141
+ // Work out the settings to write without touching disk.
142
+ function planOn(settings, state, options) {
143
+ const current = settings || {};
144
+ const opts = options || {};
145
+ const launcher = opts.launcher || launcherFile();
146
+ const existing = current.statusLine === undefined ? null : current.statusLine;
147
+ // A second "on" must not record our own launcher as the thing to restore.
148
+ const previous =
149
+ state && Object.prototype.hasOwnProperty.call(state, 'previous')
150
+ ? state.previous
151
+ : isOurs(existing, launcher)
152
+ ? null
153
+ : existing;
154
+ const command = 'node ' + JSON.stringify(launcher);
155
+ const statusLine = { type: 'command', command };
156
+ if (Number.isFinite(opts.refresh) && opts.refresh >= 1) statusLine.refreshInterval = Math.floor(opts.refresh);
157
+ const chain =
158
+ opts.chain !== false &&
159
+ Boolean(previous && previous.type === 'command' && typeof previous.command === 'string' && previous.command);
160
+ const next = Object.assign({}, current, { statusLine });
161
+ const changes = [];
162
+ if (JSON.stringify(existing) !== JSON.stringify(statusLine)) {
163
+ changes.push('statusLine: ' + (existing ? JSON.stringify(existing) : '(unset)') + ' -> ' + JSON.stringify(statusLine));
164
+ }
165
+ return {
166
+ settings: next,
167
+ state: {
168
+ installedAt: new Date().toISOString(),
169
+ previous,
170
+ chain,
171
+ launcher,
172
+ feed: opts.feed || feedFile(),
173
+ },
174
+ changes,
175
+ };
176
+ }
177
+
178
+ function planOff(settings, state) {
179
+ const current = settings || {};
180
+ const next = Object.assign({}, current);
181
+ const previous = state && Object.prototype.hasOwnProperty.call(state, 'previous') ? state.previous : null;
182
+ const changes = [];
183
+ if (previous === null || previous === undefined) {
184
+ if (next.statusLine !== undefined) {
185
+ changes.push('statusLine: removed');
186
+ delete next.statusLine;
187
+ }
188
+ } else {
189
+ changes.push('statusLine: restored');
190
+ next.statusLine = previous;
191
+ }
192
+ return { settings: next, changes };
193
+ }
194
+
195
+ function backupOnce(file) {
196
+ const backup = file + '.usage-limits-backup';
197
+ if (fs.existsSync(file) && !fs.existsSync(backup)) fs.copyFileSync(file, backup);
198
+ }
199
+
200
+ function describeStatus(settings, state) {
201
+ const current = (settings && settings.statusLine) || null;
202
+ const launcher = launcherFile();
203
+ const ours = isOurs(current, launcher);
204
+ const lines = [];
205
+ if (ours) {
206
+ lines.push('status line: on');
207
+ lines.push(' command ' + current.command);
208
+ if (current.refreshInterval) lines.push(' refresh every ' + current.refreshInterval + 's');
209
+ if (state && state.chain && state.previous && state.previous.command) {
210
+ lines.push(' chained ' + state.previous.command);
211
+ } else if (state && state.previous) {
212
+ lines.push(' replaced ' + JSON.stringify(state.previous) + ' (restored by "off")');
213
+ }
214
+ } else if (current) {
215
+ lines.push('status line: another command is set');
216
+ lines.push(' command ' + (current.command || JSON.stringify(current)));
217
+ lines.push(' "on" keeps it and prints its output above ours; --no-chain replaces it');
218
+ } else {
219
+ lines.push('status line: off');
220
+ lines.push(' "on" installs it; settings.json is edited through a temporary file and backed up once');
221
+ }
222
+ return lines.join('\n');
223
+ }
224
+
225
+ function main(argv) {
226
+ const args = parseArgs(argv || []);
227
+ const file = settingsFile();
228
+ let settings;
229
+ try {
230
+ settings = readJson(file) || {};
231
+ } catch (err) {
232
+ process.stderr.write('statusline: could not read ' + file + ': ' + err.message + '\n');
233
+ return 1;
234
+ }
235
+ const state = readState();
236
+
237
+ if (args.command === 'status') {
238
+ process.stdout.write(describeStatus(settings, state) + '\n');
239
+ return 0;
240
+ }
241
+
242
+ if (args.command === 'on') {
243
+ if (args.refresh !== null && (!Number.isFinite(args.refresh) || args.refresh < 1)) {
244
+ process.stderr.write('statusline: --refresh takes a whole number of seconds, 1 or more\n');
245
+ return 2;
246
+ }
247
+ const planned = planOn(settings, state, { refresh: args.refresh, chain: args.chain });
248
+ if (args.dryRun) {
249
+ process.stdout.write((planned.changes.length ? planned.changes.join('\n') : 'no change') + '\n');
250
+ return 0;
251
+ }
252
+ backupOnce(file);
253
+ fs.mkdirSync(path.dirname(planned.state.launcher), { recursive: true });
254
+ fs.writeFileSync(planned.state.launcher, launcherSource(planned.state.feed), 'utf8');
255
+ writeJson(file, planned.settings);
256
+ writeJson(stateFile(), planned.state);
257
+ process.stdout.write(
258
+ (planned.changes.length ? planned.changes.join('\n') : 'already on') +
259
+ '\nThe bars appear under the prompt in new Claude Code sessions' +
260
+ (planned.state.chain ? ', above the status line that was already there' : '') +
261
+ '.\n'
262
+ );
263
+ return 0;
264
+ }
265
+
266
+ if (args.command === 'off') {
267
+ const planned = planOff(settings, state);
268
+ if (args.dryRun) {
269
+ process.stdout.write((planned.changes.length ? planned.changes.join('\n') : 'no change') + '\n');
270
+ return 0;
271
+ }
272
+ if (planned.changes.length) writeJson(file, planned.settings);
273
+ for (const stale of [stateFile(), launcherFile()]) {
274
+ try {
275
+ fs.unlinkSync(stale);
276
+ } catch (err) {
277
+ // Already gone is the state we want.
278
+ }
279
+ }
280
+ process.stdout.write((planned.changes.length ? planned.changes.join('\n') : 'already off') + '\n');
281
+ return 0;
282
+ }
283
+
284
+ process.stderr.write('statusline: unknown command "' + args.command + '". Use status, on or off.\n');
285
+ return 2;
286
+ }
287
+
288
+ module.exports = {
289
+ settingsFile,
290
+ stateFile,
291
+ launcherFile,
292
+ feedFile,
293
+ readState,
294
+ launcherSource,
295
+ parseArgs,
296
+ isOurs,
297
+ planOn,
298
+ planOff,
299
+ describeStatus,
300
+ main,
301
+ };
302
+
303
+ if (require.main === module) {
304
+ process.exitCode = main(process.argv.slice(2));
305
+ }
@@ -15,12 +15,16 @@
15
15
  const usage = require('./usage.js');
16
16
  const host = require('./host.js');
17
17
  const tally = require('./tally.js');
18
+ const activity = require('./activity.js');
18
19
 
19
20
  async function run(now, hookInput) {
21
+ const sessionId = hookInput && hookInput.session_id ? hookInput.session_id : null;
22
+ // The reply is finished: the panel beside the chat can stop animating.
23
+ activity.mark('idle', sessionId, null, now);
24
+
20
25
  if (String(process.env.USAGE_LIMITS_TALLY || '').toLowerCase() === 'off') return '';
21
26
  usage.setHost(host.detect(process.argv.slice(2), process.env));
22
27
 
23
- const sessionId = hookInput && hookInput.session_id ? hookInput.session_id : null;
24
28
  const transcript = hookInput && hookInput.transcript_path ? hookInput.transcript_path : null;
25
29
  if (!sessionId || !transcript) return '';
26
30
 
@@ -123,18 +123,12 @@ function readNewLines(file, from) {
123
123
  }
124
124
 
125
125
  // Where Claude Code writes the transcripts of the subagents a session spawned.
126
+ // Plain subagents write straight into <session>/subagents/; the agents a
127
+ // Workflow runs write under subagents/workflows/<run id>/. Both spend this
128
+ // session's budget.
126
129
  function subagentFiles(transcriptPath, sessionId) {
127
130
  const dir = path.join(path.dirname(transcriptPath), sessionId, 'subagents');
128
- let names;
129
- try {
130
- names = fs.readdirSync(dir);
131
- } catch (err) {
132
- return [];
133
- }
134
- return names
135
- .filter((name) => name.endsWith('.jsonl'))
136
- .sort()
137
- .map((name) => path.join(dir, name));
131
+ return usage.subagentTranscripts(dir, 0).sort();
138
132
  }
139
133
 
140
134
  function apply(session, event, sidechain, delta) {