claude-usage-limits 1.19.0 → 1.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +220 -2
- package/bin/cli.js +16 -0
- package/commands/defer.md +47 -0
- package/commands/usage-mode.md +64 -0
- package/hooks/hooks.json +1 -1
- package/package.json +1 -1
- package/skills/usage-limits/SKILL.md +196 -19
- package/skills/usage-limits/references/tactics.md +40 -9
- package/skills/usage-limits/scripts/agy-hook.js +175 -0
- package/skills/usage-limits/scripts/brief.js +406 -46
- package/skills/usage-limits/scripts/ceiling.js +191 -0
- package/skills/usage-limits/scripts/codex-lowpower.js +95 -4
- package/skills/usage-limits/scripts/codex.js +87 -6
- package/skills/usage-limits/scripts/defer.js +318 -0
- package/skills/usage-limits/scripts/drift.js +254 -0
- package/skills/usage-limits/scripts/feed.js +23 -1
- package/skills/usage-limits/scripts/host.js +23 -3
- package/skills/usage-limits/scripts/install-antigravity.js +215 -0
- package/skills/usage-limits/scripts/install-codex-hook.js +22 -2
- package/skills/usage-limits/scripts/lowpower.js +48 -0
- package/skills/usage-limits/scripts/mode.js +1637 -0
- package/skills/usage-limits/scripts/net.js +179 -0
- package/skills/usage-limits/scripts/pulse.js +254 -17
- package/skills/usage-limits/scripts/reading.js +12 -3
- package/skills/usage-limits/scripts/relay.js +266 -2
- package/skills/usage-limits/scripts/sessionend.js +8 -0
- package/skills/usage-limits/scripts/stop.js +145 -1
- package/skills/usage-limits/scripts/usage.js +244 -17
- package/skills/usage-limits/scripts/view.js +4 -0
- package/skills/usage-limits/scripts/voice.js +10 -1
- package/skills/usage-limits/scripts/wake.js +210 -30
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// "Not now. Do it at ten."
|
|
5
|
+
//
|
|
6
|
+
// The relay answers a question the plugin asks itself: the window ran out, so
|
|
7
|
+
// when can this be picked up? This answers a question the USER asks: I do not
|
|
8
|
+
// want this started now, start it then.
|
|
9
|
+
//
|
|
10
|
+
// They share all the machinery below the surface - the same saved plan, the
|
|
11
|
+
// same scheduled task, the same wake script - and differ in one way that
|
|
12
|
+
// matters. The relay fires when a window RESETS, which is a time nobody chose
|
|
13
|
+
// and which moves. A deferral fires at a time a person named, so the grace
|
|
14
|
+
// minutes that let a meter settle are not added to it: 9:50 means 9:50.
|
|
15
|
+
//
|
|
16
|
+
// node defer.js 9:50pm tonight at 21:50
|
|
17
|
+
// node defer.js 21:50 --work "..." with the work spelled out
|
|
18
|
+
// node defer.js "in 90m" ninety minutes from now
|
|
19
|
+
// node defer.js reset when the binding window resets
|
|
20
|
+
// node defer.js status what is deferred, and when it fires
|
|
21
|
+
// node defer.js cancel call it off
|
|
22
|
+
//
|
|
23
|
+
// The reply is deliberately one line. The whole point of the command is that
|
|
24
|
+
// nothing happens now, and a paragraph explaining that would itself be the
|
|
25
|
+
// thing the user was trying to avoid.
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const os = require('os');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
|
|
31
|
+
const relay = require('./relay.js');
|
|
32
|
+
const host = require('./host.js');
|
|
33
|
+
|
|
34
|
+
const MINUTE = 60 * 1000;
|
|
35
|
+
const HOUR = 60 * MINUTE;
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// When
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
// Turn what somebody typed into a moment.
|
|
42
|
+
//
|
|
43
|
+
// Deliberately narrow. Every format here is one a person actually types at a
|
|
44
|
+
// terminal, and anything else is refused with the list rather than guessed at:
|
|
45
|
+
// a deferral that silently fires at the wrong hour is worse than one that
|
|
46
|
+
// refuses to be set.
|
|
47
|
+
function parseWhen(text, now, resetsAt) {
|
|
48
|
+
const raw = String(text == null ? '' : text).trim().toLowerCase();
|
|
49
|
+
if (!raw) return { error: 'No time given.' };
|
|
50
|
+
|
|
51
|
+
// The window reset, which is the common case and the sensible default.
|
|
52
|
+
if (/^(reset|next reset|the reset|window|when it resets)$/.test(raw)) {
|
|
53
|
+
if (!Number.isFinite(resetsAt)) {
|
|
54
|
+
return { error: 'The reset time is not known right now, so name a clock time instead.' };
|
|
55
|
+
}
|
|
56
|
+
// The one case where the relay's grace IS wanted: a meter needs a moment
|
|
57
|
+
// to turn over, and this is a window reset rather than a chosen time.
|
|
58
|
+
return { at: resetsAt + 5 * MINUTE, label: 'when the window resets' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// "in 90m", "in 2h", "in 45 minutes", "in 1.5 hours"
|
|
62
|
+
const relative = raw.match(/^in\s+([0-9]+(?:\.[0-9]+)?)\s*(m|min|mins|minute|minutes|h|hr|hrs|hour|hours)$/);
|
|
63
|
+
if (relative) {
|
|
64
|
+
const amount = Number(relative[1]);
|
|
65
|
+
if (!Number.isFinite(amount) || amount <= 0) return { error: 'That is not a length of time.' };
|
|
66
|
+
const unit = /^m/.test(relative[2]) ? MINUTE : HOUR;
|
|
67
|
+
const span = amount * unit;
|
|
68
|
+
// A year out is not a deferral, it is a mistake.
|
|
69
|
+
if (span > 14 * 24 * HOUR) return { error: 'That is more than a fortnight away.' };
|
|
70
|
+
return { at: now + span, label: 'in ' + relative[1] + (unit === MINUTE ? ' minutes' : ' hours') };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// "9:50pm", "9pm", "21:50", "09:50"
|
|
74
|
+
const clock = raw.match(/^([0-9]{1,2})(?::([0-9]{2}))?\s*(am|pm)?$/);
|
|
75
|
+
if (clock) {
|
|
76
|
+
let hour = Number(clock[1]);
|
|
77
|
+
const minute = clock[2] === undefined ? 0 : Number(clock[2]);
|
|
78
|
+
const meridiem = clock[3];
|
|
79
|
+
if (minute > 59) return { error: 'There is no such minute as ' + minute + '.' };
|
|
80
|
+
if (meridiem) {
|
|
81
|
+
if (hour < 1 || hour > 12) return { error: 'With am or pm the hour has to be 1 to 12.' };
|
|
82
|
+
if (meridiem === 'pm' && hour !== 12) hour += 12;
|
|
83
|
+
if (meridiem === 'am' && hour === 12) hour = 0;
|
|
84
|
+
} else if (hour > 23) {
|
|
85
|
+
return { error: 'There is no such hour as ' + hour + '.' };
|
|
86
|
+
} else if (clock[2] === undefined) {
|
|
87
|
+
// A bare number with no minutes and no am/pm is ambiguous - "9" could be
|
|
88
|
+
// either nine. Refuse rather than pick one.
|
|
89
|
+
return { error: 'Ambiguous: say 9am, 9pm or 09:00.' };
|
|
90
|
+
}
|
|
91
|
+
const at = new Date(now);
|
|
92
|
+
at.setHours(hour, minute, 0, 0);
|
|
93
|
+
let stamp = at.getTime();
|
|
94
|
+
// A time already past today means tomorrow. That is what a person means by
|
|
95
|
+
// "do it at nine" when they say it at eleven at night.
|
|
96
|
+
if (stamp <= now) stamp += 24 * HOUR;
|
|
97
|
+
return { at: stamp, label: formatClock(stamp) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
error:
|
|
102
|
+
'Could not read "' + text + '" as a time. Try 9:50pm, 21:50, "in 90m", or "reset".',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function formatClock(stamp) {
|
|
107
|
+
const d = new Date(stamp);
|
|
108
|
+
let hour = d.getHours();
|
|
109
|
+
const meridiem = hour >= 12 ? 'PM' : 'AM';
|
|
110
|
+
hour = hour % 12 === 0 ? 12 : hour % 12;
|
|
111
|
+
const minute = String(d.getMinutes()).padStart(2, '0');
|
|
112
|
+
return hour + ':' + minute + ' ' + meridiem;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// "3h 12m", the same shape the rest of the plugin prints.
|
|
116
|
+
function formatSpan(ms) {
|
|
117
|
+
if (!Number.isFinite(ms) || ms <= 0) return 'now';
|
|
118
|
+
const minutes = Math.round(ms / MINUTE);
|
|
119
|
+
if (minutes < 60) return minutes + 'm';
|
|
120
|
+
const hours = Math.floor(minutes / 60);
|
|
121
|
+
const rest = minutes % 60;
|
|
122
|
+
if (hours < 24) return rest ? hours + 'h ' + rest + 'm' : hours + 'h';
|
|
123
|
+
const days = Math.floor(hours / 24);
|
|
124
|
+
return days + 'd ' + (hours % 24) + 'h';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// The line
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
// One sentence, and it has to carry four things: that nothing was started,
|
|
132
|
+
// when it will start, how far away that is, and how to call it off. Everything
|
|
133
|
+
// else is noise in a command whose whole purpose is to not do things.
|
|
134
|
+
function confirmation(parts) {
|
|
135
|
+
const bits = [];
|
|
136
|
+
const descriptive = parts.label && parts.label !== parts.clock && !/^in /.test(parts.label);
|
|
137
|
+
bits.push('Doing this at ' + parts.clock + (descriptive ? ' (' + parts.label + ')' : ''));
|
|
138
|
+
bits.push('in ' + formatSpan(parts.in));
|
|
139
|
+
const line = bits.join(', ') + '. ';
|
|
140
|
+
const tail = [];
|
|
141
|
+
tail.push('Nothing has been started');
|
|
142
|
+
if (parts.items) tail.push(parts.items + ' saved');
|
|
143
|
+
if (parts.resetNote) tail.push(parts.resetNote);
|
|
144
|
+
return line + tail.join('; ') + '. Run "defer cancel" to call it off.';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Doing it
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
function bindingReset(now) {
|
|
152
|
+
try {
|
|
153
|
+
const usage = require('./usage.js');
|
|
154
|
+
usage.setHost(host.detect(process.argv.slice(2), process.env));
|
|
155
|
+
const collected = usage.collect(now);
|
|
156
|
+
const utilization = collected && collected.utilization;
|
|
157
|
+
if (!utilization) return { resetsAt: null, percent: null };
|
|
158
|
+
let worst = null;
|
|
159
|
+
for (const key of Object.keys(utilization)) {
|
|
160
|
+
const window = utilization[key];
|
|
161
|
+
if (!window || typeof window !== 'object') continue;
|
|
162
|
+
const percent = Number(window.utilization);
|
|
163
|
+
if (!Number.isFinite(percent)) continue;
|
|
164
|
+
const resets = Date.parse(window.resets_at);
|
|
165
|
+
if (!worst || percent > worst.percent) {
|
|
166
|
+
worst = { percent, resetsAt: Number.isFinite(resets) ? resets : null };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return worst || { resetsAt: null, percent: null };
|
|
170
|
+
} catch (err) {
|
|
171
|
+
return { resetsAt: null, percent: null };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function sessionId(argv, env) {
|
|
176
|
+
const at = argv.indexOf('--session-id');
|
|
177
|
+
if (at !== -1 && argv[at + 1]) return argv[at + 1];
|
|
178
|
+
return env.CLAUDE_SESSION_ID || env.CODEX_SESSION_ID || 'defer-' + Date.now().toString(36);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function argOf(argv, name) {
|
|
182
|
+
const at = argv.indexOf(name);
|
|
183
|
+
return at === -1 ? null : argv[at + 1] || null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function plan(options) {
|
|
187
|
+
const now = options.now;
|
|
188
|
+
const binding = options.binding || { resetsAt: null, percent: null };
|
|
189
|
+
const when = parseWhen(options.when, now, binding.resetsAt);
|
|
190
|
+
if (when.error) return { ok: false, error: when.error };
|
|
191
|
+
|
|
192
|
+
const notes = [];
|
|
193
|
+
// Worth saying, because it is the difference between the deferred run having
|
|
194
|
+
// a budget and hitting the same wall again.
|
|
195
|
+
if (Number.isFinite(binding.resetsAt)) {
|
|
196
|
+
if (when.at >= binding.resetsAt) notes.push('the window will have reset by then');
|
|
197
|
+
else if (Number.isFinite(binding.percent) && binding.percent >= 80) {
|
|
198
|
+
notes.push('note: that is before the window resets at ' + formatClock(binding.resetsAt));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
ok: true,
|
|
203
|
+
at: when.at,
|
|
204
|
+
label: when.label,
|
|
205
|
+
clock: formatClock(when.at),
|
|
206
|
+
in: when.at - now,
|
|
207
|
+
resetNote: notes[0] || null,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function status(now) {
|
|
212
|
+
const state = relay.read();
|
|
213
|
+
const armed = state.armed;
|
|
214
|
+
if (!armed) return 'Nothing is deferred.';
|
|
215
|
+
const when = Number(armed.wakeAt);
|
|
216
|
+
const deferred = armed.deferred === true;
|
|
217
|
+
return (
|
|
218
|
+
(deferred ? 'Deferred' : 'Relay armed') +
|
|
219
|
+
': ' +
|
|
220
|
+
(armed.project || path.basename(armed.cwd || '')) +
|
|
221
|
+
' at ' +
|
|
222
|
+
formatClock(when) +
|
|
223
|
+
' (in ' +
|
|
224
|
+
formatSpan(when - now) +
|
|
225
|
+
')' +
|
|
226
|
+
(armed.how ? ', via ' + armed.how : '') +
|
|
227
|
+
'.'
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function cancel() {
|
|
232
|
+
const state = relay.read();
|
|
233
|
+
if (!state.armed) return 'Nothing was deferred.';
|
|
234
|
+
const label = formatClock(Number(state.armed.wakeAt));
|
|
235
|
+
const result = relay.disarm('cancelled by hand', Date.now());
|
|
236
|
+
return result && result.ok === false
|
|
237
|
+
? 'Could not cancel: ' + result.error
|
|
238
|
+
: 'Cancelled the run booked for ' + label + '.';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function main(argv, now) {
|
|
242
|
+
const args = (argv || []).filter((a) => a !== undefined);
|
|
243
|
+
const first = args.find((a) => !a.startsWith('--')) || '';
|
|
244
|
+
const at = Number.isFinite(now) ? now : Date.now();
|
|
245
|
+
|
|
246
|
+
if (first === 'status') return status(at);
|
|
247
|
+
if (first === 'cancel' || first === 'off') return cancel();
|
|
248
|
+
if (!first || first === 'help') {
|
|
249
|
+
return [
|
|
250
|
+
'defer <time> [--work "..."] put the work off until then and start nothing now',
|
|
251
|
+
' 9:50pm | 21:50 | "in 90m" | reset',
|
|
252
|
+
'defer status what is deferred and when it fires',
|
|
253
|
+
'defer cancel call it off',
|
|
254
|
+
].join('\n');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const binding = bindingReset(at);
|
|
258
|
+
const decided = plan({ now: at, when: first, binding });
|
|
259
|
+
if (!decided.ok) return decided.error;
|
|
260
|
+
|
|
261
|
+
const id = sessionId(args, process.env);
|
|
262
|
+
const work = argOf(args, '--work');
|
|
263
|
+
const cwd = argOf(args, '--cwd') || process.cwd();
|
|
264
|
+
|
|
265
|
+
// The plan is saved before the task is registered. A task that fires with
|
|
266
|
+
// nothing to read is worse than a plan nobody scheduled.
|
|
267
|
+
let items = null;
|
|
268
|
+
if (work) {
|
|
269
|
+
relay.saveContinuation(id, work);
|
|
270
|
+
items = work.split('\n').filter((l) => l.trim()).length + ' line' +
|
|
271
|
+
(work.split('\n').filter((l) => l.trim()).length === 1 ? '' : 's');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const armed = relay.arm({
|
|
275
|
+
now: at,
|
|
276
|
+
sessionId: id,
|
|
277
|
+
cwd,
|
|
278
|
+
hostName: host.detect(args, process.env),
|
|
279
|
+
at: decided.at,
|
|
280
|
+
binding: { percentUsed: binding.percent, resetsAt: binding.resetsAt },
|
|
281
|
+
work: { hasWork: true, pending: 1, source: 'defer', todos: [] },
|
|
282
|
+
});
|
|
283
|
+
if (!armed.ok) return 'Could not schedule it: ' + armed.error;
|
|
284
|
+
|
|
285
|
+
// Mark it as a deferral rather than a limit relay, so `status` and the next
|
|
286
|
+
// session can tell the two apart - they read the same record.
|
|
287
|
+
try {
|
|
288
|
+
const held = relay.read();
|
|
289
|
+
if (held.armed) {
|
|
290
|
+
held.armed.deferred = true;
|
|
291
|
+
held.armed.continuation = Boolean(work);
|
|
292
|
+
relay.write(held);
|
|
293
|
+
}
|
|
294
|
+
} catch (err) {
|
|
295
|
+
// The schedule is the part that matters; the label is not worth failing for.
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
relay.note('deferred ' + id + ' until ' + new Date(decided.at).toISOString(), at);
|
|
299
|
+
return confirmation({
|
|
300
|
+
label: decided.label,
|
|
301
|
+
clock: decided.clock,
|
|
302
|
+
in: decided.in,
|
|
303
|
+
items,
|
|
304
|
+
resetNote: decided.resetNote,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (require.main === module) {
|
|
309
|
+
try {
|
|
310
|
+
process.stdout.write(main(process.argv.slice(2), Date.now()) + '\n');
|
|
311
|
+
process.exitCode = 0;
|
|
312
|
+
} catch (err) {
|
|
313
|
+
process.stderr.write('defer: ' + (err && err.message ? err.message : String(err)) + '\n');
|
|
314
|
+
process.exitCode = 1;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
module.exports = { parseWhen, formatClock, formatSpan, confirmation, plan, status, cancel, main, MINUTE, HOUR };
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// A ledger of how wrong the reading was, measured instead of argued about.
|
|
4
|
+
//
|
|
5
|
+
// The known complaint about this plugin is that its numbers lag reality
|
|
6
|
+
// mid-session - reading.js exists because of one measured instance of it (13%
|
|
7
|
+
// shown, 73% actual). But "it lags" is an anecdote until someone can say by
|
|
8
|
+
// how much, how often, and whether it is getting better or worse as the
|
|
9
|
+
// plugin changes. So every time reading.js records a fresh, trustworthy
|
|
10
|
+
// correction for a window, and there was already a figure sitting there for
|
|
11
|
+
// readers to trust, this writes down what that older figure said next to what
|
|
12
|
+
// the new one says. The gap between them is the drift a real session lived
|
|
13
|
+
// through between two corrections.
|
|
14
|
+
//
|
|
15
|
+
// Same constraints as reading.js: cheap, bounded, and it must never be the
|
|
16
|
+
// reason a hook is late or fails.
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
const host = require('./host.js');
|
|
23
|
+
const codex = require('./codex.js');
|
|
24
|
+
|
|
25
|
+
// Not "how many windows" but "how many measurements": a busy day produces a
|
|
26
|
+
// handful of corrections per window, and a fixed cap keeps the file the same
|
|
27
|
+
// size whether the plugin has run for a day or a year.
|
|
28
|
+
const MAX_ENTRIES = 200;
|
|
29
|
+
|
|
30
|
+
function configDir() {
|
|
31
|
+
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function driftFile(codexHome) {
|
|
35
|
+
return path.join(codexHome || configDir(), 'usage-limits-drift.json');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The other measurement that belongs here: what a turn actually cost while
|
|
39
|
+
// each budget mode was on. Same file, same bounded append, for the same
|
|
40
|
+
// reason - it is an after-the-fact measurement of a prediction, and a second
|
|
41
|
+
// store would be a second thing to keep correct. Modes should be evidence
|
|
42
|
+
// rather than vibes, and this is where the evidence lands.
|
|
43
|
+
const MAX_MODE_ENTRIES = 400;
|
|
44
|
+
|
|
45
|
+
function read(codexHome) {
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(fs.readFileSync(driftFile(codexHome), 'utf8'));
|
|
48
|
+
if (!parsed || typeof parsed !== 'object') return { entries: [] };
|
|
49
|
+
const state = { entries: Array.isArray(parsed.entries) ? parsed.entries : [] };
|
|
50
|
+
// The mode ledger is carried only when the file actually has one. A reader
|
|
51
|
+
// that has never recorded a mode gets back exactly the shape it wrote, so
|
|
52
|
+
// adding this second ledger did not change what the first one reads.
|
|
53
|
+
if (Array.isArray(parsed.modes)) state.modes = parsed.modes;
|
|
54
|
+
return state;
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return { entries: [] };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// One reply, priced, tagged with the mode that was in force while it ran.
|
|
61
|
+
// Called from the Stop hook, which already has the exact figures.
|
|
62
|
+
function recordTurns(mode, turns, cost, now, codexHome) {
|
|
63
|
+
if (!mode || !Number.isFinite(turns) || turns <= 0) return false;
|
|
64
|
+
try {
|
|
65
|
+
const state = read(codexHome);
|
|
66
|
+
if (!Array.isArray(state.modes)) state.modes = [];
|
|
67
|
+
state.modes.push({
|
|
68
|
+
mode: String(mode),
|
|
69
|
+
at: Number.isFinite(now) ? now : Date.now(),
|
|
70
|
+
turns,
|
|
71
|
+
cost: Number.isFinite(cost) ? cost : 0,
|
|
72
|
+
});
|
|
73
|
+
if (state.modes.length > MAX_MODE_ENTRIES) state.modes = state.modes.slice(-MAX_MODE_ENTRIES);
|
|
74
|
+
writeAtomic(driftFile(codexHome), state);
|
|
75
|
+
return true;
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Turns and observed cost per turn, per mode. Only what was measured: a mode
|
|
82
|
+
// nothing has run in is absent rather than shown at zero, because a zero here
|
|
83
|
+
// reads as "free" when it means "unknown".
|
|
84
|
+
function modeSummary(codexHome) {
|
|
85
|
+
const rows = {};
|
|
86
|
+
for (const entry of read(codexHome).modes || []) {
|
|
87
|
+
if (!entry || !entry.mode || !Number.isFinite(entry.turns)) continue;
|
|
88
|
+
const row = rows[entry.mode] || (rows[entry.mode] = { mode: entry.mode, turns: 0, usd: 0 });
|
|
89
|
+
row.turns += entry.turns;
|
|
90
|
+
row.usd += Number.isFinite(entry.cost) ? entry.cost : 0;
|
|
91
|
+
}
|
|
92
|
+
return Object.keys(rows)
|
|
93
|
+
.map((key) => {
|
|
94
|
+
const row = rows[key];
|
|
95
|
+
return {
|
|
96
|
+
mode: row.mode,
|
|
97
|
+
turns: row.turns,
|
|
98
|
+
usd: row.usd,
|
|
99
|
+
usdPerTurn: row.turns > 0 && row.usd > 0 ? row.usd / row.turns : null,
|
|
100
|
+
};
|
|
101
|
+
})
|
|
102
|
+
.sort((a, b) => b.turns - a.turns);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function writeAtomic(file, data) {
|
|
106
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
107
|
+
// Same beside-and-rename as reading.js: the pulse and the prompt hook can
|
|
108
|
+
// both land here in the same second.
|
|
109
|
+
const tmp = file + '.' + process.pid + '.tmp';
|
|
110
|
+
fs.writeFileSync(tmp, JSON.stringify(data));
|
|
111
|
+
fs.renameSync(tmp, file);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// `previous` and `next` are both entries in reading.js's own shape - see
|
|
115
|
+
// reading.record(). Called from there, right before it overwrites the slot,
|
|
116
|
+
// so `previous` is what readers were trusting and `next` is what the fresh
|
|
117
|
+
// scan just found for the same window.
|
|
118
|
+
function record(key, previous, next, now, codexHome) {
|
|
119
|
+
if (!key || !previous || !next) return false;
|
|
120
|
+
if (!Number.isFinite(previous.percentUsed) || !Number.isFinite(next.percentUsed)) return false;
|
|
121
|
+
if (!Number.isFinite(previous.at)) return false;
|
|
122
|
+
const at = Number.isFinite(now) ? now : Date.now();
|
|
123
|
+
// A window that reset between the two readings did not "drift" - it started
|
|
124
|
+
// over, and treating the jump as error would swamp every real measurement.
|
|
125
|
+
if (Number.isFinite(previous.resetsAt) && previous.resetsAt <= next.at) return false;
|
|
126
|
+
try {
|
|
127
|
+
const state = read(codexHome);
|
|
128
|
+
state.entries.push({
|
|
129
|
+
key,
|
|
130
|
+
at,
|
|
131
|
+
// How long the older figure had been sitting in front of readers before
|
|
132
|
+
// this measurement replaced it - a drift found after two minutes and
|
|
133
|
+
// one found after twenty are not the same kind of evidence.
|
|
134
|
+
ageMs: Math.max(0, next.at - previous.at),
|
|
135
|
+
predictedPercentUsed: previous.percentUsed,
|
|
136
|
+
actualPercentUsed: next.percentUsed,
|
|
137
|
+
percentDrift: next.percentUsed - previous.percentUsed,
|
|
138
|
+
predictedTurnsLeft: Number.isFinite(previous.turnsLeft) ? previous.turnsLeft : null,
|
|
139
|
+
actualTurnsLeft: Number.isFinite(next.turnsLeft) ? next.turnsLeft : null,
|
|
140
|
+
});
|
|
141
|
+
if (state.entries.length > MAX_ENTRIES) state.entries = state.entries.slice(-MAX_ENTRIES);
|
|
142
|
+
writeAtomic(driftFile(codexHome), state);
|
|
143
|
+
return true;
|
|
144
|
+
} catch (err) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function median(values) {
|
|
150
|
+
if (!values.length) return null;
|
|
151
|
+
const sorted = values.slice().sort((a, b) => a - b);
|
|
152
|
+
const mid = Math.floor(sorted.length / 2);
|
|
153
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// What the ledger has actually shown: the typical gap and the worst one, in
|
|
157
|
+
// percentage points, over whatever window filter is asked for (default:
|
|
158
|
+
// everything on record). Turns get the same treatment where both sides of a
|
|
159
|
+
// pair have a turns figure to compare.
|
|
160
|
+
function summary(codexHome, options) {
|
|
161
|
+
const opts = options || {};
|
|
162
|
+
const state = read(codexHome);
|
|
163
|
+
let entries = state.entries;
|
|
164
|
+
if (opts.key) entries = entries.filter((e) => e.key === opts.key);
|
|
165
|
+
if (!entries.length) return { sample: 0, medianAbsPercent: null, worstAbsPercent: null, medianAbsTurns: null, worstAbsTurns: null };
|
|
166
|
+
|
|
167
|
+
const percentAbs = entries.map((e) => Math.abs(e.percentDrift));
|
|
168
|
+
const turnsAbs = entries
|
|
169
|
+
.filter((e) => Number.isFinite(e.predictedTurnsLeft) && Number.isFinite(e.actualTurnsLeft))
|
|
170
|
+
.map((e) => Math.abs(e.actualTurnsLeft - e.predictedTurnsLeft));
|
|
171
|
+
|
|
172
|
+
// A number of points on its own says nothing: sixty points off is a scandal
|
|
173
|
+
// over two minutes and unremarkable over five hours. The gap the figure was
|
|
174
|
+
// wrong for is what makes it readable, and it is already on every entry.
|
|
175
|
+
const ages = entries.map((e) => e.ageMs).filter((ms) => Number.isFinite(ms));
|
|
176
|
+
const worst = entries.reduce(
|
|
177
|
+
(found, e) => (!found || Math.abs(e.percentDrift) > Math.abs(found.percentDrift) ? e : found),
|
|
178
|
+
null
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
sample: entries.length,
|
|
183
|
+
medianAbsPercent: median(percentAbs),
|
|
184
|
+
worstAbsPercent: percentAbs.length ? Math.max(...percentAbs) : null,
|
|
185
|
+
medianAbsTurns: turnsAbs.length ? median(turnsAbs) : null,
|
|
186
|
+
worstAbsTurns: turnsAbs.length ? Math.max(...turnsAbs) : null,
|
|
187
|
+
medianGapMs: ages.length ? median(ages) : null,
|
|
188
|
+
worstGapMs: worst && Number.isFinite(worst.ageMs) ? worst.ageMs : null,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Minutes, because that is the scale a correction interval lives on and the
|
|
193
|
+
// only one anybody compares against the pulse interval.
|
|
194
|
+
function gap(ms) {
|
|
195
|
+
if (!Number.isFinite(ms)) return null;
|
|
196
|
+
const minutes = ms / 60000;
|
|
197
|
+
return (minutes >= 10 ? Math.round(minutes) : Math.round(minutes * 10) / 10) + 'm';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function describe(codexHome) {
|
|
201
|
+
const stats = summary(codexHome);
|
|
202
|
+
if (!stats.sample) return 'Drift ledger: no corrections measured against an earlier one yet.';
|
|
203
|
+
const lines = [];
|
|
204
|
+
lines.push('Drift ledger: ' + stats.sample + ' measured correction' + (stats.sample === 1 ? '' : 's') + '.');
|
|
205
|
+
const medianGap = gap(stats.medianGapMs);
|
|
206
|
+
const worstGap = gap(stats.worstGapMs);
|
|
207
|
+
lines.push(
|
|
208
|
+
' Percent used: median ' + fmt(stats.medianAbsPercent) + ' points off' +
|
|
209
|
+
(medianGap ? ' over a typical ' + medianGap + ' gap' : '') + ', worst ' +
|
|
210
|
+
fmt(stats.worstAbsPercent) + ' points off' + (worstGap ? ' over ' + worstGap : '') + '.'
|
|
211
|
+
);
|
|
212
|
+
if (stats.medianAbsTurns !== null) {
|
|
213
|
+
lines.push(
|
|
214
|
+
' Turns left: median ' + fmt(stats.medianAbsTurns) + ' off, worst ' + fmt(stats.worstAbsTurns) + ' off.'
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return lines.join('\n');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function fmt(value) {
|
|
221
|
+
if (!Number.isFinite(value)) return '-';
|
|
222
|
+
return Math.round(value * 10) / 10;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function activeCodexHome(argv, env) {
|
|
226
|
+
return host.detect(argv, env) === host.CODEX ? codex.homeDir() : null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function main(argv) {
|
|
230
|
+
const args = argv || [];
|
|
231
|
+
const codexHome = activeCodexHome(args, process.env);
|
|
232
|
+
if (args[0] === '--json') return JSON.stringify(summary(codexHome, {}), null, 2);
|
|
233
|
+
return describe(codexHome);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (require.main === module) {
|
|
237
|
+
process.stdout.write(main(process.argv.slice(2)) + '\n');
|
|
238
|
+
process.exit(0);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
module.exports = {
|
|
242
|
+
MAX_ENTRIES,
|
|
243
|
+
MAX_MODE_ENTRIES,
|
|
244
|
+
configDir,
|
|
245
|
+
driftFile,
|
|
246
|
+
gap,
|
|
247
|
+
read,
|
|
248
|
+
record,
|
|
249
|
+
recordTurns,
|
|
250
|
+
modeSummary,
|
|
251
|
+
summary,
|
|
252
|
+
describe,
|
|
253
|
+
main,
|
|
254
|
+
};
|
|
@@ -204,8 +204,18 @@ function line(built, options) {
|
|
|
204
204
|
: bars.paint(effortName, effort.rgb, mode);
|
|
205
205
|
// The word, in the rainbow, the way Claude Code paints it in the prompt.
|
|
206
206
|
const thinking = built.ultrathink ? ' ' + bars.dim('·', mode) + ' ' + bars.rainbow('ultrathink', tick, { mode, reduced }) : '';
|
|
207
|
+
// The budget mode, only when it is not the one the plugin has always been
|
|
208
|
+
// in. `standard` is today's behaviour and today's line, unchanged to the
|
|
209
|
+
// character; anything else changes what the hooks say, and a line that does
|
|
210
|
+
// not mention it leaves the user guessing why the briefing went quiet.
|
|
211
|
+
// It rides on the head so the width ladder drops it with the head, before it
|
|
212
|
+
// ever costs a percentage its place.
|
|
213
|
+
const budgetText =
|
|
214
|
+
built.budget && built.budget.name !== 'standard'
|
|
215
|
+
? ' ' + bars.dim('·', mode) + ' ' + bars.dim('budget ' + built.budget.label, mode)
|
|
216
|
+
: '';
|
|
207
217
|
const head =
|
|
208
|
-
glyph + ' ' + built.modelLabel + (effortText ? ' ' + bars.dim('·', mode) + ' ' + effortText : '') + thinking;
|
|
218
|
+
glyph + ' ' + built.modelLabel + (effortText ? ' ' + bars.dim('·', mode) + ' ' + effortText : '') + thinking + budgetText;
|
|
209
219
|
|
|
210
220
|
const segment = (row, width, shorter) => {
|
|
211
221
|
const label = shortLabel(row, shorter);
|
|
@@ -396,6 +406,17 @@ function motionOff(settings, env) {
|
|
|
396
406
|
|
|
397
407
|
// Written and flushed before the process is allowed to end: stdout is a pipe
|
|
398
408
|
// here, and a pipe write can still be in flight when process.exit runs.
|
|
409
|
+
// The budget mode, for the token on the line. Wrapped and lazy: the status
|
|
410
|
+
// line must never fail over an optional word, and a machine that has never set
|
|
411
|
+
// a mode should not pay for a require to be told so.
|
|
412
|
+
function budgetNow(sessionId) {
|
|
413
|
+
try {
|
|
414
|
+
return require('./mode.js').forSession({ sessionId });
|
|
415
|
+
} catch (err) {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
399
420
|
function out(text) {
|
|
400
421
|
return new Promise((resolve) => {
|
|
401
422
|
process.stdout.write(text, () => resolve());
|
|
@@ -461,6 +482,7 @@ async function main(argv) {
|
|
|
461
482
|
const built = view.build({
|
|
462
483
|
now,
|
|
463
484
|
agents: usage.liveAgents(now),
|
|
485
|
+
budget: budgetNow(mine || (slot && slot.sessionId) || null),
|
|
464
486
|
utilization: collected.utilization,
|
|
465
487
|
fetchedAtMs: collected.snapshotFetchedAt,
|
|
466
488
|
source: collected.snapshotSource,
|
|
@@ -18,6 +18,15 @@ const path = require('path');
|
|
|
18
18
|
|
|
19
19
|
const CLAUDE = 'claude';
|
|
20
20
|
const CODEX = 'codex';
|
|
21
|
+
const GEMINI = 'gemini';
|
|
22
|
+
|
|
23
|
+
function geminiConfigDir() {
|
|
24
|
+
return process.env.GEMINI_CONFIG_DIR || path.join(os.homedir(), '.gemini');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function geminiHome() {
|
|
28
|
+
return process.env.GEMINI_HOME || path.join(geminiConfigDir(), 'antigravity-cli');
|
|
29
|
+
}
|
|
21
30
|
|
|
22
31
|
function codexHome() {
|
|
23
32
|
return process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
@@ -66,16 +75,20 @@ function codexHasSessions() {
|
|
|
66
75
|
return exists(path.join(codexHome(), 'sessions'));
|
|
67
76
|
}
|
|
68
77
|
|
|
78
|
+
function geminiHasSessions() {
|
|
79
|
+
return exists(geminiHome());
|
|
80
|
+
}
|
|
81
|
+
|
|
69
82
|
function normalise(value) {
|
|
70
83
|
const name = String(value || '').trim().toLowerCase();
|
|
84
|
+
if (name === GEMINI || name === 'agy' || name === 'antigravity' || name === 'google') return GEMINI;
|
|
71
85
|
if (name === CODEX || name === 'chatgpt' || name === 'openai') return CODEX;
|
|
72
86
|
if (name === CLAUDE || name === 'claude-code' || name === 'anthropic') return CLAUDE;
|
|
73
87
|
return null;
|
|
74
88
|
}
|
|
75
89
|
|
|
76
|
-
// `--host
|
|
77
|
-
// actually on disk.
|
|
78
|
-
// and its reader fails loudly rather than silently reporting nothing.
|
|
90
|
+
// `--host gemini` beats everything, then the environment variable, then what is
|
|
91
|
+
// actually on disk.
|
|
79
92
|
function detect(argv, env) {
|
|
80
93
|
const args = argv || [];
|
|
81
94
|
const at = args.indexOf('--host');
|
|
@@ -86,6 +99,8 @@ function detect(argv, env) {
|
|
|
86
99
|
const fromEnv = normalise(environment.USAGE_LIMITS_HOST);
|
|
87
100
|
if (fromEnv) return fromEnv;
|
|
88
101
|
|
|
102
|
+
// Set by Antigravity / Gemini CLI
|
|
103
|
+
if (environment.ANTIGRAVITY_CLI || environment.GEMINI_CLI || environment.GEMINI_WORKSPACE) return GEMINI;
|
|
89
104
|
// Set by Claude Code for plugin hooks and commands.
|
|
90
105
|
if (environment.CLAUDE_PLUGIN_ROOT || environment.CLAUDE_PROJECT_DIR) return CLAUDE;
|
|
91
106
|
// Set by Codex for the processes it launches.
|
|
@@ -93,14 +108,19 @@ function detect(argv, env) {
|
|
|
93
108
|
|
|
94
109
|
if (claudeHasSnapshot()) return CLAUDE;
|
|
95
110
|
if (codexHasSessions()) return CODEX;
|
|
111
|
+
if (geminiHasSessions()) return GEMINI;
|
|
96
112
|
return CLAUDE;
|
|
97
113
|
}
|
|
98
114
|
|
|
99
115
|
module.exports = {
|
|
100
116
|
CLAUDE,
|
|
101
117
|
CODEX,
|
|
118
|
+
GEMINI,
|
|
102
119
|
detect,
|
|
103
120
|
normalise,
|
|
121
|
+
geminiHome,
|
|
122
|
+
geminiConfigDir,
|
|
123
|
+
geminiHasSessions,
|
|
104
124
|
codexHome,
|
|
105
125
|
claudeConfigDir,
|
|
106
126
|
claudeHasSnapshot,
|