claude-usage-limits 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // The mid-turn ping.
5
+ //
6
+ // brief.js runs when a prompt is submitted, and that is the only budget figure
7
+ // the agent gets for the whole turn. A turn that runs for half an hour through
8
+ // hundreds of tool calls is working from a number taken before any of it
9
+ // happened, and it has no way to notice the budget draining underneath it.
10
+ //
11
+ // That is not hypothetical. On 2026-08-30 three sessions were told the 5-hour
12
+ // window had about 190 turns of headroom and were all rejected nine minutes
13
+ // later. Nothing in between ever told them otherwise, because nothing ran in
14
+ // between.
15
+ //
16
+ // So this runs after tool calls and puts a fresh line in front of the agent
17
+ // every couple of minutes. It has to be cheap, because it is called constantly:
18
+ // the common case is reading one small file, comparing a timestamp, and
19
+ // exiting without doing anything else.
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+
24
+ const usage = require('./usage.js');
25
+ const brief = require('./brief.js');
26
+ const host = require('./host.js');
27
+
28
+ const SECOND = 1000;
29
+ const DEFAULT_INTERVAL_SECONDS = 120;
30
+
31
+ // One slot per session, same shape and same trimming as the brief's cache.
32
+ const KEEP_SESSIONS = 8;
33
+
34
+ function stateFile() {
35
+ const dir = usage.isCodex()
36
+ ? require('./codex.js').homeDir()
37
+ : process.env.CLAUDE_CONFIG_DIR || path.join(require('os').homedir(), '.claude');
38
+ return path.join(dir, 'usage-limits-pulse.json');
39
+ }
40
+
41
+ function intervalMs() {
42
+ const configured = Number(process.env.USAGE_LIMITS_PULSE_SECONDS);
43
+ const seconds = Number.isFinite(configured) && configured > 0
44
+ ? configured
45
+ : DEFAULT_INTERVAL_SECONDS;
46
+ return seconds * SECOND;
47
+ }
48
+
49
+ function readState() {
50
+ try {
51
+ const parsed = JSON.parse(fs.readFileSync(stateFile(), 'utf8'));
52
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
53
+ } catch (err) {
54
+ return {};
55
+ }
56
+ }
57
+
58
+ function writeState(all) {
59
+ try {
60
+ const file = stateFile();
61
+ fs.mkdirSync(path.dirname(file), { recursive: true });
62
+ fs.writeFileSync(file, JSON.stringify(all), 'utf8');
63
+ } catch (err) {
64
+ // Losing the throttle means one extra scan, which is survivable. Failing
65
+ // the tool call it runs after is not.
66
+ }
67
+ }
68
+
69
+ function trim(all, sessionId, at) {
70
+ const next = Object.assign({}, all);
71
+ next[sessionId || '_'] = { at };
72
+ const ordered = Object.keys(next).sort((a, b) => (next[b].at || 0) - (next[a].at || 0));
73
+ const kept = {};
74
+ for (const key of ordered.slice(0, KEEP_SESSIONS)) kept[key] = next[key];
75
+ return kept;
76
+ }
77
+
78
+ function due(all, sessionId, now, every) {
79
+ const entry = all ? all[sessionId || '_'] : null;
80
+ if (!entry || !Number.isFinite(entry.at)) return true;
81
+ return now - entry.at >= every;
82
+ }
83
+
84
+ // Deliberately shorter than the prompt-submit brief. That one sets up the whole
85
+ // turn; this one interrupts work already in progress, so it earns its place only
86
+ // by being one line and only by carrying something that changes what happens
87
+ // next.
88
+ function pulseText(parts) {
89
+ const bits = [];
90
+ if (parts.percentUsed !== null && parts.percentUsed !== undefined) {
91
+ bits.push(parts.label + ' now ' + (parts.approximate ? 'about ' : '') + parts.percentUsed + '%');
92
+ }
93
+ if (Number.isFinite(parts.turnsLeft)) bits.push('about ' + parts.turnsLeft + ' turns left');
94
+ if (parts.runsOutIn) bits.push(parts.runsOutIn + ' at this pace');
95
+ if (parts.sessions > 1) bits.push(parts.sessions + ' sessions sharing it');
96
+ if (!bits.length) return '';
97
+
98
+ const head = '[usage-limits] ' + bits.join(', ') + '.';
99
+ if (parts.pressure === 'gone') {
100
+ return head + ' The budget is gone. Stop adding work, save what exists and write the handoff.';
101
+ }
102
+ if (parts.pressure === 'tight') {
103
+ // Mid-turn, so this has to change how the work is carried out without
104
+ // changing what the work is. Keep going; just keep it landable.
105
+ return (
106
+ head + ' Keep going with the whole job, but make a cutoff cheap: land the ' +
107
+ 'valuable part first, save at clean boundaries, and keep a note of what is ' +
108
+ 'done and what is next.'
109
+ );
110
+ }
111
+ return head + ' Still room; carry on.';
112
+ }
113
+
114
+ async function run(now, hookInput) {
115
+ if (String(process.env.USAGE_LIMITS_PULSE || '').toLowerCase() === 'off') return '';
116
+ usage.setHost(host.detect(process.argv.slice(2), process.env));
117
+
118
+ const sessionId = hookInput && hookInput.session_id ? hookInput.session_id : null;
119
+ const all = readState();
120
+ const every = intervalMs();
121
+ // The cheap path, and the one taken almost every time.
122
+ if (!due(all, sessionId, now, every)) return '';
123
+
124
+ // Claimed before the scan rather than after, so a slow scan cannot let a
125
+ // second tool call start another one.
126
+ writeState(trim(all, sessionId, now));
127
+
128
+ const data = await usage.report(now, { sessionId });
129
+ const binding = data.binding;
130
+ if (!binding) return '';
131
+
132
+ const sessions = data.sessions || [];
133
+ const share = usage.shareOf(sessions, sessionId);
134
+ const turnsLeft = Number.isFinite(binding.turnsLeft)
135
+ ? sessions.length > 1
136
+ ? Math.max(1, Math.round(binding.turnsLeft * share))
137
+ : binding.turnsLeft
138
+ : null;
139
+
140
+ const config = brief.settings();
141
+ const runwayMs = brief.RUNWAY_MENTION_MS;
142
+ const pressure = brief.pressure(binding, now, config, turnsLeft);
143
+
144
+ // Quiet when there is nothing to act on. A line every two minutes saying the
145
+ // budget is fine is noise that costs the budget it is reporting on.
146
+ if (pressure === 'roomy' && String(process.env.USAGE_LIMITS_PULSE || '').toLowerCase() !== 'always') {
147
+ return '';
148
+ }
149
+
150
+ return pulseText({
151
+ label: binding.label,
152
+ percentUsed: binding.percentUsed,
153
+ approximate: Boolean(binding.estimated || binding.adjusted),
154
+ turnsLeft,
155
+ runsOutIn:
156
+ Number.isFinite(binding.headroomMs) && binding.headroomMs <= runwayMs
157
+ ? usage.formatDuration(binding.headroomMs)
158
+ : null,
159
+ sessions: sessions.length,
160
+ pressure,
161
+ });
162
+ }
163
+
164
+ // PostToolUse does not take plain stdout as context the way UserPromptSubmit
165
+ // does, so the line is returned in the documented envelope instead.
166
+ function envelope(text) {
167
+ return JSON.stringify({
168
+ hookSpecificOutput: {
169
+ hookEventName: 'PostToolUse',
170
+ additionalContext: text,
171
+ },
172
+ });
173
+ }
174
+
175
+ function readHookInput() {
176
+ return new Promise((resolve) => {
177
+ if (process.stdin.isTTY) return resolve(null);
178
+ let raw = '';
179
+ let settled = false;
180
+ const done = () => {
181
+ if (settled) return;
182
+ settled = true;
183
+ try {
184
+ resolve(raw ? JSON.parse(raw) : null);
185
+ } catch (err) {
186
+ resolve(null);
187
+ }
188
+ };
189
+ const timer = setTimeout(done, 500);
190
+ if (timer.unref) timer.unref();
191
+ process.stdin.setEncoding('utf8');
192
+ process.stdin.on('data', (chunk) => {
193
+ raw += chunk;
194
+ });
195
+ process.stdin.on('end', done);
196
+ process.stdin.on('error', done);
197
+ });
198
+ }
199
+
200
+ if (require.main === module) {
201
+ readHookInput()
202
+ .then((input) => run(Date.now(), input))
203
+ .then(
204
+ (text) => {
205
+ if (text) process.stdout.write(envelope(text) + '\n');
206
+ process.exit(0);
207
+ },
208
+ () => {
209
+ // A hook that throws must never disturb the tool call it runs after.
210
+ process.exit(0);
211
+ }
212
+ );
213
+ }
214
+
215
+ module.exports = {
216
+ DEFAULT_INTERVAL_SECONDS,
217
+ KEEP_SESSIONS,
218
+ stateFile,
219
+ intervalMs,
220
+ readState,
221
+ writeState,
222
+ trim,
223
+ due,
224
+ pulseText,
225
+ envelope,
226
+ run,
227
+ };