claude-usage-limits 1.11.7 → 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 +257 -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
|
@@ -44,6 +44,10 @@ const STATUSLINE_MARGIN = 4;
|
|
|
44
44
|
const SHORT = { five_hour: 'session', seven_day: 'week', spend_limit: 'spend' };
|
|
45
45
|
// When even that is too wide.
|
|
46
46
|
const SHORTER = { five_hour: '5h', seven_day: 'wk', spend_limit: 'spend' };
|
|
47
|
+
// Codex names its own windows "5h limit" and "Weekly limit"; on one line they
|
|
48
|
+
// are the same two abbreviations Claude's get.
|
|
49
|
+
const SHORT_CODEX = { five_hour: '5h', seven_day: 'week' };
|
|
50
|
+
const SHORTER_CODEX = { five_hour: '5h', seven_day: 'wk' };
|
|
47
51
|
|
|
48
52
|
function configDir() {
|
|
49
53
|
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
@@ -198,7 +202,10 @@ function line(built, options) {
|
|
|
198
202
|
: effort.shimmer && built.working
|
|
199
203
|
? bars.shimmer(effortName, tick, effort.rgb, effort.shimmer, { mode, reduced })
|
|
200
204
|
: bars.paint(effortName, effort.rgb, mode);
|
|
201
|
-
|
|
205
|
+
// The word, in the rainbow, the way Claude Code paints it in the prompt.
|
|
206
|
+
const thinking = built.ultrathink ? ' ' + bars.dim('·', mode) + ' ' + bars.rainbow('ultrathink', tick, { mode, reduced }) : '';
|
|
207
|
+
const head =
|
|
208
|
+
glyph + ' ' + built.modelLabel + (effortText ? ' ' + bars.dim('·', mode) + ' ' + effortText : '') + thinking;
|
|
202
209
|
|
|
203
210
|
const segment = (row, width, shorter) => {
|
|
204
211
|
const label = shortLabel(row, shorter);
|
|
@@ -218,6 +225,14 @@ function line(built, options) {
|
|
|
218
225
|
{ width: 0, head: false, shorter: true },
|
|
219
226
|
{ width: 0, head: false, shorter: true, gap: ' ' },
|
|
220
227
|
];
|
|
228
|
+
|
|
229
|
+
// The Codex tail.
|
|
230
|
+
//
|
|
231
|
+
// It always says "left", and if that does not fit it is not shown at all.
|
|
232
|
+
// Codex reports what remains and Claude reports what is spent, so a bare
|
|
233
|
+
// "85%" sitting beside a bare "15%" would be read as the same kind of
|
|
234
|
+
// number when they run in opposite directions - which is precisely the
|
|
235
|
+
// confusion this row exists to remove.
|
|
221
236
|
// Another Claude spending the same budget is worth a word on the line.
|
|
222
237
|
const others =
|
|
223
238
|
Number.isFinite(built.othersWorking) && built.othersWorking > 0
|
|
@@ -234,6 +249,76 @@ function line(built, options) {
|
|
|
234
249
|
return text;
|
|
235
250
|
}
|
|
236
251
|
|
|
252
|
+
// The Codex line, drawn UNDERNEATH the Claude one.
|
|
253
|
+
//
|
|
254
|
+
// It began as a tail on the same line, and that was wrong twice over. It read
|
|
255
|
+
// as one more Claude window when it is a different account with a different
|
|
256
|
+
// budget, and being last it was the first thing the width ladder dropped, so on
|
|
257
|
+
// an ordinary terminal it was simply never there. A line of its own is what the
|
|
258
|
+
// panel already does and what was asked for.
|
|
259
|
+
//
|
|
260
|
+
// Every figure says "left", because Codex counts down where Claude counts up.
|
|
261
|
+
function codexLine(built, options) {
|
|
262
|
+
const opts = options || {};
|
|
263
|
+
const columns = Number.isFinite(opts.columns) && opts.columns > 0 ? opts.columns : 80;
|
|
264
|
+
const mode = opts.mode || 'none';
|
|
265
|
+
const tick = Number.isFinite(opts.tick) ? opts.tick : 0;
|
|
266
|
+
const reduced = Boolean(opts.reduced);
|
|
267
|
+
const ascii = Boolean(opts.ascii);
|
|
268
|
+
|
|
269
|
+
const block = built && built.codex;
|
|
270
|
+
if (!block || !Array.isArray(block.rows) || !block.rows.length) return '';
|
|
271
|
+
// Nothing readable at all is silence, not a line of dashes.
|
|
272
|
+
if (!block.rows.some((row) => row.percentLeft !== null)) return '';
|
|
273
|
+
|
|
274
|
+
// The name, not a mark: there is no ChatGPT logo a terminal font can draw.
|
|
275
|
+
const glyph = bars.paint(bars.CODEX_LABEL, bars.THEME.codex, mode);
|
|
276
|
+
const plan = block.plan ? bars.dim(String(block.plan), mode) : '';
|
|
277
|
+
|
|
278
|
+
const segment = (row, width, shorter) => {
|
|
279
|
+
const label = (shorter ? SHORTER_CODEX : SHORT_CODEX)[row.key] || row.key;
|
|
280
|
+
// A window whose reading has rolled over says so rather than vanishing: on
|
|
281
|
+
// a line of its own there is room, and dropping it silently would read as
|
|
282
|
+
// "Codex has one window" when it has two.
|
|
283
|
+
if (row.percentLeft === null) return label + ' ' + bars.dim(row.percentText, mode);
|
|
284
|
+
const text = Math.floor(row.percentLeft) + '% left';
|
|
285
|
+
const painted = row.level === 'fill' ? text : bars.paint(text, bars.levelColour(row.level), mode);
|
|
286
|
+
if (!width) return label + ' ' + painted;
|
|
287
|
+
return (
|
|
288
|
+
label + ' ' + bars.bar(row.percentLeft, width, { mode, level: row.level, ascii, tick, reduced }) + ' ' + painted
|
|
289
|
+
);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const attempts = [
|
|
293
|
+
{ width: 10, head: true },
|
|
294
|
+
{ width: 8, head: true },
|
|
295
|
+
{ width: 6, head: true },
|
|
296
|
+
{ width: 6, head: false },
|
|
297
|
+
{ width: 4, head: false },
|
|
298
|
+
{ width: 0, head: false },
|
|
299
|
+
{ width: 0, head: false, shorter: true },
|
|
300
|
+
{ width: 0, head: false, shorter: true, gap: ' ' },
|
|
301
|
+
// Last of all, only the windows that have a number. "5h rolling" is ten
|
|
302
|
+
// columns saying nothing a figure would not, and in a pane this narrow it
|
|
303
|
+
// is the difference between the line fitting and being clipped.
|
|
304
|
+
{ width: 0, head: false, shorter: true, gap: ' ', readable: true },
|
|
305
|
+
];
|
|
306
|
+
|
|
307
|
+
let text = '';
|
|
308
|
+
for (const attempt of attempts) {
|
|
309
|
+
const gap = attempt.gap || ' ';
|
|
310
|
+
const rows = attempt.readable ? block.rows.filter((row) => row.percentLeft !== null) : block.rows;
|
|
311
|
+
if (!rows.length) continue;
|
|
312
|
+
const body = rows.map((row) => segment(row, attempt.width, attempt.shorter)).join(gap);
|
|
313
|
+
// "Codex · ChatGPT Plus", the way the Claude line separates its model from
|
|
314
|
+
// its effort. Without the dot the two ran together as "Codex ChatGPT Plus".
|
|
315
|
+
const label = attempt.head && plan ? glyph + ' ' + bars.dim('·', mode) + ' ' + plan + gap : glyph + ' ';
|
|
316
|
+
text = label + body;
|
|
317
|
+
if (bars.visibleWidth(text) <= columns) return text;
|
|
318
|
+
}
|
|
319
|
+
return text;
|
|
320
|
+
}
|
|
321
|
+
|
|
237
322
|
function readStdin() {
|
|
238
323
|
return new Promise((resolve) => {
|
|
239
324
|
if (process.stdin.isTTY) return resolve('');
|
|
@@ -317,6 +402,12 @@ async function main(argv) {
|
|
|
317
402
|
input = null;
|
|
318
403
|
}
|
|
319
404
|
if (input && typeof input !== 'object') input = null;
|
|
405
|
+
// Claude Code handing this its own status-line JSON settles which agent is
|
|
406
|
+
// running, and it beats any guess made from the environment. Anyone with
|
|
407
|
+
// CODEX_HOME set for their Codex install was otherwise detected as Codex
|
|
408
|
+
// here and got a blank status line under Claude Code, with nothing to say
|
|
409
|
+
// why - detection is only meant to be the fallback for a hand-run script.
|
|
410
|
+
if (input && (input.session_id || input.model)) usage.setHost(host.CLAUDE);
|
|
320
411
|
|
|
321
412
|
const state = statusline.readState();
|
|
322
413
|
if (state && state.chain && state.previous && state.previous.type === 'command' && state.previous.command) {
|
|
@@ -361,13 +452,48 @@ async function main(argv) {
|
|
|
361
452
|
headersAt: slot ? slot.headersAt : null,
|
|
362
453
|
model: slot ? slot.model : null,
|
|
363
454
|
modelName: slot ? slot.modelName : null,
|
|
364
|
-
effort
|
|
455
|
+
// Claude Code hands this line the effort outright, so the slot is
|
|
456
|
+
// already current and nothing else need be read. It is only when the
|
|
457
|
+
// slot has none - the very first update of a session, or a build that
|
|
458
|
+
// does not send it - that the transcript is worth a look.
|
|
459
|
+
effort:
|
|
460
|
+
slot && slot.effort
|
|
461
|
+
? slot.effort
|
|
462
|
+
: view.pickEffort(
|
|
463
|
+
null,
|
|
464
|
+
usage.liveEffort(mine || (slot && slot.sessionId) || null),
|
|
465
|
+
collected.settings ? collected.settings.effortLevel : null
|
|
466
|
+
),
|
|
365
467
|
working: own.working || (gapMeansWorking(settings) && isWorking(slot, now)),
|
|
366
468
|
ultrathink: Boolean(own.ultrathink),
|
|
469
|
+
ultracode: Boolean(own.ultracode) || settings.ultracode === true,
|
|
367
470
|
settingsModel: collected.settings ? collected.settings.model : null,
|
|
368
471
|
env,
|
|
369
472
|
});
|
|
370
|
-
|
|
473
|
+
// The other agent's meter, from its rollouts on disk. Required lazily and
|
|
474
|
+
// guarded by a single stat, so a machine without Codex pays nothing, and
|
|
475
|
+
// wrapped because a status line must never fail over an optional row.
|
|
476
|
+
// USAGE_LIMITS_CODEX_ROW=off turns it off.
|
|
477
|
+
built.codex = null;
|
|
478
|
+
if (String(env.USAGE_LIMITS_CODEX_ROW || '').toLowerCase() !== 'off' && host.codexHasSessions()) {
|
|
479
|
+
try {
|
|
480
|
+
const codex = require('./codex.js');
|
|
481
|
+
const other = codex.collect(now);
|
|
482
|
+
const block = view.buildCodex({
|
|
483
|
+
now,
|
|
484
|
+
utilization: other.utilization,
|
|
485
|
+
fetchedAtMs: other.snapshotFetchedAt,
|
|
486
|
+
windowSpecs: other.windowSpecs,
|
|
487
|
+
plan: other.plan,
|
|
488
|
+
windowless: other.windowless,
|
|
489
|
+
});
|
|
490
|
+
if (block.present) built.codex = block;
|
|
491
|
+
} catch (err) {
|
|
492
|
+
// No Codex row, and the Claude line is unaffected.
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const drawn = {
|
|
371
497
|
columns: Math.max(20, (Number(env.COLUMNS) || 80) - STATUSLINE_MARGIN),
|
|
372
498
|
// Claude Code captures the output, so stdout is never a TTY here, and
|
|
373
499
|
// ANSI is supported all the same.
|
|
@@ -376,8 +502,12 @@ async function main(argv) {
|
|
|
376
502
|
reduced: motionOff(settings, env),
|
|
377
503
|
ascii: String(env.USAGE_LIMITS_ASCII || '') === '1',
|
|
378
504
|
clock: clockFor(settings, env),
|
|
379
|
-
}
|
|
380
|
-
|
|
505
|
+
};
|
|
506
|
+
const text = line(built, drawn);
|
|
507
|
+
// Underneath, on its own line. Claude Code draws every line the status
|
|
508
|
+
// line prints, which is how a chained status line already works.
|
|
509
|
+
const other = codexLine(built, drawn);
|
|
510
|
+
await out((chained ? chained + '\n' : '') + text + (other ? '\n' + other : '') + '\n');
|
|
381
511
|
return 0;
|
|
382
512
|
} catch (err) {
|
|
383
513
|
if (chained) await out(chained + '\n');
|
|
@@ -401,6 +531,7 @@ module.exports = {
|
|
|
401
531
|
gapMeansWorking,
|
|
402
532
|
ownState,
|
|
403
533
|
line,
|
|
534
|
+
codexLine,
|
|
404
535
|
runPrevious,
|
|
405
536
|
clockFor,
|
|
406
537
|
motionOff,
|
|
@@ -10,18 +10,27 @@
|
|
|
10
10
|
// - Codex has the whole hook engine. The binary carries UserPromptSubmit,
|
|
11
11
|
// SessionStart, PreToolUse and the rest, and `codex features list` reports
|
|
12
12
|
// `hooks` as stable and enabled.
|
|
13
|
-
// - A plugin cannot ship one. `plugin_hooks` is reported as `removed
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
13
|
+
// - A plugin cannot ship one. `plugin_hooks` is reported as `removed`, so a
|
|
14
|
+
// `hooks` field in .codex-plugin/plugin.json is accepted and ignored.
|
|
15
|
+
// - On codex-cli 0.151.0-alpha.7.2 nothing fired hooks from anywhere. On
|
|
16
|
+
// 0.153.4 the user-level ~/.codex/hooks.json does run them, but only after
|
|
17
|
+
// a one-time review: the terminal UI opens with "Hooks need review - hooks
|
|
18
|
+
// can run outside the sandbox after you trust them", and the choices are
|
|
19
|
+
// "Trust all and continue" or "Continue without trusting (hooks won't
|
|
20
|
+
// run)". The trust is persisted as a hash of the hooks, so a hook this
|
|
21
|
+
// script rewrites has to be trusted again. The desktop app never shows
|
|
22
|
+
// that review, which is why a machine using only the app can have the
|
|
23
|
+
// hooks installed for weeks and never once run them.
|
|
18
24
|
//
|
|
19
|
-
// So the hooks are
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
+
// So the hooks are written, and `status` says whether anything has ever run
|
|
26
|
+
// them. But the trust review is the user's to accept, deliberately - this
|
|
27
|
+
// script does not forge a trust hash to get round a safety prompt - and until
|
|
28
|
+
// it is accepted the hooks are inert. AGENTS.md is what works regardless:
|
|
29
|
+
// Codex reads it at the top of every session in scope, which is the one
|
|
30
|
+
// always-on instruction channel. It cannot carry live numbers the way a hook
|
|
31
|
+
// can, so instead it tells Codex to go and read them at the start of a piece
|
|
32
|
+
// of work - and, since the effort setting is what actually empties a Codex
|
|
33
|
+
// window, to look at that.
|
|
25
34
|
//
|
|
26
35
|
// Both halves are marked and reversible, and neither touches anything else in
|
|
27
36
|
// the files it edits.
|
|
@@ -40,6 +49,10 @@ const host = require('./host.js');
|
|
|
40
49
|
const EVENTS = [
|
|
41
50
|
{ event: 'UserPromptSubmit', script: 'brief.js', status: 'Checking usage limits' },
|
|
42
51
|
{ event: 'PostToolUse', script: 'pulse.js', status: 'Checking usage limits' },
|
|
52
|
+
// Codex has subagents too, and a turn that hands its work to them makes no
|
|
53
|
+
// tool calls of its own for as long as they run. Same quiet refresh as on
|
|
54
|
+
// Claude Code; pulse.js sees the event name and says nothing.
|
|
55
|
+
{ event: 'SubagentStop', script: 'pulse.js', status: 'Checking usage limits' },
|
|
43
56
|
];
|
|
44
57
|
const EVENT = EVENTS[0].event;
|
|
45
58
|
// Ten seconds is the same budget the Claude hook gets. The brief caches the
|
|
@@ -50,6 +63,23 @@ function hooksFile() {
|
|
|
50
63
|
return path.join(host.codexHome(), 'hooks.json');
|
|
51
64
|
}
|
|
52
65
|
|
|
66
|
+
// Whether Codex has ever actually run one of these hooks. Each writes a small
|
|
67
|
+
// state file beside the rollouts the first time it fires, so the hooks being
|
|
68
|
+
// installed with none of those present means the trust review has never been
|
|
69
|
+
// accepted - which is the state a machine using only the desktop app sits in
|
|
70
|
+
// indefinitely, with no error anywhere to say so.
|
|
71
|
+
function everRan() {
|
|
72
|
+
const dir = host.codexHome();
|
|
73
|
+
return ['usage-limits-pulse.json', 'usage-limits-brief.json'].some((name) =>
|
|
74
|
+
host.exists(path.join(dir, name))
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const TRUST_STEP =
|
|
79
|
+
'Codex runs hooks from this file only after a one-time review. Start `codex` in a\n' +
|
|
80
|
+
' terminal once and choose "Trust all and continue"; the desktop app never shows\n' +
|
|
81
|
+
' that review. Rewriting the hooks changes their hash, so `on` means reviewing again.';
|
|
82
|
+
|
|
53
83
|
function briefScript(name) {
|
|
54
84
|
return path.join(__dirname, name || 'brief.js');
|
|
55
85
|
}
|
|
@@ -120,8 +150,7 @@ function agentsBlock() {
|
|
|
120
150
|
AGENTS_START,
|
|
121
151
|
'## Usage limits',
|
|
122
152
|
'',
|
|
123
|
-
'Before starting any substantial piece of work,
|
|
124
|
-
'looping, run:',
|
|
153
|
+
'Before starting any substantial piece of work, run:',
|
|
125
154
|
'',
|
|
126
155
|
' node ' + quote(briefScript('usage.js')) + ' --host codex',
|
|
127
156
|
'',
|
|
@@ -130,10 +159,28 @@ function agentsBlock() {
|
|
|
130
159
|
'it does not fit, say what you will do now and what you will leave for after the',
|
|
131
160
|
'reset, rather than starting and being cut off partway.',
|
|
132
161
|
'',
|
|
162
|
+
'The number is an estimate that shrinks as the work runs, so run it again after',
|
|
163
|
+
'every ten or so tool-heavy turns, after any step that took minutes, and before',
|
|
164
|
+
'the last long step of a job. When the turns left are fewer than the steps still',
|
|
165
|
+
'ahead, or under fifteen, stop at the next clean boundary: save the work, write',
|
|
166
|
+
'what is done and what is next to WORK-PLAN.md in the working directory, commit',
|
|
167
|
+
'if there is a repository, and say so. Never push on into the limit: a job cut',
|
|
168
|
+
'off partway loses the handoff as well as the work.',
|
|
169
|
+
'',
|
|
170
|
+
'Below ten percent left, or a few turns, the check itself was the last cheap',
|
|
171
|
+
'thing: start nothing new, write the handoff in this same turn, and end the',
|
|
172
|
+
'turn. On 8 September a session read the meter at five percent, carried on,',
|
|
173
|
+
'and lost the rest of the window inside that turn.',
|
|
174
|
+
'',
|
|
133
175
|
'This is about the order of the work, not the amount. Do not economise, shorten',
|
|
134
176
|
'answers or skip verification to save budget: unspent budget is lost at the',
|
|
135
|
-
'reset, not carried over.
|
|
136
|
-
'
|
|
177
|
+
'reset, not carried over.',
|
|
178
|
+
'',
|
|
179
|
+
'The reasoning effort in config.toml is what actually empties a window: one',
|
|
180
|
+
'ordinary task at ultra effort on a Plus plan can take a whole five-hour window.',
|
|
181
|
+
'The report measures what each effort costs here and prices the window at the',
|
|
182
|
+
'one set now; if it says the window holds only a few turns at this effort, say',
|
|
183
|
+
'so, and use a lower effort for work that does not need the thinking.',
|
|
137
184
|
AGENTS_END,
|
|
138
185
|
].join('\n');
|
|
139
186
|
}
|
|
@@ -283,10 +330,14 @@ function status() {
|
|
|
283
330
|
(agentsStale() ? '\n It points at another copy of the plugin, so run `on`.' : '')
|
|
284
331
|
: 'AGENTS.md block missing from ' + agentsFile() + '. Run `on`.'
|
|
285
332
|
);
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
333
|
+
if (!missing.length) {
|
|
334
|
+
lines.push(
|
|
335
|
+
everRan()
|
|
336
|
+
? 'Codex has run these hooks: their state files are beside its rollouts.'
|
|
337
|
+
: 'Codex has never run these hooks. ' + TRUST_STEP + '\n' +
|
|
338
|
+
' Until then the AGENTS.md block is what makes this work.'
|
|
339
|
+
);
|
|
340
|
+
}
|
|
290
341
|
|
|
291
342
|
return {
|
|
292
343
|
installed: !missing.length && agents,
|
|
@@ -344,7 +395,7 @@ function enable() {
|
|
|
344
395
|
' of work. This is the part that works today.\n' +
|
|
345
396
|
' ' + hooksFile() + '\n' +
|
|
346
397
|
EVENTS.map((one) => ' ' + one.event + ' ' + command(one.script)).join('\n') + '\n' +
|
|
347
|
-
'
|
|
398
|
+
' ' + TRUST_STEP.replace(/\n /g, '\n ') + '\n' +
|
|
348
399
|
'Start a new thread for it to take effect. Run `off` to remove both.',
|
|
349
400
|
};
|
|
350
401
|
}
|
|
@@ -61,12 +61,17 @@ function parseArgs(argv) {
|
|
|
61
61
|
for (let i = 0; i < argv.length; i += 1) {
|
|
62
62
|
const arg = argv[i];
|
|
63
63
|
if (arg === '--dry-run') args.dryRun = true;
|
|
64
|
+
else if (arg === '--host') args.host = argv[++i];
|
|
65
|
+
else if (arg.startsWith('--host=')) args.host = arg.slice(7);
|
|
64
66
|
else if (arg === '--effort') args.effort = argv[++i];
|
|
65
67
|
else if (arg === '--model') args.model = argv[++i];
|
|
66
68
|
else if (arg.startsWith('--effort=')) args.effort = arg.slice('--effort='.length);
|
|
67
69
|
else if (arg.startsWith('--model=')) args.model = arg.slice('--model='.length);
|
|
68
|
-
else if (!args.command) args.command = arg;
|
|
70
|
+
else if (!args.command && !arg.startsWith('-')) args.command = arg;
|
|
71
|
+
else throw new Error('Unknown argument: ' + arg);
|
|
72
|
+
if (['--host', '--effort', '--model'].includes(arg) && (!argv[i] || argv[i].startsWith('--'))) throw new Error('Missing value for ' + arg);
|
|
69
73
|
}
|
|
74
|
+
for (const key of ['host', 'effort', 'model']) if (Object.hasOwn(args, key) && args[key] === '') throw new Error('Missing value for --' + key);
|
|
70
75
|
if (!args.command) args.command = 'status';
|
|
71
76
|
return args;
|
|
72
77
|
}
|
|
@@ -166,6 +171,9 @@ function describe(settings, state) {
|
|
|
166
171
|
|
|
167
172
|
function main(argv) {
|
|
168
173
|
const args = parseArgs(argv);
|
|
174
|
+
const host = require('./host.js');
|
|
175
|
+
if (args.host && !['codex', 'claude'].includes(args.host)) throw new Error('Expected --host codex or --host claude');
|
|
176
|
+
if ((args.host || host.detect(argv)) === host.CODEX) return require('./codex-lowpower.js').main(args);
|
|
169
177
|
const file = settingsFile();
|
|
170
178
|
const settings = readJson(file) || {};
|
|
171
179
|
const state = readJson(stateFile());
|
|
@@ -214,7 +222,7 @@ function main(argv) {
|
|
|
214
222
|
return 0;
|
|
215
223
|
}
|
|
216
224
|
|
|
217
|
-
process.stderr.write('usage: lowpower.js [status|on|off] [--effort level] [--model name] [--dry-run]\n');
|
|
225
|
+
process.stderr.write('usage: lowpower.js [status|on|off] [--host claude|codex] [--effort level] [--model name] [--dry-run]\n');
|
|
218
226
|
return 1;
|
|
219
227
|
}
|
|
220
228
|
|
|
@@ -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,
|