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.
Files changed (33) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +220 -2
  4. package/bin/cli.js +16 -0
  5. package/commands/defer.md +47 -0
  6. package/commands/usage-mode.md +64 -0
  7. package/hooks/hooks.json +1 -1
  8. package/package.json +1 -1
  9. package/skills/usage-limits/SKILL.md +196 -19
  10. package/skills/usage-limits/references/tactics.md +40 -9
  11. package/skills/usage-limits/scripts/agy-hook.js +175 -0
  12. package/skills/usage-limits/scripts/brief.js +406 -46
  13. package/skills/usage-limits/scripts/ceiling.js +191 -0
  14. package/skills/usage-limits/scripts/codex-lowpower.js +95 -4
  15. package/skills/usage-limits/scripts/codex.js +87 -6
  16. package/skills/usage-limits/scripts/defer.js +318 -0
  17. package/skills/usage-limits/scripts/drift.js +254 -0
  18. package/skills/usage-limits/scripts/feed.js +23 -1
  19. package/skills/usage-limits/scripts/host.js +23 -3
  20. package/skills/usage-limits/scripts/install-antigravity.js +215 -0
  21. package/skills/usage-limits/scripts/install-codex-hook.js +22 -2
  22. package/skills/usage-limits/scripts/lowpower.js +48 -0
  23. package/skills/usage-limits/scripts/mode.js +1637 -0
  24. package/skills/usage-limits/scripts/net.js +179 -0
  25. package/skills/usage-limits/scripts/pulse.js +254 -17
  26. package/skills/usage-limits/scripts/reading.js +12 -3
  27. package/skills/usage-limits/scripts/relay.js +266 -2
  28. package/skills/usage-limits/scripts/sessionend.js +8 -0
  29. package/skills/usage-limits/scripts/stop.js +145 -1
  30. package/skills/usage-limits/scripts/usage.js +244 -17
  31. package/skills/usage-limits/scripts/view.js +4 -0
  32. package/skills/usage-limits/scripts/voice.js +10 -1
  33. 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 codex` beats everything, then the environment variable, then what is
77
- // actually on disk. Claude wins ties: it is the host the hook was written for,
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,