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.
@@ -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
- // Two keys per session now - the spoken pulse and the quiet subagent refresh -
41
- // so this is double what it was.
42
- const KEEP_SESSIONS = 16;
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
- function intervalMs() {
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
- const seconds = Number.isFinite(configured) && configured > 0
54
- ? configured
55
- : DEFAULT_INTERVAL_SECONDS;
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
- function trim(all, sessionId, at) {
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
- const throttleKey = quiet ? (sessionId || '_') + '#subagent' : sessionId;
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
- return pulseText({
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
- (text) => {
293
- if (text) process.stdout.write(envelope(text, hookEvent) + '\n');
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
- all[binding.key] = {
75
- at: Number.isFinite(now) ? now : Date.now(),
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({