claude-usage-limits 1.9.2 → 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 +115 -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/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 +477 -17
- package/skills/usage-limits/scripts/view.js +217 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// The status line: one line of bars under the Claude Code prompt, in Claude's
|
|
5
|
+
// own colours, from Claude's own numbers.
|
|
6
|
+
//
|
|
7
|
+
// Claude Code runs this on every change and hands it JSON on stdin. Two things
|
|
8
|
+
// in that JSON are worth more than anything on disk: `rate_limits`, which
|
|
9
|
+
// Claude Code fills from the rate-limit headers on its own API responses, and
|
|
10
|
+
// `model`, which is the one certain answer to which model is running. Both
|
|
11
|
+
// are recorded in a small feed file, one slot per session, so the side panel
|
|
12
|
+
// (which never sees this JSON) can draw from them too.
|
|
13
|
+
//
|
|
14
|
+
// It must be fast and it must never fail: no transcript scan, no network, one
|
|
15
|
+
// read of a few small files, and any error prints nothing rather than a stack
|
|
16
|
+
// trace where the bars should be.
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const { spawnSync } = require('child_process');
|
|
22
|
+
|
|
23
|
+
const usage = require('./usage.js');
|
|
24
|
+
const host = require('./host.js');
|
|
25
|
+
const bars = require('./bars.js');
|
|
26
|
+
const view = require('./view.js');
|
|
27
|
+
const activity = require('./activity.js');
|
|
28
|
+
const statusline = require('./statusline.js');
|
|
29
|
+
|
|
30
|
+
const KEEP_SESSIONS = 8;
|
|
31
|
+
// Two updates this close together mean Claude is mid-turn.
|
|
32
|
+
const WORKING_GAP_MS = 4000;
|
|
33
|
+
// A previous status line gets this long, then we go on without it.
|
|
34
|
+
const CHAIN_TIMEOUT_MS = 2000;
|
|
35
|
+
const STDIN_WAIT_MS = 500;
|
|
36
|
+
// Claude Code draws the status line inside its own margins, a few columns
|
|
37
|
+
// narrower than COLUMNS, and clips what does not fit.
|
|
38
|
+
const STATUSLINE_MARGIN = 4;
|
|
39
|
+
|
|
40
|
+
const SHORT = { five_hour: 'session', seven_day: 'week', spend_limit: 'spend' };
|
|
41
|
+
// When even that is too wide.
|
|
42
|
+
const SHORTER = { five_hour: '5h', seven_day: 'wk', spend_limit: 'spend' };
|
|
43
|
+
|
|
44
|
+
function configDir() {
|
|
45
|
+
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function feedFile() {
|
|
49
|
+
return path.join(configDir(), 'usage-limits-feed.json');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readJson(file) {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readFeed() {
|
|
61
|
+
const parsed = readJson(feedFile());
|
|
62
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
63
|
+
const slots = {};
|
|
64
|
+
for (const key of Object.keys(parsed)) {
|
|
65
|
+
const value = parsed[key];
|
|
66
|
+
if (value && typeof value === 'object' && Number.isFinite(value.at)) slots[key] = value;
|
|
67
|
+
}
|
|
68
|
+
return slots;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function writeFeed(all) {
|
|
72
|
+
try {
|
|
73
|
+
const file = feedFile();
|
|
74
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
75
|
+
const temp = file + '.' + process.pid + '.usage-limits-tmp';
|
|
76
|
+
fs.writeFileSync(temp, JSON.stringify(all), 'utf8');
|
|
77
|
+
fs.renameSync(temp, file);
|
|
78
|
+
return true;
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function number(value) {
|
|
85
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// What one status line update tells us, folded onto what the last one said.
|
|
89
|
+
// A run without rate_limits (they only appear after the first API response)
|
|
90
|
+
// keeps the previous ones rather than forgetting them.
|
|
91
|
+
function slotFrom(input, previous, now) {
|
|
92
|
+
const prior = previous && typeof previous === 'object' ? previous : {};
|
|
93
|
+
const model = input.model && typeof input.model === 'object' ? input.model : {};
|
|
94
|
+
const hasHeaders = Boolean(input.rate_limits && typeof input.rate_limits === 'object');
|
|
95
|
+
return {
|
|
96
|
+
at: now,
|
|
97
|
+
prevAt: Number.isFinite(prior.at) ? prior.at : null,
|
|
98
|
+
sessionId: input.session_id || null,
|
|
99
|
+
model: typeof model.id === 'string' ? model.id : prior.model || null,
|
|
100
|
+
modelName: typeof model.display_name === 'string' ? model.display_name : prior.modelName || null,
|
|
101
|
+
effort: input.effort && typeof input.effort.level === 'string' ? input.effort.level : prior.effort || null,
|
|
102
|
+
rateLimits: hasHeaders ? input.rate_limits : prior.rateLimits || null,
|
|
103
|
+
headersAt: hasHeaders ? now : Number.isFinite(prior.headersAt) ? prior.headersAt : null,
|
|
104
|
+
context: input.context_window ? number(input.context_window.used_percentage) : number(prior.context),
|
|
105
|
+
cost: input.cost ? number(input.cost.total_cost_usd) : number(prior.cost),
|
|
106
|
+
cwd: typeof input.cwd === 'string' ? input.cwd : prior.cwd || null,
|
|
107
|
+
version: typeof input.version === 'string' ? input.version : prior.version || null,
|
|
108
|
+
fastMode: input.fast_mode === true,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function trim(all, keep) {
|
|
113
|
+
const ordered = Object.keys(all).sort((a, b) => (all[b].at || 0) - (all[a].at || 0));
|
|
114
|
+
const kept = {};
|
|
115
|
+
for (const key of ordered.slice(0, keep || KEEP_SESSIONS)) kept[key] = all[key];
|
|
116
|
+
return kept;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function record(all, input, now) {
|
|
120
|
+
if (!input || typeof input !== 'object' || !input.session_id) return all || {};
|
|
121
|
+
const next = Object.assign({}, all || {});
|
|
122
|
+
next[input.session_id] = slotFrom(input, next[input.session_id], now);
|
|
123
|
+
return trim(next);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function newest(all) {
|
|
127
|
+
let best = null;
|
|
128
|
+
for (const key of Object.keys(all || {})) {
|
|
129
|
+
const slot = all[key];
|
|
130
|
+
if (slot && Number.isFinite(slot.at) && (!best || slot.at > best.at)) best = slot;
|
|
131
|
+
}
|
|
132
|
+
return best;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isWorking(slot, now) {
|
|
136
|
+
if (!slot || !Number.isFinite(slot.at) || !Number.isFinite(slot.prevAt)) return false;
|
|
137
|
+
return now - slot.at < WORKING_GAP_MS && slot.at - slot.prevAt < WORKING_GAP_MS;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function shortLabel(row, shorter) {
|
|
141
|
+
const table = shorter ? SHORTER : SHORT;
|
|
142
|
+
if (table[row.key]) return table[row.key];
|
|
143
|
+
return row.family || row.key;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The line. Widest form first, then narrower bars, then no model, then no
|
|
147
|
+
// bars, so it always fits whatever COLUMNS says.
|
|
148
|
+
function line(built, options) {
|
|
149
|
+
const opts = options || {};
|
|
150
|
+
const columns = Number.isFinite(opts.columns) && opts.columns > 0 ? opts.columns : 80;
|
|
151
|
+
const mode = opts.mode || 'none';
|
|
152
|
+
const tick = Number.isFinite(opts.tick) ? opts.tick : 0;
|
|
153
|
+
const reduced = Boolean(opts.reduced);
|
|
154
|
+
const ascii = Boolean(opts.ascii);
|
|
155
|
+
|
|
156
|
+
if (!built || !built.rows || !built.rows.length) return '';
|
|
157
|
+
if (built.state === 'none') return bars.dim('usage: no reading yet', mode);
|
|
158
|
+
|
|
159
|
+
const glyph = built.working
|
|
160
|
+
? built.ultracode
|
|
161
|
+
? bars.rainbow(bars.spinner(tick, { ascii, reduced }), tick, { mode, reduced })
|
|
162
|
+
: bars.paint(bars.spinner(tick, { ascii, reduced }), bars.THEME.claude, mode)
|
|
163
|
+
: bars.paint(ascii ? '*' : '✻', bars.THEME.claude, mode);
|
|
164
|
+
// Ultracode is xhigh plus workflows, and Claude names it as its own level in
|
|
165
|
+
// the picker, so it is named here too, in the rainbow the picker uses.
|
|
166
|
+
const effortName = built.ultracode ? 'ultracode' : built.effort;
|
|
167
|
+
const effort = effortName ? bars.effortColour(effortName) : null;
|
|
168
|
+
const effortText = !effortName
|
|
169
|
+
? ''
|
|
170
|
+
: effort.rainbow
|
|
171
|
+
? bars.rainbow(effortName, tick, { mode, reduced })
|
|
172
|
+
: effort.shimmer && built.working
|
|
173
|
+
? bars.shimmer(effortName, tick, effort.rgb, effort.shimmer, { mode, reduced })
|
|
174
|
+
: bars.paint(effortName, effort.rgb, mode);
|
|
175
|
+
const head = glyph + ' ' + built.modelLabel + (effortText ? ' ' + bars.dim('·', mode) + ' ' + effortText : '');
|
|
176
|
+
|
|
177
|
+
const segment = (row, width, shorter) => {
|
|
178
|
+
const label = shortLabel(row, shorter);
|
|
179
|
+
const percent = row.level === 'fill' ? row.percentText : bars.paint(row.percentText, bars.levelColour(row.level), mode);
|
|
180
|
+
if (!width || row.percent === null) return label + ' ' + percent;
|
|
181
|
+
return label + ' ' + bars.bar(row.percent, width, { mode, level: row.level, ascii }) + ' ' + percent;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const attempts = [
|
|
185
|
+
{ width: 10, head: true },
|
|
186
|
+
{ width: 8, head: true },
|
|
187
|
+
{ width: 6, head: true },
|
|
188
|
+
{ width: 6, head: false },
|
|
189
|
+
{ width: 4, head: false },
|
|
190
|
+
{ width: 4, head: false, shorter: true },
|
|
191
|
+
{ width: 0, head: false },
|
|
192
|
+
{ width: 0, head: false, shorter: true },
|
|
193
|
+
{ width: 0, head: false, shorter: true, gap: ' ' },
|
|
194
|
+
];
|
|
195
|
+
// Another Claude spending the same budget is worth a word on the line.
|
|
196
|
+
const others =
|
|
197
|
+
Number.isFinite(built.othersWorking) && built.othersWorking > 0
|
|
198
|
+
? bars.paint('+' + built.othersWorking + ' working', bars.THEME.claude, mode)
|
|
199
|
+
: '';
|
|
200
|
+
let text = '';
|
|
201
|
+
for (const attempt of attempts) {
|
|
202
|
+
const parts = built.rows.map((row) => segment(row, attempt.width, attempt.shorter));
|
|
203
|
+
if (others) parts.push(others);
|
|
204
|
+
if (attempt.head) parts.unshift(head);
|
|
205
|
+
text = parts.join(attempt.gap || ' ');
|
|
206
|
+
if (bars.visibleWidth(text) <= columns) return text;
|
|
207
|
+
}
|
|
208
|
+
return text;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function readStdin() {
|
|
212
|
+
return new Promise((resolve) => {
|
|
213
|
+
if (process.stdin.isTTY) return resolve('');
|
|
214
|
+
let raw = '';
|
|
215
|
+
let settled = false;
|
|
216
|
+
const done = () => {
|
|
217
|
+
if (settled) return;
|
|
218
|
+
settled = true;
|
|
219
|
+
resolve(raw);
|
|
220
|
+
};
|
|
221
|
+
const timer = setTimeout(done, STDIN_WAIT_MS);
|
|
222
|
+
if (timer.unref) timer.unref();
|
|
223
|
+
process.stdin.setEncoding('utf8');
|
|
224
|
+
process.stdin.on('data', (chunk) => {
|
|
225
|
+
raw += chunk;
|
|
226
|
+
});
|
|
227
|
+
process.stdin.on('end', done);
|
|
228
|
+
process.stdin.on('error', done);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// The status line that was there before ours, run with the same stdin, so
|
|
233
|
+
// installing this one loses nothing.
|
|
234
|
+
function runPrevious(command, raw, env) {
|
|
235
|
+
try {
|
|
236
|
+
const result = spawnSync(command, {
|
|
237
|
+
shell: true,
|
|
238
|
+
input: raw,
|
|
239
|
+
encoding: 'utf8',
|
|
240
|
+
timeout: CHAIN_TIMEOUT_MS,
|
|
241
|
+
env: env || process.env,
|
|
242
|
+
windowsHide: true,
|
|
243
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
244
|
+
});
|
|
245
|
+
if (result.error || result.status !== 0) return '';
|
|
246
|
+
return String(result.stdout || '').replace(/\s+$/, '');
|
|
247
|
+
} catch (err) {
|
|
248
|
+
return '';
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function settingsFor(dir) {
|
|
253
|
+
return readJson(path.join(dir, 'settings.json')) || {};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function clockFor(settings, env) {
|
|
257
|
+
const e = env || process.env;
|
|
258
|
+
if (e.USAGE_LIMITS_CLOCK === '24h') return '24h';
|
|
259
|
+
if (e.USAGE_LIMITS_CLOCK === '12h') return '12h';
|
|
260
|
+
const format = settings && typeof settings.timeFormat === 'string' ? settings.timeFormat : '';
|
|
261
|
+
return format.indexOf('24') === 0 ? '24h' : '12h';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function motionOff(settings, env) {
|
|
265
|
+
const e = env || process.env;
|
|
266
|
+
const flag = String(e.USAGE_LIMITS_MOTION || '').toLowerCase();
|
|
267
|
+
if (flag === 'off' || flag === '0' || flag === 'false') return true;
|
|
268
|
+
return Boolean(settings && settings.prefersReducedMotion === true);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function main(argv) {
|
|
272
|
+
const env = process.env;
|
|
273
|
+
const now = Date.now();
|
|
274
|
+
let chained = '';
|
|
275
|
+
try {
|
|
276
|
+
usage.setHost(host.detect(argv || [], env));
|
|
277
|
+
const raw = await readStdin();
|
|
278
|
+
let input = null;
|
|
279
|
+
try {
|
|
280
|
+
input = raw.trim() ? JSON.parse(raw) : null;
|
|
281
|
+
} catch (err) {
|
|
282
|
+
input = null;
|
|
283
|
+
}
|
|
284
|
+
if (input && typeof input !== 'object') input = null;
|
|
285
|
+
|
|
286
|
+
const state = statusline.readState();
|
|
287
|
+
if (state && state.chain && state.previous && state.previous.type === 'command' && state.previous.command) {
|
|
288
|
+
chained = runPrevious(state.previous.command, raw, env);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let all = readFeed();
|
|
292
|
+
if (input && input.session_id) {
|
|
293
|
+
all = record(all, input, now);
|
|
294
|
+
writeFeed(all);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const off = String(env.USAGE_LIMITS_STATUSLINE || '').toLowerCase();
|
|
298
|
+
if (off === 'off' || off === '0' || off === 'false') {
|
|
299
|
+
if (chained) process.stdout.write(chained + '\n');
|
|
300
|
+
return 0;
|
|
301
|
+
}
|
|
302
|
+
if (usage.isCodex()) {
|
|
303
|
+
if (chained) process.stdout.write(chained + '\n');
|
|
304
|
+
return 0;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const slot = input && input.session_id ? all[input.session_id] : newest(all);
|
|
308
|
+
const collected = usage.collect(now);
|
|
309
|
+
const settings = settingsFor(configDir());
|
|
310
|
+
const marks = activity.read();
|
|
311
|
+
const seen = activity.summarise(marks, now);
|
|
312
|
+
// The other sessions working right now, so the line can say so.
|
|
313
|
+
const mine = input && input.session_id ? input.session_id : null;
|
|
314
|
+
const othersWorking = activity
|
|
315
|
+
.combine({ marks, feed: all }, now, activity.STALE_MS)
|
|
316
|
+
.filter((row) => row.state === 'working' && row.sessionId !== mine).length;
|
|
317
|
+
const built = view.build({
|
|
318
|
+
now,
|
|
319
|
+
utilization: collected.utilization,
|
|
320
|
+
fetchedAtMs: collected.snapshotFetchedAt,
|
|
321
|
+
source: collected.snapshotSource,
|
|
322
|
+
headers: slot ? slot.rateLimits : null,
|
|
323
|
+
headersAt: slot ? slot.headersAt : null,
|
|
324
|
+
model: slot ? slot.model : null,
|
|
325
|
+
modelName: slot ? slot.modelName : null,
|
|
326
|
+
effort: slot ? slot.effort : null,
|
|
327
|
+
working: isWorking(slot, now) || seen.working,
|
|
328
|
+
ultracode: seen.ultracode || settings.ultracode === true,
|
|
329
|
+
settingsModel: collected.settings ? collected.settings.model : null,
|
|
330
|
+
env,
|
|
331
|
+
});
|
|
332
|
+
const text = line(built, {
|
|
333
|
+
columns: Math.max(20, (Number(env.COLUMNS) || 80) - STATUSLINE_MARGIN),
|
|
334
|
+
// Claude Code captures the output, so stdout is never a TTY here, and
|
|
335
|
+
// ANSI is supported all the same.
|
|
336
|
+
mode: bars.colourMode(env, true),
|
|
337
|
+
tick: Math.floor(now / bars.TICK_MS),
|
|
338
|
+
reduced: motionOff(settings, env),
|
|
339
|
+
ascii: String(env.USAGE_LIMITS_ASCII || '') === '1',
|
|
340
|
+
clock: clockFor(settings, env),
|
|
341
|
+
});
|
|
342
|
+
process.stdout.write((chained ? chained + '\n' : '') + text + '\n');
|
|
343
|
+
return 0;
|
|
344
|
+
} catch (err) {
|
|
345
|
+
if (chained) process.stdout.write(chained + '\n');
|
|
346
|
+
return 0;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
module.exports = {
|
|
351
|
+
KEEP_SESSIONS,
|
|
352
|
+
WORKING_GAP_MS,
|
|
353
|
+
CHAIN_TIMEOUT_MS,
|
|
354
|
+
feedFile,
|
|
355
|
+
readFeed,
|
|
356
|
+
writeFeed,
|
|
357
|
+
slotFrom,
|
|
358
|
+
record,
|
|
359
|
+
newest,
|
|
360
|
+
isWorking,
|
|
361
|
+
line,
|
|
362
|
+
runPrevious,
|
|
363
|
+
clockFor,
|
|
364
|
+
motionOff,
|
|
365
|
+
main,
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
if (require.main === module) {
|
|
369
|
+
main(process.argv.slice(2)).then(
|
|
370
|
+
(code) => {
|
|
371
|
+
process.exitCode = code || 0;
|
|
372
|
+
},
|
|
373
|
+
() => {
|
|
374
|
+
process.exitCode = 0;
|
|
375
|
+
}
|
|
376
|
+
);
|
|
377
|
+
}
|