claude-usage-limits 1.11.6 → 1.13.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 +133 -3
- package/bin/cli.js +1 -0
- package/commands/relay.md +45 -0
- package/commands/voice.md +33 -0
- package/hooks/hooks.json +24 -1
- package/package.json +1 -1
- package/skills/usage-limits/SKILL.md +130 -5
- package/skills/usage-limits/scripts/bars.js +56 -0
- package/skills/usage-limits/scripts/brief.js +288 -17
- package/skills/usage-limits/scripts/codex-lowpower.js +135 -0
- package/skills/usage-limits/scripts/codex.js +92 -22
- package/skills/usage-limits/scripts/feed.js +136 -5
- package/skills/usage-limits/scripts/install-codex-hook.js +71 -20
- package/skills/usage-limits/scripts/lowpower.js +10 -2
- package/skills/usage-limits/scripts/panel.js +180 -15
- package/skills/usage-limits/scripts/pulse.js +82 -22
- package/skills/usage-limits/scripts/reading.js +121 -0
- package/skills/usage-limits/scripts/recommend.js +16 -3
- package/skills/usage-limits/scripts/relay.js +859 -0
- package/skills/usage-limits/scripts/tally.js +7 -8
- package/skills/usage-limits/scripts/usage.js +842 -53
- package/skills/usage-limits/scripts/view.js +150 -8
- package/skills/usage-limits/scripts/voice.js +416 -0
- package/skills/usage-limits/scripts/wake.js +312 -0
|
@@ -46,6 +46,9 @@ const POLL_FLOOR_MS = 15 * SECOND;
|
|
|
46
46
|
const FILE_CHECK_MS = SECOND;
|
|
47
47
|
const FRAME_MS = 100;
|
|
48
48
|
const MIN_COLUMNS = 24;
|
|
49
|
+
// The widest thing that follows a Codex bar, plus its space: "no reading" is
|
|
50
|
+
// ten characters and "100% left" is nine.
|
|
51
|
+
const CODEX_SUFFIX = 11;
|
|
49
52
|
const TITLE = 'Claude usage';
|
|
50
53
|
const LEVEL_RANK = { fill: 0, warning: 1, error: 2 };
|
|
51
54
|
|
|
@@ -156,8 +159,38 @@ async function snapshot(options) {
|
|
|
156
159
|
const slots = onCodex ? {} : feed.readFeed();
|
|
157
160
|
// Stay with the session already being described, so two windows on the same
|
|
158
161
|
// model at different efforts do not make the header flip back and forth.
|
|
159
|
-
const
|
|
160
|
-
|
|
162
|
+
const marks = onCodex ? {} : activity.read();
|
|
163
|
+
// The session to describe: the one this display was already describing,
|
|
164
|
+
// else the newest mark. Every session writes a mark, but only a terminal
|
|
165
|
+
// session writes a feed slot, so choosing from the feed meant a VS Code
|
|
166
|
+
// window with no status line was never the one described and its own
|
|
167
|
+
// ultrathink never showed.
|
|
168
|
+
const freshest = Object.keys(marks)
|
|
169
|
+
.filter((key) => key !== '_' && marks[key] && Number.isFinite(marks[key].at) && now - marks[key].at <= activity.STALE_MS)
|
|
170
|
+
.sort((a, b) => marks[b].at - marks[a].at)[0];
|
|
171
|
+
const sticky = opts.sessionId && marks[opts.sessionId] && now - marks[opts.sessionId].at <= feed.STICKY_QUIET_MS;
|
|
172
|
+
const described = (sticky ? opts.sessionId : null) || freshest || opts.sessionId || null;
|
|
173
|
+
// A described session with no status line of its own (VS Code) must not be
|
|
174
|
+
// dressed in another session's slot: that is how a Fable window came to say
|
|
175
|
+
// Opus. Its transcript speaks for it instead. Only a panel with no session
|
|
176
|
+
// at all falls back to the newest slot on the machine.
|
|
177
|
+
const slot = (described && slots[described]) || (described ? null : feed.stickySlot(slots, opts.sessionId, now));
|
|
178
|
+
const spoken = !onCodex && described && !slot ? usage.liveModel(described) : null;
|
|
179
|
+
// The marks are machine-wide, and this panel describes ONE session. Reading
|
|
180
|
+
// the machine-wide summary here is what let a prompt in another window put
|
|
181
|
+
// ultrathink on this window's bars. When the session being described is
|
|
182
|
+
// known, its own mark is the only one that speaks for it.
|
|
183
|
+
// A panel that was given a session speaks for that session alone. A panel
|
|
184
|
+
// with none of its own is the machine's, and there the newest working
|
|
185
|
+
// session's word is the one shown - two windows in different modes would
|
|
186
|
+
// otherwise make it depend on whose tool call landed last.
|
|
187
|
+
const seen = onCodex
|
|
188
|
+
? { working: codexWorking(now), ultracode: false, ultrathink: false, model: null }
|
|
189
|
+
: opts.sessionId && marks[opts.sessionId]
|
|
190
|
+
? Object.assign(feed.ownState(marks, opts.sessionId, now), {
|
|
191
|
+
model: (marks[opts.sessionId] && marks[opts.sessionId].model) || null,
|
|
192
|
+
})
|
|
193
|
+
: activity.summarise(marks, now);
|
|
161
194
|
const settings = onCodex ? {} : settingsFor();
|
|
162
195
|
|
|
163
196
|
const built = view.build({
|
|
@@ -168,26 +201,28 @@ async function snapshot(options) {
|
|
|
168
201
|
windowSpecs: collected.windowSpecs || null,
|
|
169
202
|
headers: slot ? slot.rateLimits : null,
|
|
170
203
|
headersAt: slot ? slot.headersAt : null,
|
|
171
|
-
model: (slot && slot.model) || seen.model || null,
|
|
204
|
+
model: (slot && slot.model) || seen.model || (spoken && spoken.model) || null,
|
|
172
205
|
modelName: slot ? slot.modelName : null,
|
|
173
|
-
//
|
|
174
|
-
// the
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
206
|
+
// Whichever of the status line and the transcript spoke last. The setting
|
|
207
|
+
// is only the last resort: a panel beside a VS Code window has no status
|
|
208
|
+
// line to ask, and the setting there never moves, which is how it came to
|
|
209
|
+
// report xhigh through a session running at max.
|
|
210
|
+
effort: view.pickEffort(
|
|
211
|
+
slot && slot.effort ? { effort: slot.effort, at: slot.at } : null,
|
|
212
|
+
onCodex || !described ? null : usage.liveEffort(described),
|
|
213
|
+
collected.settings ? collected.settings.effortLevel : null
|
|
214
|
+
),
|
|
181
215
|
working: seen.working || feed.isWorking(slot, now),
|
|
182
216
|
// Ultracode comes from the effort level above, not from here. Ultrathink
|
|
183
217
|
// is a word in a prompt, and the hooks record it per session.
|
|
184
218
|
ultrathink: Boolean(seen.ultrathink),
|
|
219
|
+
ultracode: Boolean(seen.ultracode) || settings.ultracode === true,
|
|
185
220
|
settingsModel: collected.settings ? collected.settings.model : null,
|
|
186
221
|
outcome,
|
|
187
222
|
env,
|
|
188
223
|
});
|
|
189
224
|
built.now = now;
|
|
190
|
-
built.sessionId =
|
|
225
|
+
built.sessionId = described || (slot && slot.sessionId) || null;
|
|
191
226
|
built.host = onCodex ? 'codex' : 'claude';
|
|
192
227
|
built.title = onCodex ? 'Codex usage' : TITLE;
|
|
193
228
|
built.plan = collected.plan || null;
|
|
@@ -203,6 +238,28 @@ async function snapshot(options) {
|
|
|
203
238
|
// the report, so it is taken with the readings, not with every frame; the
|
|
204
239
|
// frames in between carry the last answer forward.
|
|
205
240
|
built.pace = opts.pace !== undefined ? opts.pace : await paceOf(now);
|
|
241
|
+
// The other agent's meter, drawn under this one's.
|
|
242
|
+
//
|
|
243
|
+
// Read from Codex's own rollouts on disk and nothing else: no child process,
|
|
244
|
+
// no network, no waiting. Codex having nothing to say, or not being installed
|
|
245
|
+
// at all, must never be a reason the Claude panel is late or absent.
|
|
246
|
+
built.codex = null;
|
|
247
|
+
if (!onCodex && host.codexHasSessions()) {
|
|
248
|
+
try {
|
|
249
|
+
const other = codex.collect(now);
|
|
250
|
+
const block = view.buildCodex({
|
|
251
|
+
now,
|
|
252
|
+
utilization: other.utilization,
|
|
253
|
+
fetchedAtMs: other.snapshotFetchedAt,
|
|
254
|
+
windowSpecs: other.windowSpecs,
|
|
255
|
+
plan: other.plan,
|
|
256
|
+
windowless: other.windowless,
|
|
257
|
+
});
|
|
258
|
+
if (block.present) built.codex = block;
|
|
259
|
+
} catch (err) {
|
|
260
|
+
// An unreadable Codex is simply no Codex row.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
206
263
|
// Whether the panel is allowed the network at all, which is what the footer
|
|
207
264
|
// reports. A frame rebuilt from disk between readings is not "network off".
|
|
208
265
|
built.fetch = opts.network !== undefined ? Boolean(opts.network) : Boolean(opts.fetch);
|
|
@@ -244,10 +301,65 @@ function codexWorking(now) {
|
|
|
244
301
|
}
|
|
245
302
|
}
|
|
246
303
|
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
304
|
+
// Codex runs no hooks on this machine, so it writes no marks, and its sessions
|
|
305
|
+
// never appeared beside the Claudes. Its rollout files say what a mark would:
|
|
306
|
+
// a rollout appended in the last half minute is a session at work, one touched
|
|
307
|
+
// within the stale window is one idling. The first line of a rollout is the
|
|
308
|
+
// session's meta record - its id, its working directory, what launched it.
|
|
309
|
+
function codexSessions(now, opts) {
|
|
310
|
+
const o = opts || {};
|
|
311
|
+
const stale = Number.isFinite(o.staleMs) ? o.staleMs : activity.STALE_MS;
|
|
312
|
+
const busyMs = Number.isFinite(o.busyMs) ? o.busyMs : 30 * SECOND;
|
|
313
|
+
let files = [];
|
|
314
|
+
try {
|
|
315
|
+
files = Array.isArray(o.files) ? o.files : codex.rolloutFiles(now - stale);
|
|
316
|
+
} catch (err) {
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
const rows = [];
|
|
320
|
+
for (const entry of files) {
|
|
321
|
+
if (!entry || !Number.isFinite(entry.at) || entry.at < now - stale) continue;
|
|
322
|
+
let meta = null;
|
|
323
|
+
try {
|
|
324
|
+
const fd = fs.openSync(entry.file, 'r');
|
|
325
|
+
const buf = Buffer.alloc(4096);
|
|
326
|
+
const n = fs.readSync(fd, buf, 0, 4096, 0);
|
|
327
|
+
fs.closeSync(fd);
|
|
328
|
+
const first = buf.toString('utf8', 0, n).split(String.fromCharCode(10))[0];
|
|
329
|
+
const parsed = JSON.parse(first);
|
|
330
|
+
meta = parsed && parsed.payload && typeof parsed.payload === 'object' ? parsed.payload : null;
|
|
331
|
+
} catch (err) {
|
|
332
|
+
meta = null;
|
|
333
|
+
}
|
|
334
|
+
const id = (meta && (meta.session_id || meta.id)) || path.basename(entry.file, '.jsonl');
|
|
335
|
+
rows.push({
|
|
336
|
+
key: 'codex:' + id,
|
|
337
|
+
id,
|
|
338
|
+
host: 'codex',
|
|
339
|
+
state: entry.at >= now - busyMs ? 'working' : 'idle',
|
|
340
|
+
stateAt: entry.at,
|
|
341
|
+
lastAt: entry.at,
|
|
342
|
+
model: null,
|
|
343
|
+
modelName: 'Codex' + (meta && meta.originator ? ' (' + String(meta.originator) + ')' : ''),
|
|
344
|
+
cwd: meta && meta.cwd ? String(meta.cwd) : null,
|
|
345
|
+
ultracode: false,
|
|
346
|
+
ultrathink: false,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
rows.sort((x, y) => y.lastAt - x.lastAt);
|
|
350
|
+
return rows;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Every session this machine has heard from lately: the Claudes from the
|
|
354
|
+
// files the hooks and the status line keep, and the Codexes from their own
|
|
355
|
+
// rollouts.
|
|
250
356
|
function loadSessions(now) {
|
|
357
|
+
return claudeSessions(now).concat(codexSessions(now));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// The tally is required lazily: it requires usage.js back, and this module is
|
|
361
|
+
// loaded by the VS Code extension too.
|
|
362
|
+
function claudeSessions(now) {
|
|
251
363
|
let tallyList = [];
|
|
252
364
|
try {
|
|
253
365
|
const tally = require('./tally.js');
|
|
@@ -303,6 +415,22 @@ function shortTitle(row) {
|
|
|
303
415
|
return String(row.title).replace(/^Current week /, 'Week ').replace(/^Current /, '');
|
|
304
416
|
}
|
|
305
417
|
|
|
418
|
+
// Codex's own row titles are already short; these are for a very narrow pane.
|
|
419
|
+
function shortCodexTitle(row) {
|
|
420
|
+
if (row.key === 'five_hour') return '5h';
|
|
421
|
+
if (row.key === 'seven_day') return 'Week';
|
|
422
|
+
return String(row.title).replace(/ limit$/, '');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// The Codex rows say "left", so their sublines have to as well, and they must
|
|
426
|
+
// never point at a Claude Code command to fix a Codex reading.
|
|
427
|
+
function codexSubline(row, mode, opts) {
|
|
428
|
+
if (row.stale) return bars.dim('window rolled over since Codex last ran', mode);
|
|
429
|
+
if (row.percentLeft === null) return bars.dim('no reading yet', mode);
|
|
430
|
+
const reset = bars.formatReset(row.msToReset, row.resetsAtMs, opts.now, { clock: opts.clock });
|
|
431
|
+
return reset ? bars.dim(reset, mode) : '';
|
|
432
|
+
}
|
|
433
|
+
|
|
306
434
|
function subline(row, mode, opts) {
|
|
307
435
|
if (row.stale) return bars.dim('window rolled over, taking a fresh reading', mode);
|
|
308
436
|
if (row.unreported) return bars.dim('not reported yet, run /usage in Claude Code', mode);
|
|
@@ -408,6 +536,42 @@ function render(built, options) {
|
|
|
408
536
|
});
|
|
409
537
|
}
|
|
410
538
|
|
|
539
|
+
// The Codex block, in the same shapes and the same colours, counting the
|
|
540
|
+
// other way: Codex reports what is LEFT, so its bars drain as they are spent
|
|
541
|
+
// where Claude's fill. The mark is a plain hexagon rather than the Codex
|
|
542
|
+
// logo, which is OpenAI's Blossom and not ours to recolour.
|
|
543
|
+
if (built.codex && built.codex.rows.length) {
|
|
544
|
+
const block = built.codex;
|
|
545
|
+
const lines = [
|
|
546
|
+
bars.bold(bars.paint(block.title, bars.THEME.codex, mode), mode),
|
|
547
|
+
];
|
|
548
|
+
// "85% left" is five characters wider than "85%", so the Codex bars get
|
|
549
|
+
// their own width. Sharing the Claude one clipped every Codex row.
|
|
550
|
+
const codexWidth = Math.max(6, Math.min(50, columns - CODEX_SUFFIX));
|
|
551
|
+
for (const row of block.rows) {
|
|
552
|
+
const percent =
|
|
553
|
+
row.level === 'fill' ? row.percentText : bars.paint(row.percentText, bars.levelColour(row.level), mode);
|
|
554
|
+
lines.push(bars.bold(columns < 34 ? shortCodexTitle(row) : row.title, mode));
|
|
555
|
+
lines.push(
|
|
556
|
+
(row.percentLeft === null
|
|
557
|
+
? bars.paint((ascii ? '-' : '░').repeat(codexWidth), bars.THEME.empty, mode)
|
|
558
|
+
: // No ultracode or ultrathink styling here: those describe how this
|
|
559
|
+
// Claude is running and have nothing to do with the other agent.
|
|
560
|
+
bars.bar(row.percentLeft, codexWidth, { mode, level: row.level, ascii, tick, reduced })) +
|
|
561
|
+
' ' +
|
|
562
|
+
percent
|
|
563
|
+
);
|
|
564
|
+
const sub = codexSubline(row, mode, { now, clock });
|
|
565
|
+
if (sub) lines.push(sub);
|
|
566
|
+
}
|
|
567
|
+
const tail = [];
|
|
568
|
+
if (block.plan) tail.push(block.plan);
|
|
569
|
+
if (block.note) tail.push(block.note);
|
|
570
|
+
else if (Number.isFinite(block.ageMs)) tail.push('reading from ' + since(block.ageMs) + ' ago');
|
|
571
|
+
if (tail.length) lines.push(bars.dim(tail.join(' · '), mode));
|
|
572
|
+
body.push({ lines });
|
|
573
|
+
}
|
|
574
|
+
|
|
411
575
|
// The other Claudes. One row each: what it runs, where, and whether it is
|
|
412
576
|
// working right now, with its own spinner when it is.
|
|
413
577
|
const list = Array.isArray(built.sessionsList) ? built.sessionsList : [];
|
|
@@ -796,6 +960,7 @@ async function main(argv) {
|
|
|
796
960
|
}
|
|
797
961
|
|
|
798
962
|
module.exports = {
|
|
963
|
+
codexSessions,
|
|
799
964
|
HELP,
|
|
800
965
|
TITLE,
|
|
801
966
|
MIN_COLUMNS,
|
|
@@ -22,6 +22,7 @@ const fs = require('fs');
|
|
|
22
22
|
const path = require('path');
|
|
23
23
|
|
|
24
24
|
const usage = require('./usage.js');
|
|
25
|
+
const reading = require('./reading.js');
|
|
25
26
|
const brief = require('./brief.js');
|
|
26
27
|
const host = require('./host.js');
|
|
27
28
|
const activity = require('./activity.js');
|
|
@@ -30,8 +31,15 @@ const live = require('./live.js');
|
|
|
30
31
|
const SECOND = 1000;
|
|
31
32
|
const DEFAULT_INTERVAL_SECONDS = 120;
|
|
32
33
|
|
|
34
|
+
// Same reasoning as the brief: ten seconds for the hook, four for a live
|
|
35
|
+
// reading, five for the scan, one of slack. This hook interrupts work in
|
|
36
|
+
// progress, so being late is worse here than anywhere else.
|
|
37
|
+
const SCAN_BUDGET_MS = 5000;
|
|
38
|
+
|
|
33
39
|
// One slot per session, same shape and same trimming as the brief's cache.
|
|
34
|
-
|
|
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;
|
|
35
43
|
|
|
36
44
|
function stateFile() {
|
|
37
45
|
const dir = usage.isCodex()
|
|
@@ -58,14 +66,10 @@ function readState() {
|
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
function writeState(all) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
} catch (err) {
|
|
66
|
-
// Losing the throttle means one extra scan, which is survivable. Failing
|
|
67
|
-
// the tool call it runs after is not.
|
|
68
|
-
}
|
|
69
|
+
// Never throws. Losing the throttle means one extra scan, which is
|
|
70
|
+
// survivable; failing the tool call it runs after is not. Atomic because
|
|
71
|
+
// several sessions' tool calls land on this file within the same second.
|
|
72
|
+
usage.writeJsonAtomic(stateFile(), all);
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
function trim(all, sessionId, at) {
|
|
@@ -97,7 +101,18 @@ function pulseText(parts) {
|
|
|
97
101
|
if (parts.sessions > 1) bits.push(parts.sessions + ' sessions sharing it');
|
|
98
102
|
if (!bits.length) return '';
|
|
99
103
|
|
|
100
|
-
const head = '[usage-limits] ' + bits.join(', ') + '.';
|
|
104
|
+
const head = '[usage-limits] ' + (parts.fanout ? 'Before this fan-out: ' : '') + bits.join(', ') + '.';
|
|
105
|
+
if (parts.fanout) {
|
|
106
|
+
// Said before every Workflow or Agent call. The agents spend this same
|
|
107
|
+
// window, nothing can speak again until they stop, and a main-loop turn
|
|
108
|
+
// with a large context costs more than one whole fresh-context agent.
|
|
109
|
+
return (
|
|
110
|
+
head + ' Subagents spend this window too and nothing can warn you until they ' +
|
|
111
|
+
'stop, so size the fan-out to what is left' +
|
|
112
|
+
(parts.pressure === 'gone' ? ' - which is nothing: do not launch it' : parts.pressure === 'tight' ? ' - a handful, not dozens' : '') +
|
|
113
|
+
'. Fewer agents with a fresh context beat another turn of a long one.'
|
|
114
|
+
);
|
|
115
|
+
}
|
|
101
116
|
if (parts.pressure === 'gone') {
|
|
102
117
|
return head + ' The budget is gone. Stop adding work, save what exists and write the handoff.';
|
|
103
118
|
}
|
|
@@ -118,18 +133,49 @@ async function run(now, hookInput) {
|
|
|
118
133
|
usage.setHost(host.detect(process.argv.slice(2), process.env));
|
|
119
134
|
|
|
120
135
|
const sessionId = hookInput && hookInput.session_id ? hookInput.session_id : null;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
136
|
+
const event = hookInput && hookInput.hook_event_name ? String(hookInput.hook_event_name) : 'PostToolUse';
|
|
137
|
+
|
|
138
|
+
// A subagent finishing is the other reason to look, and on a busy afternoon
|
|
139
|
+
// it is the more important one.
|
|
140
|
+
//
|
|
141
|
+
// This hook exists because PostToolUse fires on the MAIN thread's tool calls,
|
|
142
|
+
// and a turn that hands its work to a workflow makes none for half an hour.
|
|
143
|
+
// On 2026-09-06 two workflows spent a whole five-hour window between one
|
|
144
|
+
// prompt and the next, and nothing ran in between to notice: the reading on
|
|
145
|
+
// disk aged eleven minutes while forty-three agents spent against it, and the
|
|
146
|
+
// session was cut off at a figure the plugin still believed was 3%.
|
|
147
|
+
//
|
|
148
|
+
// So it refreshes and says nothing. The mark is deliberately not written -
|
|
149
|
+
// a subagent is not a session, and marking one would put it in the list of
|
|
150
|
+
// windows sharing this budget and split the headroom with a ghost.
|
|
151
|
+
const quiet = event === 'SubagentStop';
|
|
152
|
+
// The call about to fan out. Everything it spawns spends this window with no
|
|
153
|
+
// main-thread tool call to pulse on, so this is the last word before the
|
|
154
|
+
// bill. On 2026-09-07 a 34-agent workflow took a five-hour window from 28%
|
|
155
|
+
// to 100% in sixteen minutes, and the session was cut off at a figure the
|
|
156
|
+
// pulse had last read as 24%.
|
|
157
|
+
const tool = hookInput && hookInput.tool_name ? String(hookInput.tool_name) : '';
|
|
158
|
+
const fanout = event === 'PreToolUse' && /^(Workflow|Agent|Task)$/.test(tool);
|
|
159
|
+
if (!quiet) {
|
|
160
|
+
// A tool call just finished, so the turn is still running. A few bytes, so
|
|
161
|
+
// the panel beside the chat keeps animating through a long turn.
|
|
162
|
+
activity.mark('working', sessionId, null, now);
|
|
163
|
+
}
|
|
124
164
|
|
|
125
165
|
const all = readState();
|
|
126
166
|
const every = intervalMs();
|
|
127
|
-
// The
|
|
128
|
-
|
|
167
|
+
// The quiet refresh keeps its own throttle. Sharing one with the spoken
|
|
168
|
+
// pulse would mean a workflow's subagents used up the interval and the tool
|
|
169
|
+
// call right after it, the first chance to actually tell Claude, said
|
|
170
|
+
// nothing because something had already "pulsed" two minutes ago.
|
|
171
|
+
const throttleKey = quiet ? (sessionId || '_') + '#subagent' : sessionId;
|
|
172
|
+
// The cheap path, and the one taken almost every time. A fan-out is never
|
|
173
|
+
// throttled: it is said every time, because every time it is about to cost.
|
|
174
|
+
if (!fanout && !due(all, throttleKey, now, every)) return '';
|
|
129
175
|
|
|
130
176
|
// Claimed before the scan rather than after, so a slow scan cannot let a
|
|
131
177
|
// second tool call start another one.
|
|
132
|
-
writeState(trim(all,
|
|
178
|
+
writeState(trim(all, throttleKey, now));
|
|
133
179
|
|
|
134
180
|
// A reading as old as the interval is replaced with the one Claude Code
|
|
135
181
|
// would take for /usage, so a turn that runs for an hour is measured
|
|
@@ -151,9 +197,16 @@ async function run(now, hookInput) {
|
|
|
151
197
|
// The reading on disk is still there.
|
|
152
198
|
}
|
|
153
199
|
|
|
154
|
-
|
|
200
|
+
// Refreshing was the whole errand. The next prompt, or the next tool call on
|
|
201
|
+
// the main thread, reports the number this just brought up to date.
|
|
202
|
+
if (quiet) return '';
|
|
203
|
+
|
|
204
|
+
const data = await usage.report(now, { sessionId, budgetMs: SCAN_BUDGET_MS });
|
|
155
205
|
const binding = data.binding;
|
|
156
206
|
if (!binding) return '';
|
|
207
|
+
// This turn paid for the scan, so leave the corrected figure where the
|
|
208
|
+
// status line and --status can read it without paying for one.
|
|
209
|
+
reading.recordAll(data.windows || [binding], now, usage.isCodex() ? require('./codex.js').homeDir() : null);
|
|
157
210
|
|
|
158
211
|
// The same count and the same split as the brief, so the two lines never
|
|
159
212
|
// disagree about how many sessions there are or how much of the budget is
|
|
@@ -171,7 +224,7 @@ async function run(now, hookInput) {
|
|
|
171
224
|
|
|
172
225
|
// Quiet when there is nothing to act on. A line every two minutes saying the
|
|
173
226
|
// budget is fine is noise that costs the budget it is reporting on.
|
|
174
|
-
if (pressure === 'roomy' && String(process.env.USAGE_LIMITS_PULSE || '').toLowerCase() !== 'always') {
|
|
227
|
+
if (!fanout && pressure === 'roomy' && String(process.env.USAGE_LIMITS_PULSE || '').toLowerCase() !== 'always') {
|
|
175
228
|
return '';
|
|
176
229
|
}
|
|
177
230
|
|
|
@@ -186,15 +239,18 @@ async function run(now, hookInput) {
|
|
|
186
239
|
: null,
|
|
187
240
|
sessions: active,
|
|
188
241
|
pressure,
|
|
242
|
+
fanout,
|
|
189
243
|
});
|
|
190
244
|
}
|
|
191
245
|
|
|
192
246
|
// PostToolUse does not take plain stdout as context the way UserPromptSubmit
|
|
193
247
|
// does, so the line is returned in the documented envelope instead.
|
|
194
|
-
function envelope(text) {
|
|
248
|
+
function envelope(text, event) {
|
|
249
|
+
// PreToolUse and PostToolUse both take additionalContext, each under its own
|
|
250
|
+
// event name; the wrong name is dropped without a word.
|
|
195
251
|
return JSON.stringify({
|
|
196
252
|
hookSpecificOutput: {
|
|
197
|
-
hookEventName: 'PostToolUse',
|
|
253
|
+
hookEventName: event === 'PreToolUse' ? 'PreToolUse' : 'PostToolUse',
|
|
198
254
|
additionalContext: text,
|
|
199
255
|
},
|
|
200
256
|
});
|
|
@@ -226,11 +282,15 @@ function readHookInput() {
|
|
|
226
282
|
}
|
|
227
283
|
|
|
228
284
|
if (require.main === module) {
|
|
285
|
+
let hookEvent = null;
|
|
229
286
|
readHookInput()
|
|
230
|
-
.then((input) =>
|
|
287
|
+
.then((input) => {
|
|
288
|
+
hookEvent = input && input.hook_event_name ? String(input.hook_event_name) : null;
|
|
289
|
+
return run(Date.now(), input);
|
|
290
|
+
})
|
|
231
291
|
.then(
|
|
232
292
|
(text) => {
|
|
233
|
-
if (text) process.stdout.write(envelope(text) + '\n');
|
|
293
|
+
if (text) process.stdout.write(envelope(text, hookEvent) + '\n');
|
|
234
294
|
process.exit(0);
|
|
235
295
|
},
|
|
236
296
|
() => {
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The freshest corrected percentage, left where the cheap readers can find it.
|
|
4
|
+
//
|
|
5
|
+
// There are two ways to know how much of a window is gone. The snapshot is what
|
|
6
|
+
// the account last told us, and it is free to read. The correction is the spend
|
|
7
|
+
// measured out of the transcripts since that snapshot was taken, and it costs a
|
|
8
|
+
// scan of up to several seconds - far too much for something that redraws under
|
|
9
|
+
// the prompt on every keystroke.
|
|
10
|
+
//
|
|
11
|
+
// So the fast readers used the snapshot alone, and during a heavy session that
|
|
12
|
+
// is not a small error. Measured on a real session: the snapshot said 13 per
|
|
13
|
+
// cent while the same codebase, given a scan, said 73. The status line is the
|
|
14
|
+
// number a person actually looks at, and it was sixty points wrong, in the
|
|
15
|
+
// flattering direction, for twenty minutes.
|
|
16
|
+
//
|
|
17
|
+
// The fix is not to make the cheap path expensive. It is to notice that
|
|
18
|
+
// something already paid for the scan - the prompt hook before every turn, the
|
|
19
|
+
// pulse hook during long ones - and to have it leave the answer here. One small
|
|
20
|
+
// file, one object per window key, and the readers prefer it whenever it is
|
|
21
|
+
// fresher than the snapshot it would otherwise trust.
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const os = require('os');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
|
|
27
|
+
// Older than this and the spend it measured is history: turns have happened
|
|
28
|
+
// since, and a stale correction that says 40 per cent is worse than an honest
|
|
29
|
+
// snapshot that says 13, because it looks authoritative.
|
|
30
|
+
const FRESH_MS = 8 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
function configDir() {
|
|
33
|
+
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readingFile(codexHome) {
|
|
37
|
+
return path.join(codexHome || configDir(), 'usage-limits-reading.json');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function read(codexHome) {
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(fs.readFileSync(readingFile(codexHome), 'utf8'));
|
|
43
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
44
|
+
return parsed;
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Every window that has a correction, not just the binding one.
|
|
51
|
+
//
|
|
52
|
+
// The status line prints all three, and only one of them can be binding. So
|
|
53
|
+
// recording the binding window alone left the other two showing their raw
|
|
54
|
+
// snapshots - which is the same bug, on two thirds of the line.
|
|
55
|
+
function recordAll(windows, now, codexHome) {
|
|
56
|
+
let written = 0;
|
|
57
|
+
for (const window of Array.isArray(windows) ? windows : [windows]) {
|
|
58
|
+
if (record(window, now, codexHome)) written += 1;
|
|
59
|
+
}
|
|
60
|
+
return written;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Never throws. This is written from inside hooks, and a hook that fails over a
|
|
64
|
+
// cache file would be worse than the stale number it was trying to fix.
|
|
65
|
+
function record(binding, now, codexHome) {
|
|
66
|
+
if (!binding || !binding.key) return false;
|
|
67
|
+
if (binding.percentUsed === null || binding.percentUsed === undefined) return false;
|
|
68
|
+
// A rebuilt or unreliable figure is not an improvement on the snapshot; it is
|
|
69
|
+
// a different kind of guess. Only a correction the report itself trusts is
|
|
70
|
+
// worth putting in front of the cheap readers.
|
|
71
|
+
if (binding.stale || binding.estimated || binding.correctionUnreliable) return false;
|
|
72
|
+
try {
|
|
73
|
+
const all = read(codexHome);
|
|
74
|
+
all[binding.key] = {
|
|
75
|
+
at: Number.isFinite(now) ? now : Date.now(),
|
|
76
|
+
percentUsed: binding.percentUsed,
|
|
77
|
+
pointsSinceSnapshot: binding.pointsSinceSnapshot || 0,
|
|
78
|
+
adjusted: Boolean(binding.adjusted),
|
|
79
|
+
resetsAt: Number.isFinite(binding.resetsAt) ? binding.resetsAt : null,
|
|
80
|
+
turnsLeft: Number.isFinite(binding.turnsLeft) ? binding.turnsLeft : null,
|
|
81
|
+
};
|
|
82
|
+
// One entry per window key, and there are only ever a handful of those, so
|
|
83
|
+
// this file cannot grow. Anything whose reset has passed describes a window
|
|
84
|
+
// that no longer exists.
|
|
85
|
+
const at = Number.isFinite(now) ? now : Date.now();
|
|
86
|
+
for (const key of Object.keys(all)) {
|
|
87
|
+
const entry = all[key];
|
|
88
|
+
if (!entry || !Number.isFinite(entry.at) || at - entry.at > 24 * 60 * 60 * 1000) delete all[key];
|
|
89
|
+
else if (Number.isFinite(entry.resetsAt) && entry.resetsAt <= at) delete all[key];
|
|
90
|
+
}
|
|
91
|
+
fs.mkdirSync(path.dirname(readingFile(codexHome)), { recursive: true });
|
|
92
|
+
fs.writeFileSync(readingFile(codexHome), JSON.stringify(all));
|
|
93
|
+
return true;
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The corrected percentage for one window, or null when there is nothing better
|
|
100
|
+
// than the snapshot. `snapshotAt` is when the snapshot being compared against
|
|
101
|
+
// was fetched: a correction measured before it is already included in it, and
|
|
102
|
+
// applying it again would double-count the same spend.
|
|
103
|
+
function correctedFor(key, now, snapshotAt, codexHome) {
|
|
104
|
+
const entry = read(codexHome)[key];
|
|
105
|
+
if (!entry || !Number.isFinite(entry.at)) return null;
|
|
106
|
+
const at = Number.isFinite(now) ? now : Date.now();
|
|
107
|
+
if (at - entry.at > FRESH_MS) return null;
|
|
108
|
+
if (Number.isFinite(snapshotAt) && entry.at < snapshotAt) return null;
|
|
109
|
+
if (Number.isFinite(entry.resetsAt) && entry.resetsAt <= at) return null;
|
|
110
|
+
return entry;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function clear(codexHome) {
|
|
114
|
+
try {
|
|
115
|
+
fs.unlinkSync(readingFile(codexHome));
|
|
116
|
+
} catch (err) {
|
|
117
|
+
// Already gone is the outcome that was asked for.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { FRESH_MS, configDir, readingFile, read, record, recordAll, correctedFor, clear };
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// judgement in it costs more in rework than it saves; the ladder loses height
|
|
20
20
|
// a step at a time and 'critical' is the only posture that goes straight to
|
|
21
21
|
// the floor.
|
|
22
|
-
const NEXT_LOWER = { max: 'high', xhigh: 'medium', high: 'medium', medium: 'low', low: 'low' };
|
|
22
|
+
const NEXT_LOWER = { ultra: 'xhigh', none: 'none', minimal: 'minimal', max: 'high', xhigh: 'medium', high: 'medium', medium: 'low', low: 'low' };
|
|
23
23
|
|
|
24
24
|
// Below this share of output, reasoning is not where the money is going, and
|
|
25
25
|
// turning effort down would trade quality for a saving that is not there.
|
|
@@ -187,7 +187,7 @@ function decide(inputs) {
|
|
|
187
187
|
'reasoning is only ' + Math.round(share * 100) +
|
|
188
188
|
'% of output, so effort is not where the money is going';
|
|
189
189
|
} else {
|
|
190
|
-
const target = base.posture === 'critical' ? 'low' : NEXT_LOWER[effortNow] || 'medium';
|
|
190
|
+
const target = base.posture === 'critical' ? (['none', 'minimal'].includes(effortNow) ? effortNow : 'low') : NEXT_LOWER[effortNow] || 'medium';
|
|
191
191
|
if (target !== effortNow) {
|
|
192
192
|
base.effort.target = target;
|
|
193
193
|
base.effort.changes = true;
|
|
@@ -200,6 +200,19 @@ function decide(inputs) {
|
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
if (inputs.codex) {
|
|
204
|
+
// Claude's family ladder and slash commands do not describe Codex.
|
|
205
|
+
base.model.why = 'Keep the current Codex model; select another supported model in the host controls when needed.';
|
|
206
|
+
if (base.effort.changes) {
|
|
207
|
+
base.apply.now = 'use the Codex model and effort controls and select ' + base.effort.target;
|
|
208
|
+
base.apply.next = 'node scripts/lowpower.js on --host codex --effort ' + base.effort.target;
|
|
209
|
+
}
|
|
210
|
+
base.notes.push('Saved defaults affect new sessions, not the running task. Profiles and CLI overrides take precedence.');
|
|
211
|
+
if (Number.isFinite(inputs.sessions) && inputs.sessions > 1)
|
|
212
|
+
base.notes.push(inputs.sessions + ' sessions share this account budget.');
|
|
213
|
+
return base;
|
|
214
|
+
}
|
|
215
|
+
|
|
203
216
|
// The main model is only worth flipping when things are critical, and even
|
|
204
217
|
// then it lands in settings.json for the next session: switching the running
|
|
205
218
|
// session's model mid-task invalidates the prompt cache, so the change
|
|
@@ -321,7 +334,7 @@ function renderRecommend(data, turns) {
|
|
|
321
334
|
lines.push(' new sessions: ' + decision.apply.next);
|
|
322
335
|
}
|
|
323
336
|
lines.push(' Model ' + decision.model.why);
|
|
324
|
-
lines.push(' ' + decision.apply.delegate);
|
|
337
|
+
if (decision.apply.delegate) lines.push(' ' + decision.apply.delegate);
|
|
325
338
|
if (decision.model.nextSession) {
|
|
326
339
|
lines.push(' new sessions: main model to ' + decision.model.nextSession + ' until the window resets');
|
|
327
340
|
}
|