claude-usage-limits 1.18.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 +364 -17
- package/skills/usage-limits/scripts/view.js +4 -0
- package/skills/usage-limits/scripts/voice.js +10 -1
|
@@ -27,6 +27,8 @@ const brief = require('./brief.js');
|
|
|
27
27
|
const host = require('./host.js');
|
|
28
28
|
const activity = require('./activity.js');
|
|
29
29
|
const live = require('./live.js');
|
|
30
|
+
const mode = require('./mode.js');
|
|
31
|
+
const ceiling = require('./ceiling.js');
|
|
30
32
|
|
|
31
33
|
const SECOND = 1000;
|
|
32
34
|
const DEFAULT_INTERVAL_SECONDS = 120;
|
|
@@ -37,9 +39,11 @@ const DEFAULT_INTERVAL_SECONDS = 120;
|
|
|
37
39
|
const SCAN_BUDGET_MS = 5000;
|
|
38
40
|
|
|
39
41
|
// One slot per session, same shape and same trimming as the brief's cache.
|
|
40
|
-
//
|
|
41
|
-
// so this is
|
|
42
|
-
|
|
42
|
+
// Three keys per session now - the spoken pulse, the quiet subagent refresh,
|
|
43
|
+
// and the reading taken before a long call - so this is three times what it
|
|
44
|
+
// started at. Evicting a key only costs one extra scan, but evicting them as
|
|
45
|
+
// fast as they are written would mean nothing is ever throttled.
|
|
46
|
+
const KEEP_SESSIONS = 24;
|
|
43
47
|
|
|
44
48
|
function stateFile() {
|
|
45
49
|
const dir = usage.isCodex()
|
|
@@ -48,12 +52,15 @@ function stateFile() {
|
|
|
48
52
|
return path.join(dir, 'usage-limits-pulse.json');
|
|
49
53
|
}
|
|
50
54
|
|
|
51
|
-
|
|
55
|
+
// How often this hook is allowed to do real work. The budget mode owns this
|
|
56
|
+
// number - a mode that says the reading itself costs too much has to be able
|
|
57
|
+
// to take fewer of them - but an explicit environment setting is the user
|
|
58
|
+
// saying it outright, and that still wins.
|
|
59
|
+
function intervalMs(policy) {
|
|
52
60
|
const configured = Number(process.env.USAGE_LIMITS_PULSE_SECONDS);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return seconds * SECOND;
|
|
61
|
+
if (Number.isFinite(configured) && configured > 0) return configured * SECOND;
|
|
62
|
+
const fromMode = policy && Number.isFinite(policy.pulseSeconds) ? policy.pulseSeconds : null;
|
|
63
|
+
return (fromMode > 0 ? fromMode : DEFAULT_INTERVAL_SECONDS) * SECOND;
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
function readState() {
|
|
@@ -72,15 +79,42 @@ function writeState(all) {
|
|
|
72
79
|
usage.writeJsonAtomic(stateFile(), all);
|
|
73
80
|
}
|
|
74
81
|
|
|
75
|
-
|
|
82
|
+
// `extra` carries whatever else the slot has to remember, and it exists for
|
|
83
|
+
// one reason: the re-cost has to know what it said last time. A slot that is
|
|
84
|
+
// only a timestamp can throttle, but it cannot tell "two minutes have passed"
|
|
85
|
+
// from "two minutes have passed and nothing has changed", and those are
|
|
86
|
+
// different questions.
|
|
87
|
+
function trim(all, sessionId, at, extra) {
|
|
76
88
|
const next = Object.assign({}, all);
|
|
77
|
-
next[sessionId || '_'] = { at };
|
|
89
|
+
next[sessionId || '_'] = Object.assign({ at }, extra || null);
|
|
78
90
|
const ordered = Object.keys(next).sort((a, b) => (next[b].at || 0) - (next[a].at || 0));
|
|
79
91
|
const kept = {};
|
|
80
92
|
for (const key of ordered.slice(0, KEEP_SESSIONS)) kept[key] = next[key];
|
|
81
93
|
return kept;
|
|
82
94
|
}
|
|
83
95
|
|
|
96
|
+
// A tool call the agent itself said would run long.
|
|
97
|
+
//
|
|
98
|
+
// PreToolUse now matches Bash, so a build or a test suite can be measured
|
|
99
|
+
// before it starts rather than only after. But sharing the spoken pulse's one
|
|
100
|
+
// throttle slot makes that almost never happen: a tool call finished seconds
|
|
101
|
+
// ago, that PostToolUse pulse claimed the slot, and the one moment that
|
|
102
|
+
// matters - just before the turn goes blind for ten minutes - says nothing.
|
|
103
|
+
// Measured: a PostToolUse pulse at t, then `npm test` with a ten-minute
|
|
104
|
+
// timeout at t+5s, and the pre-call pulse returned empty.
|
|
105
|
+
//
|
|
106
|
+
// The declaration is the agent's own: a Bash call carries the timeout it was
|
|
107
|
+
// given, and anything above the pulse interval is a call that will outlast the
|
|
108
|
+
// next scheduled reading. Nothing is inferred from the command text. A
|
|
109
|
+
// backgrounded call is not this: it returns at once and PostToolUse fires
|
|
110
|
+
// normally, so the turn never goes blind.
|
|
111
|
+
function longCall(toolInput, every) {
|
|
112
|
+
if (!toolInput || typeof toolInput !== 'object') return false;
|
|
113
|
+
if (toolInput.run_in_background === true) return false;
|
|
114
|
+
const declared = Number(toolInput.timeout);
|
|
115
|
+
return Number.isFinite(declared) && declared > every;
|
|
116
|
+
}
|
|
117
|
+
|
|
84
118
|
function due(all, sessionId, now, every) {
|
|
85
119
|
const entry = all ? all[sessionId || '_'] : null;
|
|
86
120
|
if (!entry || !Number.isFinite(entry.at)) return true;
|
|
@@ -128,13 +162,62 @@ function pulseText(parts) {
|
|
|
128
162
|
return head + ' Still room; carry on.';
|
|
129
163
|
}
|
|
130
164
|
|
|
165
|
+
// The two-minute re-cost. The distinguishing feature of `high`, and the reason
|
|
166
|
+
// it is not simply `standard` with a shorter interval.
|
|
167
|
+
//
|
|
168
|
+
// A PostToolUse hook CAN put text in front of the model mid-turn: the hook
|
|
169
|
+
// output contract takes additionalContext on this event, and it has been seen
|
|
170
|
+
// arriving mid-turn in a running session. So this is a real nudge, not a note
|
|
171
|
+
// filed for the next prompt.
|
|
172
|
+
//
|
|
173
|
+
// What it must not do is claim a lever nobody has. Nothing a hook emits can
|
|
174
|
+
// change the running session's own model or effort - there is no such field in
|
|
175
|
+
// the whole hook output contract, and PreModelSwitch is a veto on a switch
|
|
176
|
+
// someone else started rather than a way to start one. So for the main loop
|
|
177
|
+
// this names the command and leaves it with the person, and for what the turn
|
|
178
|
+
// SPAWNS it says the thing that is genuinely the agent's to decide.
|
|
179
|
+
//
|
|
180
|
+
// It is built every two minutes and SAID only when it differs from the last
|
|
181
|
+
// thing said in this session. See the caller: a re-cost that repeats itself is
|
|
182
|
+
// not a re-cost, it is a bill for advice already given.
|
|
183
|
+
function recheckText(parts) {
|
|
184
|
+
const bits = [];
|
|
185
|
+
if (parts.fit) {
|
|
186
|
+
bits.push(
|
|
187
|
+
'this turn is at ' + parts.fit.effort + ', measured ' + parts.fit.multiple + ' times the cost of ' +
|
|
188
|
+
parts.fit.cheaper + ' a turn on this account'
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
if (parts.escape && parts.escape.kind === 'effort' && parts.escape.to) {
|
|
192
|
+
bits.push(parts.escape.to + ' would free the window that binds');
|
|
193
|
+
}
|
|
194
|
+
if (!bits.length) return '';
|
|
195
|
+
const command = (parts.fit && parts.fit.command) || (parts.escape && parts.escape.command) || null;
|
|
196
|
+
return (
|
|
197
|
+
'[usage-limits] Re-costed: ' + bits.join(', and ') + '. If the stretch in front of you is ' +
|
|
198
|
+
'mechanical, the tier is bigger than the work' + (command ? ' - the change is ' + command + ', and it is the ' +
|
|
199
|
+
"user's to make, so say it in one line rather than waiting for it" : '') +
|
|
200
|
+
(parts.pin
|
|
201
|
+
? '. Self-switching is pinned, so this is a report: change nothing on the strength of it.'
|
|
202
|
+
: '. Size what you spawn the same way: low effort for mechanical stages. Step back up when the work turns hard again.')
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
131
206
|
async function run(now, hookInput) {
|
|
132
207
|
if (String(process.env.USAGE_LIMITS_PULSE || '').toLowerCase() === 'off') return '';
|
|
133
|
-
usage.setHost(host.detect(process.argv.slice(2), process.env));
|
|
134
208
|
|
|
135
209
|
const sessionId = hookInput && hookInput.session_id ? hookInput.session_id : null;
|
|
136
210
|
const event = hookInput && hookInput.hook_event_name ? String(hookInput.hook_event_name) : 'PostToolUse';
|
|
137
211
|
|
|
212
|
+
// The budget mode, settled before anything is read, marked or scanned. In
|
|
213
|
+
// `off` the hook stops here: no reading, no activity mark, no state write.
|
|
214
|
+
// That mode's whole promise is that the plugin costs nothing, and a hook
|
|
215
|
+
// that "returns immediately" after a transcript scan has already broken it.
|
|
216
|
+
const budget = mode.forSession({ sessionId });
|
|
217
|
+
if (budget.policy.refreshSeconds === 0) return '';
|
|
218
|
+
|
|
219
|
+
usage.setHost(host.detect(process.argv.slice(2), process.env));
|
|
220
|
+
|
|
138
221
|
// A subagent finishing is the other reason to look, and on a busy afternoon
|
|
139
222
|
// it is the more important one.
|
|
140
223
|
//
|
|
@@ -156,6 +239,30 @@ async function run(now, hookInput) {
|
|
|
156
239
|
// pulse had last read as 24%.
|
|
157
240
|
const tool = hookInput && hookInput.tool_name ? String(hookInput.tool_name) : '';
|
|
158
241
|
const fanout = event === 'PreToolUse' && /^(Workflow|Agent|Task)$/.test(tool);
|
|
242
|
+
|
|
243
|
+
// The ceiling, checked before the throttle and before any scan.
|
|
244
|
+
//
|
|
245
|
+
// Everything else in this hook is advice, and advice is throttled so it does
|
|
246
|
+
// not become noise. A refusal that only arrives when the pulse happens to be
|
|
247
|
+
// due is not a refusal, so this runs on every fan-out regardless, off the two
|
|
248
|
+
// readings that are already on disk. It is the one thing here that does not
|
|
249
|
+
// ask the model to agree with it.
|
|
250
|
+
if (event === 'PreToolUse' && ceiling.isMultiplier(tool)) {
|
|
251
|
+
try {
|
|
252
|
+
const at = ceiling.assess({
|
|
253
|
+
percent: ceilingPercent(now),
|
|
254
|
+
state: budget.state,
|
|
255
|
+
env: process.env,
|
|
256
|
+
});
|
|
257
|
+
const call = ceiling.verdict(at, tool);
|
|
258
|
+
if (call.decision === 'deny') return { deny: true, reason: call.reason };
|
|
259
|
+
} catch (err) {
|
|
260
|
+
// A ceiling that throws must not block the call it was asked to judge.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// The other way a turn goes quiet for a long time: one foreground tool call
|
|
264
|
+
// that runs for minutes. See longCall().
|
|
265
|
+
const long = event === 'PreToolUse' && !fanout && longCall(hookInput && hookInput.tool_input, intervalMs(budget.policy));
|
|
159
266
|
if (!quiet) {
|
|
160
267
|
// A tool call just finished, so the turn is still running. A few bytes, so
|
|
161
268
|
// the panel beside the chat keeps animating through a long turn.
|
|
@@ -163,12 +270,20 @@ async function run(now, hookInput) {
|
|
|
163
270
|
}
|
|
164
271
|
|
|
165
272
|
const all = readState();
|
|
166
|
-
const every = intervalMs();
|
|
273
|
+
const every = intervalMs(budget.policy);
|
|
167
274
|
// The quiet refresh keeps its own throttle. Sharing one with the spoken
|
|
168
275
|
// pulse would mean a workflow's subagents used up the interval and the tool
|
|
169
276
|
// call right after it, the first chance to actually tell Claude, said
|
|
170
277
|
// nothing because something had already "pulsed" two minutes ago.
|
|
171
|
-
|
|
278
|
+
// Same reasoning for the call about to run long: its own slot, so it is
|
|
279
|
+
// still throttled to one reading per interval and a run of long calls does
|
|
280
|
+
// not scan before every one of them, but the post-tool pulses cannot use up
|
|
281
|
+
// the interval and leave the blind stretch unmeasured.
|
|
282
|
+
const throttleKey = quiet
|
|
283
|
+
? (sessionId || '_') + '#subagent'
|
|
284
|
+
: long
|
|
285
|
+
? (sessionId || '_') + '#long'
|
|
286
|
+
: sessionId;
|
|
172
287
|
// The cheap path, and the one taken almost every time. A fan-out is never
|
|
173
288
|
// throttled: it is said every time, because every time it is about to cost.
|
|
174
289
|
if (!fanout && !due(all, throttleKey, now, every)) return '';
|
|
@@ -222,13 +337,66 @@ async function run(now, hookInput) {
|
|
|
222
337
|
const runwayMs = brief.RUNWAY_MENTION_MS;
|
|
223
338
|
const pressure = brief.pressure(binding, now, config, turnsLeft);
|
|
224
339
|
|
|
340
|
+
// The mode's own mid-turn re-cost, on its own throttle slot.
|
|
341
|
+
//
|
|
342
|
+
// Its own slot because the "quiet when roomy" rule below would otherwise
|
|
343
|
+
// swallow it exactly when it matters most: a turn running a tier far bigger
|
|
344
|
+
// than the work needs is precisely the case where the budget still looks
|
|
345
|
+
// roomy and nothing else would say a word.
|
|
346
|
+
let recheck = '';
|
|
347
|
+
const recheckMs = (budget.policy.recheckSeconds || 0) * SECOND;
|
|
348
|
+
const recheckKey = (sessionId || '_') + '#recheck';
|
|
349
|
+
if (recheckMs > 0 && due(readState(), recheckKey, now, recheckMs)) {
|
|
350
|
+
// From the per-effort TABLE, which is what report() returns. It was asked
|
|
351
|
+
// for from `data.events`, a field report() has never had, so the measured
|
|
352
|
+
// half of the re-cost was null on every call on every machine and `high`
|
|
353
|
+
// was left emitting its escape clause alone.
|
|
354
|
+
const fit = usage.fitFromRates(data.effortRates || [], data.effortNow || null, usage.currentHost());
|
|
355
|
+
// The user's bounds are the user's, and `pin` is one of them: it lives on
|
|
356
|
+
// bounds, not on the mode's policy, so it has to be handed over here or
|
|
357
|
+
// the route comes back unable to say it is a report.
|
|
358
|
+
const route = usage.escapeRoute(data.windows || [binding], binding, data.effortWarning || null, usage.currentHost(), budget.policy, budget.bounds);
|
|
359
|
+
const allowed = (suggestion) => mode.allows(budget.bounds, suggestion);
|
|
360
|
+
// The measured half is a recommendation about the user's own setting, so
|
|
361
|
+
// it goes out through the advice rules rather than around them: never once
|
|
362
|
+
// muted, never once declined, never below a bound, and not a second time
|
|
363
|
+
// in a session where the brief has already made it. Filtering only on the
|
|
364
|
+
// bounds here meant `mode --no-advice` and `mode --decline` were both
|
|
365
|
+
// routed around every two minutes, in the one mode that speaks mid-turn.
|
|
366
|
+
const advice = mode.advicePending({ decided: budget, fit, sessionId });
|
|
367
|
+
const offerFit = advice.ok && !advice.alreadyOffered ? fit : null;
|
|
368
|
+
const text = recheckText({
|
|
369
|
+
fit: offerFit,
|
|
370
|
+
escape: route && route.kind === 'effort' && allowed({ effort: route.to }) ? route : null,
|
|
371
|
+
pin: budget.bounds && budget.bounds.pin,
|
|
372
|
+
});
|
|
373
|
+
// Say it once.
|
|
374
|
+
//
|
|
375
|
+
// Nothing this line is built from moves within a turn, so on the throttle
|
|
376
|
+
// alone it repeated itself verbatim every two minutes: measured, fifteen
|
|
377
|
+
// identical injections in one half-hour turn at 20 per cent used. That is
|
|
378
|
+
// the failure this whole feature exists to avoid, arriving in the mode
|
|
379
|
+
// named for efficiency. `high` re-costs continuously - it takes the
|
|
380
|
+
// measurement every two minutes - and speaks when the answer CHANGES.
|
|
381
|
+
const state = readState();
|
|
382
|
+
const before = state[recheckKey] && state[recheckKey].said;
|
|
383
|
+
recheck = text && text !== before ? text : '';
|
|
384
|
+
// Recorded only when it is actually said, and only for the half that is a
|
|
385
|
+
// recommendation, so that "no, leave it" has something to refuse: without
|
|
386
|
+
// this, `mode --decline` after a mid-turn re-cost had no id to act on.
|
|
387
|
+
if (recheck && offerFit && advice.id) mode.adviceOffer(advice.id, sessionId, now, advice.text);
|
|
388
|
+
// Claimed whether or not it produced a line: the measurement was taken,
|
|
389
|
+
// and taking it again in ten seconds would cost the same and say the same.
|
|
390
|
+
writeState(trim(state, recheckKey, now, text ? { said: text } : null));
|
|
391
|
+
}
|
|
392
|
+
|
|
225
393
|
// Quiet when there is nothing to act on. A line every two minutes saying the
|
|
226
394
|
// budget is fine is noise that costs the budget it is reporting on.
|
|
227
395
|
if (!fanout && pressure === 'roomy' && String(process.env.USAGE_LIMITS_PULSE || '').toLowerCase() !== 'always') {
|
|
228
|
-
return
|
|
396
|
+
return recheck;
|
|
229
397
|
}
|
|
230
398
|
|
|
231
|
-
|
|
399
|
+
const spoken = pulseText({
|
|
232
400
|
label: binding.label,
|
|
233
401
|
percentUsed: binding.percentUsed,
|
|
234
402
|
approximate: Boolean(binding.estimated || binding.adjusted),
|
|
@@ -241,6 +409,49 @@ async function run(now, hookInput) {
|
|
|
241
409
|
pressure,
|
|
242
410
|
fanout,
|
|
243
411
|
});
|
|
412
|
+
return recheck ? (spoken ? spoken + ' ' + recheck : recheck) : spoken;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// The cheapest percentage good enough to enforce a ceiling against.
|
|
416
|
+
//
|
|
417
|
+
// The ceiling is checked before every fan-out, which is far too often to scan
|
|
418
|
+
// transcripts for. Two sources are already paid for: the corrected reading the
|
|
419
|
+
// last scan left behind, and the account snapshot, which is one small file
|
|
420
|
+
// read. The highest of them wins, because a ceiling means "no window past
|
|
421
|
+
// here" - taking the emptiest window would be a ceiling that never binds.
|
|
422
|
+
//
|
|
423
|
+
// Returns null when neither source has anything, and a ceiling with no reading
|
|
424
|
+
// behind it never refuses. Guessing high would block work over a number nobody
|
|
425
|
+
// measured; guessing low would not be a ceiling at all.
|
|
426
|
+
function ceilingPercent(now) {
|
|
427
|
+
let worst = null;
|
|
428
|
+
const consider = (value) => {
|
|
429
|
+
if (!Number.isFinite(value)) return;
|
|
430
|
+
if (worst === null || value > worst) worst = value;
|
|
431
|
+
};
|
|
432
|
+
const codexHome = usage.isCodex() ? require('./codex.js').homeDir() : null;
|
|
433
|
+
try {
|
|
434
|
+
const entries = reading.read(codexHome);
|
|
435
|
+
for (const key of Object.keys(entries)) {
|
|
436
|
+
const entry = reading.correctedFor(key, now, null, codexHome);
|
|
437
|
+
if (entry) consider(entry.percentUsed);
|
|
438
|
+
}
|
|
439
|
+
} catch (err) {
|
|
440
|
+
// A missing or unreadable correction just means the snapshot decides.
|
|
441
|
+
}
|
|
442
|
+
try {
|
|
443
|
+
const snapshot = usage.collect(now);
|
|
444
|
+
const utilization = snapshot && snapshot.utilization;
|
|
445
|
+
if (utilization && typeof utilization === 'object') {
|
|
446
|
+
for (const key of Object.keys(utilization)) {
|
|
447
|
+
const window = utilization[key];
|
|
448
|
+
if (window && typeof window === 'object') consider(Number(window.utilization));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
} catch (err) {
|
|
452
|
+
// Same: no snapshot is a reason not to enforce, not a reason to throw.
|
|
453
|
+
}
|
|
454
|
+
return worst;
|
|
244
455
|
}
|
|
245
456
|
|
|
246
457
|
// PostToolUse does not take plain stdout as context the way UserPromptSubmit
|
|
@@ -256,6 +467,22 @@ function envelope(text, event) {
|
|
|
256
467
|
});
|
|
257
468
|
}
|
|
258
469
|
|
|
470
|
+
// The refusal envelope.
|
|
471
|
+
//
|
|
472
|
+
// Claude Code and Codex document the same PreToolUse shape: a permissionDecision
|
|
473
|
+
// of "deny" with a reason, which is shown to the model in place of the tool
|
|
474
|
+
// result. Exit code stays 0 - exit 2 also blocks, but routes the reason through
|
|
475
|
+
// stderr, and a reason the model can read is the entire point of refusing.
|
|
476
|
+
function refusal(reason) {
|
|
477
|
+
return JSON.stringify({
|
|
478
|
+
hookSpecificOutput: {
|
|
479
|
+
hookEventName: 'PreToolUse',
|
|
480
|
+
permissionDecision: 'deny',
|
|
481
|
+
permissionDecisionReason: reason,
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
259
486
|
function readHookInput() {
|
|
260
487
|
return new Promise((resolve) => {
|
|
261
488
|
if (process.stdin.isTTY) return resolve(null);
|
|
@@ -289,8 +516,14 @@ if (require.main === module) {
|
|
|
289
516
|
return run(Date.now(), input);
|
|
290
517
|
})
|
|
291
518
|
.then(
|
|
292
|
-
(
|
|
293
|
-
|
|
519
|
+
(result) => {
|
|
520
|
+
// A refusal, not a line. Claude Code and Codex take the same shape
|
|
521
|
+
// here; Antigravity's differs and is handled by its own entry point.
|
|
522
|
+
if (result && typeof result === 'object' && result.deny) {
|
|
523
|
+
process.stdout.write(refusal(result.reason) + '\n');
|
|
524
|
+
process.exit(0);
|
|
525
|
+
}
|
|
526
|
+
if (result) process.stdout.write(envelope(result, hookEvent) + '\n');
|
|
294
527
|
process.exit(0);
|
|
295
528
|
},
|
|
296
529
|
() => {
|
|
@@ -301,6 +534,7 @@ if (require.main === module) {
|
|
|
301
534
|
}
|
|
302
535
|
|
|
303
536
|
module.exports = {
|
|
537
|
+
recheckText,
|
|
304
538
|
DEFAULT_INTERVAL_SECONDS,
|
|
305
539
|
KEEP_SESSIONS,
|
|
306
540
|
stateFile,
|
|
@@ -309,7 +543,10 @@ module.exports = {
|
|
|
309
543
|
writeState,
|
|
310
544
|
trim,
|
|
311
545
|
due,
|
|
546
|
+
longCall,
|
|
312
547
|
pulseText,
|
|
313
548
|
envelope,
|
|
549
|
+
refusal,
|
|
550
|
+
ceilingPercent,
|
|
314
551
|
run,
|
|
315
552
|
};
|
|
@@ -24,6 +24,8 @@ const fs = require('fs');
|
|
|
24
24
|
const os = require('os');
|
|
25
25
|
const path = require('path');
|
|
26
26
|
|
|
27
|
+
const drift = require('./drift.js');
|
|
28
|
+
|
|
27
29
|
// Older than this and the spend it measured is history: turns have happened
|
|
28
30
|
// since, and a stale correction that says 40 per cent is worse than an honest
|
|
29
31
|
// snapshot that says 13, because it looks authoritative.
|
|
@@ -71,18 +73,25 @@ function record(binding, now, codexHome) {
|
|
|
71
73
|
if (binding.stale || binding.estimated || binding.correctionUnreliable) return false;
|
|
72
74
|
try {
|
|
73
75
|
const all = read(codexHome);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
const at = Number.isFinite(now) ? now : Date.now();
|
|
77
|
+
// Whatever was sitting here before is what the cheap readers had been
|
|
78
|
+
// trusting; this fresh, checked correction is what the meter actually
|
|
79
|
+
// said. The gap between them is real drift a session just lived through,
|
|
80
|
+
// and it is worth keeping regardless of what happens to this record next.
|
|
81
|
+
const previous = all[binding.key];
|
|
82
|
+
const next = {
|
|
83
|
+
at,
|
|
76
84
|
percentUsed: binding.percentUsed,
|
|
77
85
|
pointsSinceSnapshot: binding.pointsSinceSnapshot || 0,
|
|
78
86
|
adjusted: Boolean(binding.adjusted),
|
|
79
87
|
resetsAt: Number.isFinite(binding.resetsAt) ? binding.resetsAt : null,
|
|
80
88
|
turnsLeft: Number.isFinite(binding.turnsLeft) ? binding.turnsLeft : null,
|
|
81
89
|
};
|
|
90
|
+
if (previous) drift.record(binding.key, previous, next, at, codexHome);
|
|
91
|
+
all[binding.key] = next;
|
|
82
92
|
// One entry per window key, and there are only ever a handful of those, so
|
|
83
93
|
// this file cannot grow. Anything whose reset has passed describes a window
|
|
84
94
|
// that no longer exists.
|
|
85
|
-
const at = Number.isFinite(now) ? now : Date.now();
|
|
86
95
|
for (const key of Object.keys(all)) {
|
|
87
96
|
const entry = all[key];
|
|
88
97
|
if (!entry || !Number.isFinite(entry.at) || at - entry.at > 24 * 60 * 60 * 1000) delete all[key];
|
|
@@ -12,9 +12,17 @@ const usage = require('./usage.js');
|
|
|
12
12
|
const host = require('./host.js');
|
|
13
13
|
const tally = require('./tally.js');
|
|
14
14
|
const activity = require('./activity.js');
|
|
15
|
+
const mode = require('./mode.js');
|
|
15
16
|
|
|
16
17
|
async function run(now, hookInput) {
|
|
17
18
|
const sessionId = hookInput && hookInput.session_id ? hookInput.session_id : null;
|
|
19
|
+
|
|
20
|
+
// The same rule as the Stop hook, for the same reason: `off` promises the
|
|
21
|
+
// hooks return before reading anything, and this one read the whole
|
|
22
|
+
// transcript one last time and printed a closing line. See stop.js.
|
|
23
|
+
const budget = mode.forSession({ sessionId });
|
|
24
|
+
if (budget.policy.briefStyle === 'none') return '';
|
|
25
|
+
|
|
18
26
|
// The session is over, so as far as the panel is concerned it is idle.
|
|
19
27
|
activity.mark('idle', sessionId, null, now);
|
|
20
28
|
|
|
@@ -16,9 +16,33 @@ const usage = require('./usage.js');
|
|
|
16
16
|
const host = require('./host.js');
|
|
17
17
|
const tally = require('./tally.js');
|
|
18
18
|
const activity = require('./activity.js');
|
|
19
|
+
const mode = require('./mode.js');
|
|
20
|
+
const drift = require('./drift.js');
|
|
19
21
|
|
|
20
22
|
async function run(now, hookInput) {
|
|
21
23
|
const sessionId = hookInput && hookInput.session_id ? hookInput.session_id : null;
|
|
24
|
+
|
|
25
|
+
// `off` means off here too, and that is the whole reason this check is the
|
|
26
|
+
// first thing in the hook rather than a filter on the line at the end.
|
|
27
|
+
//
|
|
28
|
+
// This hook was the expensive one left running: it read the entire
|
|
29
|
+
// transcript after every reply - 2.77 MB on the session that measured it -
|
|
30
|
+
// wrote two state files and printed a line, in the mode documented as "the
|
|
31
|
+
// hooks return before reading anything: no scan, no state write, no line".
|
|
32
|
+
// The line goes to the person rather than into the context, so it cost no
|
|
33
|
+
// tokens; the scan cost exactly what `standard` costs, which is the half of
|
|
34
|
+
// that promise that was false.
|
|
35
|
+
//
|
|
36
|
+
// Two consequences, both stated where the user sets the mode and in the
|
|
37
|
+
// docs: no end-of-reply cost line in `off`, and no drift-ledger rows tagged
|
|
38
|
+
// `off` - a mode that injects nothing has no injection to attribute a cost
|
|
39
|
+
// to, so there is nothing for the ledger to compare.
|
|
40
|
+
//
|
|
41
|
+
// USAGE_LIMITS_TALLY stays the independent control, for anyone who wants the
|
|
42
|
+
// briefing and not the cost line.
|
|
43
|
+
const budget = mode.forSession({ sessionId });
|
|
44
|
+
if (budget.policy.briefStyle === 'none') return '';
|
|
45
|
+
|
|
22
46
|
// The reply is finished: the panel beside the chat can stop animating.
|
|
23
47
|
activity.mark('idle', sessionId, null, now);
|
|
24
48
|
|
|
@@ -34,6 +58,25 @@ async function run(now, hookInput) {
|
|
|
34
58
|
});
|
|
35
59
|
tally.writeState(tally.trim(all));
|
|
36
60
|
|
|
61
|
+
// The mode ledger: what a reply actually cost, tagged with the mode that was
|
|
62
|
+
// in force while it ran. Modes should be evidence rather than vibes, and this
|
|
63
|
+
// is the only place that knows both halves at once. Skipped on the first
|
|
64
|
+
// sighting of a session, where the figures are history rather than this
|
|
65
|
+
// reply, and never allowed to disturb the hook.
|
|
66
|
+
try {
|
|
67
|
+
if (!created) {
|
|
68
|
+
drift.recordTurns(
|
|
69
|
+
budget.name,
|
|
70
|
+
delta.turns,
|
|
71
|
+
delta.cost,
|
|
72
|
+
now,
|
|
73
|
+
usage.isCodex() ? require('./codex.js').homeDir() : null
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
} catch (err) {
|
|
77
|
+
// A ledger entry is worth nothing next to the line this hook exists for.
|
|
78
|
+
}
|
|
79
|
+
|
|
37
80
|
// The first time a session is seen, everything read is history rather than
|
|
38
81
|
// the reply that just finished, so only the total is shown.
|
|
39
82
|
return JSON.stringify({
|