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.
- 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 +198 -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 +400 -0
- package/skills/usage-limits/scripts/live.js +489 -0
- package/skills/usage-limits/scripts/panel.js +731 -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 +222 -0
|
@@ -0,0 +1,198 @@
|
|
|
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
|
+
const temp = file + '.' + process.pid + '.usage-limits-tmp';
|
|
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
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The picture across every session: is anything working, and is the most
|
|
92
|
+
// recently active working session in ultracode.
|
|
93
|
+
function summarise(all, now) {
|
|
94
|
+
const at = Number.isFinite(now) ? now : Date.now();
|
|
95
|
+
let working = null;
|
|
96
|
+
let newest = null;
|
|
97
|
+
for (const key of Object.keys(all || {})) {
|
|
98
|
+
// A mark with no session id cannot be attributed, so it is nobody's: the
|
|
99
|
+
// summary and the sessions list must agree about who is working.
|
|
100
|
+
if (key === '_') continue;
|
|
101
|
+
const entry = all[key];
|
|
102
|
+
if (!entry || !Number.isFinite(entry.at)) continue;
|
|
103
|
+
if (at - entry.at > STALE_MS) continue;
|
|
104
|
+
if (!newest || entry.at > newest.at) newest = entry;
|
|
105
|
+
if (entry.state === 'working' && (!working || entry.at > working.at)) working = entry;
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
working: Boolean(working),
|
|
109
|
+
ultracode: Boolean(working ? working.ultracode : newest && newest.ultracode),
|
|
110
|
+
model: (working && working.model) || (newest && newest.model) || null,
|
|
111
|
+
at: newest ? newest.at : null,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Every Claude on this machine that has been heard from lately, one row per
|
|
116
|
+
// session, from all the places the plugin hears about them: the hook marks
|
|
117
|
+
// (working or idle), the status line feed (model, effort, directory), the
|
|
118
|
+
// Stop hook's tally (project, cost, turns) and the prompt hook's cache (when
|
|
119
|
+
// it last prompted). A session is listed if any of them saw it within the
|
|
120
|
+
// window; it is "working" only if its own mark says so and is fresh.
|
|
121
|
+
function combine(sources, now, windowMs) {
|
|
122
|
+
const at = Number.isFinite(now) ? now : Date.now();
|
|
123
|
+
const within = Number.isFinite(windowMs) ? windowMs : STALE_MS;
|
|
124
|
+
const src = sources || {};
|
|
125
|
+
const rows = new Map();
|
|
126
|
+
|
|
127
|
+
const touch = (id, seenAt) => {
|
|
128
|
+
if (!id || id === '_') return null;
|
|
129
|
+
let row = rows.get(id);
|
|
130
|
+
if (!row) {
|
|
131
|
+
row = {
|
|
132
|
+
sessionId: id,
|
|
133
|
+
lastAt: 0,
|
|
134
|
+
state: 'idle',
|
|
135
|
+
stateAt: null,
|
|
136
|
+
ultracode: false,
|
|
137
|
+
model: null,
|
|
138
|
+
modelName: null,
|
|
139
|
+
effort: null,
|
|
140
|
+
cwd: null,
|
|
141
|
+
project: null,
|
|
142
|
+
cost: null,
|
|
143
|
+
turns: null,
|
|
144
|
+
};
|
|
145
|
+
rows.set(id, row);
|
|
146
|
+
}
|
|
147
|
+
if (Number.isFinite(seenAt) && seenAt > row.lastAt) row.lastAt = seenAt;
|
|
148
|
+
return row;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
for (const id of Object.keys(src.marks || {})) {
|
|
152
|
+
const m = src.marks[id];
|
|
153
|
+
if (!m || !Number.isFinite(m.at)) continue;
|
|
154
|
+
const row = touch(id, m.at);
|
|
155
|
+
if (!row) continue;
|
|
156
|
+
row.stateAt = m.at;
|
|
157
|
+
row.state = m.state === 'working' && at - m.at <= within ? 'working' : 'idle';
|
|
158
|
+
row.ultracode = Boolean(m.ultracode);
|
|
159
|
+
if (m.model && !row.model) row.model = m.model;
|
|
160
|
+
}
|
|
161
|
+
for (const id of Object.keys(src.feed || {})) {
|
|
162
|
+
const s = src.feed[id];
|
|
163
|
+
if (!s || !Number.isFinite(s.at)) continue;
|
|
164
|
+
const row = touch(id, s.at);
|
|
165
|
+
if (!row) continue;
|
|
166
|
+
if (s.model) row.model = s.model;
|
|
167
|
+
if (s.modelName) row.modelName = s.modelName;
|
|
168
|
+
if (s.effort) row.effort = s.effort;
|
|
169
|
+
if (s.cwd) row.cwd = s.cwd;
|
|
170
|
+
}
|
|
171
|
+
for (const s of Array.isArray(src.tally) ? src.tally : []) {
|
|
172
|
+
if (!s || !s.sessionId) continue;
|
|
173
|
+
const row = touch(s.sessionId, s.lastAt);
|
|
174
|
+
if (!row) continue;
|
|
175
|
+
if (s.project) row.project = s.project;
|
|
176
|
+
if (Number.isFinite(s.cost)) row.cost = s.cost;
|
|
177
|
+
if (Number.isFinite(s.turns)) row.turns = s.turns;
|
|
178
|
+
}
|
|
179
|
+
for (const id of Object.keys(src.brief || {})) {
|
|
180
|
+
const b = src.brief[id];
|
|
181
|
+
if (b && Number.isFinite(b.at)) touch(id, b.at);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return [...rows.values()]
|
|
185
|
+
.filter((row) => at - row.lastAt <= within)
|
|
186
|
+
.sort((a, b) => b.lastAt - a.lastAt);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = {
|
|
190
|
+
KEEP_SESSIONS,
|
|
191
|
+
STALE_MS,
|
|
192
|
+
activityFile,
|
|
193
|
+
read,
|
|
194
|
+
mark,
|
|
195
|
+
trim,
|
|
196
|
+
summarise,
|
|
197
|
+
combine,
|
|
198
|
+
};
|
|
@@ -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
|
-
|
|
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 + '
|
|
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 + '
|
|
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
|
-
'
|
|
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
|
|