claude-usage-limits 1.18.0 → 1.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +220 -2
- package/bin/cli.js +16 -0
- package/commands/usage-mode.md +64 -0
- package/hooks/hooks.json +1 -1
- package/package.json +1 -1
- package/skills/usage-limits/SKILL.md +166 -18
- package/skills/usage-limits/references/tactics.md +40 -9
- package/skills/usage-limits/scripts/agy-hook.js +175 -0
- package/skills/usage-limits/scripts/brief.js +406 -46
- package/skills/usage-limits/scripts/ceiling.js +191 -0
- package/skills/usage-limits/scripts/codex-lowpower.js +95 -4
- package/skills/usage-limits/scripts/codex.js +87 -6
- package/skills/usage-limits/scripts/drift.js +254 -0
- package/skills/usage-limits/scripts/feed.js +23 -1
- package/skills/usage-limits/scripts/host.js +23 -3
- package/skills/usage-limits/scripts/install-antigravity.js +215 -0
- package/skills/usage-limits/scripts/install-codex-hook.js +22 -2
- package/skills/usage-limits/scripts/lowpower.js +48 -0
- package/skills/usage-limits/scripts/mode.js +1637 -0
- package/skills/usage-limits/scripts/pulse.js +254 -17
- package/skills/usage-limits/scripts/reading.js +12 -3
- package/skills/usage-limits/scripts/sessionend.js +8 -0
- package/skills/usage-limits/scripts/stop.js +43 -0
- package/skills/usage-limits/scripts/usage.js +364 -17
- package/skills/usage-limits/scripts/view.js +4 -0
- package/skills/usage-limits/scripts/voice.js +10 -1
|
@@ -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
|
+
};
|