claude-usage-limits 1.26.0 → 1.39.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 +107 -2
- package/commands/relay.md +10 -5
- package/commands/usage-mode.md +3 -0
- package/package.json +4 -3
- package/skills/usage-limits/SKILL.md +39 -2
- package/skills/usage-limits/references/how-it-works.md +3 -2
- package/skills/usage-limits/scripts/agy-hook.js +11 -14
- package/skills/usage-limits/scripts/brief.js +281 -36
- package/skills/usage-limits/scripts/ceiling.js +47 -9
- package/skills/usage-limits/scripts/defer.js +9 -7
- package/skills/usage-limits/scripts/feed.js +36 -8
- package/skills/usage-limits/scripts/install-antigravity.js +2 -1
- package/skills/usage-limits/scripts/mode.js +98 -3
- package/skills/usage-limits/scripts/pulse.js +80 -29
- package/skills/usage-limits/scripts/relay.js +599 -67
- package/skills/usage-limits/scripts/stop.js +2 -2
- package/skills/usage-limits/scripts/tally.js +10 -1
- package/skills/usage-limits/scripts/usage.js +113 -14
- package/skills/usage-limits/scripts/wake.js +356 -31
|
@@ -71,14 +71,14 @@ function bindingFromCollect(collected, now) {
|
|
|
71
71
|
function armAtCompletion(now, hookInput, sessionId) {
|
|
72
72
|
let state;
|
|
73
73
|
try {
|
|
74
|
-
state = relay.read();
|
|
74
|
+
state = (relay.reapLost(Date.now()), relay.read());
|
|
75
75
|
} catch (err) {
|
|
76
76
|
return null;
|
|
77
77
|
}
|
|
78
78
|
const config = relay.settings(state);
|
|
79
79
|
if (!config.enabled || config.armOn !== 'completion') return null;
|
|
80
80
|
// Already carrying this session forward: nothing to decide.
|
|
81
|
-
if (
|
|
81
|
+
if (relay.armedFor(state, sessionId)) return null;
|
|
82
82
|
|
|
83
83
|
const transcript = hookInput && hookInput.transcript_path ? hookInput.transcript_path : null;
|
|
84
84
|
const work = relay.detectWork(transcript, {});
|
|
@@ -188,6 +188,14 @@ function update(all, sessionId, transcriptPath, now, options) {
|
|
|
188
188
|
subagentFiles(transcriptPath, sessionId).map((file) => ({ file, sidechain: true }))
|
|
189
189
|
);
|
|
190
190
|
|
|
191
|
+
// Each subagent transcript is priced at the model its own records name. One
|
|
192
|
+
// that names none is priced at this session's model: the newest main-thread
|
|
193
|
+
// turn read here, or failing that the tail of the session transcript, read
|
|
194
|
+
// only if a record actually asks.
|
|
195
|
+
let mainModel = null;
|
|
196
|
+
const readParent = usage.sessionModelReader(transcriptPath);
|
|
197
|
+
const sessionModel = () => mainModel || readParent();
|
|
198
|
+
|
|
191
199
|
for (const entry of files) {
|
|
192
200
|
const key = path.resolve(entry.file);
|
|
193
201
|
const read = readNewLines(entry.file, session.cursors[key]);
|
|
@@ -197,8 +205,9 @@ function update(all, sessionId, transcriptPath, now, options) {
|
|
|
197
205
|
session.prompts += 1;
|
|
198
206
|
continue;
|
|
199
207
|
}
|
|
200
|
-
const event = usage.eventFrom(line, seen, project);
|
|
208
|
+
const event = usage.eventFrom(line, seen, project, entry.sidechain ? sessionModel : null);
|
|
201
209
|
if (!event || event.rejected) continue;
|
|
210
|
+
if (!entry.sidechain && !event.sidechain && event.model) mainModel = event.model;
|
|
202
211
|
apply(session, event, entry.sidechain || Boolean(event.sidechain), delta);
|
|
203
212
|
}
|
|
204
213
|
session.cursors[key] = read.next;
|
|
@@ -386,7 +386,14 @@ function tokenParts(usage) {
|
|
|
386
386
|
}
|
|
387
387
|
|
|
388
388
|
// One transcript line to an event, or null if it is not a billable turn.
|
|
389
|
-
|
|
389
|
+
//
|
|
390
|
+
// Priced at the model the line itself names. A subagent's transcript names its
|
|
391
|
+
// own model on every assistant record - a Sonnet agent under a Fable session
|
|
392
|
+
// says claude-sonnet-5 - and that, not the session's model, is what its tokens
|
|
393
|
+
// cost. `fallbackModel` is only for a record that names none: a string, or a
|
|
394
|
+
// function called at most once so the parent transcript is read only when a
|
|
395
|
+
// record actually needs it.
|
|
396
|
+
function eventFrom(line, seen, project, fallbackModel) {
|
|
390
397
|
if (line.indexOf('"assistant"') === -1 || line.indexOf('"usage"') === -1) return null;
|
|
391
398
|
let entry;
|
|
392
399
|
try {
|
|
@@ -442,15 +449,20 @@ function eventFrom(line, seen, project) {
|
|
|
442
449
|
}
|
|
443
450
|
|
|
444
451
|
const parts = tokenParts(entry.message.usage);
|
|
452
|
+
let model = typeof entry.message.model === 'string' ? entry.message.model : '';
|
|
453
|
+
if (!model && fallbackModel) {
|
|
454
|
+
const assumed = typeof fallbackModel === 'function' ? fallbackModel() : fallbackModel;
|
|
455
|
+
if (typeof assumed === 'string') model = assumed;
|
|
456
|
+
}
|
|
445
457
|
return {
|
|
446
458
|
at,
|
|
447
459
|
// Carried on the event so the scan cache can dedup across files without
|
|
448
460
|
// re-parsing them: a resumed or forked session repeats earlier turns, and
|
|
449
461
|
// a cached file is never read again to find that out.
|
|
450
462
|
dedupId: id === '|' ? null : id,
|
|
451
|
-
model
|
|
463
|
+
model,
|
|
452
464
|
effort: entry.effort || null,
|
|
453
|
-
cost: costOf(entry.message.usage,
|
|
465
|
+
cost: costOf(entry.message.usage, model),
|
|
454
466
|
tokens: tokensOf(entry.message.usage),
|
|
455
467
|
parts,
|
|
456
468
|
// What the model was shown on this call: everything except what it wrote.
|
|
@@ -631,8 +643,11 @@ function claudeTranscriptFiles(since) {
|
|
|
631
643
|
// A session's subagents write their transcripts under
|
|
632
644
|
// <project>/<session id>/subagents/. Same budget, different file, and
|
|
633
645
|
// for a long time an Explore or Plan agent's whole spend went unseen.
|
|
646
|
+
// The directory is named for the session, and the session's own
|
|
647
|
+
// transcript sits beside it: that is the model a subagent record
|
|
648
|
+
// falls back to when it names none of its own.
|
|
634
649
|
for (const file of subagentTranscripts(path.join(full, entry.name, 'subagents'), since)) {
|
|
635
|
-
files.push({ file, project: dir.name });
|
|
650
|
+
files.push({ file, project: dir.name, parent: path.join(full, entry.name + '.jsonl') });
|
|
636
651
|
}
|
|
637
652
|
continue;
|
|
638
653
|
}
|
|
@@ -725,7 +740,34 @@ function liveEffort(sessionId) {
|
|
|
725
740
|
// slot on the machine - which on 2026-09-08 was a 19-hour-old Opus session,
|
|
726
741
|
// shown over a Fable one.
|
|
727
742
|
function liveModel(sessionId) {
|
|
728
|
-
|
|
743
|
+
return modelFromTail(sessionTranscriptFile(sessionId));
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// The same answer for a transcript named by path rather than by session id.
|
|
747
|
+
function transcriptModel(file) {
|
|
748
|
+
let stat;
|
|
749
|
+
try {
|
|
750
|
+
stat = fs.statSync(file);
|
|
751
|
+
} catch (err) {
|
|
752
|
+
return null;
|
|
753
|
+
}
|
|
754
|
+
return modelFromTail({ file, at: stat.mtimeMs, size: stat.size });
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// The fallback a subagent record without a model is priced at: the parent
|
|
758
|
+
// session's model, looked up once and only when asked for.
|
|
759
|
+
function sessionModelReader(parentTranscript) {
|
|
760
|
+
let resolved;
|
|
761
|
+
return () => {
|
|
762
|
+
if (resolved === undefined) {
|
|
763
|
+
const seen = transcriptModel(parentTranscript);
|
|
764
|
+
resolved = seen && seen.model ? seen.model : null;
|
|
765
|
+
}
|
|
766
|
+
return resolved;
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function modelFromTail(found) {
|
|
729
771
|
if (!found) return null;
|
|
730
772
|
const from = Math.max(0, found.size - EFFORT_TAIL_BYTES);
|
|
731
773
|
const text = readSlice(found.file, from, found.size).toString('utf8');
|
|
@@ -933,13 +975,13 @@ function unpackEvent(row, project) {
|
|
|
933
975
|
// the offset has to be a byte count: a transcript is full of characters that
|
|
934
976
|
// are more than one byte, and counting them as one would drift the offset and
|
|
935
977
|
// silently drop turns. A trailing partial line is left unconsumed.
|
|
936
|
-
function parseSlice(buffer, project, baseOffset) {
|
|
978
|
+
function parseSlice(buffer, project, baseOffset, fallbackModel) {
|
|
937
979
|
const events = [];
|
|
938
980
|
let start = 0;
|
|
939
981
|
let consumed = 0;
|
|
940
982
|
for (let i = 0; i < buffer.length; i += 1) {
|
|
941
983
|
if (buffer[i] !== 0x0a) continue;
|
|
942
|
-
const event = eventFrom(buffer.toString('utf8', start, i), null, project);
|
|
984
|
+
const event = eventFrom(buffer.toString('utf8', start, i), null, project, fallbackModel);
|
|
943
985
|
if (event) events.push(event);
|
|
944
986
|
start = i + 1;
|
|
945
987
|
consumed = start;
|
|
@@ -974,7 +1016,15 @@ function eventsForFile(entry, cache, keepFrom) {
|
|
|
974
1016
|
}
|
|
975
1017
|
|
|
976
1018
|
const from = usable ? cached.offset : 0;
|
|
977
|
-
|
|
1019
|
+
// A subagent record that names no model is priced at its session's model,
|
|
1020
|
+
// read from the tail of the parent transcript - and read only if such a
|
|
1021
|
+
// record turns up, which on 14,778 cached rows here it never has.
|
|
1022
|
+
const parsed = parseSlice(
|
|
1023
|
+
readSlice(entry.file, from, stat.size),
|
|
1024
|
+
entry.project,
|
|
1025
|
+
from,
|
|
1026
|
+
entry.parent ? sessionModelReader(entry.parent) : null
|
|
1027
|
+
);
|
|
978
1028
|
const rows = (usable ? cached.rows : [])
|
|
979
1029
|
.concat(parsed.events.map(packEvent))
|
|
980
1030
|
.filter((row) => row[1] >= keepFrom);
|
|
@@ -1074,18 +1124,32 @@ async function readClaudeEvents(since, options) {
|
|
|
1074
1124
|
if (changed) writeScanCache(cache);
|
|
1075
1125
|
}
|
|
1076
1126
|
|
|
1077
|
-
|
|
1127
|
+
// One event per message id, and it is the LAST line that carries it.
|
|
1128
|
+
//
|
|
1129
|
+
// A message that streams several content blocks is written as several
|
|
1130
|
+
// lines sharing one message id, each with a usage block, and in subagent
|
|
1131
|
+
// transcripts only the last has the final output count: measured on
|
|
1132
|
+
// 2026-09-21 across 179 agent files, 1,991 of 2,504 such groups ran like
|
|
1133
|
+
// [1, 1, 202], so keeping the first block priced a 202-token answer at one
|
|
1134
|
+
// token. Main transcripts repeat the same figure on every line (1,811
|
|
1135
|
+
// groups, all identical), so for them this is the same answer either way.
|
|
1136
|
+
const seen = new Map();
|
|
1078
1137
|
const events = [];
|
|
1079
1138
|
for (const held of collected) {
|
|
1080
1139
|
for (const row of held.rows) {
|
|
1081
1140
|
if (row[1] < since) continue;
|
|
1082
1141
|
const id = row[0] === ROW_TURN ? row[13] : '';
|
|
1142
|
+
const event = unpackEvent(row, held.project);
|
|
1143
|
+
if (!event) continue;
|
|
1083
1144
|
if (id) {
|
|
1084
|
-
|
|
1085
|
-
|
|
1145
|
+
const index = seen.get(id);
|
|
1146
|
+
if (index !== undefined) {
|
|
1147
|
+
events[index] = event;
|
|
1148
|
+
continue;
|
|
1149
|
+
}
|
|
1150
|
+
seen.set(id, events.length);
|
|
1086
1151
|
}
|
|
1087
|
-
|
|
1088
|
-
if (event) events.push(event);
|
|
1152
|
+
events.push(event);
|
|
1089
1153
|
}
|
|
1090
1154
|
}
|
|
1091
1155
|
|
|
@@ -2636,16 +2700,20 @@ function limitWindows(utilization) {
|
|
|
2636
2700
|
// So: the bucket keys first, the `limits` array for anything they did not
|
|
2637
2701
|
// cover, real labels on both, corrections applied, and the same applies-flag
|
|
2638
2702
|
// every other reader makes its decisions from.
|
|
2639
|
-
function snapshotWindows(collected, now, codexHome) {
|
|
2703
|
+
function snapshotWindows(collected, now, codexHome, extraModels) {
|
|
2640
2704
|
const utilization = collected && collected.utilization;
|
|
2641
2705
|
if (!utilization) return [];
|
|
2642
2706
|
const at = Number.isFinite(now) ? now : Date.now();
|
|
2643
2707
|
// No transcripts here on purpose - that is the cost being avoided - so the
|
|
2644
2708
|
// setting is the only thing that says which model is running. It is enough
|
|
2645
2709
|
// to keep a weekly for a model this agent is not using out of a worst-of.
|
|
2710
|
+
// A caller that knows what the session is actually running - the pulse reads
|
|
2711
|
+
// it off the transcript tail - adds it, so a /model switch the setting never
|
|
2712
|
+
// heard about cannot suppress the window being spent.
|
|
2646
2713
|
const families = familiesInUse(null, null, [
|
|
2647
2714
|
collected.settings && collected.settings.model,
|
|
2648
2715
|
process.env.ANTHROPIC_MODEL,
|
|
2716
|
+
...(Array.isArray(extraModels) ? extraModels : []),
|
|
2649
2717
|
]);
|
|
2650
2718
|
const specs = collected.windowSpecs && collected.windowSpecs.length ? collected.windowSpecs : WINDOWS;
|
|
2651
2719
|
const byKey = new Map();
|
|
@@ -2777,6 +2845,23 @@ function accountUuid() {
|
|
|
2777
2845
|
: null;
|
|
2778
2846
|
}
|
|
2779
2847
|
|
|
2848
|
+
// Per window key, the percentage each account source reports and which one
|
|
2849
|
+
// the report is using: { five_hour: { cache: 14, live: 20, used: 'live' } }.
|
|
2850
|
+
// Null unless both sources have the window.
|
|
2851
|
+
function sourcePercents(cache, fresh, used) {
|
|
2852
|
+
if (!cache || !cache.utilization || typeof cache.utilization !== 'object') return null;
|
|
2853
|
+
if (!fresh || !fresh.utilization || typeof fresh.utilization !== 'object') return null;
|
|
2854
|
+
const out = {};
|
|
2855
|
+
for (const key of Object.keys(cache.utilization)) {
|
|
2856
|
+
const a = cache.utilization[key];
|
|
2857
|
+
const b = fresh.utilization[key];
|
|
2858
|
+
if (a && b && typeof a.utilization === 'number' && typeof b.utilization === 'number') {
|
|
2859
|
+
out[key] = { cache: a.utilization, live: b.utilization, used };
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
return Object.keys(out).length ? out : null;
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2780
2865
|
function collectClaude(now) {
|
|
2781
2866
|
const account = readJson(accountFile()) || {};
|
|
2782
2867
|
const settings = readJson(path.join(configDir(), 'settings.json')) || {};
|
|
@@ -2790,6 +2875,12 @@ function collectClaude(now) {
|
|
|
2790
2875
|
const snapshot = useLive ? fresh : cache;
|
|
2791
2876
|
const utilization = snapshot && snapshot.utilization ? snapshot.utilization : null;
|
|
2792
2877
|
const plan = detectPlan(oauth);
|
|
2878
|
+
// The reading that was not chosen, kept beside the one that was, so a
|
|
2879
|
+
// window can be described by both when they disagree. A live file for
|
|
2880
|
+
// another account or from a clock that is ahead is not a second reading of
|
|
2881
|
+
// this account and is left out, by the same rule that keeps it from being
|
|
2882
|
+
// preferred.
|
|
2883
|
+
const comparable = fresh && preferLive(null, fresh, oauth.accountUuid, now) ? fresh : null;
|
|
2793
2884
|
|
|
2794
2885
|
return {
|
|
2795
2886
|
now,
|
|
@@ -2803,6 +2894,7 @@ function collectClaude(now) {
|
|
|
2803
2894
|
snapshotAgeMs: snapshot && snapshot.fetchedAtMs ? now - snapshot.fetchedAtMs : null,
|
|
2804
2895
|
snapshotFetchedAt: snapshot && snapshot.fetchedAtMs ? snapshot.fetchedAtMs : null,
|
|
2805
2896
|
snapshotSource: utilization ? (useLive ? 'live' : 'cache') : null,
|
|
2897
|
+
sources: sourcePercents(cache, comparable, useLive ? 'live' : 'cache'),
|
|
2806
2898
|
utilization,
|
|
2807
2899
|
settings: {
|
|
2808
2900
|
model: settings.model || 'default',
|
|
@@ -3141,6 +3233,11 @@ async function report(now, options) {
|
|
|
3141
3233
|
// exists.
|
|
3142
3234
|
calibrated.planChanged ? new Map() : rejections
|
|
3143
3235
|
);
|
|
3236
|
+
// Both account readings ride on the window, so whatever describes it can
|
|
3237
|
+
// name them when they disagree.
|
|
3238
|
+
if (base.sources) {
|
|
3239
|
+
for (const window of windows) if (base.sources[window.key]) window.sources = base.sources[window.key];
|
|
3240
|
+
}
|
|
3144
3241
|
|
|
3145
3242
|
// Which of those windows this agent can actually spend into. Everything that
|
|
3146
3243
|
// ranks or warns about a window reads the answer off the window itself.
|
|
@@ -4180,6 +4277,8 @@ module.exports = {
|
|
|
4180
4277
|
sessionTranscriptFile,
|
|
4181
4278
|
liveEffort,
|
|
4182
4279
|
liveModel,
|
|
4280
|
+
transcriptModel,
|
|
4281
|
+
sessionModelReader,
|
|
4183
4282
|
EFFORT_TAIL_BYTES,
|
|
4184
4283
|
scanFile,
|
|
4185
4284
|
readScanCache,
|