claude-usage-limits 1.19.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1637 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Budget modes: how hard the plugin leans, and what it costs to lean.
5
+ //
6
+ // The plugin is not free. It puts a line into the context before every prompt,
7
+ // refreshes readings after tool calls, and keeps a status line alive. A mode
8
+ // called "save tokens" that still injects four hundred tokens of advice per
9
+ // turn is not saving anything - it is charging for the advice about saving.
10
+ //
11
+ // So a mode changes two things, never one:
12
+ //
13
+ // 1. What the plugin TELLS the agent to do (the directive).
14
+ // 2. What the plugin COSTS to say it (verbosity, cadence, silence).
15
+ //
16
+ // Both halves are in the table below, and there is a test for the second one:
17
+ // the `max` line must never be longer than the `standard` line for the same
18
+ // reading.
19
+ //
20
+ // What a mode must NEVER do is lower the quality of the work. When things are
21
+ // tight you change the ORDER of the work, not the amount or the quality. The
22
+ // efficient modes buy their savings from ceremony - speculative reads,
23
+ // re-reads, preamble, subagents nobody needed, workflows that cost more
24
+ // context than they save - and never from doing the job worse. The directives
25
+ // say that outright, because a model reading "use fewer tokens" will otherwise
26
+ // quietly decide to skip the hard part.
27
+ //
28
+ // It also never writes settings.json. See "two planes" below.
29
+
30
+ const fs = require('fs');
31
+ const os = require('os');
32
+ const path = require('path');
33
+
34
+ const host = require('./host.js');
35
+ const codex = require('./codex.js');
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // The policy table
39
+ //
40
+ // Each mode is a record other scripts READ. Nothing downstream hardcodes a
41
+ // mode name in a conditional beyond reading these fields, so a fifth mode is
42
+ // one entry here and nothing else.
43
+ //
44
+ // Two kinds of field live here and --explain keeps them apart, because a
45
+ // record that lists both as though they were the same thing is a saving that
46
+ // exists only where it is described. WIRED names the fields code actually
47
+ // reads; the rest are the mode's stance, and the only way they reach anything
48
+ // is by being restated in the directive prose below, which means they say
49
+ // nothing at all in the two modes that have no directive.
50
+ //
51
+ // Two fields have been deleted rather than sorted: `statusline` and
52
+ // `returnToBaseline`. Nothing read either of them, nothing said either of
53
+ // them, and `statusline: compact` was not merely inert but backwards - the
54
+ // status line is fourteen characters LONGER in `max`, because feed.js adds a
55
+ // "budget max" token there and nothing consumes a compact flag.
56
+
57
+ const MODES = {
58
+ max: {
59
+ name: 'max',
60
+ summary: 'fewest tokens that can still finish the job',
61
+ // One line, no table, no per-model rows.
62
+ briefStyle: 'terse',
63
+ // Say nothing when nothing a decision depends on has moved. The pressure
64
+ // is part of that digest, so the wall always speaks.
65
+ briefWhenUnchanged: false,
66
+ // The reading itself costs IO and tokens, so it is taken less often.
67
+ refreshSeconds: 600,
68
+ // The mid-turn cadence is a different number from the prompt-time one and
69
+ // always has been: the brief refreshes a reading it is about to print,
70
+ // while the pulse interrupts work in progress. They are listed separately
71
+ // so `standard` can keep BOTH of today's numbers rather than accidentally
72
+ // slowing the pulse to the brief's cadence.
73
+ pulseSeconds: 600,
74
+ recheckSeconds: 0,
75
+ directive: 'max',
76
+ // Claude runs the cheapest tier that can still do the job.
77
+ selfSwitch: 'active',
78
+ subagents: 'avoid',
79
+ workflows: 'off',
80
+ // How much emptier another window has to be before a switch is worth
81
+ // naming. Aggressive modes act on a smaller improvement; see escapeRoute.
82
+ switchGapPoints: 5,
83
+ },
84
+ high: {
85
+ name: 'high',
86
+ summary: 'full capability, continuously re-costed',
87
+ briefStyle: 'normal',
88
+ briefWhenUnchanged: true,
89
+ // The explicit two minutes.
90
+ refreshSeconds: 120,
91
+ pulseSeconds: 120,
92
+ // The mid-turn re-cost. See pulse.js.
93
+ recheckSeconds: 120,
94
+ directive: 'high',
95
+ // DOWN when the work is mechanical, back UP when it is not.
96
+ selfSwitch: 'balanced',
97
+ subagents: 'sized',
98
+ workflows: 'when-cheaper',
99
+ switchGapPoints: 5,
100
+ },
101
+ standard: {
102
+ name: 'standard',
103
+ summary: 'what the plugin does today',
104
+ briefStyle: 'normal',
105
+ briefWhenUnchanged: true,
106
+ // Today's default, unchanged.
107
+ refreshSeconds: 180,
108
+ // Today's pulse interval, also unchanged.
109
+ pulseSeconds: 120,
110
+ recheckSeconds: 0,
111
+ directive: null,
112
+ // Today's behaviour: switch rather than stop, at the wall.
113
+ selfSwitch: 'at-pressure',
114
+ subagents: 'allowed',
115
+ workflows: 'allowed',
116
+ switchGapPoints: 10,
117
+ },
118
+ off: {
119
+ name: 'off',
120
+ summary: 'injects nothing, hooks return immediately',
121
+ // Inject nothing at all.
122
+ briefStyle: 'none',
123
+ briefWhenUnchanged: true,
124
+ // Hooks short-circuit before any reading.
125
+ refreshSeconds: 0,
126
+ pulseSeconds: 0,
127
+ recheckSeconds: 0,
128
+ directive: null,
129
+ // Whatever the user set stands, untouched.
130
+ selfSwitch: 'never',
131
+ subagents: 'untouched',
132
+ workflows: 'untouched',
133
+ switchGapPoints: 10,
134
+ },
135
+ };
136
+
137
+ const ORDER = ['max', 'high', 'standard', 'off'];
138
+ const DEFAULT_MODE = 'standard';
139
+
140
+ // The fields some other script reads, and where. Kept beside the table so a
141
+ // new field cannot be added and quietly reported as behaviour: if it is not
142
+ // here, --explain says outright that nothing reads it.
143
+ const WIRED = {
144
+ briefStyle: 'brief.js: how much of the line is said, and "none" for silence',
145
+ briefWhenUnchanged: 'brief.js: whether to repeat a line nothing has moved',
146
+ refreshSeconds: 'brief.js: how old a reading may be before it is retaken; 0 short-circuits the hook',
147
+ pulseSeconds: 'pulse.js: how often the mid-turn hook does real work',
148
+ recheckSeconds: 'pulse.js: how often the mid-turn re-cost may speak',
149
+ switchGapPoints: 'usage.js: how much emptier another window must be before a switch is named',
150
+ };
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Names
154
+ //
155
+ // "normal" is the trap. In the vocabulary this was asked in, "normal" means
156
+ // the FOURTH mode - the plugin ignored. To almost everyone else it means the
157
+ // THIRD - the plugin working as usual. A silent wrong guess picks the opposite
158
+ // of what was asked, so `normal` is an alias for neither: it is accepted and
159
+ // answered with a disambiguation.
160
+ const ALIASES = {
161
+ ultra: 'max',
162
+ ultraefficient: 'max',
163
+ 'ultra-efficient': 'max',
164
+ maxtoken: 'max',
165
+ 'max-token': 'max',
166
+ maxefficient: 'max',
167
+ maxefficiency: 'max',
168
+ 'max-efficient': 'max',
169
+ highefficient: 'high',
170
+ 'high-efficient': 'high',
171
+ 'high-efficiency': 'high',
172
+ highefficiency: 'high',
173
+ smart: 'high',
174
+ tokenefficient: 'standard',
175
+ 'token-efficient': 'standard',
176
+ efficient: 'standard',
177
+ default: 'standard',
178
+ on: 'standard',
179
+ none: 'off',
180
+ ignore: 'off',
181
+ quiet: 'off',
182
+ silent: 'off',
183
+ };
184
+
185
+ const AMBIGUOUS = {
186
+ normal: ['standard', 'off'],
187
+ };
188
+
189
+ function ambiguityText(word) {
190
+ const choices = AMBIGUOUS[word];
191
+ if (!choices) return null;
192
+ return (
193
+ word + ' is ambiguous here. Did you mean:\n' +
194
+ ' standard the plugin working as usual\n' +
195
+ ' off the plugin stays out of the way'
196
+ );
197
+ }
198
+
199
+ // Returns { mode } for a name that resolves, { ambiguous, message } for one
200
+ // that deliberately does not, and null for a word that is not a mode at all.
201
+ // Three outcomes rather than two, because "I will not guess" is an answer.
202
+ function normalise(value) {
203
+ const word = String(value === undefined || value === null ? '' : value).trim().toLowerCase();
204
+ if (!word) return null;
205
+ if (AMBIGUOUS[word]) return { ambiguous: AMBIGUOUS[word].slice(), message: ambiguityText(word) };
206
+ if (MODES[word]) return { mode: word };
207
+ if (ALIASES[word]) return { mode: ALIASES[word], alias: word };
208
+ if (word === 'auto') return { auto: true };
209
+ return null;
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Tiers, for the user's bounds
214
+ //
215
+ // The canonical effort ladder the host accepts is low|medium|high|xhigh|max.
216
+ // "ultracode" is not a sixth level: it is xhigh plus standing dynamic workflow
217
+ // orchestration, set through its own settings key, so it ranks with xhigh.
218
+ //
219
+ // Two guards the modes have to respect and do not get to argue with:
220
+ // - xhigh and max are refused outright when thinking is disabled.
221
+ // - a model bound cannot be enforced against a model nobody has ranked, so
222
+ // an unknown name is reported rather than silently treated as the floor.
223
+ const EFFORT_ORDER = ['low', 'medium', 'high', 'xhigh', 'max'];
224
+ const THINKING_ONLY_EFFORTS = ['xhigh', 'max'];
225
+
226
+ // Cheapest first. The order is the reverse of usage.js's FAMILIES, which is
227
+ // the account's own ordering of these families, so the two cannot drift into
228
+ // disagreeing about which way is "down".
229
+ const MODEL_ORDER = ['haiku', 'sonnet', 'opus', 'mythos', 'fable'];
230
+
231
+ function effortRank(value) {
232
+ const name = String(value || '').trim().toLowerCase();
233
+ if (name === 'ultracode') return EFFORT_ORDER.indexOf('xhigh');
234
+ const at = EFFORT_ORDER.indexOf(name);
235
+ return at === -1 ? null : at;
236
+ }
237
+
238
+ function modelRank(value) {
239
+ const name = String(value || '').trim().toLowerCase();
240
+ for (let i = 0; i < MODEL_ORDER.length; i += 1) {
241
+ if (name.indexOf(MODEL_ORDER[i]) !== -1) return i;
242
+ }
243
+ return null;
244
+ }
245
+
246
+ // "sonnet/medium", "sonnet", "medium" - whichever half the user gave.
247
+ function parseTier(value) {
248
+ const text = String(value || '').trim().toLowerCase();
249
+ if (!text) return null;
250
+ const parts = text.split('/').map((part) => part.trim()).filter(Boolean);
251
+ const tier = { model: null, effort: null };
252
+ for (const part of parts) {
253
+ if (effortRank(part) !== null) tier.effort = part === 'ultracode' ? 'xhigh' : part;
254
+ else if (modelRank(part) !== null) tier.model = MODEL_ORDER[modelRank(part)];
255
+ else return { error: 'not a model or effort: ' + part };
256
+ }
257
+ if (!tier.model && !tier.effort) return { error: 'nothing recognised in "' + value + '"' };
258
+ return tier;
259
+ }
260
+
261
+ function tierText(tier) {
262
+ if (!tier) return null;
263
+ return [tier.model, tier.effort].filter(Boolean).join('/');
264
+ }
265
+
266
+ // Does a suggestion respect the bounds the user set?
267
+ //
268
+ // A floor says "never below this, even in max". A ceiling says "never above
269
+ // this, even on the hard part". Anything the plugin would SAY that points
270
+ // outside them is dropped at the rendering boundary rather than argued with
271
+ // downstream, because the invariant is about what reaches the reader.
272
+ function allows(bounds, suggestion) {
273
+ if (!bounds || !suggestion) return true;
274
+ const check = (kind, rank) => {
275
+ const floor = bounds.floor && bounds.floor[kind] ? rank(bounds.floor[kind]) : null;
276
+ const ceiling = bounds.ceiling && bounds.ceiling[kind] ? rank(bounds.ceiling[kind]) : null;
277
+ const mine = suggestion[kind] ? rank(suggestion[kind]) : null;
278
+ // An unranked name is not evidence of a breach. Reporting it as one would
279
+ // suppress a true statement over a spelling.
280
+ if (mine === null) return true;
281
+ if (floor !== null && mine < floor) return false;
282
+ if (ceiling !== null && mine > ceiling) return false;
283
+ return true;
284
+ };
285
+ return check('effort', effortRank) && check('model', modelRank);
286
+ }
287
+
288
+ function boundsNote(bounds) {
289
+ if (!bounds) return null;
290
+ const bits = [];
291
+ if (bounds.floor) bits.push('never below ' + tierText(bounds.floor));
292
+ if (bounds.ceiling) bits.push('never above ' + tierText(bounds.ceiling));
293
+ if (bounds.pin) bits.push('no self-switching at all: report the gap and leave it');
294
+ if (!bits.length) return null;
295
+ return 'Bounds the user set: ' + bits.join('; ') + '.';
296
+ }
297
+
298
+ function thinkingCaveat(tier) {
299
+ if (!tier || !tier.effort) return null;
300
+ if (THINKING_ONLY_EFFORTS.indexOf(tier.effort) === -1) return null;
301
+ return (
302
+ tier.effort + ' is only accepted while thinking is on; with thinking disabled the request is ' +
303
+ 'refused outright, so check that before pointing anything at it.'
304
+ );
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // The directives
309
+ //
310
+ // Injected verbatim. The wording is the feature.
311
+ //
312
+ // One correction against what the host actually allows, because a directive
313
+ // that promises a lever nobody has is worse than no directive. Nothing a hook
314
+ // emits can change the running session's own model or effort: the whole hook
315
+ // output contract carries no such field, and PreModelSwitch is a veto on a
316
+ // switch someone else started, not a way to start one. What IS the agent's is
317
+ // the tier of what it spawns - the model on an Agent call, and model AND
318
+ // effort inside a Workflow script - so that is what the wording points at.
319
+ //
320
+ // The max directive is also the shortest, and that is not a coincidence: it is
321
+ // the mode whose whole promise is that the plugin costs little, and a
322
+ // four-hundred-token lecture about saving tokens spends what it is asking to
323
+ // save. Every clause of the longer draft is still here; the padding is not.
324
+ const DIRECTIVES = {
325
+ max:
326
+ 'Budget mode: max efficiency. Fewest tokens that still get it right: search before you read, ' +
327
+ 'read ranges not whole files, never re-read what you just wrote, batch edits, no preamble. ' +
328
+ 'No subagent or workflow unless this loop truly cannot do it. Quality is not negotiable - ' +
329
+ 'ceremony is.',
330
+ high:
331
+ 'Budget mode: high efficiency. Work at full capability, and every couple of minutes check ' +
332
+ 'whether the model, effort and approach you are running are bigger than this task needs - ' +
333
+ 'step down when they are, and step back up when the work turns hard again. Your own tier is ' +
334
+ 'not yours to set mid-turn, so when it is the thing that is too big, say so in one line with ' +
335
+ 'the exact command and carry on. What is yours is the tier of what you spawn: the model on an ' +
336
+ 'Agent call, and the model and effort inside a Workflow script. Size those to the stage - low ' +
337
+ 'effort for mechanical ones - and reach for a workflow only when it saves more context than ' +
338
+ 'it costs.',
339
+ standard: null,
340
+ off: null,
341
+ };
342
+
343
+ // The bounds are deliberately NOT glued on here. They are the user's own
344
+ // limits on what may be suggested, they apply in every mode including the two
345
+ // with no directive at all, and a renderer that wants them asks boundsNote()
346
+ // for them. Appending them here would have made them invisible in `standard`,
347
+ // which is the mode most people are in.
348
+ function directive(name) {
349
+ return DIRECTIVES[name] || null;
350
+ }
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // State
354
+ //
355
+ // One small file in the host-aware config directory, so a Codex session reads
356
+ // its own mode and not the Claude Code one. Atomic write, same beside-and-
357
+ // rename as the other stores: the prompt hook and a pulse can land here in the
358
+ // same second.
359
+
360
+ function configDir() {
361
+ return host.detect(process.argv.slice(2), process.env) === host.CODEX
362
+ ? codex.homeDir()
363
+ : process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
364
+ }
365
+
366
+ function modeFile() {
367
+ return path.join(configDir(), 'usage-limits-mode.json');
368
+ }
369
+
370
+ function changesFile() {
371
+ return path.join(configDir(), 'usage-limits-changes.json');
372
+ }
373
+
374
+ // How many declines and how many change-log entries are worth keeping. Both
375
+ // are bounded for the same reason as the drift ledger: the file must be the
376
+ // same size after a year as after a day.
377
+ const KEEP_DECLINED = 40;
378
+ const KEEP_OFFERS = 10;
379
+ const KEEP_CHANGES = 100;
380
+
381
+ function empty() {
382
+ return {
383
+ version: 1,
384
+ mode: DEFAULT_MODE,
385
+ auto: false,
386
+ guardPercent: null,
387
+ // The ceiling is the only setting here that is ENFORCED rather than
388
+ // reported: past it, fan-out calls are refused at the hook. Null means no
389
+ // ceiling, and a ceiling nobody set never refuses anything. See ceiling.js.
390
+ ceilingPercent: null,
391
+ setAt: null,
392
+ setBy: null,
393
+ session: null,
394
+ floor: null,
395
+ ceiling: null,
396
+ pin: false,
397
+ advice: { off: false, declined: {}, offered: {} },
398
+ };
399
+ }
400
+
401
+ // A corrupt file falls back to the default rather than throwing. This is read
402
+ // from inside hooks, and a hook that fails over its own state file would cost
403
+ // more than the setting it was trying to honour.
404
+ function read() {
405
+ const base = empty();
406
+ let raw = null;
407
+ try {
408
+ raw = fs.readFileSync(modeFile(), 'utf8');
409
+ } catch (err) {
410
+ // Never written is the ordinary case. Anything else is a file that exists
411
+ // and could not be read, which is a different thing from "no mode set" and
412
+ // is flagged so describe() can say so instead of reporting the default as
413
+ // though the user had chosen it.
414
+ if (err.code !== 'ENOENT') base.unreadable = true;
415
+ return base;
416
+ }
417
+ let parsed = null;
418
+ try {
419
+ parsed = JSON.parse(raw);
420
+ } catch (err) {
421
+ base.unreadable = true;
422
+ return base;
423
+ }
424
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
425
+ base.unreadable = true;
426
+ return base;
427
+ }
428
+ const named = normalise(parsed.mode);
429
+ if (named && named.mode) base.mode = named.mode;
430
+ base.auto = parsed.auto === true;
431
+ base.guardPercent = Number.isFinite(parsed.guardPercent) ? parsed.guardPercent : null;
432
+ // Anything outside 1-100 is not a ceiling, and enforcing a number that was
433
+ // never a percentage would refuse work over a typo.
434
+ base.ceilingPercent =
435
+ Number.isFinite(parsed.ceilingPercent) && parsed.ceilingPercent > 0 && parsed.ceilingPercent <= 100
436
+ ? parsed.ceilingPercent
437
+ : null;
438
+ base.setAt = Number.isFinite(parsed.setAt) ? parsed.setAt : null;
439
+ base.setBy = typeof parsed.setBy === 'string' ? parsed.setBy : null;
440
+ if (parsed.session && typeof parsed.session === 'object' && parsed.session.id) {
441
+ const sessionMode = normalise(parsed.session.mode);
442
+ if (sessionMode && sessionMode.mode) {
443
+ base.session = { id: String(parsed.session.id), mode: sessionMode.mode, at: Number.isFinite(parsed.session.at) ? parsed.session.at : null };
444
+ }
445
+ }
446
+ for (const key of ['floor', 'ceiling']) {
447
+ const value = parsed[key];
448
+ if (value && typeof value === 'object' && (value.model || value.effort)) {
449
+ base[key] = {
450
+ model: modelRank(value.model) === null ? null : String(value.model).toLowerCase(),
451
+ effort: effortRank(value.effort) === null ? null : String(value.effort).toLowerCase(),
452
+ };
453
+ if (!base[key].model && !base[key].effort) base[key] = null;
454
+ }
455
+ }
456
+ base.pin = parsed.pin === true;
457
+ if (parsed.advice && typeof parsed.advice === 'object') {
458
+ base.advice.off = parsed.advice.off === true;
459
+ if (parsed.advice.declined && typeof parsed.advice.declined === 'object') {
460
+ base.advice.declined = Object.assign({}, parsed.advice.declined);
461
+ }
462
+ if (parsed.advice.offered && typeof parsed.advice.offered === 'object') {
463
+ base.advice.offered = Object.assign({}, parsed.advice.offered);
464
+ }
465
+ }
466
+ return base;
467
+ }
468
+
469
+ function writeAtomic(file, value) {
470
+ fs.mkdirSync(path.dirname(file), { recursive: true });
471
+ const tmp = file + '.' + process.pid + '.tmp';
472
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n');
473
+ fs.renameSync(tmp, file);
474
+ }
475
+
476
+ // Never throws for the same reason read() does not.
477
+ function write(state) {
478
+ try {
479
+ const next = Object.assign({}, state);
480
+ next.advice = Object.assign({ off: false, declined: {}, offered: {} }, state.advice || {});
481
+ next.advice.declined = trimStamps(next.advice.declined, KEEP_DECLINED);
482
+ next.advice.offered = trimStamps(next.advice.offered, KEEP_OFFERS, (entry) => entry && entry.at);
483
+ writeAtomic(modeFile(), next);
484
+ return true;
485
+ } catch (err) {
486
+ return false;
487
+ }
488
+ }
489
+
490
+ // Keeps the newest N by timestamp. `at` is either the value itself (declined)
491
+ // or a field on it (offered), so one trimmer serves both.
492
+ function trimStamps(table, keep, pick) {
493
+ const entries = Object.keys(table || {}).map((key) => ({
494
+ key,
495
+ at: pick ? Number(pick(table[key])) || 0 : Number(table[key]) || 0,
496
+ }));
497
+ entries.sort((a, b) => b.at - a.at);
498
+ const kept = {};
499
+ for (const entry of entries.slice(0, keep)) kept[entry.key] = table[entry.key];
500
+ return kept;
501
+ }
502
+
503
+ // ---------------------------------------------------------------------------
504
+ // auto
505
+ //
506
+ // Not a fifth mode: a switch that picks one from pressure, so it is always
507
+ // reported as the mode it resolved to ("auto -> max"), never as a mystery.
508
+ //
509
+ // It never resolves to `off`. Turning the plugin off is a decision a person
510
+ // makes, not one a threshold makes.
511
+ function autoPick(reading) {
512
+ const percent = reading && Number.isFinite(reading.percentUsed) ? reading.percentUsed : null;
513
+ const pressure = reading ? reading.pressure : null;
514
+ if (pressure === 'tight' || pressure === 'gone') return 'max';
515
+ if (percent === null) return DEFAULT_MODE;
516
+ if (percent >= 80) return 'max';
517
+ if (percent >= 50) return 'high';
518
+ return DEFAULT_MODE;
519
+ }
520
+
521
+ // Where autoPick's reading comes from when the caller has none.
522
+ //
523
+ // Every production caller had none. brief.js, pulse.js, feed.js, stop.js and
524
+ // the report all ask forSession({ sessionId }) and nothing else, because they
525
+ // need the mode BEFORE they can afford a reading - that is the whole point of
526
+ // settling it first. autoPick's no-measurement branch then returned
527
+ // `standard`, so an account could sit at 95 per cent used with `auto` on and
528
+ // every hook, the status line and the ledger would read and behave as
529
+ // standard. The headline behaviour of the mode was inert.
530
+ //
531
+ // So it takes the cheap reading itself: the snapshot already on disk plus
532
+ // whatever correction an earlier turn has already paid for. No scan, no
533
+ // request, no wait - the same source the status line redraws from many times a
534
+ // second. It is only ever reached while `auto` is actually on.
535
+ //
536
+ // Required lazily because usage.js requires this module back; at module scope
537
+ // that is a half-built export table.
538
+ function readingNow(now) {
539
+ try {
540
+ const usage = require('./usage.js');
541
+ // The host this hook is running for, settled the same way every caller
542
+ // settles it, so a Codex session reads Codex's meter.
543
+ usage.setHost(host.detect(process.argv.slice(2), process.env));
544
+ const collected = usage.collect(Number.isFinite(now) ? now : Date.now());
545
+ if (!collected || !collected.utilization) return null;
546
+ const codexHome = usage.isCodex() ? codex.homeDir() : null;
547
+ const windows = usage.snapshotWindows(collected, now, codexHome);
548
+ // The same suppression every other reader makes: a per-model weekly for a
549
+ // model this session is not running cannot be the window that stops it, so
550
+ // it must not be the thing that picks the mode either.
551
+ const usable = windows.filter((w) => w.applies !== false && !w.stale && Number.isFinite(w.percentUsed));
552
+ if (!usable.length) return null;
553
+ return { percentUsed: usable.reduce((worst, w) => (w.percentUsed > worst.percentUsed ? w : worst)).percentUsed };
554
+ } catch (err) {
555
+ // No reading is the one thing autoPick already handles: it falls to the
556
+ // default rather than guessing.
557
+ return null;
558
+ }
559
+ }
560
+
561
+ // ---------------------------------------------------------------------------
562
+ // Resolution and precedence
563
+ //
564
+ // USAGE_LIMITS_MODE env -> --session override -> persisted file -> standard
565
+ //
566
+ // Each level reports its own source, the way the effort chain does, because
567
+ // "which mode am I in" is uninteresting next to "and who said so".
568
+ function resolve(options) {
569
+ const opts = options || {};
570
+ const env = opts.env || process.env;
571
+ const state = opts.state || read();
572
+ const sessionId = opts.sessionId || null;
573
+ const reading = opts.reading || null;
574
+
575
+ let name = null;
576
+ let source = null;
577
+ let auto = false;
578
+
579
+ const fromEnv = normalise(env.USAGE_LIMITS_MODE);
580
+ if (fromEnv && fromEnv.mode) {
581
+ name = fromEnv.mode;
582
+ source = 'environment';
583
+ } else if (fromEnv && fromEnv.auto) {
584
+ auto = true;
585
+ source = 'environment';
586
+ } else if (state.session && sessionId && state.session.id === sessionId) {
587
+ name = state.session.mode;
588
+ source = 'this session';
589
+ } else if (state.auto) {
590
+ auto = true;
591
+ source = 'auto, from the file';
592
+ } else {
593
+ name = state.mode || DEFAULT_MODE;
594
+ source = state.setAt ? 'the file' : 'the default';
595
+ }
596
+
597
+ let label = name;
598
+ if (auto) {
599
+ // A caller that has a reading passes it; one that has none gets the cheap
600
+ // one rather than silently resolving to the default. See readingNow().
601
+ name = autoPick(reading || readingNow(opts.now));
602
+ label = 'auto -> ' + name;
603
+ }
604
+
605
+ const policy = MODES[name] || MODES[DEFAULT_MODE];
606
+ return {
607
+ name: policy.name,
608
+ label,
609
+ source,
610
+ auto,
611
+ policy,
612
+ guardPercent: state.guardPercent,
613
+ ceilingPercent: state.ceilingPercent,
614
+ bounds: { floor: state.floor, ceiling: state.ceiling, pin: state.pin === true },
615
+ advice: state.advice,
616
+ state,
617
+ // The one field every caller needs and nobody should re-derive.
618
+ directive: directive(policy.name),
619
+ };
620
+ }
621
+
622
+ // What a hook wants: one call, never throws, and the reading is optional
623
+ // because a hook that has not scanned yet still has to know whether to bother.
624
+ function forSession(options) {
625
+ try {
626
+ return resolve(options);
627
+ } catch (err) {
628
+ const policy = MODES[DEFAULT_MODE];
629
+ return {
630
+ name: policy.name,
631
+ label: policy.name,
632
+ source: 'the default',
633
+ auto: false,
634
+ policy,
635
+ guardPercent: null,
636
+ ceilingPercent: null,
637
+ bounds: { floor: null, ceiling: null, pin: false },
638
+ advice: { off: false, declined: {}, offered: {} },
639
+ state: empty(),
640
+ directive: null,
641
+ };
642
+ }
643
+ }
644
+
645
+ // ---------------------------------------------------------------------------
646
+ // The change log
647
+ //
648
+ // "Can you change it back?" needs a referent. Without one the agent is
649
+ // guessing at the user's own settings, and a guess written into their file is
650
+ // exactly the surprise the read-only rule exists to prevent. So every change
651
+ // the plugin knows about is written down, and "back" means the last entry.
652
+ //
653
+ // { at, plane: user|agent|mode, key, from, to, by: user|claude, reason }
654
+
655
+ // "Nothing has been changed yet" and "the record of what changed is gone" are
656
+ // different facts, and a file that cannot be parsed must not be reported as
657
+ // the first. A user who has been making changes and is told nothing was ever
658
+ // changed has no reason to suspect the file. ENOENT is one err.code check
659
+ // away, so the distinction costs a line.
660
+ function readChanges() {
661
+ let raw = null;
662
+ try {
663
+ raw = fs.readFileSync(changesFile(), 'utf8');
664
+ } catch (err) {
665
+ // Never written is the ordinary case, and it is not a fault.
666
+ return err.code === 'ENOENT' ? { entries: [] } : { entries: [], unreadable: true };
667
+ }
668
+ try {
669
+ const parsed = JSON.parse(raw);
670
+ if (!parsed || !Array.isArray(parsed.entries)) return { entries: [], unreadable: true };
671
+ // A truncated file can leave entries that are not objects; they are
672
+ // dropped rather than allowed to reach a renderer.
673
+ return { entries: parsed.entries.filter((e) => e && typeof e === 'object' && Number.isFinite(e.at)) };
674
+ } catch (err) {
675
+ return { entries: [], unreadable: true };
676
+ }
677
+ }
678
+
679
+ const UNREADABLE_LOG =
680
+ 'The change log exists but could not be read, so what changed is not known ' +
681
+ 'rather than empty. The file is ';
682
+
683
+ function logChange(entry, now) {
684
+ if (!entry || !entry.key) return false;
685
+ try {
686
+ const state = readChanges();
687
+ state.entries.push({
688
+ at: Number.isFinite(now) ? now : Date.now(),
689
+ plane: entry.plane || 'mode',
690
+ key: String(entry.key),
691
+ from: entry.from === undefined ? null : entry.from,
692
+ to: entry.to === undefined ? null : entry.to,
693
+ by: entry.by === 'claude' ? 'claude' : 'user',
694
+ reason: entry.reason ? String(entry.reason).slice(0, 200) : null,
695
+ // How far the change reached, as a field rather than as English inside
696
+ // `to`. A session override used to be recorded as the string
697
+ // "max (this session)", which reads correctly and is useless to undo:
698
+ // it parsed back to the persisted mode, rewrote that, left the override
699
+ // in place, and reported a revert that had not happened.
700
+ scope: entry.scope === 'session' ? 'session' : 'global',
701
+ sessionId: entry.sessionId ? String(entry.sessionId) : null,
702
+ });
703
+ if (state.entries.length > KEEP_CHANGES) state.entries = state.entries.slice(-KEEP_CHANGES);
704
+ writeAtomic(changesFile(), state);
705
+ return true;
706
+ } catch (err) {
707
+ return false;
708
+ }
709
+ }
710
+
711
+ // The change undo will act on: the newest one that is still standing.
712
+ //
713
+ // Two kinds of entry are skipped. An undo's own reversal is a record of the
714
+ // undo, not a change to reverse - taking it as the target made a second `undo`
715
+ // redo the first, oscillating between two states forever instead of stepping
716
+ // back through the log. And an entry already reversed is done with, so undo
717
+ // twice reaches the change before it.
718
+ function lastUndoable(entries) {
719
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
720
+ const entry = entries[i];
721
+ if (!entry || entry.undone || entry.reason === 'undo') continue;
722
+ return { entry, index: i };
723
+ }
724
+ return null;
725
+ }
726
+
727
+ // Stamps the entry undo just reversed, so it is not offered again.
728
+ function markUndone(index, now) {
729
+ try {
730
+ const state = readChanges();
731
+ if (!state.entries[index]) return false;
732
+ state.entries[index] = Object.assign({}, state.entries[index], { undone: Number.isFinite(now) ? now : Date.now() });
733
+ writeAtomic(changesFile(), state);
734
+ return true;
735
+ } catch (err) {
736
+ return false;
737
+ }
738
+ }
739
+
740
+ function ago(ms) {
741
+ if (!Number.isFinite(ms) || ms < 0) return 'just now';
742
+ const minutes = Math.round(ms / 60000);
743
+ if (minutes < 1) return 'just now';
744
+ if (minutes < 60) return minutes + 'm ago';
745
+ const hours = Math.round(minutes / 60);
746
+ if (hours < 48) return hours + 'h ago';
747
+ return Math.round(hours / 24) + 'd ago';
748
+ }
749
+
750
+ function describeChange(entry, now) {
751
+ return (
752
+ ' ' + ago(now - entry.at).padEnd(9) + entry.plane.padEnd(9) + entry.key + ': ' +
753
+ (entry.from === null ? '(unset)' : entry.from) + ' -> ' + (entry.to === null ? '(unset)' : entry.to) +
754
+ (entry.scope === 'session' ? ' (this session only)' : '') +
755
+ ' (by ' + entry.by + (entry.reason ? ', ' + entry.reason : '') + (entry.undone ? ', since undone' : '') + ')'
756
+ );
757
+ }
758
+
759
+ function history(now, limit) {
760
+ const log = readChanges();
761
+ const entries = log.entries.slice(-(limit || 20)).reverse();
762
+ if (!entries.length) {
763
+ return log.unreadable ? UNREADABLE_LOG + changesFile() + '.' : 'Nothing has been changed through this plugin yet.';
764
+ }
765
+ const at = Number.isFinite(now) ? now : Date.now();
766
+ return ['What changed, newest first:'].concat(entries.map((e) => describeChange(e, at))).join('\n');
767
+ }
768
+
769
+ // Reverses the last logged change, naming it first.
770
+ //
771
+ // It only reverses what the plugin itself owns - the mode plane and the agent
772
+ // plane. A user-plane entry is settings.json, and this file never writes that:
773
+ // it names the entry and the one command that undoes it, and leaves the file
774
+ // alone. That is not timidity, it is the two-planes rule - the user's baseline
775
+ // is theirs - and it is what keeps "no mode writes settings.json" true by
776
+ // construction rather than by remembering.
777
+ function undo(now) {
778
+ const state = readChanges();
779
+ const found = lastUndoable(state.entries);
780
+ if (!found) {
781
+ return {
782
+ ok: false,
783
+ text: state.unreadable
784
+ ? UNREADABLE_LOG + changesFile() + '. Nothing was changed.'
785
+ : 'There is nothing in the change log to undo.',
786
+ };
787
+ }
788
+ const last = found.entry;
789
+ const at = Number.isFinite(now) ? now : Date.now();
790
+ if (last.plane === 'user') {
791
+ // In Codex the command needs the host on it, or it edits the other agent's
792
+ // baseline: lowpower.js falls back to host.detect(argv), where Claude wins
793
+ // ties, and a machine with both installed has both.
794
+ const codexHere = host.detect(process.argv.slice(2), process.env) === host.CODEX;
795
+ // Which way the change went decides which command reverses it. Naming
796
+ // "lowpower off" after a restore would be telling the user to run the
797
+ // thing they just ran, which is a no-op dressed up as an undo.
798
+ const had = last.from === null || last.from === '(unset)' ? null : last.from;
799
+ const reverse = last.reason === 'lowpower off'
800
+ ? 'node scripts/lowpower.js on' +
801
+ (last.key === 'effortLevel' && had ? ' --effort ' + had : '') +
802
+ (last.key === 'model' && had ? ' --model ' + had : '')
803
+ : 'node scripts/lowpower.js off';
804
+ return {
805
+ ok: false,
806
+ entry: last,
807
+ text:
808
+ 'The last change was to your own settings (' + last.key + ': ' +
809
+ (last.from === null ? '(unset)' : last.from) + ' -> ' + (last.to === null ? '(unset)' : last.to) +
810
+ '), ' + ago(at - last.at) + '. That plane is yours and this ' +
811
+ 'script does not write it. To put it back: ' + reverse +
812
+ (codexHere ? ' --host codex' : '') + '. Note that it applies to ' +
813
+ 'NEW sessions - the one you are in keeps ' +
814
+ (codexHere ? 'the tier it started with; Codex changes a running session through its own controls.' : 'the tier it started with.'),
815
+ };
816
+ }
817
+ if (last.plane !== 'mode') {
818
+ return {
819
+ ok: false,
820
+ entry: last,
821
+ text:
822
+ 'The last change was on the agent plane (' + last.key + ': ' + last.from + ' -> ' + last.to +
823
+ '), which lives in the turn that made it and cannot be rewritten from here. Nothing was changed.',
824
+ };
825
+ }
826
+ const target = normalise(last.from);
827
+ const current = read();
828
+ const before = current.auto ? 'auto' : current.mode;
829
+ // A reversal is only true if the EFFECTIVE mode moves, and the session
830
+ // override outranks the file. Rewriting the file under a live override and
831
+ // reporting "reverted, nothing else was touched" was a revert that had not
832
+ // happened: mode --session-id S1 still answered with the override.
833
+ if (last.key === 'mode' && last.scope === 'session') {
834
+ const had = current.session;
835
+ current.session = null;
836
+ write(current);
837
+ markUndone(found.index, now);
838
+ logChange({ plane: 'mode', key: 'mode', from: last.to, to: current.mode, by: 'user', reason: 'undo' }, now);
839
+ return {
840
+ ok: true,
841
+ entry: last,
842
+ text:
843
+ 'Reverting the session override' + (had && had.mode ? ' (' + had.mode + ')' : '') +
844
+ '. This session is back on the persisted mode, ' + current.mode + '. Nothing else was touched.',
845
+ };
846
+ }
847
+ if (last.key === 'mode' && target && (target.mode || target.auto)) {
848
+ // "turn auto on, try a mode, put it back" is the likeliest undo there is,
849
+ // and it was the one that failed: normalise('auto') returns { auto: true }
850
+ // with no `.mode`, so the branch fell through to "not one this script can
851
+ // reverse" while the log held exactly what was needed.
852
+ current.auto = Boolean(target.auto);
853
+ if (target.mode) current.mode = target.mode;
854
+ current.setAt = at;
855
+ current.setBy = 'undo';
856
+ write(current);
857
+ markUndone(found.index, now);
858
+ const back = target.auto ? 'auto' : target.mode;
859
+ logChange({ plane: 'mode', key: 'mode', from: before, to: back, by: 'user', reason: 'undo' }, now);
860
+ return { ok: true, entry: last, text: 'Reverting mode ' + before + ' back to ' + back + '. Nothing else was touched.' };
861
+ }
862
+ if (last.key === 'auto') {
863
+ current.auto = last.from === true || last.from === 'on';
864
+ write(current);
865
+ markUndone(found.index, now);
866
+ logChange({ plane: 'mode', key: 'auto', from: last.to, to: current.auto, by: 'user', reason: 'undo' }, now);
867
+ return { ok: true, entry: last, text: 'Reverting auto back to ' + (current.auto ? 'on' : 'off') + '.' };
868
+ }
869
+ if (last.key === 'guard') {
870
+ current.guardPercent = Number.isFinite(last.from) ? last.from : null;
871
+ write(current);
872
+ markUndone(found.index, now);
873
+ logChange({ plane: 'mode', key: 'guard', from: last.to, to: current.guardPercent, by: 'user', reason: 'undo' }, now);
874
+ return { ok: true, entry: last, text: 'Reverting the guard back to ' + (current.guardPercent === null ? 'none' : current.guardPercent + '%') + '.' };
875
+ }
876
+ if (last.key === 'floor' || last.key === 'ceiling') {
877
+ current[last.key] = last.from ? parseTier(last.from) : null;
878
+ if (current[last.key] && current[last.key].error) current[last.key] = null;
879
+ write(current);
880
+ markUndone(found.index, now);
881
+ logChange({ plane: 'mode', key: last.key, from: last.to, to: tierText(current[last.key]), by: 'user', reason: 'undo' }, now);
882
+ return { ok: true, entry: last, text: 'Reverting the ' + last.key + ' back to ' + (tierText(current[last.key]) || 'none') + '.' };
883
+ }
884
+ if (last.key === 'pin') {
885
+ current.pin = last.from === true;
886
+ write(current);
887
+ markUndone(found.index, now);
888
+ logChange({ plane: 'mode', key: 'pin', from: last.to, to: current.pin, by: 'user', reason: 'undo' }, now);
889
+ return { ok: true, entry: last, text: 'Reverting pin back to ' + (current.pin ? 'on' : 'off') + '.' };
890
+ }
891
+ return { ok: false, entry: last, text: 'The last change (' + last.key + ') is not one this script can reverse. Nothing was changed.' };
892
+ }
893
+
894
+ // ---------------------------------------------------------------------------
895
+ // The advice channel
896
+ //
897
+ // The two planes are not sealed off from each other: they talk, in both
898
+ // directions, through the conversation. The whole rule in one line:
899
+ //
900
+ // Claude may RECOMMEND a user-plane change. Claude may MAKE one when asked.
901
+ // Claude may never make one unasked.
902
+ //
903
+ // The failure mode of a feature like this is nagging, and a plugin that nags
904
+ // gets turned off, at which point it protects nothing. So:
905
+ // - Evidence or silence. A recommendation cites a measurement or it is not
906
+ // made. "Recommended" with no number is nagging.
907
+ // - One per session, at most.
908
+ // - A declined recommendation is remembered and never raised again.
909
+ // - Never in off. In max, allowed but terse, and still capped at one.
910
+ // - Always names the exact command, the plane it changes, and when it takes
911
+ // effect. A recommendation the user cannot act on in one step is a
912
+ // complaint.
913
+
914
+ // The id is what a decline is remembered by, so it describes the SUGGESTION
915
+ // and not the moment: the same advice next week is the same advice.
916
+ function adviceId(fit) {
917
+ if (!fit) return null;
918
+ return 'effort:' + fit.effort + '>' + fit.cheaper;
919
+ }
920
+
921
+ // `fit` is usage.settingFit()'s output: measured, or null. Passing it in
922
+ // rather than reaching for usage.js keeps this module free of the cycle and
923
+ // makes "evidence or silence" structural - with no measurement there is
924
+ // nothing to build a recommendation out of.
925
+ function advicePending(options) {
926
+ const opts = options || {};
927
+ const decided = opts.decided || resolve(opts);
928
+ const fit = opts.fit || null;
929
+ const sessionId = opts.sessionId || null;
930
+ const advice = decided.advice || { off: false, declined: {}, offered: {} };
931
+
932
+ if (decided.policy.briefStyle === 'none') return { ok: false, reason: 'off', text: null };
933
+ if (advice.off) return { ok: false, reason: 'muted', text: null };
934
+ if (!fit) return { ok: false, reason: 'no measurement', text: null };
935
+ const id = adviceId(fit);
936
+ if (advice.declined && advice.declined[id]) return { ok: false, reason: 'declined before', id, text: null };
937
+ // A bound the user set outranks the measurement: advice that points below
938
+ // their own floor is advice they already refused.
939
+ if (!allows(decided.bounds, { effort: fit.cheaper })) return { ok: false, reason: 'below the bounds set', id, text: null };
940
+ const offered = advice.offered && sessionId ? advice.offered[sessionId] : null;
941
+ const alreadyOffered = Boolean(offered && offered.id === id);
942
+
943
+ // Terse in max, and the same measurement either way.
944
+ const terse = decided.policy.briefStyle === 'terse';
945
+ const text = terse
946
+ ? 'Recommendation: ' + fit.effort + ' measured ' + fit.multiple + 'x ' + fit.cheaper +
947
+ ' a turn here (' + fit.sample + ' turns). Yours to make: ' + fit.command + ', from your next turn.'
948
+ : 'One recommendation, from this account\'s own record: ' + fit.effort + ' has measured ' +
949
+ fit.multiple + ' times the cost of ' + fit.cheaper + ' a turn (' + fit.sample + ' turns against ' +
950
+ fit.cheaperSample + '). If the stretch ahead is mechanical, ' + fit.command + ' is the change and it ' +
951
+ 'is the user\'s own setting to make - offer it, do not make it. Say plainly that it applies from ' +
952
+ 'the next turn onward, not retroactively.';
953
+
954
+ return { ok: true, id, text, alreadyOffered, terse };
955
+ }
956
+
957
+ // What was offered, and what it said.
958
+ //
959
+ // The text is stored, not only the id, because the CLI has to be able to
960
+ // answer "what is pending" and "decline what you just offered" without a
961
+ // transcript scan. The brief is the only thing that HAS the measurement - it
962
+ // has already paid for the scan - so the offer it writes is where a later
963
+ // `mode --advice` gets its evidence from.
964
+ function adviceOffer(id, sessionId, now, text) {
965
+ if (!id || !sessionId) return false;
966
+ const state = read();
967
+ state.advice.offered[sessionId] = {
968
+ id,
969
+ at: Number.isFinite(now) ? now : Date.now(),
970
+ text: text ? String(text).slice(0, 600) : null,
971
+ };
972
+ return write(state);
973
+ }
974
+
975
+ // The most recent offer: this session's when there is one, otherwise the
976
+ // newest across sessions. "The user just said no" is said in a session, but a
977
+ // user typing the command in a fresh shell has no session id to give and still
978
+ // means the recommendation they were just shown.
979
+ function adviceLastOffer(sessionId, state) {
980
+ const current = state || read();
981
+ const offered = (current.advice && current.advice.offered) || {};
982
+ if (sessionId && offered[sessionId] && offered[sessionId].id) {
983
+ return Object.assign({ sessionId }, offered[sessionId]);
984
+ }
985
+ let best = null;
986
+ for (const key of Object.keys(offered)) {
987
+ const entry = offered[key];
988
+ if (!entry || !entry.id) continue;
989
+ if (!best || (Number(entry.at) || 0) > (Number(best.at) || 0)) best = Object.assign({ sessionId: key }, entry);
990
+ }
991
+ return best;
992
+ }
993
+
994
+ function adviceDecline(id, now) {
995
+ if (!id) return false;
996
+ const state = read();
997
+ state.advice.declined[id] = Number.isFinite(now) ? now : Date.now();
998
+ return write(state);
999
+ }
1000
+
1001
+ function adviceMute(off) {
1002
+ const state = read();
1003
+ state.advice.off = Boolean(off);
1004
+ return write(state);
1005
+ }
1006
+
1007
+ // ---------------------------------------------------------------------------
1008
+ // The two planes, side by side
1009
+ //
1010
+ // The user plane is settings.json, the pickers, lowpower.js: the user saying
1011
+ // what they want for themselves. It is read here and never written.
1012
+ //
1013
+ // The agent plane is the tier actually running this turn. That is what costs
1014
+ // money, and it is the agent's to move - for what it spawns. Its own tier it
1015
+ // can only report and name the command for.
1016
+ //
1017
+ // CLAUDE_EFFORT is read FIRST and deliberately not folded into usage.js's
1018
+ // effort chain. The host sets it per turn, after any silent downgrade for the
1019
+ // selected model, which makes it the most accurate reading there is - and it
1020
+ // is present in the environment of everything the host launches, so putting it
1021
+ // into the shared chain would change what every other caller sees.
1022
+ function tierNow(options) {
1023
+ const opts = options || {};
1024
+ const env = opts.env || process.env;
1025
+ const usage = opts.usage || require('./usage.js');
1026
+ const sessionId = opts.sessionId || null;
1027
+
1028
+ let effort = null;
1029
+ const perTurn = String(env.CLAUDE_EFFORT || '').trim().toLowerCase();
1030
+ if (perTurn && (effortRank(perTurn) !== null || perTurn === 'ultracode')) {
1031
+ effort = { effort: perTurn, source: 'this turn', live: true };
1032
+ }
1033
+ if (!effort) {
1034
+ try {
1035
+ effort = usage.effortNow(sessionId, env);
1036
+ } catch (err) {
1037
+ effort = null;
1038
+ }
1039
+ }
1040
+
1041
+ let settings = null;
1042
+ try {
1043
+ settings = usage.collect(Number.isFinite(opts.now) ? opts.now : Date.now()).settings || null;
1044
+ } catch (err) {
1045
+ settings = null;
1046
+ }
1047
+
1048
+ let running = null;
1049
+ try {
1050
+ const seen = sessionId ? usage.liveModel(sessionId) : null;
1051
+ running = seen && seen.model ? seen.model : null;
1052
+ } catch (err) {
1053
+ running = null;
1054
+ }
1055
+
1056
+ return {
1057
+ baseline: {
1058
+ model: settings && settings.model ? settings.model : null,
1059
+ effort: settings && settings.effortLevel ? settings.effortLevel : null,
1060
+ },
1061
+ running: {
1062
+ model: running,
1063
+ effort: effort ? effort.effort : null,
1064
+ source: effort ? effort.source : null,
1065
+ },
1066
+ };
1067
+ }
1068
+
1069
+ function sameFamily(a, b) {
1070
+ const left = modelRank(a);
1071
+ const right = modelRank(b);
1072
+ if (left === null || right === null) return String(a || '') === String(b || '');
1073
+ return left === right;
1074
+ }
1075
+
1076
+ // One clause for the brief, or two lines for `--baseline`.
1077
+ //
1078
+ // Where the baseline and the running tier agree there is nothing interesting
1079
+ // to say, so it says it once. Where they differ, THAT is the story, and it is
1080
+ // the whole reason the line exists: a turn that opened with the window and
1081
+ // never said what tier was producing it was hiding the number that decides
1082
+ // what the turn costs.
1083
+ function tierLine(tier, options) {
1084
+ const opts = options || {};
1085
+ if (!tier) return null;
1086
+ const base = tier.baseline || {};
1087
+ const run = tier.running || {};
1088
+ const shortModel = (name) => {
1089
+ const rank = modelRank(name);
1090
+ return rank === null ? name : MODEL_ORDER[rank];
1091
+ };
1092
+ const runningText = [shortModel(run.model) || shortModel(base.model), run.effort || base.effort].filter(Boolean).join('/');
1093
+ if (!runningText) return null;
1094
+ const baseText = [shortModel(base.model), base.effort].filter(Boolean).join('/');
1095
+ const differs =
1096
+ baseText && runningText !== baseText &&
1097
+ (!sameFamily(run.model || base.model, base.model) || (run.effort || base.effort) !== base.effort);
1098
+ const source = run.source ? ' (' + run.source + ')' : '';
1099
+ if (opts.terse) {
1100
+ return differs ? runningText + source + ', yours ' + baseText : runningText + source;
1101
+ }
1102
+ return differs
1103
+ ? 'Running ' + runningText + source + '; your baseline is ' + baseText + '. The gap is the ' +
1104
+ 'interesting part: your baseline is yours and is not being changed.'
1105
+ : 'Running ' + runningText + source + '.';
1106
+ }
1107
+
1108
+ // ---------------------------------------------------------------------------
1109
+ // The ledger
1110
+ //
1111
+ // Modes are evidence, not vibes. The measurement lives in drift.js, which
1112
+ // already has the bounded-append pattern and a file of its own; a second store
1113
+ // for the same kind of after-the-fact measurement would be a second thing to
1114
+ // keep correct.
1115
+ function ledger(now) {
1116
+ const drift = require('./drift.js');
1117
+ const codexHome = host.detect(process.argv.slice(2), process.env) === host.CODEX ? codex.homeDir() : null;
1118
+ const rows = drift.modeSummary(codexHome);
1119
+ if (!rows.length) return 'Mode ledger: nothing measured yet. It fills in as replies land in each mode.';
1120
+ const lines = ['Measured cost per mode:'];
1121
+ for (const row of rows) {
1122
+ lines.push(
1123
+ ' ' + row.mode.padEnd(9) + String(row.turns).padStart(4) + ' turns ' +
1124
+ (Number.isFinite(row.usdPerTurn) ? '$' + row.usdPerTurn.toFixed(2) + '/turn' : 'no price')
1125
+ );
1126
+ }
1127
+ lines.push('');
1128
+ lines.push('Observed, not predicted: what replies actually cost while each mode was on.');
1129
+ // Said rather than left to be noticed: `off` returns before the Stop hook
1130
+ // reads anything, so it has no rows here and never will. A mode that
1131
+ // injects nothing has no injection to attribute a cost to.
1132
+ if (!rows.some((row) => row.mode === 'off')) {
1133
+ lines.push('`off` never appears: its hooks return before the reply is measured.');
1134
+ }
1135
+ return lines.join('\n');
1136
+ }
1137
+
1138
+ // ---------------------------------------------------------------------------
1139
+ // Rendering
1140
+
1141
+ // The mode's full record, in two halves that are not the same kind of fact.
1142
+ //
1143
+ // The first half is what the plugin DOES: every field here is read by a named
1144
+ // script, and changing it changes behaviour. The second is the mode's stance
1145
+ // towards the agent, which has no effect except through the directive - so
1146
+ // when there is no directive it is said plainly that nothing carries it. The
1147
+ // old version printed both under one heading, which reported five fields as
1148
+ // behaviour when nothing anywhere read them.
1149
+ function explain(name) {
1150
+ const policy = MODES[name];
1151
+ if (!policy) return 'No such mode: ' + name + '. Try one of: ' + ORDER.join(', ') + '.';
1152
+ const text = DIRECTIVES[name];
1153
+ const lines = [name + ' - ' + policy.summary, '', ' What the plugin does (read by the scripts named):'];
1154
+ for (const key of Object.keys(policy)) {
1155
+ if (!WIRED[key]) continue;
1156
+ lines.push(' ' + key.padEnd(20) + String(policy[key]).padEnd(10) + WIRED[key]);
1157
+ }
1158
+ const stance = Object.keys(policy).filter((key) => key !== 'name' && key !== 'summary' && key !== 'directive' && !WIRED[key]);
1159
+ if (stance.length) {
1160
+ lines.push('');
1161
+ lines.push(
1162
+ text
1163
+ ? ' What the mode asks of the agent. Nothing reads these fields; they reach'
1164
+ : ' What the mode asks of the agent. Nothing reads these fields, and this mode'
1165
+ );
1166
+ lines.push(text ? ' the agent only by being restated in the directive below:' : ' has no directive, so nothing carries them - they describe intent only:');
1167
+ for (const key of stance) lines.push(' ' + key.padEnd(20) + String(policy[key]));
1168
+ }
1169
+ lines.push('');
1170
+ lines.push(text ? ' Directive, injected verbatim:' : ' Directive: none. ' + (name === 'off' ? 'Nothing is injected at all.' : 'The line is what it is today.'));
1171
+ if (text) lines.push(' ' + text);
1172
+ return lines.join('\n');
1173
+ }
1174
+
1175
+ function list(decided) {
1176
+ const lines = ['Budget modes:', ''];
1177
+ for (const name of ORDER) {
1178
+ const policy = MODES[name];
1179
+ const here = decided && decided.name === name ? ' <- current' : '';
1180
+ lines.push(' ' + name.padEnd(9) + policy.summary + here);
1181
+ }
1182
+ lines.push('');
1183
+ lines.push('Aliases: ' + Object.keys(ALIASES).sort().join(', ') + '.');
1184
+ lines.push('"normal" is deliberately not an alias: it means opposite things to different people.');
1185
+ return lines.join('\n');
1186
+ }
1187
+
1188
+ function describe(decided, now) {
1189
+ const lines = [];
1190
+ lines.push('Mode: ' + decided.label + ' (from ' + decided.source + ')');
1191
+ lines.push(' ' + decided.policy.summary);
1192
+ lines.push('');
1193
+ lines.push(' brief ' + decided.policy.briefStyle + (decided.policy.briefWhenUnchanged ? '' : ', silent when nothing moved'));
1194
+ lines.push(' readings ' + (decided.policy.refreshSeconds ? 'every ' + decided.policy.refreshSeconds + 's' : 'none: the hooks return before reading anything'));
1195
+ if (decided.policy.recheckSeconds) lines.push(' recheck every ' + decided.policy.recheckSeconds + 's, mid-turn');
1196
+ lines.push(' subagents ' + decided.policy.subagents);
1197
+ lines.push(' workflows ' + decided.policy.workflows);
1198
+ if (decided.guardPercent !== null && decided.guardPercent !== undefined) {
1199
+ lines.push(' guard one line at ' + decided.guardPercent + '% used, and nothing else');
1200
+ }
1201
+ const note = boundsNote(decided.bounds);
1202
+ if (note) lines.push(' bounds ' + note.replace('Bounds the user set: ', ''));
1203
+ if (decided.advice && decided.advice.off) lines.push(' advice off');
1204
+ lines.push('');
1205
+ lines.push(' It governs the agent plane only. Your settings.json is never written by this.');
1206
+ lines.push(' File: ' + modeFile());
1207
+ if (decided.state && decided.state.unreadable) {
1208
+ lines.push('');
1209
+ lines.push(' That file exists but could not be read, so the mode above is the default');
1210
+ lines.push(' rather than anything you chose. Setting it again rewrites it.');
1211
+ }
1212
+ return lines.join('\n');
1213
+ }
1214
+
1215
+ // ---------------------------------------------------------------------------
1216
+ // CLI
1217
+
1218
+ function setMode(name, opts, now) {
1219
+ const state = read();
1220
+ const before = state.auto ? 'auto' : state.mode;
1221
+ const at = Number.isFinite(now) ? now : Date.now();
1222
+ if (opts && opts.session) {
1223
+ if (!opts.sessionId) {
1224
+ return 'A session override needs the session id. Run it with --session-id <id>, or set it for good with: mode ' + name;
1225
+ }
1226
+ state.session = { id: opts.sessionId, mode: name, at };
1227
+ write(state);
1228
+ logChange({ plane: 'mode', key: 'mode', from: before, to: name, by: 'user', reason: 'session override', scope: 'session', sessionId: opts.sessionId }, at);
1229
+ return 'Mode ' + name + ' for this session only. The persisted mode is still ' + state.mode + '.';
1230
+ }
1231
+ // A live session override outranks the file, so setting the mode globally
1232
+ // while one is in force changed nothing at all for the session that typed
1233
+ // the command - and the reply named a directive that would never appear.
1234
+ // Setting the mode outright is the user saying what the mode is now, so the
1235
+ // override goes, and it is said rather than done quietly.
1236
+ const cleared = state.session;
1237
+ state.session = null;
1238
+ state.mode = name;
1239
+ state.auto = false;
1240
+ state.setAt = at;
1241
+ state.setBy = 'user';
1242
+ if (opts && Number.isFinite(opts.guard)) state.guardPercent = opts.guard;
1243
+ if (opts && Number.isFinite(opts.ceiling)) state.ceilingPercent = opts.ceiling;
1244
+ write(state);
1245
+ logChange({ plane: 'mode', key: 'mode', from: before, to: name, by: 'user', reason: null }, at);
1246
+
1247
+ const policy = MODES[name];
1248
+ const lines = ['Mode ' + name + ': ' + policy.summary + '.'];
1249
+ if (cleared && cleared.mode) {
1250
+ lines.push(
1251
+ 'The session override (' + cleared.mode + ') was cleared, so this takes effect ' +
1252
+ 'in that session too.'
1253
+ );
1254
+ }
1255
+ if (name === 'off') {
1256
+ // off means off, including at 100 per cent. That is what was asked and it
1257
+ // is honoured literally. But a silent cutoff at the wall is the exact
1258
+ // failure this plugin exists to prevent, so the consequence is stated once
1259
+ // - and the way to keep one line is offered rather than imposed.
1260
+ lines.push('off: nothing will be injected, including at the wall.');
1261
+ // Said out loud because it is the one consequence a user would otherwise
1262
+ // discover by noticing something missing: the end-of-reply cost line and
1263
+ // the closing line are hooks too, and off stops them reading the
1264
+ // transcript at all. The panel stops animating for the same reason.
1265
+ lines.push(
1266
+ 'That covers every hook: no end-of-reply cost line, no closing line, and ' +
1267
+ 'the live panel will not animate, because nothing runs to tell it anything.'
1268
+ );
1269
+ if (state.guardPercent === null || state.guardPercent === undefined) {
1270
+ lines.push('Run "mode off --guard 95" if you want one short line when the window is nearly spent.');
1271
+ } else {
1272
+ lines.push('The guard is set: one short line at ' + state.guardPercent + '% used, and nothing else.');
1273
+ }
1274
+ } else {
1275
+ const text = DIRECTIVES[name];
1276
+ if (text) lines.push('From the next prompt, this goes in front of the work: ' + text);
1277
+ }
1278
+ return lines.join('\n');
1279
+ }
1280
+
1281
+ function main(argv) {
1282
+ const args = (argv || []).slice();
1283
+ const now = Date.now();
1284
+ const flag = (name) => args.indexOf(name) !== -1;
1285
+ const value = (name) => {
1286
+ const at = args.indexOf(name);
1287
+ if (at === -1) return null;
1288
+ const next = args[at + 1];
1289
+ return next && next.indexOf('--') !== 0 ? next : null;
1290
+ };
1291
+ const sessionId = value('--session-id') || process.env.CLAUDE_SESSION_ID || null;
1292
+ const decided = resolve({ sessionId });
1293
+
1294
+ if (flag('--list')) return list(decided);
1295
+ if (flag('--explain')) return explain(String(value('--explain') || '').toLowerCase());
1296
+ if (flag('--ledger')) return ledger(now);
1297
+ if (flag('--history')) return history(now, Number(value('--history')) || 20);
1298
+ if (flag('--baseline')) {
1299
+ const tier = tierNow({ sessionId, now });
1300
+ const base = [tier.baseline.model, tier.baseline.effort].filter(Boolean).join('/') || 'not set';
1301
+ const run = [tier.running.model, tier.running.effort].filter(Boolean).join('/') || 'unknown';
1302
+ // Codex has no settings.json and no /model or /effort, so naming them
1303
+ // there is telling Codex to reach for controls it does not have. usage.js
1304
+ // is already scrupulous about this for the commands it prints; these two
1305
+ // lines were not.
1306
+ const codexHere = host.detect(process.argv.slice(2), process.env) === host.CODEX;
1307
+ return [
1308
+ 'baseline ' + base + ' (yours: ' +
1309
+ (codexHere ? 'config.toml and the /model picker' : 'settings.json and the pickers') +
1310
+ ', never written by this plugin)',
1311
+ 'running ' + run + (tier.running.source ? ' (' + tier.running.source + ')' : ''),
1312
+ '',
1313
+ 'Changing the baseline applies to NEW sessions. For the one you are in, ' +
1314
+ (codexHere
1315
+ ? "Codex's own model and effort controls are the only lever."
1316
+ : 'the picker is the only lever.'),
1317
+ ].join('\n');
1318
+ }
1319
+ // What is pending, read off the record of what was actually offered.
1320
+ //
1321
+ // It used to call usage.settingFit(null, ...) - an event list of null, which
1322
+ // that function turns into [] and returns null from before it looks at
1323
+ // anything - so the answer was "nothing to recommend (no measurement)" on
1324
+ // every machine, including one with four thousand measured turns on disk.
1325
+ // The measurement is not this command's to take: it costs a transcript scan,
1326
+ // the brief has already paid for one, and what the brief offered is written
1327
+ // down. So this reads that.
1328
+ if (flag('--advice')) {
1329
+ const state = read();
1330
+ if (state.advice && state.advice.off) {
1331
+ return 'Recommendations are off (mode --advice-on turns them back on). Nothing is pending.';
1332
+ }
1333
+ const offer = adviceLastOffer(sessionId, state);
1334
+ if (!offer) {
1335
+ return 'Nothing has been offered yet. A recommendation is made from a measurement the ' +
1336
+ 'briefing takes, so there is nothing to show until one has been.';
1337
+ }
1338
+ if (state.advice.declined && state.advice.declined[offer.id]) {
1339
+ return 'Nothing pending: "' + offer.id + '" was offered and declined, and will not be raised again.';
1340
+ }
1341
+ return (offer.text || 'Pending: ' + offer.id + '.') +
1342
+ '\nTo say no to it, and never see it again: mode --decline';
1343
+ }
1344
+ // Somebody has to be able to say no, or "a declined recommendation is never
1345
+ // raised again" is a promise with no way to keep it. This is the verb for
1346
+ // the moment the user says "no, leave it": it records the id and that
1347
+ // recommendation is never volunteered again, in this session or any later
1348
+ // one. With no id it declines whatever is currently pending.
1349
+ if (flag('--decline')) {
1350
+ const given = value('--decline');
1351
+ // Same fix as --advice, and it matters more here: this is the documented
1352
+ // way for the user to say no, and with the id taken from a settingFit()
1353
+ // call that could only ever return null, saying no recorded nothing and
1354
+ // the same recommendation came back in the next session.
1355
+ const id = given || (adviceLastOffer(sessionId) || {}).id || null;
1356
+ if (!id) return 'There is nothing pending to decline.';
1357
+ adviceDecline(id, now);
1358
+ logChange({ plane: 'mode', key: 'advice', from: id, to: 'declined', by: 'user', reason: null }, now);
1359
+ return 'Declined, and remembered: "' + id + '" will not be suggested again.';
1360
+ }
1361
+ if (flag('--no-advice')) {
1362
+ adviceMute(true);
1363
+ logChange({ plane: 'mode', key: 'advice', from: 'on', to: 'off', by: 'user', reason: null }, now);
1364
+ return 'Recommendations are off. Nothing will be suggested about your own settings again until: mode --advice-on';
1365
+ }
1366
+ if (flag('--advice-on')) {
1367
+ adviceMute(false);
1368
+ logChange({ plane: 'mode', key: 'advice', from: 'off', to: 'on', by: 'user', reason: null }, now);
1369
+ return 'Recommendations are back on, capped at one per session and never repeated once declined.';
1370
+ }
1371
+ if (flag('--pin') || flag('--no-pin')) {
1372
+ const state = read();
1373
+ const before = state.pin;
1374
+ state.pin = flag('--pin');
1375
+ write(state);
1376
+ logChange({ plane: 'mode', key: 'pin', from: before, to: state.pin, by: 'user', reason: null }, now);
1377
+ return state.pin
1378
+ ? 'Pinned. Nothing will self-switch: the gap between your baseline and what is running is reported and left alone.'
1379
+ : 'Unpinned. Self-switching is back to what the mode says.';
1380
+ }
1381
+ // The bounds, and the answer for each one asked for.
1382
+ //
1383
+ // Returned as a list rather than straight out of the loop, because this ran
1384
+ // BEFORE the positional argument was looked at: `mode max --floor sonnet`
1385
+ // set the floor, said so, and returned - leaving the mode untouched and
1386
+ // unmentioned. The user asked for two things and was told about one.
1387
+ const boundLines = [];
1388
+ for (const key of ['--floor', '--ceiling']) {
1389
+ if (!flag(key)) continue;
1390
+ const raw = value(key);
1391
+ const field = key.slice(2);
1392
+ const state = read();
1393
+ const before = tierText(state[field]);
1394
+ if (!raw || raw === 'none' || raw === 'off') {
1395
+ state[field] = null;
1396
+ write(state);
1397
+ logChange({ plane: 'mode', key: field, from: before, to: null, by: 'user', reason: null }, now);
1398
+ boundLines.push('The ' + field + ' is cleared.');
1399
+ continue;
1400
+ }
1401
+ const tier = parseTier(raw);
1402
+ if (!tier || tier.error) {
1403
+ return 'Could not read a tier from "' + raw + '": ' + ((tier && tier.error) || 'nothing recognised') + '. Try sonnet/medium.';
1404
+ }
1405
+ state[field] = tier;
1406
+ write(state);
1407
+ logChange({ plane: 'mode', key: field, from: before, to: tierText(tier), by: 'user', reason: null }, now);
1408
+ const caveat = thinkingCaveat(tier);
1409
+ boundLines.push(
1410
+ 'The ' + field + ' is ' + tierText(tier) + '. Nothing the plugin says will point ' +
1411
+ (field === 'floor' ? 'below' : 'above') + ' it.' + (caveat ? '\nNote: ' + caveat : '')
1412
+ );
1413
+ }
1414
+
1415
+ const first = String(args[0] || '').toLowerCase();
1416
+ if (first === 'undo') return undo(now).text;
1417
+ if (first === 'auto') {
1418
+ const state = read();
1419
+ const before = state.auto;
1420
+ const off = String(args[1] || '').toLowerCase() === 'off';
1421
+ state.auto = !off;
1422
+ write(state);
1423
+ logChange({ plane: 'mode', key: 'auto', from: before, to: state.auto, by: 'user', reason: null }, now);
1424
+ if (off) return 'auto is off. The mode is ' + state.mode + ' until you change it.';
1425
+ return [
1426
+ 'auto is on. It picks from pressure and always reports which one it picked:',
1427
+ ' under 50% used and roomy -> standard',
1428
+ ' 50 to 79 -> high',
1429
+ ' 80+, or tight or gone -> max',
1430
+ 'It never picks off. Turning the plugin off is a decision a person makes.',
1431
+ ].join('\n');
1432
+ }
1433
+ if (first && first.indexOf('--') !== 0) {
1434
+ const named = normalise(first);
1435
+ if (!named) return 'No such mode: ' + first + '.\n\n' + list(decided);
1436
+ if (named.ambiguous) return named.message;
1437
+ if (named.auto) return main(['auto'].concat(args.slice(1)));
1438
+ const guard = guardValue(args, flag, value);
1439
+ if (guard.error) return guard.error;
1440
+ const ceilingArg = ceilingValue(args, flag, value);
1441
+ if (ceilingArg.error) return ceilingArg.error;
1442
+ const set = setMode(named.mode, {
1443
+ session: flag('--session'),
1444
+ sessionId,
1445
+ guard: guard.percent,
1446
+ ceiling: ceilingArg.percent,
1447
+ }, now);
1448
+ if (ceilingArg.clear) clearCeiling(now);
1449
+ const lines = [set];
1450
+ if (ceilingArg.percent !== undefined || ceilingArg.clear) lines.push(ceilingLine());
1451
+ return lines.concat(boundLines).join('\n');
1452
+ }
1453
+
1454
+ // The usage cap on its own, without changing the mode.
1455
+ //
1456
+ // Spelled --cap, NOT --ceiling: this file already uses --ceiling for the
1457
+ // model/effort bound ("never go above opus/xhigh"), and taking that spelling
1458
+ // swallowed it - mode --ceiling opus/xhigh started answering "could not read a
1459
+ // percentage". The two are unrelated
1460
+ // settings: the mode says how loudly the plugin talks, the ceiling says where
1461
+ // it stops the work.
1462
+ if (flag('--cap')) {
1463
+ const ceilingArg = ceilingValue(args, flag, value);
1464
+ if (ceilingArg.error) return ceilingArg.error;
1465
+ const state = read();
1466
+ const previous = state.ceilingPercent;
1467
+ state.ceilingPercent = ceilingArg.clear ? null : ceilingArg.percent;
1468
+ write(state);
1469
+ logChange(
1470
+ { plane: 'mode', key: 'ceiling', from: previous, to: state.ceilingPercent, by: 'user', reason: null },
1471
+ now
1472
+ );
1473
+ return ceilingLine();
1474
+ }
1475
+
1476
+ if (boundLines.length) return boundLines.join('\n');
1477
+ return describe(decided, now);
1478
+ }
1479
+
1480
+ function clearCeiling(now) {
1481
+ const state = read();
1482
+ const previous = state.ceilingPercent;
1483
+ state.ceilingPercent = null;
1484
+ write(state);
1485
+ logChange({ plane: 'mode', key: 'ceiling', from: previous, to: null, by: 'user', reason: null }, now);
1486
+ }
1487
+
1488
+ // What the ceiling is now, and what it will actually do. Said in terms of the
1489
+ // refusal, because a percentage on its own does not tell anyone what changes.
1490
+ function ceilingLine() {
1491
+ const state = read();
1492
+ if (state.ceilingPercent === null || state.ceilingPercent === undefined) {
1493
+ return 'Ceiling off. Nothing is refused; the plugin reports and does not intervene.';
1494
+ }
1495
+ return (
1496
+ 'Ceiling ' + state.ceilingPercent + '%. Past that, fan-out calls (Agent, Task, Workflow and ' +
1497
+ 'their equivalents) are refused at the hook, in every session on this machine. Nothing else ' +
1498
+ 'is blocked, so the work still finishes - sequentially, in one session, which is where the ' +
1499
+ 'saving comes from. "mode --cap off" removes it.'
1500
+ );
1501
+ }
1502
+
1503
+ // The ceiling percentage, or the reason it was refused. Same validation as the
1504
+ // guard, and for the same reason: a number accepted without being read is a
1505
+ // setting that silently does the opposite of what was asked.
1506
+ //
1507
+ // A ceiling differs from the guard in one way - "off" is a meaningful value,
1508
+ // because a ceiling is the one setting here that takes something away, and
1509
+ // taking it back has to be as easy as setting it.
1510
+ function ceilingValue(args, flag, value) {
1511
+ if (!flag('--cap')) return { percent: undefined, clear: false };
1512
+ const token = value('--cap');
1513
+ if (token === null) {
1514
+ return { error: '--cap needs a percentage or "off", for example: mode --cap 60' };
1515
+ }
1516
+ const text = String(token).trim().toLowerCase();
1517
+ if (text === 'off' || text === 'none' || text === 'no') return { percent: undefined, clear: true };
1518
+ const raw = Number(text.replace(/%$/, ''));
1519
+ if (!Number.isFinite(raw)) {
1520
+ return { error: 'Could not read a percentage from "' + token + '". Try: mode --cap 60' };
1521
+ }
1522
+ if (raw < 1 || raw > 100) {
1523
+ return {
1524
+ error:
1525
+ 'The ceiling is a percentage of a window, so it has to be between 1 and 100. ' +
1526
+ (raw > 100
1527
+ ? String(raw) + ' can never be reached, so nothing would ever be refused.'
1528
+ : String(raw) + ' would refuse every fan-out from the start of the window.'),
1529
+ };
1530
+ }
1531
+ return { percent: raw, clear: false };
1532
+ }
1533
+
1534
+ // The guard percentage, or the reason it was refused.
1535
+ //
1536
+ // Three ways this went wrong and all three are the same shape - a number that
1537
+ // was accepted without being read.
1538
+ //
1539
+ // `mode off --guard` with no value: value() returns null when the next token
1540
+ // is missing, and Number(null) is 0, which is finite. That stored a guard of
1541
+ // zero, which fires on every prompt at any usage - the exact opposite of
1542
+ // what "off" was asked for, and described in the reply as "one short line at
1543
+ // 0% used".
1544
+ //
1545
+ // `--guard 500`: never fires, so the user asked for a line at the wall and
1546
+ // silently has none. That is the one scenario the guard exists for.
1547
+ //
1548
+ // `--guard 0`: a line on every prompt, as above.
1549
+ //
1550
+ // A percentage of a window is a number between 1 and 100. Anything else is
1551
+ // refused with the reason, because a guard that silently does nothing is worse
1552
+ // than no guard at all.
1553
+ function guardValue(args, flag, value) {
1554
+ if (!flag('--guard')) return { percent: undefined };
1555
+ const token = value('--guard');
1556
+ if (token === null) {
1557
+ return { error: '--guard needs a percentage, for example: mode off --guard 95' };
1558
+ }
1559
+ const raw = Number(token);
1560
+ if (!Number.isFinite(raw)) {
1561
+ return { error: 'Could not read a percentage from "' + token + '". Try: mode off --guard 95' };
1562
+ }
1563
+ if (raw < 1 || raw > 100) {
1564
+ return {
1565
+ error:
1566
+ 'The guard is a percentage of a window, so it has to be between 1 and 100. ' +
1567
+ (raw > 100
1568
+ ? String(raw) + ' can never be reached, so the line would never appear.'
1569
+ : String(raw) + ' is reached immediately, so the line would appear on every prompt.'),
1570
+ };
1571
+ }
1572
+ return { percent: raw };
1573
+ }
1574
+
1575
+ if (require.main === module) {
1576
+ try {
1577
+ process.stdout.write(main(process.argv.slice(2)) + '\n');
1578
+ } catch (err) {
1579
+ process.stdout.write('mode: ' + err.message + '\n');
1580
+ }
1581
+ process.exit(0);
1582
+ }
1583
+
1584
+ module.exports = {
1585
+ MODES,
1586
+ ORDER,
1587
+ WIRED,
1588
+ ALIASES,
1589
+ AMBIGUOUS,
1590
+ DIRECTIVES,
1591
+ DEFAULT_MODE,
1592
+ EFFORT_ORDER,
1593
+ MODEL_ORDER,
1594
+ THINKING_ONLY_EFFORTS,
1595
+ KEEP_CHANGES,
1596
+ KEEP_DECLINED,
1597
+ configDir,
1598
+ modeFile,
1599
+ changesFile,
1600
+ empty,
1601
+ read,
1602
+ write,
1603
+ normalise,
1604
+ ambiguityText,
1605
+ parseTier,
1606
+ tierText,
1607
+ effortRank,
1608
+ modelRank,
1609
+ allows,
1610
+ boundsNote,
1611
+ thinkingCaveat,
1612
+ directive,
1613
+ autoPick,
1614
+ readingNow,
1615
+ resolve,
1616
+ forSession,
1617
+ readChanges,
1618
+ logChange,
1619
+ lastUndoable,
1620
+ history,
1621
+ undo,
1622
+ adviceId,
1623
+ advicePending,
1624
+ adviceOffer,
1625
+ adviceLastOffer,
1626
+ adviceDecline,
1627
+ adviceMute,
1628
+ tierNow,
1629
+ tierLine,
1630
+ ledger,
1631
+ explain,
1632
+ list,
1633
+ describe,
1634
+ setMode,
1635
+ guardValue,
1636
+ main,
1637
+ };