claude-usage-limits 1.11.0 → 1.11.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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +12 -7
- package/package.json +1 -1
- package/skills/usage-limits/scripts/activity.js +13 -3
- package/skills/usage-limits/scripts/feed.js +45 -22
- package/skills/usage-limits/scripts/live.js +86 -19
- package/skills/usage-limits/scripts/panel.js +112 -25
- package/skills/usage-limits/scripts/view.js +6 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "usage-limits",
|
|
3
3
|
"displayName": "Usage Limits",
|
|
4
|
-
"version": "1.11.
|
|
4
|
+
"version": "1.11.3",
|
|
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",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "usage-limits",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.3",
|
|
4
4
|
"description": "Reports how much of your Codex usage limit is left as turns of work rather than a percentage, prices a job before you start it, and counts the other agents sharing the same budget.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Ridelink",
|
package/README.md
CHANGED
|
@@ -347,6 +347,12 @@ every tool call keeps it so, the Stop hook marks it idle), so the list is live
|
|
|
347
347
|
without anyone polling anything. The status line adds `+1 working` when
|
|
348
348
|
another session is spending.
|
|
349
349
|
|
|
350
|
+
Under the bars it says when the binding window runs out at the pace of the
|
|
351
|
+
last hour, measured from every session's spend, whenever that comes before
|
|
352
|
+
the reset: `at this pace the 5-hour window runs out in 25m, about 12 turns`,
|
|
353
|
+
yellow inside half an hour and red inside ten minutes. The pane rings the
|
|
354
|
+
terminal bell once when a window turns yellow and once more when it turns red.
|
|
355
|
+
|
|
350
356
|
`panel` alone runs it in the current pane, `--once` prints one frame, `--json`
|
|
351
357
|
prints the fields, `--no-fetch` (or `USAGE_LIMITS_FETCH=off`) keeps it
|
|
352
358
|
entirely offline on the reading already on disk, and `--poll N` sets the
|
|
@@ -461,6 +467,7 @@ minute, so it costs about 400ms cold and 120ms warm.
|
|
|
461
467
|
| `USAGE_LIMITS_CLOCK` | from settings | `12h` or `24h` for reset times; otherwise follows Claude Code's `timeFormat`. |
|
|
462
468
|
| `USAGE_LIMITS_COLOUR` | detected | `256` or `none` to override colour detection. `NO_COLOR` and `FORCE_COLOR` are honoured. |
|
|
463
469
|
| `USAGE_LIMITS_ASCII` | off | `1` draws the bars and the spinner with plain characters. |
|
|
470
|
+
| `USAGE_LIMITS_BELL` | on | `off` silences the panel's terminal bell when a window turns yellow or red (`--no-bell` does the same). |
|
|
464
471
|
|
|
465
472
|
## What a session cost
|
|
466
473
|
|
|
@@ -666,12 +673,10 @@ allowance and never quotes a price, so the percentages stand alone. And
|
|
|
666
673
|
|
|
667
674
|
The Claude Code extension for VS Code shows the limits only when you ask with
|
|
668
675
|
`/usage`, and it does not render a custom status line. So there is an
|
|
669
|
-
extension of its own in [vscode/](vscode/): the same bars as
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
and red at the same thresholds, the Sessions list, and the same animations in
|
|
674
|
-
CSS. It carries the plugin's scripts inside it, so it has no dependencies and
|
|
676
|
+
extension of its own in [vscode/](vscode/): the same bars as one full-height
|
|
677
|
+
view in the right sidebar, beside the chat, opened for you when VS Code
|
|
678
|
+
starts, with the Sessions list and the same animations in CSS. Nothing at the
|
|
679
|
+
bottom unless you turn `claudeUsageLimits.statusBar` on. It carries the plugin's scripts inside it, so it has no dependencies and
|
|
675
680
|
reads the same files and takes the same reading as the terminal panel.
|
|
676
681
|
|
|
677
682
|
```
|
|
@@ -859,7 +864,7 @@ test/ node --test, no dependencies
|
|
|
859
864
|
node --test
|
|
860
865
|
```
|
|
861
866
|
|
|
862
|
-
|
|
867
|
+
477 tests over the pricing, the window arithmetic, plan and credit detection,
|
|
863
868
|
the status line, the before-prompt line, the mid-turn pulse, the after-reply tally and the session history, job forecasting,
|
|
864
869
|
per-project attribution, the Codex reader and its installer, the CLI,
|
|
865
870
|
packaging, and the settings save/restore.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-usage-limits",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.3",
|
|
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",
|
|
@@ -68,10 +68,20 @@ function mark(state, sessionId, extra, now) {
|
|
|
68
68
|
else if (previous.model) entry.model = previous.model;
|
|
69
69
|
all[sessionId || '_'] = entry;
|
|
70
70
|
const file = activityFile();
|
|
71
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
72
71
|
const temp = file + '.' + process.pid + '.usage-limits-tmp';
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
try {
|
|
73
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
74
|
+
fs.writeFileSync(temp, JSON.stringify(trim(all)), 'utf8');
|
|
75
|
+
fs.renameSync(temp, file);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
// A rename Windows refused leaves nothing behind.
|
|
78
|
+
try {
|
|
79
|
+
fs.unlinkSync(temp);
|
|
80
|
+
} catch (gone) {
|
|
81
|
+
// Nothing to clean up.
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
75
85
|
return true;
|
|
76
86
|
} catch (err) {
|
|
77
87
|
return false;
|
|
@@ -26,6 +26,7 @@ const bars = require('./bars.js');
|
|
|
26
26
|
const view = require('./view.js');
|
|
27
27
|
const activity = require('./activity.js');
|
|
28
28
|
const statusline = require('./statusline.js');
|
|
29
|
+
const live = require('./live.js');
|
|
29
30
|
|
|
30
31
|
const KEEP_SESSIONS = 8;
|
|
31
32
|
// Two updates this close together mean Claude is mid-turn.
|
|
@@ -69,16 +70,24 @@ function readFeed() {
|
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
function writeFeed(all) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
73
|
+
return live.writeAtomic(feedFile(), JSON.stringify(all));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Two updates a few seconds apart mean a turn in progress, unless the status
|
|
77
|
+
// line is on a timer that fires that often anyway, in which case the gap says
|
|
78
|
+
// nothing and only the hooks' marks do.
|
|
79
|
+
function gapMeansWorking(settings) {
|
|
80
|
+
const line = settings && settings.statusLine;
|
|
81
|
+
const every = line && Number.isFinite(line.refreshInterval) ? line.refreshInterval * 1000 : null;
|
|
82
|
+
return !(every !== null && every <= WORKING_GAP_MS);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// This session's own mark, from the hooks. Another window working must not
|
|
86
|
+
// spin this one's line.
|
|
87
|
+
function ownState(marks, sessionId, now) {
|
|
88
|
+
const mine = sessionId && marks ? marks[sessionId] : null;
|
|
89
|
+
if (!mine || !Number.isFinite(mine.at) || now - mine.at > activity.STALE_MS) return { working: false, ultracode: false };
|
|
90
|
+
return { working: mine.state === 'working', ultracode: Boolean(mine.ultracode) };
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
function number(value) {
|
|
@@ -231,13 +240,13 @@ function readStdin() {
|
|
|
231
240
|
|
|
232
241
|
// The status line that was there before ours, run with the same stdin, so
|
|
233
242
|
// installing this one loses nothing.
|
|
234
|
-
function runPrevious(command, raw, env) {
|
|
243
|
+
function runPrevious(command, raw, env, budgetMs) {
|
|
235
244
|
try {
|
|
236
245
|
const result = spawnSync(command, {
|
|
237
246
|
shell: true,
|
|
238
247
|
input: raw,
|
|
239
248
|
encoding: 'utf8',
|
|
240
|
-
timeout: CHAIN_TIMEOUT_MS,
|
|
249
|
+
timeout: Math.max(300, Number.isFinite(budgetMs) ? budgetMs : CHAIN_TIMEOUT_MS),
|
|
241
250
|
env: env || process.env,
|
|
242
251
|
windowsHide: true,
|
|
243
252
|
stdio: ['pipe', 'pipe', 'ignore'],
|
|
@@ -268,9 +277,18 @@ function motionOff(settings, env) {
|
|
|
268
277
|
return Boolean(settings && settings.prefersReducedMotion === true);
|
|
269
278
|
}
|
|
270
279
|
|
|
280
|
+
// Written and flushed before the process is allowed to end: stdout is a pipe
|
|
281
|
+
// here, and a pipe write can still be in flight when process.exit runs.
|
|
282
|
+
function out(text) {
|
|
283
|
+
return new Promise((resolve) => {
|
|
284
|
+
process.stdout.write(text, () => resolve());
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
271
288
|
async function main(argv) {
|
|
272
289
|
const env = process.env;
|
|
273
|
-
const
|
|
290
|
+
const started = Date.now();
|
|
291
|
+
const now = started;
|
|
274
292
|
let chained = '';
|
|
275
293
|
try {
|
|
276
294
|
usage.setHost(host.detect(argv || [], env));
|
|
@@ -285,7 +303,8 @@ async function main(argv) {
|
|
|
285
303
|
|
|
286
304
|
const state = statusline.readState();
|
|
287
305
|
if (state && state.chain && state.previous && state.previous.type === 'command' && state.previous.command) {
|
|
288
|
-
|
|
306
|
+
// Whatever the stdin wait used comes out of the previous line's time.
|
|
307
|
+
chained = runPrevious(state.previous.command, raw, env, CHAIN_TIMEOUT_MS - (Date.now() - started));
|
|
289
308
|
}
|
|
290
309
|
|
|
291
310
|
let all = readFeed();
|
|
@@ -296,11 +315,11 @@ async function main(argv) {
|
|
|
296
315
|
|
|
297
316
|
const off = String(env.USAGE_LIMITS_STATUSLINE || '').toLowerCase();
|
|
298
317
|
if (off === 'off' || off === '0' || off === 'false') {
|
|
299
|
-
if (chained)
|
|
318
|
+
if (chained) await out(chained + '\n');
|
|
300
319
|
return 0;
|
|
301
320
|
}
|
|
302
321
|
if (usage.isCodex()) {
|
|
303
|
-
if (chained)
|
|
322
|
+
if (chained) await out(chained + '\n');
|
|
304
323
|
return 0;
|
|
305
324
|
}
|
|
306
325
|
|
|
@@ -308,9 +327,11 @@ async function main(argv) {
|
|
|
308
327
|
const collected = usage.collect(now);
|
|
309
328
|
const settings = settingsFor(configDir());
|
|
310
329
|
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
330
|
const mine = input && input.session_id ? input.session_id : null;
|
|
331
|
+
// This session's own state; only with no session id at all does the
|
|
332
|
+
// machine-wide picture stand in for it.
|
|
333
|
+
const own = mine ? ownState(marks, mine, now) : activity.summarise(marks, now);
|
|
334
|
+
// The other sessions working right now, so the line can say so.
|
|
314
335
|
const othersWorking = activity
|
|
315
336
|
.combine({ marks, feed: all }, now, activity.STALE_MS)
|
|
316
337
|
.filter((row) => row.state === 'working' && row.sessionId !== mine).length;
|
|
@@ -324,8 +345,8 @@ async function main(argv) {
|
|
|
324
345
|
model: slot ? slot.model : null,
|
|
325
346
|
modelName: slot ? slot.modelName : null,
|
|
326
347
|
effort: slot ? slot.effort : null,
|
|
327
|
-
working: isWorking(slot, now)
|
|
328
|
-
ultracode:
|
|
348
|
+
working: own.working || (gapMeansWorking(settings) && isWorking(slot, now)),
|
|
349
|
+
ultracode: own.ultracode || settings.ultracode === true,
|
|
329
350
|
settingsModel: collected.settings ? collected.settings.model : null,
|
|
330
351
|
env,
|
|
331
352
|
});
|
|
@@ -339,10 +360,10 @@ async function main(argv) {
|
|
|
339
360
|
ascii: String(env.USAGE_LIMITS_ASCII || '') === '1',
|
|
340
361
|
clock: clockFor(settings, env),
|
|
341
362
|
});
|
|
342
|
-
|
|
363
|
+
await out((chained ? chained + '\n' : '') + text + '\n');
|
|
343
364
|
return 0;
|
|
344
365
|
} catch (err) {
|
|
345
|
-
if (chained)
|
|
366
|
+
if (chained) await out(chained + '\n');
|
|
346
367
|
return 0;
|
|
347
368
|
}
|
|
348
369
|
}
|
|
@@ -358,6 +379,8 @@ module.exports = {
|
|
|
358
379
|
record,
|
|
359
380
|
newest,
|
|
360
381
|
isWorking,
|
|
382
|
+
gapMeansWorking,
|
|
383
|
+
ownState,
|
|
361
384
|
line,
|
|
362
385
|
runPrevious,
|
|
363
386
|
clockFor,
|
|
@@ -105,12 +105,21 @@ function readToken(options) {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
if (platform === 'darwin') {
|
|
108
|
+
// The keychain is a child process each time, so a long-lived panel or the
|
|
109
|
+
// VS Code extension remembers the answer for a few minutes.
|
|
110
|
+
const nowMs = Date.now();
|
|
111
|
+
// Only the real keychain is remembered; an injected reader (the tests)
|
|
112
|
+
// is asked every time.
|
|
113
|
+
const remember = opts.cache !== false && !opts.exec;
|
|
114
|
+
if (remember && keychainMemo && nowMs - keychainMemo.at < KEYCHAIN_MEMO_MS) return keychainMemo.result;
|
|
108
115
|
const exec = opts.exec || defaultExec;
|
|
109
116
|
try {
|
|
110
117
|
const out = exec(['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w']);
|
|
111
118
|
const parsed = parseCredentials(String(out || '').trim());
|
|
112
119
|
if (parsed && parsed.token) {
|
|
113
|
-
|
|
120
|
+
const result = { token: parsed.token, expiresAt: parsed.expiresAt, source: 'keychain' };
|
|
121
|
+
if (remember) keychainMemo = { at: nowMs, result };
|
|
122
|
+
return result;
|
|
114
123
|
}
|
|
115
124
|
} catch (err) {
|
|
116
125
|
// No keychain entry, or no permission to read it: same answer as no file.
|
|
@@ -120,6 +129,34 @@ function readToken(options) {
|
|
|
120
129
|
return { token: null, reason: 'no_credentials' };
|
|
121
130
|
}
|
|
122
131
|
|
|
132
|
+
let keychainMemo = null;
|
|
133
|
+
const KEYCHAIN_MEMO_MS = 5 * MINUTE;
|
|
134
|
+
|
|
135
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
|
|
136
|
+
|
|
137
|
+
function isLoopback(hostname) {
|
|
138
|
+
return LOOPBACK.has(String(hostname || '').toLowerCase());
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Where the login may be sent: Anthropic over https, or this machine (the
|
|
142
|
+
// tests run a stub on a loopback port). A project's settings file can put
|
|
143
|
+
// anything into the environment of a hook, and USAGE_LIMITS_USAGE_URL must
|
|
144
|
+
// not be a way to walk off with the token.
|
|
145
|
+
function allowedUrl(raw) {
|
|
146
|
+
if (!raw) return null;
|
|
147
|
+
let url;
|
|
148
|
+
try {
|
|
149
|
+
url = new URL(String(raw));
|
|
150
|
+
} catch (err) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
if (isLoopback(url.hostname)) return url.toString();
|
|
154
|
+
if (url.protocol !== 'https:') return null;
|
|
155
|
+
const host = url.hostname.toLowerCase();
|
|
156
|
+
if (host === 'anthropic.com' || host.endsWith('.anthropic.com')) return url.toString();
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
123
160
|
function bad(kind, status, retryAfterMs, message) {
|
|
124
161
|
return {
|
|
125
162
|
ok: false,
|
|
@@ -150,12 +187,11 @@ function fetchUsage(options) {
|
|
|
150
187
|
return new Promise((resolve) => {
|
|
151
188
|
if (!opts.token) return resolve(bad('no_credentials', null, null, 'no Claude login found'));
|
|
152
189
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
} catch (err) {
|
|
157
|
-
return resolve(bad('bad_response', null, null, 'the usage url is not a url'));
|
|
190
|
+
const permitted = allowedUrl(opts.url || USAGE_URL);
|
|
191
|
+
if (!permitted) {
|
|
192
|
+
return resolve(bad('bad_response', null, null, 'refusing to send the login anywhere but Anthropic over https'));
|
|
158
193
|
}
|
|
194
|
+
const url = new URL(permitted);
|
|
159
195
|
const client = url.protocol === 'http:' ? http : https;
|
|
160
196
|
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
161
197
|
|
|
@@ -242,6 +278,7 @@ function nextDelayMs(outcome, previousMs, options) {
|
|
|
242
278
|
? Math.min(Math.max(outcome.retryAfterMs, 1000), 10 * MINUTE)
|
|
243
279
|
: 60 * 1000;
|
|
244
280
|
case 'unauthorized':
|
|
281
|
+
case 'expired':
|
|
245
282
|
case 'forbidden':
|
|
246
283
|
case 'no_credentials':
|
|
247
284
|
return 30 * 1000;
|
|
@@ -263,6 +300,8 @@ function describe(outcome) {
|
|
|
263
300
|
return 'offline';
|
|
264
301
|
case 'unauthorized':
|
|
265
302
|
return 'sign in to Claude Code again';
|
|
303
|
+
case 'expired':
|
|
304
|
+
return 'login expired, Claude Code renews it on its next call';
|
|
266
305
|
case 'forbidden':
|
|
267
306
|
return 'usage not available for this login';
|
|
268
307
|
case 'no_credentials':
|
|
@@ -287,20 +326,30 @@ function readLive() {
|
|
|
287
326
|
return parsed;
|
|
288
327
|
}
|
|
289
328
|
|
|
290
|
-
// Through a temporary file, so a reader never sees
|
|
291
|
-
|
|
329
|
+
// Through a temporary file named for this process, so a reader never sees
|
|
330
|
+
// half a reading and two writers never share a temp file. A rename that fails
|
|
331
|
+
// (Windows, with the target held open) leaves nothing behind.
|
|
332
|
+
function writeAtomic(file, text) {
|
|
333
|
+
const temp = file + '.' + process.pid + '.usage-limits-tmp';
|
|
292
334
|
try {
|
|
293
|
-
const file = liveFile();
|
|
294
335
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
295
|
-
|
|
296
|
-
fs.writeFileSync(temp, JSON.stringify(snapshot), 'utf8');
|
|
336
|
+
fs.writeFileSync(temp, text, 'utf8');
|
|
297
337
|
fs.renameSync(temp, file);
|
|
298
338
|
return true;
|
|
299
339
|
} catch (err) {
|
|
340
|
+
try {
|
|
341
|
+
fs.unlinkSync(temp);
|
|
342
|
+
} catch (gone) {
|
|
343
|
+
// Nothing to clean up.
|
|
344
|
+
}
|
|
300
345
|
return false;
|
|
301
346
|
}
|
|
302
347
|
}
|
|
303
348
|
|
|
349
|
+
function writeLive(snapshot) {
|
|
350
|
+
return writeAtomic(liveFile(), JSON.stringify(snapshot));
|
|
351
|
+
}
|
|
352
|
+
|
|
304
353
|
function fetchDisabled(env) {
|
|
305
354
|
return String((env || process.env).USAGE_LIMITS_FETCH || '').toLowerCase() === 'off';
|
|
306
355
|
}
|
|
@@ -320,9 +369,14 @@ async function refresh(options) {
|
|
|
320
369
|
snapshot: readLive(),
|
|
321
370
|
};
|
|
322
371
|
}
|
|
372
|
+
// A token past its expiry gets a 401 and nothing else. Claude Code renews it
|
|
373
|
+
// on its own next call, so do not send it, and say what is being waited for.
|
|
374
|
+
if (Number.isFinite(creds.expiresAt) && creds.expiresAt < (Number.isFinite(opts.now) ? opts.now : Date.now())) {
|
|
375
|
+
return { outcome: bad('expired', null, null, 'the login has expired; Claude Code renews it on its next call'), snapshot: readLive() };
|
|
376
|
+
}
|
|
323
377
|
const outcome = await fetchUsage({
|
|
324
378
|
token: creds.token,
|
|
325
|
-
url: opts.url || env.USAGE_LIMITS_USAGE_URL || USAGE_URL,
|
|
379
|
+
url: allowedUrl(opts.url || env.USAGE_LIMITS_USAGE_URL) || USAGE_URL,
|
|
326
380
|
timeoutMs: opts.timeoutMs,
|
|
327
381
|
});
|
|
328
382
|
if (!outcome.ok) return { outcome, snapshot: readLive() };
|
|
@@ -352,11 +406,9 @@ function readAttempt() {
|
|
|
352
406
|
}
|
|
353
407
|
|
|
354
408
|
function writeAttempt(record) {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
// Without it the next caller merely tries again a little sooner.
|
|
359
|
-
}
|
|
409
|
+
// Atomic, so a concurrent reader never parses half a record and skips the
|
|
410
|
+
// backoff by accident.
|
|
411
|
+
writeAtomic(attemptFile(), JSON.stringify(record));
|
|
360
412
|
}
|
|
361
413
|
|
|
362
414
|
// A reading only when the one on disk has aged. This is what the hooks call:
|
|
@@ -381,10 +433,19 @@ async function refreshIfStale(options) {
|
|
|
381
433
|
if (newestAt > 0 && now - newestAt < maxAgeMs) return { outcome: null, snapshot: current, skipped: 'fresh' };
|
|
382
434
|
|
|
383
435
|
const attempt = readAttempt();
|
|
384
|
-
|
|
436
|
+
// A record from a clock that was ahead would hold the backoff until the
|
|
437
|
+
// wall clock caught up with it; a minute of skew is all that is honoured.
|
|
438
|
+
const sinceAttempt = attempt ? now - attempt.attemptedAtMs : 0;
|
|
439
|
+
if (attempt && Number.isFinite(attempt.delayMs) && sinceAttempt >= -MINUTE && sinceAttempt < attempt.delayMs) {
|
|
385
440
|
return { outcome: null, snapshot: current, skipped: 'backoff' };
|
|
386
441
|
}
|
|
387
442
|
|
|
443
|
+
// Claim the attempt before making it, so the other hooks that fire in the
|
|
444
|
+
// same second (parallel agents, several windows) skip rather than each
|
|
445
|
+
// sending its own request and each waiting out its own timeout.
|
|
446
|
+
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
447
|
+
writeAttempt({ attemptedAtMs: now, delayMs: timeoutMs, kind: 'inflight' });
|
|
448
|
+
|
|
388
449
|
const result = await refresh({
|
|
389
450
|
now,
|
|
390
451
|
accountUuid: opts.accountUuid,
|
|
@@ -395,7 +456,10 @@ async function refreshIfStale(options) {
|
|
|
395
456
|
});
|
|
396
457
|
writeAttempt({
|
|
397
458
|
attemptedAtMs: now,
|
|
398
|
-
delayMs: nextDelayMs(result.outcome, attempt ? attempt.delayMs : 0, {
|
|
459
|
+
delayMs: nextDelayMs(result.outcome, attempt && attempt.kind !== 'inflight' ? attempt.delayMs : 0, {
|
|
460
|
+
baseMs: maxAgeMs,
|
|
461
|
+
maxMs: 10 * MINUTE,
|
|
462
|
+
}),
|
|
399
463
|
kind: result.outcome.ok ? 'ok' : result.outcome.kind,
|
|
400
464
|
});
|
|
401
465
|
return { outcome: result.outcome, snapshot: result.snapshot, skipped: null };
|
|
@@ -409,6 +473,9 @@ module.exports = {
|
|
|
409
473
|
credentialsFile,
|
|
410
474
|
liveFile,
|
|
411
475
|
attemptFile,
|
|
476
|
+
allowedUrl,
|
|
477
|
+
isLoopback,
|
|
478
|
+
writeAtomic,
|
|
412
479
|
userAgent,
|
|
413
480
|
readToken,
|
|
414
481
|
fetchUsage,
|
|
@@ -47,6 +47,7 @@ const FILE_CHECK_MS = SECOND;
|
|
|
47
47
|
const FRAME_MS = 100;
|
|
48
48
|
const MIN_COLUMNS = 24;
|
|
49
49
|
const TITLE = 'Claude usage';
|
|
50
|
+
const LEVEL_RANK = { fill: 0, warning: 1, error: 2 };
|
|
50
51
|
|
|
51
52
|
const HELP = `claude-usage-limits panel - live limits in a pane beside the chat
|
|
52
53
|
|
|
@@ -58,6 +59,7 @@ const HELP = `claude-usage-limits panel - live limits in a pane beside the chat
|
|
|
58
59
|
panel --poll 45 seconds between readings (default 30 working, 120 idle)
|
|
59
60
|
panel --width 40 draw for this many columns instead of the terminal's
|
|
60
61
|
panel --ascii plain characters instead of block glyphs
|
|
62
|
+
panel --no-bell no terminal bell when a window turns yellow or red
|
|
61
63
|
|
|
62
64
|
Shows the current session (5-hour) window, the current week, and the week for
|
|
63
65
|
the model in use when the account caps that model on its own. Bars turn yellow
|
|
@@ -88,12 +90,14 @@ function parseArgs(argv) {
|
|
|
88
90
|
width: null,
|
|
89
91
|
help: false,
|
|
90
92
|
ascii: String(process.env.USAGE_LIMITS_ASCII || '') === '1',
|
|
93
|
+
bell: true,
|
|
91
94
|
hostName: null,
|
|
92
95
|
};
|
|
93
96
|
const list = argv || [];
|
|
94
97
|
for (let i = 0; i < list.length; i += 1) {
|
|
95
98
|
const arg = list[i];
|
|
96
99
|
if (arg === '--once') args.once = true;
|
|
100
|
+
else if (arg === '--no-bell') args.bell = false;
|
|
97
101
|
else if (arg === '--json') args.json = true;
|
|
98
102
|
else if (arg === '--open') args.open = true;
|
|
99
103
|
else if (arg === '--no-fetch') args.fetch = false;
|
|
@@ -182,6 +186,11 @@ async function snapshot(options) {
|
|
|
182
186
|
built.sessionsList = onCodex ? [] : loadSessions(now);
|
|
183
187
|
built.sessions = onCodex ? 0 : Math.max(built.sessionsList.length, brief.liveSessions(brief.readCache(), now, brief.LIVE_WINDOW_MS, null));
|
|
184
188
|
built.othersWorking = built.sessionsList.filter((row) => row.state === 'working').length;
|
|
189
|
+
// What the pace says: when the binding window runs out at the current rate,
|
|
190
|
+
// if that comes before its reset. The transcript scan behind it belongs to
|
|
191
|
+
// the report, so it is taken with the readings, not with every frame; the
|
|
192
|
+
// frames in between carry the last answer forward.
|
|
193
|
+
built.pace = opts.pace !== undefined ? opts.pace : await paceOf(now);
|
|
185
194
|
// Whether the panel is allowed the network at all, which is what the footer
|
|
186
195
|
// reports. A frame rebuilt from disk between readings is not "network off".
|
|
187
196
|
built.fetch = opts.network !== undefined ? Boolean(opts.network) : Boolean(opts.fetch);
|
|
@@ -189,6 +198,28 @@ async function snapshot(options) {
|
|
|
189
198
|
return built;
|
|
190
199
|
}
|
|
191
200
|
|
|
201
|
+
// The report's runway: how long the binding window lasts at the pace of the
|
|
202
|
+
// last hour, measured from every session's spend. The percentage says where
|
|
203
|
+
// you are; this says when you hit the wall, which is the number the rival
|
|
204
|
+
// monitors lead with and the one that matters on a busy afternoon.
|
|
205
|
+
async function paceOf(now) {
|
|
206
|
+
try {
|
|
207
|
+
const data = await usage.report(now, {});
|
|
208
|
+
const binding = data && data.binding;
|
|
209
|
+
if (!binding || !Number.isFinite(binding.headroomMs)) return null;
|
|
210
|
+
const resetsInMs = Number.isFinite(binding.resetsAt) ? binding.resetsAt - now : null;
|
|
211
|
+
return {
|
|
212
|
+
label: binding.label,
|
|
213
|
+
headroomMs: binding.headroomMs,
|
|
214
|
+
resetsInMs,
|
|
215
|
+
turnsLeft: Number.isFinite(binding.turnsLeft) ? binding.turnsLeft : null,
|
|
216
|
+
runsOut: binding.verdict === 'runs-out' || (resetsInMs !== null && binding.headroomMs < resetsInMs),
|
|
217
|
+
};
|
|
218
|
+
} catch (err) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
192
223
|
// Codex has no hooks to say when it is working, but it appends to its rollout
|
|
193
224
|
// file as it goes, so a rollout touched in the last few seconds is a turn in
|
|
194
225
|
// progress.
|
|
@@ -252,6 +283,13 @@ function fit(text, width) {
|
|
|
252
283
|
return out + '\x1b[0m';
|
|
253
284
|
}
|
|
254
285
|
|
|
286
|
+
// In a narrow pane the titles are the short ones.
|
|
287
|
+
function shortTitle(row) {
|
|
288
|
+
if (row.key === 'five_hour') return 'Session';
|
|
289
|
+
if (row.key === 'seven_day') return 'Week';
|
|
290
|
+
return String(row.title).replace(/^Current week /, 'Week ').replace(/^Current /, '');
|
|
291
|
+
}
|
|
292
|
+
|
|
255
293
|
function subline(row, mode, opts) {
|
|
256
294
|
if (row.stale) return bars.dim('window rolled over, taking a fresh reading', mode);
|
|
257
295
|
if (row.unreported) return bars.dim('not reported yet, run /usage in Claude Code', mode);
|
|
@@ -281,7 +319,7 @@ function stateLine(built, mode) {
|
|
|
281
319
|
function noteColour(built) {
|
|
282
320
|
const kind = built.outcome && !built.outcome.ok ? built.outcome.kind : null;
|
|
283
321
|
if (kind === 'unauthorized' || kind === 'forbidden' || kind === 'no_credentials') return bars.THEME.error;
|
|
284
|
-
if (kind === 'offline' || kind === 'rate_limited' || kind === 'server' || kind === 'http' || kind === 'bad_response') {
|
|
322
|
+
if (kind === 'offline' || kind === 'expired' || kind === 'rate_limited' || kind === 'server' || kind === 'http' || kind === 'bad_response') {
|
|
285
323
|
return bars.THEME.warning;
|
|
286
324
|
}
|
|
287
325
|
return null;
|
|
@@ -291,7 +329,10 @@ function noteColour(built) {
|
|
|
291
329
|
// dropping breathing room first and footers second.
|
|
292
330
|
function render(built, options) {
|
|
293
331
|
const opts = options || {};
|
|
294
|
-
|
|
332
|
+
// Laid out for at least MIN_COLUMNS, but cut to the width that really
|
|
333
|
+
// exists: a line wider than the pane wraps, and a wrapped frame scrolls.
|
|
334
|
+
const real = Number.isFinite(opts.columns) ? Math.max(1, Math.floor(opts.columns)) : 40;
|
|
335
|
+
const columns = Math.max(MIN_COLUMNS, real);
|
|
295
336
|
const height = Number.isFinite(opts.rows) ? Math.floor(opts.rows) : null;
|
|
296
337
|
const mode = opts.mode || 'none';
|
|
297
338
|
const tick = Number.isFinite(opts.tick) ? opts.tick : 0;
|
|
@@ -333,7 +374,7 @@ function render(built, options) {
|
|
|
333
374
|
row.level === 'fill' ? row.percentText : bars.paint(row.percentText, bars.levelColour(row.level), mode);
|
|
334
375
|
body.push({
|
|
335
376
|
lines: [
|
|
336
|
-
bars.bold(row.title, mode),
|
|
377
|
+
bars.bold(columns < 34 ? shortTitle(row) : row.title, mode),
|
|
337
378
|
(row.percent === null ? bars.paint((ascii ? '-' : '░').repeat(barWidth), bars.THEME.empty, mode) : bars.bar(row.percent, barWidth, { mode, level: row.level, ascii })) +
|
|
338
379
|
' ' +
|
|
339
380
|
percent,
|
|
@@ -379,6 +420,13 @@ function render(built, options) {
|
|
|
379
420
|
const colour = noteColour(built);
|
|
380
421
|
footer.push(colour ? bars.paint(built.note, colour, mode) : bars.dim(built.note, mode));
|
|
381
422
|
}
|
|
423
|
+
if (built.pace && built.pace.runsOut && Number.isFinite(built.pace.headroomMs)) {
|
|
424
|
+
const text =
|
|
425
|
+
'at this pace the ' + built.pace.label + ' window runs out in ' + usage.formatDuration(built.pace.headroomMs) +
|
|
426
|
+
(Number.isFinite(built.pace.turnsLeft) ? ', about ' + built.pace.turnsLeft + ' turns' : '');
|
|
427
|
+
const colour = built.pace.headroomMs < 10 * MINUTE ? bars.THEME.error : built.pace.headroomMs < 30 * MINUTE ? bars.THEME.warning : null;
|
|
428
|
+
footer.push(colour ? bars.paint(text, colour, mode) : bars.dim(text, mode));
|
|
429
|
+
}
|
|
382
430
|
footer.push(stateLine(built, mode));
|
|
383
431
|
if (opts.interactive !== false) footer.push(bars.dim('q quit · r refresh', mode));
|
|
384
432
|
|
|
@@ -399,7 +447,7 @@ function render(built, options) {
|
|
|
399
447
|
if (height !== null && lines.length > height) lines = compose(false, true);
|
|
400
448
|
if (height !== null && lines.length > height) lines = compose(false, false);
|
|
401
449
|
if (height !== null && lines.length > height) lines = lines.slice(0, height);
|
|
402
|
-
return lines.map((line) => fit(line,
|
|
450
|
+
return lines.map((line) => fit(line, real));
|
|
403
451
|
}
|
|
404
452
|
|
|
405
453
|
function quote(value) {
|
|
@@ -408,7 +456,7 @@ function quote(value) {
|
|
|
408
456
|
|
|
409
457
|
// How to put the panel in a pane to the right of the current one, for the
|
|
410
458
|
// terminals that can be told to. Pure, so the table can be tested.
|
|
411
|
-
function openCommand(env, panelPath, nodePath, platform, extraArgs) {
|
|
459
|
+
function openCommand(env, panelPath, nodePath, platform, extraArgs, options) {
|
|
412
460
|
const e = env || process.env;
|
|
413
461
|
const os = platform || process.platform;
|
|
414
462
|
const node = nodePath || process.execPath;
|
|
@@ -417,23 +465,26 @@ function openCommand(env, panelPath, nodePath, platform, extraArgs) {
|
|
|
417
465
|
const cmd = [quote(node), quote(panel)].concat(extra.map(quote)).join(' ');
|
|
418
466
|
|
|
419
467
|
if (e.TMUX) {
|
|
468
|
+
// A percentage on -l arrived in tmux 3.1; older ones want the old -p.
|
|
469
|
+
const version = options && options.tmuxVersion ? String(options.tmuxVersion).match(/(\d+)\.(\d+)/) : null;
|
|
470
|
+
const old = version && (Number(version[1]) < 3 || (Number(version[1]) === 3 && Number(version[2]) < 1));
|
|
420
471
|
return {
|
|
421
472
|
program: 'tmux',
|
|
422
|
-
args: ['split-window', '-h', '-d', '-l', '
|
|
473
|
+
args: ['split-window', '-h', '-d'].concat(old ? ['-p', '24'] : ['-l', '24%'], [cmd]),
|
|
423
474
|
note: 'opened a pane to the right in tmux',
|
|
424
475
|
};
|
|
425
476
|
}
|
|
426
477
|
if (e.WEZTERM_PANE) {
|
|
427
478
|
return {
|
|
428
479
|
program: 'wezterm',
|
|
429
|
-
args: ['cli', 'split-pane', '--right', '--percent', '
|
|
480
|
+
args: ['cli', 'split-pane', '--right', '--percent', '24', '--', node, panel].concat(extra),
|
|
430
481
|
note: 'opened a pane to the right in WezTerm',
|
|
431
482
|
};
|
|
432
483
|
}
|
|
433
484
|
if (e.KITTY_WINDOW_ID) {
|
|
434
485
|
return {
|
|
435
486
|
program: 'kitten',
|
|
436
|
-
args: ['@', 'launch', '--location=vsplit', '--bias=
|
|
487
|
+
args: ['@', 'launch', '--location=vsplit', '--bias=24', '--cwd=current', node, panel].concat(extra),
|
|
437
488
|
note: 'opened a pane to the right in kitty (needs allow_remote_control)',
|
|
438
489
|
};
|
|
439
490
|
}
|
|
@@ -448,7 +499,7 @@ function openCommand(env, panelPath, nodePath, platform, extraArgs) {
|
|
|
448
499
|
if (e.WT_SESSION) {
|
|
449
500
|
return {
|
|
450
501
|
command:
|
|
451
|
-
'start "" wt.exe -w 0 sp -V --size 0.
|
|
502
|
+
'start "" wt.exe -w 0 sp -V --size 0.24 --title "Claude usage" --suppressApplicationTitle ' + cmd,
|
|
452
503
|
shell: true,
|
|
453
504
|
note: 'opened a pane to the right in Windows Terminal',
|
|
454
505
|
};
|
|
@@ -468,8 +519,20 @@ function openCommand(env, panelPath, nodePath, platform, extraArgs) {
|
|
|
468
519
|
return null;
|
|
469
520
|
}
|
|
470
521
|
|
|
522
|
+
function tmuxVersion() {
|
|
523
|
+
try {
|
|
524
|
+
const result = require('child_process').spawnSync('tmux', ['-V'], { encoding: 'utf8', timeout: 2000, windowsHide: true });
|
|
525
|
+
return result && result.stdout ? String(result.stdout).trim() : null;
|
|
526
|
+
} catch (err) {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
471
531
|
function openPanel(env, extraArgs) {
|
|
472
|
-
const
|
|
532
|
+
const e = env || process.env;
|
|
533
|
+
const plan = openCommand(e, __filename, process.execPath, process.platform, extraArgs, {
|
|
534
|
+
tmuxVersion: e.TMUX ? tmuxVersion() : null,
|
|
535
|
+
});
|
|
473
536
|
if (!plan) {
|
|
474
537
|
process.stdout.write(
|
|
475
538
|
'This terminal cannot be told to split. Open a second pane to the right and run:\n ' +
|
|
@@ -521,8 +584,10 @@ async function interactive(args) {
|
|
|
521
584
|
const clock = feed.clockFor(settings, env);
|
|
522
585
|
const mode = bars.colourMode(env, out.isTTY);
|
|
523
586
|
const fetch = args.fetch && !live.fetchDisabled(env);
|
|
587
|
+
const bell = args.bell && String(env.USAGE_LIMITS_BELL || '').toLowerCase() !== 'off';
|
|
524
588
|
|
|
525
589
|
const state = {
|
|
590
|
+
levels: {},
|
|
526
591
|
built: null,
|
|
527
592
|
outcome: null,
|
|
528
593
|
lastFetchAt: 0,
|
|
@@ -601,27 +666,49 @@ async function interactive(args) {
|
|
|
601
666
|
const now = Date.now();
|
|
602
667
|
const due = fetch && !state.fetching && now - state.lastFetchAt >= state.delayMs;
|
|
603
668
|
if (due) {
|
|
669
|
+
// The reading runs beside the frames, never in front of them: a slow
|
|
670
|
+
// network must not freeze the spinner or the countdown.
|
|
604
671
|
state.fetching = true;
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
672
|
+
snapshot({ fetch: true, network: fetch, env, now })
|
|
673
|
+
.then((built) => {
|
|
674
|
+
state.built = built;
|
|
675
|
+
state.outcome = built.outcome;
|
|
676
|
+
})
|
|
677
|
+
.catch((err) => {
|
|
678
|
+
state.outcome = { ok: false, kind: 'bad_response', message: err && err.message ? err.message : String(err) };
|
|
679
|
+
})
|
|
680
|
+
.then(() => {
|
|
681
|
+
state.lastFetchAt = Date.now();
|
|
682
|
+
state.delayMs = live.nextDelayMs(state.outcome, state.delayMs, {
|
|
683
|
+
baseMs: pollBase(state.built, args, env),
|
|
684
|
+
maxMs: POLL_IDLE_MS,
|
|
685
|
+
});
|
|
686
|
+
state.fetching = false;
|
|
687
|
+
state.dirty = true;
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
if (!state.built || now - state.lastCheck >= FILE_CHECK_MS) {
|
|
619
691
|
state.lastCheck = now;
|
|
620
692
|
try {
|
|
621
|
-
state.built = await snapshot({
|
|
693
|
+
state.built = await snapshot({
|
|
694
|
+
fetch: false,
|
|
695
|
+
network: fetch,
|
|
696
|
+
env,
|
|
697
|
+
now,
|
|
698
|
+
outcome: state.outcome,
|
|
699
|
+
pace: state.built ? state.built.pace : null,
|
|
700
|
+
});
|
|
622
701
|
} catch (err) {
|
|
623
702
|
// Keep the last frame; a transient read error is not worth a blank.
|
|
624
703
|
}
|
|
704
|
+
// One bell when a window first turns yellow, another when it turns red.
|
|
705
|
+
if (bell && state.built) {
|
|
706
|
+
for (const row of state.built.rows) {
|
|
707
|
+
const before = state.levels[row.key];
|
|
708
|
+
if (before !== undefined && LEVEL_RANK[row.level] > LEVEL_RANK[before]) out.write('\x07');
|
|
709
|
+
state.levels[row.key] = row.level;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
625
712
|
// A window that rolled over deserves a reading sooner than the timer.
|
|
626
713
|
if (fetch && state.built && state.built.rows.some((row) => row.stale) && state.delayMs > 5 * SECOND) {
|
|
627
714
|
state.delayMs = 5 * SECOND;
|
|
@@ -116,6 +116,8 @@ function noteFor(outcome, ageMs) {
|
|
|
116
116
|
return 'network off' + suffix;
|
|
117
117
|
case 'unauthorized':
|
|
118
118
|
return 'sign in to Claude Code again' + suffix;
|
|
119
|
+
case 'expired':
|
|
120
|
+
return 'the login has expired, Claude Code renews it on its next call' + suffix;
|
|
119
121
|
case 'forbidden':
|
|
120
122
|
return 'usage is not available for this login' + suffix;
|
|
121
123
|
case 'no_credentials':
|
|
@@ -141,7 +143,10 @@ function build(input) {
|
|
|
141
143
|
// knows for certain; the setting is the fallback; nothing is hidden when
|
|
142
144
|
// neither says.
|
|
143
145
|
const model = opts.model || opts.settingsModel || env.ANTHROPIC_MODEL || null;
|
|
144
|
-
|
|
146
|
+
// When Claude Code has said which model is running, that is the whole
|
|
147
|
+
// answer: adding the setting on top would keep a Fable week on screen after
|
|
148
|
+
// /model moved the session to Opus. The setting is only the fallback.
|
|
149
|
+
const families = usage.familiesInUse(null, null, opts.model ? [opts.model] : [opts.settingsModel, env.ANTHROPIC_MODEL]);
|
|
145
150
|
|
|
146
151
|
const rows = [];
|
|
147
152
|
for (const key of ['five_hour', 'seven_day']) {
|