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.
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ // Whether Claude is working right now, as told by the hooks that already run.
4
+ //
5
+ // The panel wants to animate while Claude works and sit still while it waits,
6
+ // and the hooks are the cheapest honest signal there is: the prompt hook
7
+ // fires when a turn starts, the tool hook fires after every tool call, and the
8
+ // Stop hook fires when the reply is done. Each one writes a few bytes here.
9
+ //
10
+ // One slot per session, because two windows can be in different states at
11
+ // once and a Stop in one must not make the other look idle.
12
+
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+ const path = require('path');
16
+
17
+ const KEEP_SESSIONS = 8;
18
+ // A session that has said nothing for this long is not working, whatever its
19
+ // last word was: a crash never sends Stop.
20
+ const STALE_MS = 15 * 60 * 1000;
21
+
22
+ function configDir() {
23
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
24
+ }
25
+
26
+ function activityFile() {
27
+ return path.join(configDir(), 'usage-limits-activity.json');
28
+ }
29
+
30
+ function read() {
31
+ let parsed;
32
+ try {
33
+ parsed = JSON.parse(fs.readFileSync(activityFile(), 'utf8'));
34
+ } catch (err) {
35
+ return {};
36
+ }
37
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
38
+ const slots = {};
39
+ for (const key of Object.keys(parsed)) {
40
+ const value = parsed[key];
41
+ if (value && typeof value === 'object' && Number.isFinite(value.at)) slots[key] = value;
42
+ }
43
+ return slots;
44
+ }
45
+
46
+ function trim(all, keep) {
47
+ const ordered = Object.keys(all).sort((a, b) => (all[b].at || 0) - (all[a].at || 0));
48
+ const kept = {};
49
+ for (const key of ordered.slice(0, keep || KEEP_SESSIONS)) kept[key] = all[key];
50
+ return kept;
51
+ }
52
+
53
+ // Record a state for a session. Never throws: this runs inside hooks, and a
54
+ // hook that fails over a status file would be far worse than a panel that
55
+ // animates a little late.
56
+ function mark(state, sessionId, extra, now) {
57
+ try {
58
+ const all = read();
59
+ const previous = all[sessionId || '_'] || {};
60
+ const entry = {
61
+ at: Number.isFinite(now) ? now : Date.now(),
62
+ state: state === 'working' ? 'working' : 'idle',
63
+ // The keyword is per prompt, so a Stop keeps what the prompt said and the
64
+ // next prompt says again.
65
+ ultracode: extra && typeof extra.ultracode === 'boolean' ? extra.ultracode : Boolean(previous.ultracode),
66
+ };
67
+ if (extra && extra.model) entry.model = String(extra.model);
68
+ else if (previous.model) entry.model = previous.model;
69
+ all[sessionId || '_'] = entry;
70
+ const file = activityFile();
71
+ fs.mkdirSync(path.dirname(file), { recursive: true });
72
+ const temp = file + '.' + process.pid + '.usage-limits-tmp';
73
+ fs.writeFileSync(temp, JSON.stringify(trim(all)), 'utf8');
74
+ fs.renameSync(temp, file);
75
+ return true;
76
+ } catch (err) {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ // The picture across every session: is anything working, and is the most
82
+ // recently active working session in ultracode.
83
+ function summarise(all, now) {
84
+ const at = Number.isFinite(now) ? now : Date.now();
85
+ let working = null;
86
+ let newest = null;
87
+ for (const key of Object.keys(all || {})) {
88
+ // A mark with no session id cannot be attributed, so it is nobody's: the
89
+ // summary and the sessions list must agree about who is working.
90
+ if (key === '_') continue;
91
+ const entry = all[key];
92
+ if (!entry || !Number.isFinite(entry.at)) continue;
93
+ if (at - entry.at > STALE_MS) continue;
94
+ if (!newest || entry.at > newest.at) newest = entry;
95
+ if (entry.state === 'working' && (!working || entry.at > working.at)) working = entry;
96
+ }
97
+ return {
98
+ working: Boolean(working),
99
+ ultracode: Boolean(working ? working.ultracode : newest && newest.ultracode),
100
+ model: (working && working.model) || (newest && newest.model) || null,
101
+ at: newest ? newest.at : null,
102
+ };
103
+ }
104
+
105
+ // Every Claude on this machine that has been heard from lately, one row per
106
+ // session, from all the places the plugin hears about them: the hook marks
107
+ // (working or idle), the status line feed (model, effort, directory), the
108
+ // Stop hook's tally (project, cost, turns) and the prompt hook's cache (when
109
+ // it last prompted). A session is listed if any of them saw it within the
110
+ // window; it is "working" only if its own mark says so and is fresh.
111
+ function combine(sources, now, windowMs) {
112
+ const at = Number.isFinite(now) ? now : Date.now();
113
+ const within = Number.isFinite(windowMs) ? windowMs : STALE_MS;
114
+ const src = sources || {};
115
+ const rows = new Map();
116
+
117
+ const touch = (id, seenAt) => {
118
+ if (!id || id === '_') return null;
119
+ let row = rows.get(id);
120
+ if (!row) {
121
+ row = {
122
+ sessionId: id,
123
+ lastAt: 0,
124
+ state: 'idle',
125
+ stateAt: null,
126
+ ultracode: false,
127
+ model: null,
128
+ modelName: null,
129
+ effort: null,
130
+ cwd: null,
131
+ project: null,
132
+ cost: null,
133
+ turns: null,
134
+ };
135
+ rows.set(id, row);
136
+ }
137
+ if (Number.isFinite(seenAt) && seenAt > row.lastAt) row.lastAt = seenAt;
138
+ return row;
139
+ };
140
+
141
+ for (const id of Object.keys(src.marks || {})) {
142
+ const m = src.marks[id];
143
+ if (!m || !Number.isFinite(m.at)) continue;
144
+ const row = touch(id, m.at);
145
+ if (!row) continue;
146
+ row.stateAt = m.at;
147
+ row.state = m.state === 'working' && at - m.at <= within ? 'working' : 'idle';
148
+ row.ultracode = Boolean(m.ultracode);
149
+ if (m.model && !row.model) row.model = m.model;
150
+ }
151
+ for (const id of Object.keys(src.feed || {})) {
152
+ const s = src.feed[id];
153
+ if (!s || !Number.isFinite(s.at)) continue;
154
+ const row = touch(id, s.at);
155
+ if (!row) continue;
156
+ if (s.model) row.model = s.model;
157
+ if (s.modelName) row.modelName = s.modelName;
158
+ if (s.effort) row.effort = s.effort;
159
+ if (s.cwd) row.cwd = s.cwd;
160
+ }
161
+ for (const s of Array.isArray(src.tally) ? src.tally : []) {
162
+ if (!s || !s.sessionId) continue;
163
+ const row = touch(s.sessionId, s.lastAt);
164
+ if (!row) continue;
165
+ if (s.project) row.project = s.project;
166
+ if (Number.isFinite(s.cost)) row.cost = s.cost;
167
+ if (Number.isFinite(s.turns)) row.turns = s.turns;
168
+ }
169
+ for (const id of Object.keys(src.brief || {})) {
170
+ const b = src.brief[id];
171
+ if (b && Number.isFinite(b.at)) touch(id, b.at);
172
+ }
173
+
174
+ return [...rows.values()]
175
+ .filter((row) => at - row.lastAt <= within)
176
+ .sort((a, b) => b.lastAt - a.lastAt);
177
+ }
178
+
179
+ module.exports = {
180
+ KEEP_SESSIONS,
181
+ STALE_MS,
182
+ activityFile,
183
+ read,
184
+ mark,
185
+ trim,
186
+ summarise,
187
+ combine,
188
+ };
@@ -0,0 +1,340 @@
1
+ 'use strict';
2
+
3
+ // How the numbers are drawn: Claude Code's own colours, its spinner, its
4
+ // shimmer and its rainbow, so a bar from this plugin sits beside the chat
5
+ // without looking like it came from somewhere else.
6
+ //
7
+ // Everything here is a pure function of its arguments. The status line, the
8
+ // side panel and the VS Code view all draw from this one file, which is what
9
+ // keeps the three of them agreeing about what 85 percent looks like.
10
+ //
11
+ // The palette is Claude Code's dark theme, read out of the CLI itself rather
12
+ // than approximated. The names are Claude's: rate_limit_fill is what /usage
13
+ // paints its bars with, claudeShimmer is the highlight that sweeps across
14
+ // "Thinking", and the seven rainbow colours are what "ultrathink" and the
15
+ // max effort setting are painted in.
16
+
17
+ const usage = require('./usage.js');
18
+
19
+ const MINUTE = 60 * 1000;
20
+ const HOUR = 60 * MINUTE;
21
+
22
+ const THEME = {
23
+ claude: [215, 119, 87],
24
+ claudeShimmer: [235, 159, 127],
25
+ fill: [177, 185, 249],
26
+ empty: [80, 83, 112],
27
+ warning: [255, 193, 7],
28
+ warningShimmer: [255, 223, 57],
29
+ error: [255, 107, 128],
30
+ success: [78, 186, 101],
31
+ text: [255, 255, 255],
32
+ inactive: [153, 153, 153],
33
+ subtle: [80, 80, 80],
34
+ permission: [177, 185, 249],
35
+ ultra: [175, 135, 255],
36
+ ultraShimmer: [208, 180, 255],
37
+ rainbow: [
38
+ [235, 95, 87],
39
+ [245, 139, 87],
40
+ [250, 195, 95],
41
+ [145, 200, 130],
42
+ [130, 170, 220],
43
+ [155, 130, 200],
44
+ [200, 130, 180],
45
+ ],
46
+ rainbowShimmer: [
47
+ [250, 155, 147],
48
+ [255, 185, 137],
49
+ [255, 225, 155],
50
+ [185, 230, 180],
51
+ [180, 205, 240],
52
+ [195, 180, 230],
53
+ [230, 180, 210],
54
+ ],
55
+ };
56
+
57
+ // One frame every 150ms is the cadence Claude Code animates at.
58
+ const TICK_MS = 150;
59
+
60
+ // The two thresholds. A bar is its normal colour below 80, yellow from 80,
61
+ // red from 90. Each window is judged on its own.
62
+ const WARN_AT = 80;
63
+ const DANGER_AT = 90;
64
+
65
+ // Claude Code's spinner, forward then back again.
66
+ const SPINNER = ['·', '✢', '✳', '✶', '✻', '✽'];
67
+ const SPINNER_ASCII = ['.', '+', '*', 'x', '*', '+'];
68
+ const FRAMES = SPINNER.concat(SPINNER.slice().reverse());
69
+ const FRAMES_ASCII = SPINNER_ASCII.concat(SPINNER_ASCII.slice().reverse());
70
+
71
+ const FILL = '█';
72
+ const EMPTY = '░';
73
+ const FILL_ASCII = '#';
74
+ const EMPTY_ASCII = '-';
75
+
76
+ const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
77
+
78
+ function level(percent) {
79
+ if (!Number.isFinite(percent)) return 'fill';
80
+ if (percent >= DANGER_AT) return 'error';
81
+ if (percent >= WARN_AT) return 'warning';
82
+ return 'fill';
83
+ }
84
+
85
+ function levelColour(name) {
86
+ if (name === 'error') return THEME.error;
87
+ if (name === 'warning') return THEME.warning;
88
+ return THEME.fill;
89
+ }
90
+
91
+ // What the terminal can show. The status line is a special case: Claude Code
92
+ // captures the script's output, so stdout is never a TTY there, yet ANSI is
93
+ // supported. Callers that know that pass isTTY as true.
94
+ function colourMode(env, isTTY) {
95
+ const e = env || process.env;
96
+ if (e.NO_COLOR !== undefined && e.NO_COLOR !== '') return 'none';
97
+ const forced = e.FORCE_COLOR;
98
+ if (forced !== undefined && forced !== '' && forced !== '0' && forced !== 'false') return 'truecolor';
99
+ if (isTTY === false) return 'none';
100
+ if (e.USAGE_LIMITS_COLOUR === '256') return '256';
101
+ if (e.USAGE_LIMITS_COLOUR === 'none' || e.USAGE_LIMITS_COLOUR === 'off') return 'none';
102
+ const term = String(e.TERM || '').toLowerCase();
103
+ if (term === 'dumb') return 'none';
104
+ const colorterm = String(e.COLORTERM || '').toLowerCase();
105
+ if (colorterm === 'truecolor' || colorterm === '24bit') return 'truecolor';
106
+ const program = String(e.TERM_PROGRAM || '').toLowerCase();
107
+ if (e.WT_SESSION) return 'truecolor';
108
+ if (['vscode', 'iterm.app', 'wezterm', 'ghostty', 'hyper', 'alacritty', 'kitty'].indexOf(program) !== -1) {
109
+ return 'truecolor';
110
+ }
111
+ return '256';
112
+ }
113
+
114
+ // Nearest entry in the 6x6x6 cube, or the grey ramp for greys.
115
+ function to256(rgb) {
116
+ const r = rgb[0];
117
+ const g = rgb[1];
118
+ const b = rgb[2];
119
+ if (r === g && g === b) {
120
+ if (r < 8) return 16;
121
+ if (r > 248) return 231;
122
+ return Math.round(((r - 8) / 247) * 24) + 232;
123
+ }
124
+ const step = (v) => Math.round((v / 255) * 5);
125
+ return 16 + 36 * step(r) + 6 * step(g) + step(b);
126
+ }
127
+
128
+ function colourCode(rgb, mode) {
129
+ if (!rgb || mode === 'none' || !mode) return null;
130
+ if (mode === '256') return '38;5;' + to256(rgb);
131
+ return '38;2;' + rgb[0] + ';' + rgb[1] + ';' + rgb[2];
132
+ }
133
+
134
+ function paint(text, rgb, mode) {
135
+ const code = colourCode(rgb, mode);
136
+ if (!code || text === '') return String(text);
137
+ return '\x1b[' + code + 'm' + text + '\x1b[39m';
138
+ }
139
+
140
+ function dim(text, mode) {
141
+ if (!mode || mode === 'none' || text === '') return String(text);
142
+ return '\x1b[2m' + text + '\x1b[22m';
143
+ }
144
+
145
+ function bold(text, mode) {
146
+ if (!mode || mode === 'none' || text === '') return String(text);
147
+ return '\x1b[1m' + text + '\x1b[22m';
148
+ }
149
+
150
+ function clamp(value, low, high) {
151
+ return Math.min(high, Math.max(low, value));
152
+ }
153
+
154
+ // The bar. Rounded to the nearest cell, with two honesty rules: anything
155
+ // spent shows as at least one cell, and anything short of the whole window is
156
+ // never drawn as full. The number beside the bar carries the precision; the
157
+ // bar carries the shape.
158
+ function bar(percent, width, options) {
159
+ const opts = options || {};
160
+ const cells = Math.max(0, Math.floor(width || 0));
161
+ if (!cells) return '';
162
+ const pct = clamp(Number.isFinite(percent) ? percent : 0, 0, 100);
163
+ let filled = Math.round((cells * pct) / 100);
164
+ if (pct > 0 && filled === 0) filled = 1;
165
+ if (pct < 100 && filled === cells) filled = cells - 1;
166
+ const fillGlyph = opts.ascii ? FILL_ASCII : FILL;
167
+ const emptyGlyph = opts.ascii ? EMPTY_ASCII : EMPTY;
168
+ const mode = opts.mode || 'none';
169
+ return (
170
+ paint(fillGlyph.repeat(filled), levelColour(opts.level || level(pct)), mode) +
171
+ paint(emptyGlyph.repeat(cells - filled), THEME.empty, mode)
172
+ );
173
+ }
174
+
175
+ function spinner(tick, options) {
176
+ const opts = options || {};
177
+ const frames = opts.ascii ? FRAMES_ASCII : FRAMES;
178
+ if (opts.reduced) return frames[0];
179
+ const count = frames.length;
180
+ const index = (((Number.isFinite(tick) ? Math.floor(tick) : 0) % count) + count) % count;
181
+ return frames[index];
182
+ }
183
+
184
+ // A three-character highlight that walks along the text and starts again, the
185
+ // way "Thinking" glows in Claude Code. Reduced motion keeps the base colour
186
+ // and drops the walk.
187
+ function shimmer(text, tick, base, highlight, options) {
188
+ const opts = options || {};
189
+ const mode = opts.mode || 'none';
190
+ const chars = Array.from(String(text));
191
+ if (mode === 'none') return chars.join('');
192
+ if (opts.reduced || !highlight) return paint(chars.join(''), base, mode);
193
+ const period = chars.length + 4;
194
+ const centre = ((((Number.isFinite(tick) ? Math.floor(tick) : 0) % period) + period) % period) - 1;
195
+ let out = '';
196
+ for (let i = 0; i < chars.length; i += 1) {
197
+ out += paint(chars[i], Math.abs(i - centre) <= 1 ? highlight : base, mode);
198
+ }
199
+ return out;
200
+ }
201
+
202
+ // Seven colours sliding along the text, with the brighter variant at the
203
+ // leading edge. This is what Claude Code paints "ultrathink" and the max
204
+ // effort tag with.
205
+ function rainbow(text, tick, options) {
206
+ const opts = options || {};
207
+ const mode = opts.mode || 'none';
208
+ const chars = Array.from(String(text));
209
+ if (mode === 'none') return chars.join('');
210
+ const step = opts.reduced ? 0 : Number.isFinite(tick) ? Math.floor(tick) : 0;
211
+ const count = THEME.rainbow.length;
212
+ let out = '';
213
+ for (let i = 0; i < chars.length; i += 1) {
214
+ const index = ((((i - step) % count) + count) % count);
215
+ const leading = !opts.reduced && chars.length > 0 && i === (((step % chars.length) + chars.length) % chars.length);
216
+ out += paint(chars[i], leading ? THEME.rainbowShimmer[index] : THEME.rainbow[index], mode);
217
+ }
218
+ return out;
219
+ }
220
+
221
+ // The colours the /effort picker uses for each level. xhigh gets the purple
222
+ // shimmer, max and ultracode the rainbow.
223
+ function effortColour(name) {
224
+ const key = String(name || '').toLowerCase();
225
+ switch (key) {
226
+ case 'low':
227
+ return { rgb: THEME.warning, shimmer: null, rainbow: false };
228
+ case 'medium':
229
+ return { rgb: THEME.success, shimmer: null, rainbow: false };
230
+ case 'high':
231
+ return { rgb: THEME.permission, shimmer: null, rainbow: false };
232
+ case 'xhigh':
233
+ return { rgb: THEME.ultra, shimmer: THEME.ultraShimmer, rainbow: false };
234
+ case 'max':
235
+ case 'ultracode':
236
+ return { rgb: THEME.ultra, shimmer: THEME.ultraShimmer, rainbow: true };
237
+ default:
238
+ return { rgb: THEME.inactive, shimmer: null, rainbow: false };
239
+ }
240
+ }
241
+
242
+ const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
243
+
244
+ function stripAnsi(text) {
245
+ return String(text).replace(ANSI, '');
246
+ }
247
+
248
+ function visibleWidth(text) {
249
+ return Array.from(stripAnsi(text)).length;
250
+ }
251
+
252
+ function clockText(ms, now, options) {
253
+ const opts = options || {};
254
+ const date = new Date(ms);
255
+ const hours = opts.utc ? date.getUTCHours() : date.getHours();
256
+ const minutes = opts.utc ? date.getUTCMinutes() : date.getMinutes();
257
+ const day = opts.utc ? date.getUTCDay() : date.getDay();
258
+ const prefix = ms - now > 24 * HOUR ? DAYS[day] + ' ' : '';
259
+ const mm = String(minutes).padStart(2, '0');
260
+ if (opts.clock === '24h') return prefix + String(hours).padStart(2, '0') + ':' + mm;
261
+ const twelve = hours % 12 || 12;
262
+ return prefix + twelve + ':' + mm + (hours < 12 ? ' AM' : ' PM');
263
+ }
264
+
265
+ // "resets in 4h 12m at 4:12 PM". The duration is what you plan against; the
266
+ // clock time is what you tell someone else.
267
+ function formatReset(msToReset, resetsAtMs, now, options) {
268
+ if (msToReset === null || msToReset === undefined || !Number.isFinite(msToReset)) return '';
269
+ if (msToReset <= 0) return 'resets now';
270
+ let text = 'resets in ' + usage.formatDuration(msToReset);
271
+ if (Number.isFinite(resetsAtMs)) {
272
+ text += ' at ' + clockText(resetsAtMs, Number.isFinite(now) ? now : Date.now(), options);
273
+ }
274
+ return text;
275
+ }
276
+
277
+ function capitalise(word) {
278
+ return word ? word.charAt(0).toUpperCase() + word.slice(1) : word;
279
+ }
280
+
281
+ // "claude-fable-5-1[1m]" -> "Fable 5.1 1M", the way the model picker says it.
282
+ function prettyModel(id) {
283
+ if (id === null || id === undefined) return 'Unknown model';
284
+ let name = String(id).trim();
285
+ if (!name) return 'Unknown model';
286
+ const lower = name.toLowerCase();
287
+ if (lower === 'default') return 'Default model';
288
+ if (lower === 'opusplan') return 'Opus plan';
289
+ let suffix = '';
290
+ const bracket = name.match(/\[([^\]]+)\]$/);
291
+ if (bracket) {
292
+ suffix = ' ' + bracket[1].toUpperCase();
293
+ name = name.slice(0, bracket.index);
294
+ }
295
+ // OpenAI's names, for the Codex side: "gpt-6-astra" is "GPT-6 Astra",
296
+ // "gpt-5.6-sol" is "GPT-5.6 Sol", "o4-mini" is "o4 Mini".
297
+ const gpt = name.match(/^gpt-?(\d+(?:\.\d+)?)(?:-(.+))?$/i);
298
+ if (gpt) {
299
+ const rest = gpt[2] ? ' ' + gpt[2].split('-').filter(Boolean).map(capitalise).join(' ') : '';
300
+ return 'GPT-' + gpt[1] + rest + suffix;
301
+ }
302
+ const oSeries = name.match(/^(o\d+)(?:-(.+))?$/i);
303
+ if (oSeries) {
304
+ const rest = oSeries[2] ? ' ' + oSeries[2].split('-').filter(Boolean).map(capitalise).join(' ') : '';
305
+ return oSeries[1].toLowerCase() + rest + suffix;
306
+ }
307
+ name = name.replace(/^.*?claude-/, '');
308
+ name = name.replace(/-v\d+:\d+$/, '');
309
+ name = name.replace(/-\d{8}$/, '');
310
+ const parts = name.split('-').filter(Boolean);
311
+ const words = parts.filter((part) => !/^\d+$/.test(part)).map(capitalise);
312
+ const numbers = parts.filter((part) => /^\d+$/.test(part));
313
+ const label = words.join(' ') + (numbers.length ? ' ' + numbers.join('.') : '');
314
+ return (label.trim() || capitalise(String(id))) + suffix;
315
+ }
316
+
317
+ module.exports = {
318
+ THEME,
319
+ TICK_MS,
320
+ WARN_AT,
321
+ DANGER_AT,
322
+ SPINNER,
323
+ FRAMES,
324
+ level,
325
+ levelColour,
326
+ colourMode,
327
+ to256,
328
+ paint,
329
+ dim,
330
+ bold,
331
+ bar,
332
+ spinner,
333
+ shimmer,
334
+ rainbow,
335
+ effortColour,
336
+ stripAnsi,
337
+ visibleWidth,
338
+ formatReset,
339
+ prettyModel,
340
+ };
@@ -16,6 +16,8 @@ const path = require('path');
16
16
  const usage = require('./usage.js');
17
17
  const host = require('./host.js');
18
18
  const tally = require('./tally.js');
19
+ const activity = require('./activity.js');
20
+ const live = require('./live.js');
19
21
 
20
22
  const SECOND = 1000;
21
23
  const DAY = 24 * 60 * 60 * 1000;
@@ -50,8 +52,13 @@ const DEFAULTS = {
50
52
  // something ambitious" - that judgement belongs to whoever is doing the work,
51
53
  // and it needs the number, not an instruction.
52
54
  runwayMinutes: 10,
55
+ // How old the reading may be before the hook takes a fresh one.
56
+ refreshSeconds: 180,
53
57
  };
54
58
 
59
+ // A hook has ten seconds; the reading gets four of them at most.
60
+ const REFRESH_TIMEOUT_MS = 4000;
61
+
55
62
  // The runway is worth saying long before it is worth acting on, because it is
56
63
  // the figure that stops a turn count from flattering. Two hundred turns sounds
57
64
  // like plenty and can be twenty minutes when three sessions are spending.
@@ -181,6 +188,7 @@ function settings() {
181
188
  cacheSeconds: number(env.USAGE_LIMITS_CACHE, DEFAULTS.cacheSeconds),
182
189
  fewTurns: number(env.USAGE_LIMITS_FEW_TURNS, DEFAULTS.fewTurns),
183
190
  runwayMinutes: number(env.USAGE_LIMITS_RUNWAY, DEFAULTS.runwayMinutes),
191
+ refreshSeconds: number(env.USAGE_LIMITS_REFRESH, DEFAULTS.refreshSeconds),
184
192
  };
185
193
  }
