claude-usage-limits 1.39.5 → 1.40.1
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 +106 -3
- package/commands/usage-mode.md +23 -0
- package/package.json +1 -1
- package/skills/usage-limits/SKILL.md +45 -1
- package/skills/usage-limits/scripts/activity.js +4 -16
- package/skills/usage-limits/scripts/atomic.js +173 -0
- package/skills/usage-limits/scripts/brief.js +207 -11
- package/skills/usage-limits/scripts/codex-lowpower.js +3 -3
- package/skills/usage-limits/scripts/codex.js +3 -6
- package/skills/usage-limits/scripts/drift.js +5 -6
- package/skills/usage-limits/scripts/install-antigravity.js +2 -4
- package/skills/usage-limits/scripts/live.js +5 -15
- package/skills/usage-limits/scripts/lowpower.js +3 -4
- package/skills/usage-limits/scripts/lowpri.js +423 -0
- package/skills/usage-limits/scripts/mode.js +100 -4
- package/skills/usage-limits/scripts/reading.js +4 -4
- package/skills/usage-limits/scripts/relay.js +31 -3
- package/skills/usage-limits/scripts/statusline.js +3 -4
- package/skills/usage-limits/scripts/usage.js +3 -10
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Claude Code's own features at the usage wall, as far as a hook can read them.
|
|
4
|
+
//
|
|
5
|
+
// There are five of them and they are not equally knowable, so this module
|
|
6
|
+
// exists mostly to keep the difference straight. Everything here was verified
|
|
7
|
+
// against the installed CLI on 2026-09-25 (@anthropic-ai/claude-code 2.1.283,
|
|
8
|
+
// C:/Users/OWNER/AppData/Roaming/npm/node_modules/@anthropic-ai/claude-code).
|
|
9
|
+
//
|
|
10
|
+
// /low-priority - a hidden toggle offered when the 5-hour session limit is
|
|
11
|
+
// reached: it keeps the session running at lower priority and spends the
|
|
12
|
+
// WEEKLY limit, plus a separate weekly lower-priority allowance. Whether the
|
|
13
|
+
// account is PROVISIONED for it is readable, from one field in the same
|
|
14
|
+
// ~/.claude.json this plugin already parses:
|
|
15
|
+
// cachedGrowthBookFeatures.tengu_toasty_breeze
|
|
16
|
+
// In the bundle: Cj="tengu_toasty_breeze", AKe()=Au().enabled===!0, and the
|
|
17
|
+
// command declares isEnabled:()=>pt()&&(WL()||AKe()).
|
|
18
|
+
//
|
|
19
|
+
// Whether it is ON is NOT readable. The state lives in process memory only -
|
|
20
|
+
// WL()=mr().state.phase==="active", phases idle/armed/active/stale - and is
|
|
21
|
+
// written to no file. ~/.claude/state holds only mcp-discover-verdicts.json
|
|
22
|
+
// and skill-runs.jsonl; ~/.claude/usage-limits is empty; the documented hook
|
|
23
|
+
// payload (code.claude.com/docs/en/hooks) carries no usage field on any
|
|
24
|
+
// event; the statusLine payload has no low_priority field. So this module
|
|
25
|
+
// offers three states and never a fourth: absent, offered, and acknowledged
|
|
26
|
+
// by the user in their own words. activeKnown is false in all of them.
|
|
27
|
+
//
|
|
28
|
+
// Two further gates sit in front of the offer that nothing here can see: the
|
|
29
|
+
// experiment arm arrives in a response header (ZIe() requires
|
|
30
|
+
// lowPriorityOffer==="treatment"), and the CLI withholds the offer during a
|
|
31
|
+
// cooloff and once the weekly allowance is spent. So every sentence built
|
|
32
|
+
// from this is a possibility, never a promise - and the grant can be
|
|
33
|
+
// withdrawn mid-week (github.com/anthropics/claude-code/issues/95470), which
|
|
34
|
+
// is why the flag is re-read on every brief instead of remembered.
|
|
35
|
+
//
|
|
36
|
+
// The wait and retry are per-request, from lowPriorityRetryAfterSeconds and
|
|
37
|
+
// lowPriorityMaxWaitSeconds on the response headers. There is no client-side
|
|
38
|
+
// constant, so no number is printed for them. ($Fn=1800000 in the bundle is a
|
|
39
|
+
// 30-minute freshness cutoff on cached window readings, nothing to do with
|
|
40
|
+
// this.)
|
|
41
|
+
//
|
|
42
|
+
// /limit-reset - a once-weekly manual refill of the 5-hour window, usable
|
|
43
|
+
// only AT a limit, whose work still spends the weekly. Gated on
|
|
44
|
+
// tengu_cedar_ember, which is ABSENT from this account's feature cache, with
|
|
45
|
+
// cachedUsageUtilization.utilization.cedar_ember null. So there is nothing to
|
|
46
|
+
// spend here and nothing is built on it: only a detector that starts
|
|
47
|
+
// reporting if a grant ever appears. resets_left is served by a live
|
|
48
|
+
// endpoint, never a file, so the count is never claimed.
|
|
49
|
+
//
|
|
50
|
+
// The graceful wrap-up note - the CLI injecting "finish up" at the wall. The
|
|
51
|
+
// mechanism is real and the treatment TEXT is provisioned on this machine
|
|
52
|
+
// (tengu_lantern_wick_text), but the gate is the MODE flag, and the bundle's
|
|
53
|
+
// own normalizer keeps only "wrap-up" and "next-steps" and maps everything
|
|
54
|
+
// else to "off":
|
|
55
|
+
// function kuo(e){let n=typeof e==="string"?e.trim().toLowerCase():e;
|
|
56
|
+
// return n==="wrap-up"||n==="next-steps"?n:"off"}
|
|
57
|
+
// tengu_lantern_wick_mode reads "off" here, so the note does not fire on this
|
|
58
|
+
// machine today and the plugin's own near-wall instruction must stay. When
|
|
59
|
+
// the flag flips, hostWrapsUp goes true and the plugin stands down rather
|
|
60
|
+
// than telling the model to wrap up in different words. (tengu_lantern_wick
|
|
61
|
+
// is in the cache but is not a string this build contains at all, so it reads
|
|
62
|
+
// nothing; the near-limit note is a separate flag, tengu_vellum_anchor,
|
|
63
|
+
// T1()=x("tengu_vellum_anchor",!1), which reads false here.)
|
|
64
|
+
//
|
|
65
|
+
// Usage credits - blocked at the org level on this account
|
|
66
|
+
// (cachedExtraUsageDisabledReason "org_level_disabled", extra_usage
|
|
67
|
+
// is_enabled false), so nothing should offer /usage-credits here.
|
|
68
|
+
//
|
|
69
|
+
// autoContinueAtUsageLimit - documented at
|
|
70
|
+
// code.claude.com/docs/en/settings-reference, shipped in 2.1.234, and
|
|
71
|
+
// DEFAULTED TO TRUE in the bundle (value:r?.autoContinueAtUsageLimit??!0). It
|
|
72
|
+
// waits out the reset and continues the same open session. That is better
|
|
73
|
+
// than a scheduled wake when the terminal stays open, and it is why the relay
|
|
74
|
+
// has to say so rather than presenting its wake as the only route.
|
|
75
|
+
|
|
76
|
+
const fs = require('fs');
|
|
77
|
+
const os = require('os');
|
|
78
|
+
const path = require('path');
|
|
79
|
+
|
|
80
|
+
const atomic = require('./atomic.js');
|
|
81
|
+
|
|
82
|
+
const FLAG = 'tengu_toasty_breeze';
|
|
83
|
+
const RESET_FLAG = 'tengu_cedar_ember';
|
|
84
|
+
const WRAPUP_MODE_FLAG = 'tengu_lantern_wick_mode';
|
|
85
|
+
const WRAPUP_TEXT_FLAG = 'tengu_lantern_wick_text';
|
|
86
|
+
const NEAR_WALL_FLAG = 'tengu_vellum_anchor';
|
|
87
|
+
|
|
88
|
+
// The only two mode values the CLI's own normalizer keeps.
|
|
89
|
+
const WRAPUP_MODES = new Set(['wrap-up', 'next-steps']);
|
|
90
|
+
|
|
91
|
+
// The client default in the bundle (xj=10), clamped there to 1440 minutes.
|
|
92
|
+
const DEFAULT_COOLOFF_MINUTES = 10;
|
|
93
|
+
const MAX_COOLOFF_MINUTES = 1440;
|
|
94
|
+
|
|
95
|
+
// The recommendation rule, in one number so it can be argued with.
|
|
96
|
+
//
|
|
97
|
+
// /low-priority spends the weekly and draws on a weekly allowance whose size is
|
|
98
|
+
// exposed nowhere a hook can read, and a real user measured it burning "almost
|
|
99
|
+
// a week of usage in a couple hours"
|
|
100
|
+
// (github.com/anthropics/claude-code/issues/92544). So it is worth naming only
|
|
101
|
+
// while the weekly still has real room. At or below this it is offered; above
|
|
102
|
+
// it the brief says not to, and says why.
|
|
103
|
+
const WEEKLY_HEADROOM_MAX = 80;
|
|
104
|
+
|
|
105
|
+
// Past this the 5-hour window is the wall, which is the only place the CLI
|
|
106
|
+
// offers the toggle at all.
|
|
107
|
+
const WALL_PERCENT = 90;
|
|
108
|
+
|
|
109
|
+
function configDir() {
|
|
110
|
+
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function stateFile() {
|
|
114
|
+
return path.join(configDir(), 'usage-limits-lowpri.json');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function readJson(file) {
|
|
118
|
+
try {
|
|
119
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
120
|
+
} catch (err) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The same choice usage.js and host.js make, for the same reason: a migration
|
|
126
|
+
// leaves a small ~/.claude/.claude.json with machine ids and no meter while the
|
|
127
|
+
// account state stays in the home file, so pick the one that carries a
|
|
128
|
+
// snapshot and only fall back to existence.
|
|
129
|
+
function accountFiles() {
|
|
130
|
+
const scoped = path.join(configDir(), '.claude.json');
|
|
131
|
+
const home = path.join(os.homedir(), '.claude.json');
|
|
132
|
+
return scoped === home ? [home] : [scoped, home];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function snapshot() {
|
|
136
|
+
const files = accountFiles();
|
|
137
|
+
let fallback = null;
|
|
138
|
+
for (const file of files) {
|
|
139
|
+
const parsed = readJson(file);
|
|
140
|
+
if (!parsed) continue;
|
|
141
|
+
if (parsed.cachedUsageUtilization) return parsed;
|
|
142
|
+
if (!fallback) fallback = parsed;
|
|
143
|
+
}
|
|
144
|
+
return fallback;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function features(account) {
|
|
148
|
+
return account && account.cachedGrowthBookFeatures && typeof account.cachedGrowthBookFeatures === 'object'
|
|
149
|
+
? account.cachedGrowthBookFeatures
|
|
150
|
+
: null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function utilization(account) {
|
|
154
|
+
const cache = account && account.cachedUsageUtilization;
|
|
155
|
+
return cache && cache.utilization && typeof cache.utilization === 'object' ? cache.utilization : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A string the server sent, or null. Never a default: the wording is
|
|
159
|
+
// Anthropic's and it can change, so a sentence built on a made-up version of it
|
|
160
|
+
// would be quoting the plugin to itself.
|
|
161
|
+
function copy(value) {
|
|
162
|
+
return typeof value === 'string' && value.trim() ? value : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Whether this ACCOUNT is provisioned for /low-priority, plus the copy the
|
|
166
|
+
// server shipped with it.
|
|
167
|
+
//
|
|
168
|
+
// A missing key is "not offered" and known:false - it is the absence of a fact,
|
|
169
|
+
// not the fact of an absence, and the difference decides whether the docs can
|
|
170
|
+
// say anything about this machine at all.
|
|
171
|
+
function offer(account) {
|
|
172
|
+
const gb = features(account);
|
|
173
|
+
const raw = gb && Object.prototype.hasOwnProperty.call(gb, FLAG) ? gb[FLAG] : undefined;
|
|
174
|
+
if (raw === undefined) {
|
|
175
|
+
return {
|
|
176
|
+
known: false,
|
|
177
|
+
offered: false,
|
|
178
|
+
version: null,
|
|
179
|
+
label: null,
|
|
180
|
+
noticeLine: null,
|
|
181
|
+
statusLine: null,
|
|
182
|
+
allowanceNote: null,
|
|
183
|
+
waitBanner: null,
|
|
184
|
+
budgetExhaustedCopy: null,
|
|
185
|
+
cooloffMinutes: DEFAULT_COOLOFF_MINUTES,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const flag = raw && typeof raw === 'object' ? raw : {};
|
|
189
|
+
const cooloff = Number(flag.cooloffMinutes);
|
|
190
|
+
return {
|
|
191
|
+
known: true,
|
|
192
|
+
offered: flag.enabled === true,
|
|
193
|
+
version: Number.isFinite(Number(flag.version)) ? Number(flag.version) : null,
|
|
194
|
+
label: copy(flag.label),
|
|
195
|
+
noticeLine: copy(flag.noticeLine),
|
|
196
|
+
statusLine: copy(flag.statusLine),
|
|
197
|
+
allowanceNote: copy(flag.allowanceNote),
|
|
198
|
+
waitBanner: copy(flag.waitBanner),
|
|
199
|
+
budgetExhaustedCopy: copy(flag.budgetExhaustedCopy),
|
|
200
|
+
cooloffMinutes: Number.isFinite(cooloff)
|
|
201
|
+
? Math.round(Math.min(MAX_COOLOFF_MINUTES, Math.max(0, cooloff)))
|
|
202
|
+
: DEFAULT_COOLOFF_MINUTES,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// The manual session reset. Detected, never spent: this account holds no grant,
|
|
207
|
+
// so a feature built on it would be untestable here.
|
|
208
|
+
function sessionReset(account) {
|
|
209
|
+
const gb = features(account);
|
|
210
|
+
const util = utilization(account);
|
|
211
|
+
const flagged = Boolean(gb && Object.prototype.hasOwnProperty.call(gb, RESET_FLAG) && gb[RESET_FLAG]);
|
|
212
|
+
const grant = util && util.cedar_ember ? util.cedar_ember : null;
|
|
213
|
+
return {
|
|
214
|
+
known: Boolean(gb || util),
|
|
215
|
+
present: Boolean(flagged || grant),
|
|
216
|
+
// resets_left is served from /api/organizations/<uuid>/reset_rate_limits,
|
|
217
|
+
// not from any file a hook can read, so it stays unreported.
|
|
218
|
+
resetsLeft: null,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Whether the CLI itself will tell the model to wrap up at the wall.
|
|
223
|
+
function wrapUp(account) {
|
|
224
|
+
const gb = features(account);
|
|
225
|
+
const rawMode = gb ? gb[WRAPUP_MODE_FLAG] : undefined;
|
|
226
|
+
const normalised = typeof rawMode === 'string' ? rawMode.trim().toLowerCase() : rawMode;
|
|
227
|
+
const mode = WRAPUP_MODES.has(normalised) ? normalised : 'off';
|
|
228
|
+
return {
|
|
229
|
+
known: Boolean(gb && (Object.prototype.hasOwnProperty.call(gb, WRAPUP_MODE_FLAG) || Object.prototype.hasOwnProperty.call(gb, WRAPUP_TEXT_FLAG))),
|
|
230
|
+
mode,
|
|
231
|
+
hostWrapsUp: mode !== 'off',
|
|
232
|
+
textProvisioned: Boolean(gb && copy(gb[WRAPUP_TEXT_FLAG])),
|
|
233
|
+
nearWallNote: Boolean(gb && gb[NEAR_WALL_FLAG] === true),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Whether usage credits are a lever on this account at all.
|
|
238
|
+
function credits(account) {
|
|
239
|
+
const util = utilization(account);
|
|
240
|
+
const extra = util && util.extra_usage ? util.extra_usage : null;
|
|
241
|
+
const reason =
|
|
242
|
+
account && typeof account.cachedExtraUsageDisabledReason === 'string'
|
|
243
|
+
? account.cachedExtraUsageDisabledReason
|
|
244
|
+
: (extra && typeof extra.disabled_reason === 'string' ? extra.disabled_reason : null);
|
|
245
|
+
if (!extra) return { available: null, reason: reason };
|
|
246
|
+
if (extra.is_enabled === true) return { available: true, reason: null };
|
|
247
|
+
if (extra.is_enabled === false) return { available: false, reason: reason };
|
|
248
|
+
return { available: null, reason: reason };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function settingsFiles() {
|
|
252
|
+
return [path.join(configDir(), 'settings.local.json'), path.join(configDir(), 'settings.json')];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// The CLI's own wait-and-continue. Documented as user-or-managed scope, and the
|
|
256
|
+
// bundle defaults it to true, so an absent key is TRUE - the one reading a
|
|
257
|
+
// relay must not get wrong, because it decides whether the wake is the only
|
|
258
|
+
// route across the reset or a second one.
|
|
259
|
+
function autoContinue() {
|
|
260
|
+
for (const file of settingsFiles()) {
|
|
261
|
+
const parsed = readJson(file);
|
|
262
|
+
if (parsed && typeof parsed.autoContinueAtUsageLimit === 'boolean') {
|
|
263
|
+
return { value: parsed.autoContinueAtUsageLimit, source: path.basename(file) };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return { value: true, source: 'default' };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// The acknowledgement. The plugin's own record that the USER said he turned
|
|
271
|
+
// low-priority on, because nothing else can tell it.
|
|
272
|
+
//
|
|
273
|
+
// It carries the window it belongs to and that window's reset, and it lapses at
|
|
274
|
+
// that reset: low-priority ends when the limit does, so a fact that outlived
|
|
275
|
+
// its window would go on redirecting the headroom maths at the weekly in a
|
|
276
|
+
// session where the 5-hour wall is real again.
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
function readState() {
|
|
280
|
+
const parsed = readJson(stateFile());
|
|
281
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function writeState(state) {
|
|
285
|
+
try {
|
|
286
|
+
atomic.writeFileAtomic(stateFile(), JSON.stringify(state, null, 2) + '\n');
|
|
287
|
+
return true;
|
|
288
|
+
} catch (err) {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function acknowledge(input) {
|
|
294
|
+
const options = input || {};
|
|
295
|
+
const now = Number.isFinite(options.now) ? options.now : Date.now();
|
|
296
|
+
if (!options.on) {
|
|
297
|
+
const state = readState();
|
|
298
|
+
delete state.ack;
|
|
299
|
+
writeState(state);
|
|
300
|
+
return { on: false, at: now };
|
|
301
|
+
}
|
|
302
|
+
const record = {
|
|
303
|
+
on: true,
|
|
304
|
+
at: now,
|
|
305
|
+
windowKey: options.windowKey || null,
|
|
306
|
+
resetsAt: Number.isFinite(options.resetsAt) ? options.resetsAt : null,
|
|
307
|
+
};
|
|
308
|
+
const state = readState();
|
|
309
|
+
state.ack = record;
|
|
310
|
+
writeState(state);
|
|
311
|
+
return record;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function readAck(now) {
|
|
315
|
+
const at = Number.isFinite(now) ? now : Date.now();
|
|
316
|
+
const state = readState();
|
|
317
|
+
const ack = state.ack;
|
|
318
|
+
if (!ack || ack.on !== true) return null;
|
|
319
|
+
// Past the reset of the window it was recorded against, the fact is spent.
|
|
320
|
+
if (Number.isFinite(ack.resetsAt) && at >= ack.resetsAt) return null;
|
|
321
|
+
return ack;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Exactly three states, and activeKnown is false in every one of them.
|
|
325
|
+
function stateOf(input) {
|
|
326
|
+
const options = input || {};
|
|
327
|
+
const now = Number.isFinite(options.now) ? options.now : Date.now();
|
|
328
|
+
const account = options.account !== undefined ? options.account : snapshot();
|
|
329
|
+
const provisioned = offer(account);
|
|
330
|
+
const ack = readAck(now);
|
|
331
|
+
return {
|
|
332
|
+
state: ack ? 'acknowledged' : provisioned.offered ? 'offered' : 'absent',
|
|
333
|
+
offered: provisioned.offered,
|
|
334
|
+
known: provisioned.known,
|
|
335
|
+
ack,
|
|
336
|
+
// Never readable. Said out loud here so no caller can talk itself into it.
|
|
337
|
+
activeKnown: false,
|
|
338
|
+
offer: provisioned,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function weeklyOf(windows) {
|
|
343
|
+
return (windows || []).find((w) => w && w.key === 'seven_day') || null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Whether to say anything about /low-priority on this prompt, and which way.
|
|
347
|
+
//
|
|
348
|
+
// Null unless the account is provisioned, the 5-hour window is the binding one,
|
|
349
|
+
// it is at the wall, and there is a weekly reading to judge against. A guess at
|
|
350
|
+
// any of those would be the plugin recommending a spend it cannot price.
|
|
351
|
+
function advise(input) {
|
|
352
|
+
const options = input || {};
|
|
353
|
+
const now = Number.isFinite(options.now) ? options.now : Date.now();
|
|
354
|
+
const account = options.account !== undefined ? options.account : snapshot();
|
|
355
|
+
if (!offer(account).offered) return null;
|
|
356
|
+
const binding = options.binding;
|
|
357
|
+
if (!binding || binding.key !== 'five_hour') return null;
|
|
358
|
+
if (binding.stale) return null;
|
|
359
|
+
if (!Number.isFinite(binding.percentUsed) || binding.percentUsed < WALL_PERCENT) return null;
|
|
360
|
+
const weekly = weeklyOf(options.windows);
|
|
361
|
+
if (!weekly || !Number.isFinite(weekly.percentUsed) || weekly.stale) return null;
|
|
362
|
+
const percent = Math.round(weekly.percentUsed);
|
|
363
|
+
return {
|
|
364
|
+
kind: percent <= WEEKLY_HEADROOM_MAX ? 'offer' : 'hold',
|
|
365
|
+
weeklyPercent: percent,
|
|
366
|
+
threshold: WEEKLY_HEADROOM_MAX,
|
|
367
|
+
weeklyLabel: weekly.label || 'weekly',
|
|
368
|
+
now,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Everything the brief needs, in one read of one file.
|
|
373
|
+
function forBrief(input) {
|
|
374
|
+
const options = input || {};
|
|
375
|
+
const now = Number.isFinite(options.now) ? options.now : Date.now();
|
|
376
|
+
const account = options.account !== undefined ? options.account : snapshot();
|
|
377
|
+
const state = stateOf({ account, now });
|
|
378
|
+
const weekly = weeklyOf(options.windows);
|
|
379
|
+
const five = (options.windows || []).find((w) => w && w.key === 'five_hour') || null;
|
|
380
|
+
return {
|
|
381
|
+
state: state.state,
|
|
382
|
+
activeKnown: false,
|
|
383
|
+
advise: advise({ account, now, binding: options.binding, windows: options.windows }),
|
|
384
|
+
notice: state.offer.noticeLine,
|
|
385
|
+
cooloffMinutes: state.offer.cooloffMinutes,
|
|
386
|
+
budgetExhaustedCopy: state.offer.budgetExhaustedCopy,
|
|
387
|
+
weeklyLabel: weekly ? weekly.label || 'weekly' : 'weekly',
|
|
388
|
+
weeklyPercent: weekly && Number.isFinite(weekly.percentUsed) ? Math.round(weekly.percentUsed) : null,
|
|
389
|
+
fiveHourPercent: five && Number.isFinite(five.percentUsed) ? Math.round(five.percentUsed) : null,
|
|
390
|
+
resetGrant: sessionReset(account).present,
|
|
391
|
+
credits: credits(account),
|
|
392
|
+
autoContinue: autoContinue(),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
module.exports = {
|
|
397
|
+
FLAG,
|
|
398
|
+
RESET_FLAG,
|
|
399
|
+
WRAPUP_MODE_FLAG,
|
|
400
|
+
WRAPUP_TEXT_FLAG,
|
|
401
|
+
NEAR_WALL_FLAG,
|
|
402
|
+
WRAPUP_MODES,
|
|
403
|
+
DEFAULT_COOLOFF_MINUTES,
|
|
404
|
+
MAX_COOLOFF_MINUTES,
|
|
405
|
+
WEEKLY_HEADROOM_MAX,
|
|
406
|
+
WALL_PERCENT,
|
|
407
|
+
configDir,
|
|
408
|
+
stateFile,
|
|
409
|
+
accountFiles,
|
|
410
|
+
snapshot,
|
|
411
|
+
offer,
|
|
412
|
+
sessionReset,
|
|
413
|
+
wrapUp,
|
|
414
|
+
credits,
|
|
415
|
+
autoContinue,
|
|
416
|
+
acknowledge,
|
|
417
|
+
readAck,
|
|
418
|
+
readState,
|
|
419
|
+
stateOf,
|
|
420
|
+
advise,
|
|
421
|
+
forBrief,
|
|
422
|
+
weeklyOf,
|
|
423
|
+
};
|
|
@@ -31,8 +31,10 @@ const fs = require('fs');
|
|
|
31
31
|
const os = require('os');
|
|
32
32
|
const path = require('path');
|
|
33
33
|
|
|
34
|
+
const atomic = require('./atomic.js');
|
|
34
35
|
const host = require('./host.js');
|
|
35
36
|
const codex = require('./codex.js');
|
|
37
|
+
const lowpri = require('./lowpri.js');
|
|
36
38
|
|
|
37
39
|
// ---------------------------------------------------------------------------
|
|
38
40
|
// The policy table
|
|
@@ -480,10 +482,7 @@ function read() {
|
|
|
480
482
|
}
|
|
481
483
|
|
|
482
484
|
function writeAtomic(file, value) {
|
|
483
|
-
|
|
484
|
-
const tmp = file + '.' + process.pid + '.tmp';
|
|
485
|
-
fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n');
|
|
486
|
-
fs.renameSync(tmp, file);
|
|
485
|
+
atomic.writeFileAtomic(file, JSON.stringify(value, null, 2) + '\n');
|
|
487
486
|
}
|
|
488
487
|
|
|
489
488
|
// Never throws for the same reason read() does not.
|
|
@@ -1462,6 +1461,103 @@ function main(argv) {
|
|
|
1462
1461
|
logChange({ plane: 'mode', key: 'advice', from: 'off', to: 'on', by: 'user', reason: null }, now);
|
|
1463
1462
|
return 'Recommendations are back on, capped at one per session and never repeated once declined.';
|
|
1464
1463
|
}
|
|
1464
|
+
// Whether the user has switched /low-priority on.
|
|
1465
|
+
//
|
|
1466
|
+
// This is a RECORD of something the user said, not a reading. Claude Code
|
|
1467
|
+
// keeps the live state in process memory and writes it nowhere - not to
|
|
1468
|
+
// ~/.claude.json, not to ~/.claude/state, and no hook payload or status-line
|
|
1469
|
+
// field carries it - so the plugin cannot know. What it can do is take the
|
|
1470
|
+
// user's word, stamp it with the window it belongs to, and let it lapse at
|
|
1471
|
+
// that window's reset, which is when low-priority ends anyway.
|
|
1472
|
+
//
|
|
1473
|
+
// Nothing else may write this. The model cannot type a slash command, so it
|
|
1474
|
+
// cannot have turned low-priority on, so it has nothing to acknowledge.
|
|
1475
|
+
if (flag('--low-priority')) {
|
|
1476
|
+
const asked = String(value('--low-priority') || '').trim().toLowerCase();
|
|
1477
|
+
const account = lowpri.snapshot();
|
|
1478
|
+
const provisioned = lowpri.offer(account);
|
|
1479
|
+
const detail = provisioned.offered
|
|
1480
|
+
? 'This account is provisioned for it (' + lowpri.FLAG + ' enabled' +
|
|
1481
|
+
(provisioned.version ? ', config version ' + provisioned.version : '') + '), though the ' +
|
|
1482
|
+
'offer itself also depends on an experiment arm that arrives in a response header, so it ' +
|
|
1483
|
+
'is not guaranteed to appear at every wall.'
|
|
1484
|
+
: provisioned.known
|
|
1485
|
+
? 'This account is NOT provisioned for it right now (' + lowpri.FLAG + ' is present and disabled), ' +
|
|
1486
|
+
'so the wall will not offer it.'
|
|
1487
|
+
: 'Whether this account is provisioned for it is unknown: ' + lowpri.FLAG + ' has never been ' +
|
|
1488
|
+
'seen in the feature cache, which is the absence of a fact rather than the fact of an absence.';
|
|
1489
|
+
if (asked === 'on' || asked === 'yes' || asked === 'true') {
|
|
1490
|
+
// Stamp it with the 5-hour window it belongs to, so the fact lapses when
|
|
1491
|
+
// that window resets instead of steering the headroom maths for ever.
|
|
1492
|
+
let windowKey = null;
|
|
1493
|
+
let resetsAt = null;
|
|
1494
|
+
try {
|
|
1495
|
+
const usage = require('./usage.js');
|
|
1496
|
+
const windows = usage.snapshotWindows(usage.collect(now), now, null) || [];
|
|
1497
|
+
const five = windows.find((w) => w && w.key === 'five_hour');
|
|
1498
|
+
if (five && Number.isFinite(five.resetsAt)) {
|
|
1499
|
+
windowKey = 'five_hour';
|
|
1500
|
+
resetsAt = five.resetsAt;
|
|
1501
|
+
}
|
|
1502
|
+
} catch (err) {
|
|
1503
|
+
// No reading is not a reason to refuse the user's own statement; the
|
|
1504
|
+
// record simply has no expiry to hang on.
|
|
1505
|
+
}
|
|
1506
|
+
lowpri.acknowledge({ on: true, windowKey, resetsAt, now });
|
|
1507
|
+
logChange({ plane: 'mode', key: 'low-priority', from: 'unknown', to: 'on', by: 'user', reason: null }, now);
|
|
1508
|
+
return [
|
|
1509
|
+
'Recorded: you have switched /low-priority on.',
|
|
1510
|
+
'',
|
|
1511
|
+
'From here the brief brakes on your WEEKLY window rather than the 5-hour one, stops telling ' +
|
|
1512
|
+
'you to wind down at the 5-hour wall, says replies may pause while lower priority waits for ' +
|
|
1513
|
+
'spare capacity, and will not book a relay wake for the 5-hour reset - that reset is no ' +
|
|
1514
|
+
'longer a wall this session stops at.',
|
|
1515
|
+
'',
|
|
1516
|
+
'What it costs, in Anthropic\'s own words: it uses your weekly limit, and it draws on a ' +
|
|
1517
|
+
'separate weekly lower-priority allowance whose size is exposed to no hook and no file, so ' +
|
|
1518
|
+
'the plugin cannot track it. Nothing here can tell whether it is still on, so when you ' +
|
|
1519
|
+
'turn it off: mode --low-priority off',
|
|
1520
|
+
'',
|
|
1521
|
+
resetsAt
|
|
1522
|
+
? 'This lapses on its own at ' + new Date(resetsAt).toLocaleString() + ', when the 5-hour window resets.'
|
|
1523
|
+
: 'No 5-hour reset time was readable, so this has no expiry: clear it by hand when it ends.',
|
|
1524
|
+
'',
|
|
1525
|
+
detail,
|
|
1526
|
+
].join('\n');
|
|
1527
|
+
}
|
|
1528
|
+
if (asked === 'off' || asked === 'no' || asked === 'false') {
|
|
1529
|
+
const had = lowpri.readAck(now);
|
|
1530
|
+
lowpri.acknowledge({ on: false, now });
|
|
1531
|
+
logChange({ plane: 'mode', key: 'low-priority', from: had ? 'on' : 'unknown', to: 'off', by: 'user', reason: null }, now);
|
|
1532
|
+
return had
|
|
1533
|
+
? 'Cleared: low-priority is off again, so the 5-hour window counts as a wall from the next prompt.'
|
|
1534
|
+
: 'Nothing was recorded, so there was nothing to clear. Low-priority is off as far as this plugin knows.';
|
|
1535
|
+
}
|
|
1536
|
+
if (asked) {
|
|
1537
|
+
return 'mode --low-priority takes "on" or "off". Say which; this is a record of what YOU did, so it is never guessed.';
|
|
1538
|
+
}
|
|
1539
|
+
const ack = lowpri.readAck(now);
|
|
1540
|
+
const wrap = lowpri.wrapUp(account);
|
|
1541
|
+
const auto = lowpri.autoContinue();
|
|
1542
|
+
return [
|
|
1543
|
+
ack
|
|
1544
|
+
? 'You have acknowledged low-priority ON, at ' + new Date(ack.at).toLocaleString() +
|
|
1545
|
+
(Number.isFinite(ack.resetsAt) ? ', lapsing at ' + new Date(ack.resetsAt).toLocaleString() : ', with no expiry') + '.'
|
|
1546
|
+
: 'Low-priority has not been acknowledged, so the plugin treats the 5-hour window as a real wall.',
|
|
1547
|
+
'',
|
|
1548
|
+
'It is a toggle you type yourself. The model cannot run a slash command, and /low-priority is ' +
|
|
1549
|
+
'declared supportsNonInteractive:false, so it is unusable in a headless or relayed run as well.',
|
|
1550
|
+
'',
|
|
1551
|
+
detail,
|
|
1552
|
+
'',
|
|
1553
|
+
'Claude Code will ' + (wrap.hostWrapsUp ? '' : 'NOT ') + 'inject its own wrap-up note at the wall on ' +
|
|
1554
|
+
'this machine (' + lowpri.WRAPUP_MODE_FLAG + ' is "' + wrap.mode + '")' +
|
|
1555
|
+
(wrap.textProvisioned && !wrap.hostWrapsUp ? ', although the note text is provisioned' : '') + '.',
|
|
1556
|
+
'Claude Code\'s own continue-after-reset is ' + (auto.value ? 'ON' : 'OFF') + ' (' + auto.source + ').',
|
|
1557
|
+
'',
|
|
1558
|
+
'To set it: mode --low-priority on / mode --low-priority off',
|
|
1559
|
+
].join('\n');
|
|
1560
|
+
}
|
|
1465
1561
|
if (flag('--pin') || flag('--no-pin')) {
|
|
1466
1562
|
const state = read();
|
|
1467
1563
|
const before = state.pin;
|
|
@@ -24,6 +24,7 @@ const fs = require('fs');
|
|
|
24
24
|
const os = require('os');
|
|
25
25
|
const path = require('path');
|
|
26
26
|
|
|
27
|
+
const atomic = require('./atomic.js');
|
|
27
28
|
const drift = require('./drift.js');
|
|
28
29
|
|
|
29
30
|
// Older than this and the spend it measured is history: turns have happened
|
|
@@ -102,10 +103,9 @@ function record(binding, now, codexHome) {
|
|
|
102
103
|
// here in the same second, and a reader between a truncate and a write
|
|
103
104
|
// would see half a file.
|
|
104
105
|
const file = readingFile(codexHome);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
return true;
|
|
106
|
+
// A refused rename is retried and a failed one leaves nothing behind: see
|
|
107
|
+
// atomic.js. The caller only ever needed to know whether it landed.
|
|
108
|
+
return atomic.tryWriteFileAtomic(file, JSON.stringify(all));
|
|
109
109
|
} catch (err) {
|
|
110
110
|
return false;
|
|
111
111
|
}
|
|
@@ -34,8 +34,10 @@ const os = require('os');
|
|
|
34
34
|
const path = require('path');
|
|
35
35
|
const { spawn, spawnSync } = require('child_process');
|
|
36
36
|
|
|
37
|
+
const atomic = require('./atomic.js');
|
|
37
38
|
const host = require('./host.js');
|
|
38
39
|
const voice = require('./voice.js');
|
|
40
|
+
const lowpri = require('./lowpri.js');
|
|
39
41
|
|
|
40
42
|
const MINUTE = 60 * 1000;
|
|
41
43
|
|
|
@@ -353,9 +355,7 @@ function preflightPrompts(cwd, config, options) {
|
|
|
353
355
|
// reader that lands between the truncate and the write of a plain writeFileSync
|
|
354
356
|
// sees half a file; the rename is the only step another process can observe.
|
|
355
357
|
function writeAtomic(file, text) {
|
|
356
|
-
|
|
357
|
-
fs.writeFileSync(tmp, text);
|
|
358
|
-
fs.renameSync(tmp, file);
|
|
358
|
+
atomic.writeFileAtomic(file, text);
|
|
359
359
|
}
|
|
360
360
|
|
|
361
361
|
function write(state) {
|
|
@@ -1174,6 +1174,34 @@ function armable(input) {
|
|
|
1174
1174
|
const raw = binding.percentUsed - Math.max(0, beyond);
|
|
1175
1175
|
if (raw < config.at) return { ok: false, why: 'the account reads ' + Math.round(raw) + ' per cent; only the local estimate (' + Math.round(binding.percentUsed) + ') is past ' + config.at };
|
|
1176
1176
|
if (!Number.isFinite(binding.resetsAt)) return { ok: false, why: 'the window has no known reset time' };
|
|
1177
|
+
// /low-priority retires the 5-hour wall while it is on: the session carries
|
|
1178
|
+
// straight past that reset at lower priority, spending the weekly. A wake
|
|
1179
|
+
// booked for that reset would fire into a session that was never stopped -
|
|
1180
|
+
// and with Claude Code's own autoContinueAtUsageLimit on by default, two
|
|
1181
|
+
// things starting the same work is the one bug here that costs real weekly
|
|
1182
|
+
// budget. So it is refused, in plain words, and only for that window: the
|
|
1183
|
+
// weekly is a different wall and still arms.
|
|
1184
|
+
//
|
|
1185
|
+
// This reads the plugin's own acknowledgement, never a guess. Whether
|
|
1186
|
+
// low-priority is actually running lives in the CLI's process memory and is
|
|
1187
|
+
// readable nowhere; see lowpri.js.
|
|
1188
|
+
if (binding.key === 'five_hour') {
|
|
1189
|
+
let ack = null;
|
|
1190
|
+
try {
|
|
1191
|
+
ack = lowpri.readAck(options.now);
|
|
1192
|
+
} catch (err) {
|
|
1193
|
+
ack = null;
|
|
1194
|
+
}
|
|
1195
|
+
if (ack) {
|
|
1196
|
+
return {
|
|
1197
|
+
ok: false,
|
|
1198
|
+
why:
|
|
1199
|
+
'low-priority is acknowledged on, so the 5-hour reset is not a wall to wake after - ' +
|
|
1200
|
+
'this session carries past it and spends the weekly instead. Only the weekly window ' +
|
|
1201
|
+
'would be worth a wake; say so with usage-mode --low-priority off if it has been switched back off',
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1177
1205
|
if (!options.sessionId) return { ok: false, why: 'no session id' };
|
|
1178
1206
|
const work = workWithContinuation(options.work, options.sessionId);
|
|
1179
1207
|
if (!work.hasWork) return { ok: false, why: 'no plan or unfinished todo list to carry, and no saved continuation' };
|
|
@@ -21,6 +21,8 @@ const fs = require('fs');
|
|
|
21
21
|
const os = require('os');
|
|
22
22
|
const path = require('path');
|
|
23
23
|
|
|
24
|
+
const atomic = require('./atomic.js');
|
|
25
|
+
|
|
24
26
|
function configDir() {
|
|
25
27
|
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
26
28
|
}
|
|
@@ -62,10 +64,7 @@ function readState() {
|
|
|
62
64
|
// Replace through a temporary file so an interrupted run cannot leave
|
|
63
65
|
// settings.json half written.
|
|
64
66
|
function writeJson(file, value) {
|
|
65
|
-
|
|
66
|
-
const temp = file + '.usage-limits-tmp';
|
|
67
|
-
fs.writeFileSync(temp, JSON.stringify(value, null, 2) + '\n', 'utf8');
|
|
68
|
-
fs.renameSync(temp, file);
|
|
67
|
+
atomic.writeFileAtomic(file, JSON.stringify(value, null, 2) + '\n');
|
|
69
68
|
}
|
|
70
69
|
|
|
71
70
|
// The launcher. It is deliberately dumb: find feed.js, hand over, and if that
|
|
@@ -21,6 +21,7 @@ const os = require('os');
|
|
|
21
21
|
const path = require('path');
|
|
22
22
|
const readline = require('readline');
|
|
23
23
|
|
|
24
|
+
const atomic = require('./atomic.js');
|
|
24
25
|
const host = require('./host.js');
|
|
25
26
|
const codex = require('./codex.js');
|
|
26
27
|
const live = require('./live.js');
|
|
@@ -836,18 +837,10 @@ function readScanCache() {
|
|
|
836
837
|
// sees either the old whole or the new whole. A rename Windows refuses leaves
|
|
837
838
|
// nothing behind, and the caller carries on with what is on disk.
|
|
838
839
|
function writeJsonAtomic(file, value) {
|
|
839
|
-
const temp = file + '.' + process.pid + '.usage-limits-tmp';
|
|
840
840
|
try {
|
|
841
|
-
|
|
842
|
-
fs.writeFileSync(temp, JSON.stringify(value), 'utf8');
|
|
843
|
-
fs.renameSync(temp, file);
|
|
844
|
-
return true;
|
|
841
|
+
return atomic.tryWriteFileAtomic(file, JSON.stringify(value));
|
|
845
842
|
} catch (err) {
|
|
846
|
-
|
|
847
|
-
fs.unlinkSync(temp);
|
|
848
|
-
} catch (gone) {
|
|
849
|
-
// Nothing to clean up.
|
|
850
|
-
}
|
|
843
|
+
// A value that will not serialise is not written either.
|
|
851
844
|
return false;
|
|
852
845
|
}
|
|
853
846
|
}
|