claude-usage-limits 1.19.0 → 1.23.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/usage-mode.md +64 -0
- package/hooks/hooks.json +1 -1
- package/package.json +1 -1
- package/skills/usage-limits/SKILL.md +166 -18
- 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/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/pulse.js +254 -17
- package/skills/usage-limits/scripts/reading.js +12 -3
- package/skills/usage-limits/scripts/sessionend.js +8 -0
- package/skills/usage-limits/scripts/stop.js +43 -0
- 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
|
@@ -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,
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Installs the plugin into Antigravity.
|
|
5
|
+
//
|
|
6
|
+
// Antigravity discovers customizations from ~/.gemini/config/ on a machine, and
|
|
7
|
+
// a plugin there is a directory holding plugin.json, optionally hooks.json,
|
|
8
|
+
// rules/ and skills/. That is documented on the machine itself, in the built-in
|
|
9
|
+
// agy-customizations skill, and this installer follows it rather than guessing:
|
|
10
|
+
//
|
|
11
|
+
// ~/.gemini/config/plugins/usage-limits/
|
|
12
|
+
// plugin.json the marker that makes the directory a plugin
|
|
13
|
+
// hooks.json PreInvocation for the budget line, PreToolUse for the ceiling
|
|
14
|
+
// rules/AGENTS.md the always-on rules
|
|
15
|
+
//
|
|
16
|
+
// The installed directory is deliberately small. Hooks run with their working
|
|
17
|
+
// directory set to the folder containing hooks.json, so a copied plugin would
|
|
18
|
+
// need the whole script tree copied with it and would then go stale the moment
|
|
19
|
+
// the real one was updated. Instead the commands carry an absolute path back to
|
|
20
|
+
// this checkout, so there is one copy of the code and updating it updates what
|
|
21
|
+
// Antigravity runs.
|
|
22
|
+
//
|
|
23
|
+
// node install-antigravity.js status
|
|
24
|
+
// node install-antigravity.js on
|
|
25
|
+
// node install-antigravity.js off
|
|
26
|
+
//
|
|
27
|
+
// Everything written is confined to that one directory, and `off` removes
|
|
28
|
+
// exactly what `on` created and nothing else.
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const os = require('os');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
|
|
34
|
+
const host = require('./host.js');
|
|
35
|
+
|
|
36
|
+
const PLUGIN = 'usage-limits';
|
|
37
|
+
|
|
38
|
+
function pluginsDir() {
|
|
39
|
+
return path.join(host.geminiConfigDir(), 'config', 'plugins');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function pluginDir() {
|
|
43
|
+
return path.join(pluginsDir(), PLUGIN);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Forward slashes on every platform. They work in Windows paths and keep the
|
|
47
|
+
// command free of escapes in both JSON and the shell that runs it. Antigravity
|
|
48
|
+
// runs hook commands through `cmd /c` on Windows and `sh -c` elsewhere, so the
|
|
49
|
+
// quoting has to survive both.
|
|
50
|
+
function quote(file) {
|
|
51
|
+
return '"' + String(file).replace(/\\/g, '/') + '"';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function scriptPath(name) {
|
|
55
|
+
return path.join(__dirname, name);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hookCommand(event) {
|
|
59
|
+
return 'node ' + quote(scriptPath('agy-hook.js')) + ' --event ' + event;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function manifest() {
|
|
63
|
+
return {
|
|
64
|
+
name: PLUGIN,
|
|
65
|
+
description:
|
|
66
|
+
'Puts the remaining usage budget in front of the agent before each turn, and enforces a ' +
|
|
67
|
+
'ceiling past which fan-out calls are refused.',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hooks() {
|
|
72
|
+
return {
|
|
73
|
+
'usage-limits-brief': {
|
|
74
|
+
PreInvocation: [{ type: 'command', command: hookCommand('PreInvocation'), timeout: 10 }],
|
|
75
|
+
},
|
|
76
|
+
'usage-limits-ceiling': {
|
|
77
|
+
PreToolUse: [
|
|
78
|
+
{
|
|
79
|
+
matcher: '.*',
|
|
80
|
+
hooks: [{ type: 'command', command: hookCommand('PreToolUse'), timeout: 10 }],
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function rulesText() {
|
|
88
|
+
// Shipped from the repo so there is one copy of the wording, but never fails
|
|
89
|
+
// the install over a missing file: the hooks are the part that matters.
|
|
90
|
+
try {
|
|
91
|
+
return fs.readFileSync(path.join(__dirname, '..', '..', '..', 'rules', 'AGENTS.md'), 'utf8');
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function writeFile(file, text) {
|
|
98
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
99
|
+
const tmp = file + '.' + process.pid + '.tmp';
|
|
100
|
+
fs.writeFileSync(tmp, text, 'utf8');
|
|
101
|
+
fs.renameSync(tmp, file);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function installed() {
|
|
105
|
+
try {
|
|
106
|
+
fs.accessSync(path.join(pluginDir(), 'plugin.json'));
|
|
107
|
+
return true;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Whether Antigravity is on this machine at all. Its config directory is the
|
|
114
|
+
// evidence; ~/.gemini alone is not, because the Gemini CLI uses that too.
|
|
115
|
+
function present() {
|
|
116
|
+
return host.exists(path.join(host.geminiConfigDir(), 'antigravity-cli')) ||
|
|
117
|
+
host.exists(path.join(host.geminiConfigDir(), 'antigravity'));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function status() {
|
|
121
|
+
const lines = [];
|
|
122
|
+
lines.push('Antigravity ' + (present() ? 'found at ' + host.geminiConfigDir() : 'not found on this machine'));
|
|
123
|
+
lines.push('Plugin ' + (installed() ? 'installed at ' + pluginDir() : 'not installed'));
|
|
124
|
+
if (installed()) {
|
|
125
|
+
let current = null;
|
|
126
|
+
try {
|
|
127
|
+
current = JSON.parse(fs.readFileSync(path.join(pluginDir(), 'hooks.json'), 'utf8'));
|
|
128
|
+
} catch (err) {
|
|
129
|
+
// An unreadable hooks.json is worth saying rather than throwing.
|
|
130
|
+
}
|
|
131
|
+
const events = current ? Object.keys(current).map((name) => Object.keys(current[name]).filter((k) => k !== 'enabled').join(', ')) : [];
|
|
132
|
+
lines.push('Hooks ' + (events.length ? events.join(', ') : 'none readable'));
|
|
133
|
+
lines.push(
|
|
134
|
+
'Enabled recorded in ' +
|
|
135
|
+
path.join(host.geminiConfigDir(), 'config', 'config.json') +
|
|
136
|
+
' under plugins.' + PLUGIN + '; a plugin with no entry there is on by default.'
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (!present()) {
|
|
140
|
+
lines.push('');
|
|
141
|
+
lines.push('Nothing to install into. Antigravity keeps its configuration in ~/.gemini/config.');
|
|
142
|
+
}
|
|
143
|
+
return lines.join('\n');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function enable() {
|
|
147
|
+
if (!present()) {
|
|
148
|
+
return (
|
|
149
|
+
'Antigravity was not found on this machine, so there is nothing to install into.\n' +
|
|
150
|
+
'Expected its configuration at ' + host.geminiConfigDir() + '.'
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const dir = pluginDir();
|
|
154
|
+
writeFile(path.join(dir, 'plugin.json'), JSON.stringify(manifest(), null, 2) + '\n');
|
|
155
|
+
writeFile(path.join(dir, 'hooks.json'), JSON.stringify(hooks(), null, 2) + '\n');
|
|
156
|
+
const rules = rulesText();
|
|
157
|
+
if (rules) writeFile(path.join(dir, 'rules', 'AGENTS.md'), rules);
|
|
158
|
+
|
|
159
|
+
return [
|
|
160
|
+
'Installed into ' + dir + '.',
|
|
161
|
+
' PreInvocation the budget line, as an injected ephemeral message',
|
|
162
|
+
' PreToolUse the ceiling, which refuses fan-out calls past it',
|
|
163
|
+
rules ? ' rules/AGENTS.md the always-on rules' : ' (rules/AGENTS.md was not found in this checkout and was skipped)',
|
|
164
|
+
'',
|
|
165
|
+
'The hooks run this checkout directly, so updating it updates what Antigravity runs.',
|
|
166
|
+
'Antigravity picks the plugin up when it next starts. There is no quota figure for it: ' +
|
|
167
|
+
'Antigravity refreshes its own quota but writes it nowhere readable, so the budget line ' +
|
|
168
|
+
'reports what it can and says so where it cannot.',
|
|
169
|
+
].join('\n');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Removes only what enable() wrote, one named file at a time, and only takes
|
|
173
|
+
// the directory away when nothing else has been put in it.
|
|
174
|
+
function disable() {
|
|
175
|
+
const dir = pluginDir();
|
|
176
|
+
if (!installed()) return 'Not installed. Nothing to remove.';
|
|
177
|
+
const removed = [];
|
|
178
|
+
for (const relative of ['plugin.json', 'hooks.json', path.join('rules', 'AGENTS.md')]) {
|
|
179
|
+
const file = path.join(dir, relative);
|
|
180
|
+
try {
|
|
181
|
+
fs.unlinkSync(file);
|
|
182
|
+
removed.push(relative);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
// Already gone is the outcome that was asked for.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
for (const relative of ['rules', '']) {
|
|
188
|
+
try {
|
|
189
|
+
fs.rmdirSync(path.join(dir, relative));
|
|
190
|
+
} catch (err) {
|
|
191
|
+
// Not empty, or already gone. Either way it is not ours to force.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return 'Removed ' + (removed.length ? removed.join(', ') : 'nothing') + ' from ' + dir + '.';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function main(argv) {
|
|
198
|
+
const command = (argv || []).find((arg) => !arg.startsWith('-')) || 'status';
|
|
199
|
+
if (command === 'status') return status();
|
|
200
|
+
if (command === 'on') return enable();
|
|
201
|
+
if (command === 'off') return disable();
|
|
202
|
+
throw new Error('Expected status, on or off');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (require.main === module) {
|
|
206
|
+
try {
|
|
207
|
+
process.stdout.write(main(process.argv.slice(2)) + '\n');
|
|
208
|
+
process.exitCode = 0;
|
|
209
|
+
} catch (err) {
|
|
210
|
+
process.stderr.write('install-antigravity: ' + (err && err.message ? err.message : String(err)) + '\n');
|
|
211
|
+
process.exitCode = 1;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = { PLUGIN, pluginsDir, pluginDir, hookCommand, manifest, hooks, installed, present, status, enable, disable, main };
|
|
@@ -53,6 +53,22 @@ const EVENTS = [
|
|
|
53
53
|
// tool calls of its own for as long as they run. Same quiet refresh as on
|
|
54
54
|
// Claude Code; pulse.js sees the event name and says nothing.
|
|
55
55
|
{ event: 'SubagentStop', script: 'pulse.js', status: 'Checking usage limits' },
|
|
56
|
+
// The ceiling, and the only entry here that can refuse anything.
|
|
57
|
+
//
|
|
58
|
+
// Codex is the host where a reported figure demonstrably does not change
|
|
59
|
+
// behaviour, and the reason is not stubbornness: gpt-6-astra's own system
|
|
60
|
+
// prompt, shipped in models_cache.json, tells it "do not settle for a partial
|
|
61
|
+
// or helpful enough solution that does not fully satisfy the user's task to
|
|
62
|
+
// save time, effort or tokens", and ranks the live user instruction above
|
|
63
|
+
// anything an AGENTS.md or a skill says. A line asking it to economise is
|
|
64
|
+
// arguing with its own instructions and losing.
|
|
65
|
+
//
|
|
66
|
+
// So this one does not ask. PreToolUse takes a permissionDecision of "deny",
|
|
67
|
+
// and past the ceiling the fan-out calls get one. The matcher is broad
|
|
68
|
+
// because ceiling.js decides what is actually a multiplier; a hook that fires
|
|
69
|
+
// and returns nothing costs a few milliseconds, and a matcher that misses a
|
|
70
|
+
// renamed tool costs the window.
|
|
71
|
+
{ event: 'PreToolUse', script: 'pulse.js', status: 'Checking usage limits', matcher: '.*' },
|
|
56
72
|
];
|
|
57
73
|
const EVENT = EVENTS[0].event;
|
|
58
74
|
// Ten seconds is the same budget the Claude hook gets. The brief caches the
|
|
@@ -360,7 +376,7 @@ function enable() {
|
|
|
360
376
|
|
|
361
377
|
for (const one of EVENTS) {
|
|
362
378
|
const rest = withoutOurs(config.hooks[one.event]);
|
|
363
|
-
|
|
379
|
+
const group = {
|
|
364
380
|
hooks: [
|
|
365
381
|
{
|
|
366
382
|
type: 'command',
|
|
@@ -369,7 +385,11 @@ function enable() {
|
|
|
369
385
|
statusMessage: one.status,
|
|
370
386
|
},
|
|
371
387
|
],
|
|
372
|
-
}
|
|
388
|
+
};
|
|
389
|
+
// Tool-scoped events take a matcher; the others ignore one, and writing a
|
|
390
|
+
// matcher where none belongs is the kind of thing a strict parser rejects.
|
|
391
|
+
if (one.matcher) group.matcher = one.matcher;
|
|
392
|
+
rest.push(group);
|
|
373
393
|
config.hooks[one.event] = rest;
|
|
374
394
|
}
|
|
375
395
|
if (!config.description) {
|