186
194
 
@@ -261,6 +269,7 @@ function pressure(window, now, config, turnsLeft) {
261
269
  const CACHED_BINDING_FIELDS = [
262
270
  'key',
263
271
  'label',
272
+ 'applies',
264
273
  'percentUsed',
265
274
  'stale',
266
275
  'estimated',
@@ -327,11 +336,22 @@ function readHookInput() {
327
336
  // Kept as a re-export so there is exactly one implementation.
328
337
  const sessionSpend = usage.sessionSpend;
329
338
 
339
+ // One is not a plural. The line is read by an agent that then repeats it to
340
+ // the user, so "about 1 points" is a small error that gets copied out loud.
341
+ function count(value, word) {
342
+ return value + ' ' + word + (Math.abs(value) === 1 ? '' : 's');
343
+ }
344
+
330
345
  function describeWindow(window) {
331
346
  if (!window) return null;
332
347
  if (window.stale) return window.label + ' rolling over';
333
348
  const about = window.estimated || window.adjusted ? ' about ' : ' ';
334
- return window.label + about + window.percentUsed + '%';
349
+ // A per-model weekly for a model this session is not running is listed, but
350
+ // never bare: 88% beside the other percentages is read as 88% of the budget
351
+ // in hand, and the reply that follows sizes the work down for a limit not one
352
+ // turn here can move.
353
+ const idle = window.applies === false ? " (not this session's model)" : '';
354
+ return window.label + about + window.percentUsed + '%' + idle;
335
355
  }
336
356
 
337
357
  // Everything except the window that will actually stop the work.
@@ -360,7 +380,7 @@ function briefText(parts) {
360
380
  ? ' (' + parts.sessions + ' sessions active, roughly ' + parts.yourTurnsLeft +
361
381
  ' of them yours)'
362
382
  : '';
363
- bound.push('about ' + parts.turnsLeft + ' turns of headroom' + shared);
383
+ bound.push('about ' + count(parts.turnsLeft, 'turn') + ' of headroom' + shared);
364
384
  // A turn count is a poor sense of urgency when several agents are spending
365
385
  // at once: two hundred turns sounds like plenty and can be gone in ten
366
386
  // minutes. The runway is the figure that does not flatter.
@@ -394,7 +414,7 @@ function briefText(parts) {
394
414
  );
395
415
  } else if (parts.pointsSinceSnapshot) {
396
416
  sentences.push(
397
- 'That includes about ' + parts.pointsSinceSnapshot + ' points spent since the ' +
417
+ 'That includes about ' + count(parts.pointsSinceSnapshot, 'point') + ' spent since the ' +
398
418
  'snapshot was taken ' + parts.snapshotAge + ' ago, which it does not know about yet.'
399
419
  );
400
420
  } else if (parts.staleWindows) {
@@ -411,8 +431,8 @@ function briefText(parts) {
411
431
  // not 82%; it is unknown, with 82% as the floor. Say exactly that.
412
432
  sentences.push(
413
433
  'That percentage is a floor, not a current reading: the last real snapshot is ' +
414
- parts.snapshotAge + ' old and about ' + parts.pointsBeyondSnapshot +
415
- ' points have been spent since, more than it said was left. Either the window is ' +
434
+ parts.snapshotAge + ' old and about ' + count(parts.pointsBeyondSnapshot, 'point') +
435
+ ' have been spent since, more than it said was left. Either the window is ' +
416
436
  'already exhausted or the snapshot is wrong; /usage refreshes it.'
417
437
  );
418
438
  }
@@ -594,11 +614,44 @@ async function run(now, hookInput) {
594
614
  // before any file is read.
595
615
  usage.setHost(host.detect(process.argv.slice(2), process.env));
596
616
 
617
+ const sessionId = hookInput && hookInput.session_id ? hookInput.session_id : null;
618
+ // A prompt has arrived, so this session is working, and the prompt itself
619
+ // says whether it asked for ultracode. The panel animates from this.
620
+ activity.mark(
621
+ 'working',
622
+ sessionId,
623
+ {
624
+ ultracode: Boolean(
625
+ hookInput && typeof hookInput.prompt === 'string' && /\bultracode\b/i.test(hookInput.prompt)
626
+ ),
627
+ },
628
+ now
629
+ );
630
+
597
631
  const config = settings();
632
+ // The reading ages during long turns, and a burst of parallel agents can
633
+ // spend half a window between two of them. Before the numbers go in front
634
+ // of Claude, take the same reading Claude Code takes for /usage when the
635
+ // one on disk is older than a few minutes. Offline or signed out this is
636
+ // one quick failure and then a widening backoff, never a wait on every
637
+ // prompt; USAGE_LIMITS_FETCH=off turns it off.
638
+ if (!usage.isCodex()) {
639
+ try {
640
+ const cached = usage.collect(now);
641
+ await live.refreshIfStale({
642
+ now,
643
+ maxAgeMs: config.refreshSeconds * SECOND,
644
+ cacheFetchedAtMs: cached.snapshotFetchedAt,
645
+ accountUuid: usage.accountUuid(),
646
+ timeoutMs: REFRESH_TIMEOUT_MS,
647
+ });
648
+ } catch (err) {
649
+ // The reading on disk is still there.
650
+ }
651
+ }
598
652
  const base = usage.collect(now);
599
653
  if (!base.utilization) return '';
600
654
 
601
- const sessionId = hookInput && hookInput.session_id ? hookInput.session_id : null;
602
655
  const all = readCache();
603
656
  let view = pickCached(all, sessionId, now, config.cacheSeconds * SECOND);
604
657