claude-usage-limits 1.39.5 → 1.39.6

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "usage-limits",
3
3
  "displayName": "Usage Limits",
4
- "version": "1.39.5",
4
+ "version": "1.39.6",
5
5
  "description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.",
6
6
  "author": {
7
7
  "name": "Ridelink",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usage-limits",
3
- "version": "1.39.5",
3
+ "version": "1.39.6",
4
4
  "description": "Reports how much of your Codex usage limit is left as turns of work rather than a percentage, prices a job before you start it, and counts the other agents sharing the same budget.",
5
5
  "author": {
6
6
  "name": "Ridelink",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-usage-limits",
3
- "version": "1.39.5",
3
+ "version": "1.39.6",
4
4
  "description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.",
5
5
  "keywords": [
6
6
  "claude",
@@ -14,6 +14,8 @@ const fs = require('fs');
14
14
  const os = require('os');
15
15
  const path = require('path');
16
16
 
17
+ const atomic = require('./atomic.js');
18
+
17
19
  const KEEP_SESSIONS = 8;
18
20
  // A session that has said nothing for this long is not working, whatever its
19
21
  // last word was: a crash never sends Stop.
@@ -68,22 +70,8 @@ function mark(state, sessionId, extra, now) {
68
70
  if (extra && extra.model) entry.model = String(extra.model);
69
71
  else if (previous.model) entry.model = previous.model;
70
72
  all[sessionId || '_'] = entry;
71
- const file = activityFile();
72
- const temp = file + '.' + process.pid + '.usage-limits-tmp';
73
- try {
74
- fs.mkdirSync(path.dirname(file), { recursive: true });
75
- fs.writeFileSync(temp, JSON.stringify(trim(all)), 'utf8');
76
- fs.renameSync(temp, file);
77
- } catch (err) {
78
- // A rename Windows refused leaves nothing behind.
79
- try {
80
- fs.unlinkSync(temp);
81
- } catch (gone) {
82
- // Nothing to clean up.
83
- }
84
- return false;
85
- }
86
- return true;
73
+ // A rename Windows refused leaves nothing behind: see atomic.js.
74
+ return atomic.tryWriteFileAtomic(activityFile(), JSON.stringify(trim(all)));
87
75
  } catch (err) {
88
76
  return false;
89
77
  }
@@ -0,0 +1,173 @@
1
+ 'use strict';
2
+
3
+ // Every file this plugin replaces goes through here: written beside the target
4
+ // under a name that is this process's own, then renamed into place, so a
5
+ // reader sees either the old whole file or the new whole file.
6
+ //
7
+ // On Windows that rename is the step that fails. Two processes renaming onto
8
+ // the same target at the same moment - the prompt hook, the pulse and the
9
+ // status line do exactly that - get EPERM back from MoveFileEx for the one
10
+ // that loses. Measured on 2026-09-25 with four processes writing one file for
11
+ // three seconds: 2109 renames refused with EPERM against 469 that went
12
+ // through. The writers that did not delete their temporary file on that
13
+ // failure left one behind every time, and about seventy of them had piled up
14
+ // in ~/.claude by then. The same retry on EPERM, EACCES and EBUSY is what
15
+ // graceful-fs does for npm on Windows; here it is bounded tightly, because
16
+ // the hooks that call this have ten seconds between them and the prompt.
17
+ //
18
+ // What no code path can clean is a process killed between creating its
19
+ // temporary file and renaming it - a hook that ran into its timeout. sweep()
20
+ // removes those at the next prompt, once they are old enough that no live
21
+ // writer can still own them.
22
+
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+
26
+ // One suffix for every temporary file, and nobody else's: anything ending in
27
+ // it is this plugin's, so the sweep never has to guess.
28
+ const SUFFIX = '.usage-limits-tmp';
29
+
30
+ // The codes Windows returns while another process has the target, or is
31
+ // replacing it. Anything else - ENOSPC, ENOENT, EISDIR - will not change on a
32
+ // second try.
33
+ const RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
34
+
35
+ // About a third of a second in all. Contention clears in milliseconds; a
36
+ // target that is still held after this is held by something that will not let
37
+ // go soon, and the caller carries on with what is on disk.
38
+ const RETRY_DELAYS_MS = [5, 10, 20, 40, 60, 80, 100];
39
+
40
+ // A write takes milliseconds and the longest hook is killed at ten seconds, so
41
+ // nothing alive owns a temporary file this old.
42
+ const STALE_MS = 10 * 60 * 1000;
43
+
44
+ // Temporary names this plugin has used. The first is the one written now; the
45
+ // second is what reading.js, drift.js, mode.js and relay.js wrote before
46
+ // 1.39.6 - `usage-limits-<name>.json.<pid>.tmp` - and is matched only with the
47
+ // usage-limits- prefix and the pid, because a bare .tmp could be anyone's.
48
+ const OWN_TEMP = [
49
+ /^.+\.usage-limits-tmp$/,
50
+ /^usage-limits-[A-Za-z0-9_-]+\.json\.\d+\.tmp$/,
51
+ ];
52
+
53
+ function sleepSync(ms) {
54
+ try {
55
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
56
+ } catch (err) {
57
+ // No shared memory here: spin for the same time instead.
58
+ const until = Date.now() + ms;
59
+ while (Date.now() < until) {
60
+ // Waiting.
61
+ }
62
+ }
63
+ }
64
+
65
+ function tempFor(file) {
66
+ return file + '.' + process.pid + SUFFIX;
67
+ }
68
+
69
+ function renameWithRetry(from, to) {
70
+ for (let attempt = 0; ; attempt += 1) {
71
+ try {
72
+ fs.renameSync(from, to);
73
+ return;
74
+ } catch (err) {
75
+ if (!RETRY_CODES.has(err && err.code) || attempt >= RETRY_DELAYS_MS.length) throw err;
76
+ sleepSync(RETRY_DELAYS_MS[attempt]);
77
+ }
78
+ }
79
+ }
80
+
81
+ // Never throws. True when the file is gone, whoever removed it.
82
+ function removeQuietly(file) {
83
+ for (let attempt = 0; ; attempt += 1) {
84
+ try {
85
+ fs.unlinkSync(file);
86
+ return true;
87
+ } catch (err) {
88
+ if (err && err.code === 'ENOENT') return true;
89
+ if (!RETRY_CODES.has(err && err.code) || attempt >= 3) return false;
90
+ sleepSync(RETRY_DELAYS_MS[attempt]);
91
+ }
92
+ }
93
+ }
94
+
95
+ // Throws what the write or the last rename threw, after removing the
96
+ // temporary file. `options` is passed to writeFileSync (encoding, mode).
97
+ function writeFileAtomic(file, text, options) {
98
+ const temp = tempFor(file);
99
+ try {
100
+ fs.mkdirSync(path.dirname(file), { recursive: true });
101
+ fs.writeFileSync(temp, text, options === undefined ? 'utf8' : options);
102
+ renameWithRetry(temp, file);
103
+ } catch (err) {
104
+ removeQuietly(temp);
105
+ throw err;
106
+ }
107
+ }
108
+
109
+ // The same, for callers that carry on either way.
110
+ function tryWriteFileAtomic(file, text, options) {
111
+ try {
112
+ writeFileAtomic(file, text, options);
113
+ return true;
114
+ } catch (err) {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ function isOwnTemp(name) {
120
+ return OWN_TEMP.some((pattern) => pattern.test(name));
121
+ }
122
+
123
+ // Removes this plugin's temporary files older than STALE_MS from each
124
+ // directory. Only names isOwnTemp() accepts are ever looked at. Never throws;
125
+ // returns how many were removed.
126
+ function sweep(dirs, now, staleMs) {
127
+ const at = Number.isFinite(now) ? now : Date.now();
128
+ const age = Number.isFinite(staleMs) ? staleMs : STALE_MS;
129
+ let removed = 0;
130
+ const seen = new Set();
131
+ for (const dir of [].concat(dirs || [])) {
132
+ if (!dir || seen.has(path.resolve(dir))) continue;
133
+ seen.add(path.resolve(dir));
134
+ let names;
135
+ try {
136
+ names = fs.readdirSync(dir);
137
+ } catch (err) {
138
+ continue;
139
+ }
140
+ for (const name of names) {
141
+ if (!isOwnTemp(name)) continue;
142
+ const file = path.join(dir, name);
143
+ try {
144
+ const stat = fs.statSync(file);
145
+ if (!stat.isFile() || at - stat.mtimeMs < age) continue;
146
+ } catch (err) {
147
+ continue;
148
+ }
149
+ try {
150
+ fs.unlinkSync(file);
151
+ removed += 1;
152
+ } catch (err) {
153
+ // Gone already is someone else's sweep; held is worth one more try.
154
+ if (err && err.code !== 'ENOENT' && removeQuietly(file)) removed += 1;
155
+ }
156
+ }
157
+ }
158
+ return removed;
159
+ }
160
+
161
+ module.exports = {
162
+ SUFFIX,
163
+ STALE_MS,
164
+ RETRY_CODES,
165
+ RETRY_DELAYS_MS,
166
+ tempFor,
167
+ renameWithRetry,
168
+ removeQuietly,
169
+ writeFileAtomic,
170
+ tryWriteFileAtomic,
171
+ isOwnTemp,
172
+ sweep,
173
+ };
@@ -13,6 +13,7 @@ const fs = require('fs');
13
13
  const os = require('os');
14
14
  const path = require('path');
15
15
 
16
+ const atomic = require('./atomic.js');
16
17
  const usage = require('./usage.js');
17
18
  const host = require('./host.js');
18
19
  const tally = require('./tally.js');
@@ -1450,6 +1451,20 @@ const GEMINI_UNREADABLE =
1450
1451
  const LIMIT_REACHED =
1451
1452
  '[usage-limits] The meter says the usage limit is reached. Start nothing new: save the work, write the hand-off note in this turn, and end the turn.';
1452
1453
 
1454
+ // Temporary files a killed process left behind: a hook that ran into its
1455
+ // timeout between writing one and renaming it. Nothing else can clean them,
1456
+ // and on 2026-09-25 about seventy had piled up in ~/.claude. Once per prompt
1457
+ // is often enough and costs one directory listing per home. The clock here
1458
+ // is always the real one, never the run's `now`: a stale file is judged by
1459
+ // its mtime, which is real time too. Never throws.
1460
+ function sweepDebris() {
1461
+ try {
1462
+ return atomic.sweep([configDir(), host.codexHome()], Date.now());
1463
+ } catch (err) {
1464
+ return 0;
1465
+ }
1466
+ }
1467
+
1453
1468
  async function run(now, hookInput, opts) {
1454
1469
  if (String(process.env.USAGE_LIMITS_BRIEF || '').toLowerCase() === 'off') return '';
1455
1470
 
@@ -1463,6 +1478,8 @@ async function run(now, hookInput, opts) {
1463
1478
  const budget = mode.forSession({ sessionId });
1464
1479
  if (budget.policy.briefStyle === 'none') return guardLine(now, budget);
1465
1480
 
1481
+ sweepDebris();
1482
+
1466
1483
  // Settle host here, before any file is read. A caller that already knows the
1467
1484
  // host (agy-hook runs only inside Antigravity) says so, and that wins over
1468
1485
  // detection: an empty or unparseable payload used to fall through to
@@ -1804,7 +1821,7 @@ function withBugcheck(text) {
1804
1821
  return text;
1805
1822
  }
1806
1823
 
1807
- module.exports = { withBugcheck, sayOnce, shapeOf, saidFile, REPEAT_MS, staleVersionFor, relayNewsFor, installedVersion, runningVersion,
1824
+ module.exports = { sweepDebris, withBugcheck, sayOnce, shapeOf, saidFile, REPEAT_MS, staleVersionFor, relayNewsFor, installedVersion, runningVersion,
1808
1825
  readSaid, standingSaid, markStanding, standingShortFor, STANDING_SHORT, cacheMissWhyFor, missReason, MISS_RECENT_MS,
1809
1826
  DEFAULTS,
1810
1827
  HOOK_BUDGET_MS,
@@ -3,6 +3,7 @@
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const os = require('node:os');
6
+ const atomicWrite = require('./atomic.js');
6
7
  const KEYS = ['model', 'model_reasoning_effort'];
7
8
  const EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'];
8
9
 
@@ -118,9 +119,8 @@ function read(file, fallback) {
118
119
  try { return fs.readFileSync(file, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return fallback; throw e; }
119
120
  }
120
121
  function atomic(file, text) {
121
- const temp = file + '.' + process.pid + '.tmp';
122
- try { fs.writeFileSync(temp, text, { encoding: 'utf8', mode: 0o600 }); fs.renameSync(temp, file); }
123
- finally { if (fs.existsSync(temp)) fs.unlinkSync(temp); }
122
+ // Throws on failure, as before, with the temporary file already removed.
123
+ atomicWrite.writeFileAtomic(file, text, { encoding: 'utf8', mode: 0o600 });
124
124
  }
125
125
  function checkState(state) {
126
126
  if (!state) return;
@@ -23,6 +23,7 @@ const os = require('os');
23
23
  const path = require('path');
24
24
  const readline = require('readline');
25
25
 
26
+ const atomic = require('./atomic.js');
26
27
  const host = require('./host.js');
27
28
 
28
29
  const MINUTE = 60 * 1000;
@@ -815,13 +816,9 @@ function readLiveMeter() {
815
816
 
816
817
  function writeLiveMeter(reading) {
817
818
  try {
818
- const file = liveFile();
819
- fs.mkdirSync(path.dirname(file), { recursive: true });
820
- const temp = file + '.' + process.pid + '.usage-limits-tmp';
821
- fs.writeFileSync(temp, JSON.stringify(reading), 'utf8');
822
- fs.renameSync(temp, file);
823
- return true;
819
+ return atomic.tryWriteFileAtomic(liveFile(), JSON.stringify(reading));
824
820
  } catch (err) {
821
+ // A reading that will not serialise is not written either.
825
822
  return false;
826
823
  }
827
824
  }
@@ -19,6 +19,7 @@ const fs = require('fs');
19
19
  const os = require('os');
20
20
  const path = require('path');
21
21
 
22
+ const atomic = require('./atomic.js');
22
23
  const host = require('./host.js');
23
24
  const codex = require('./codex.js');
24
25
 
@@ -102,13 +103,11 @@ function modeSummary(codexHome) {
102
103
  .sort((a, b) => b.turns - a.turns);
103
104
  }
104
105
 
106
+ // Same beside-and-rename as reading.js: the pulse and the prompt hook can both
107
+ // land here in the same second. A refused rename is retried, and on failure
108
+ // the temporary file is removed before this throws: see atomic.js.
105
109
  function writeAtomic(file, data) {
106
- fs.mkdirSync(path.dirname(file), { recursive: true });
107
- // Same beside-and-rename as reading.js: the pulse and the prompt hook can
108
- // both land here in the same second.
109
- const tmp = file + '.' + process.pid + '.tmp';
110
- fs.writeFileSync(tmp, JSON.stringify(data));
111
- fs.renameSync(tmp, file);
110
+ atomic.writeFileAtomic(file, JSON.stringify(data));
112
111
  }
113
112
 
114
113
  // `previous` and `next` are both entries in reading.js's own shape - see
@@ -31,6 +31,7 @@ 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
 
36
37
  const PLUGIN = 'usage-limits';
@@ -144,10 +145,7 @@ function rulesText() {
144
145
  }
145
146
 
146
147
  function writeFile(file, text) {
147
- fs.mkdirSync(path.dirname(file), { recursive: true });
148
- const tmp = file + '.' + process.pid + '.tmp';
149
- fs.writeFileSync(tmp, text, 'utf8');
150
- fs.renameSync(tmp, file);
148
+ atomic.writeFileAtomic(file, text);
151
149
  }
152
150
 
153
151
  function installed() {
@@ -26,6 +26,8 @@ const http = require('http');
26
26
  const https = require('https');
27
27
  const { execFileSync } = require('child_process');
28
28
 
29
+ const atomic = require('./atomic.js');
30
+
29
31
  const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
30
32
  const BETA = 'oauth-2025-04-20';
31
33
  // Claude Code gives the call five seconds; so does this.
@@ -328,22 +330,10 @@ function readLive() {
328
330
 
329
331
  // Through a temporary file named for this process, so a reader never sees
330
332
  // half a reading and two writers never share a temp file. A rename that fails
331
- // (Windows, with the target held open) leaves nothing behind.
333
+ // (Windows, with the target held open) is retried, and one that still fails
334
+ // leaves nothing behind: see atomic.js.
332
335
  function writeAtomic(file, text) {
333
- const temp = file + '.' + process.pid + '.usage-limits-tmp';
334
- try {
335
- fs.mkdirSync(path.dirname(file), { recursive: true });
336
- fs.writeFileSync(temp, text, 'utf8');
337
- fs.renameSync(temp, file);
338
- return true;
339
- } catch (err) {
340
- try {
341
- fs.unlinkSync(temp);
342
- } catch (gone) {
343
- // Nothing to clean up.
344
- }
345
- return false;
346
- }
336
+ return atomic.tryWriteFileAtomic(file, text);
347
337
  }
348
338
 
349
339
  function writeLive(snapshot) {
@@ -22,6 +22,8 @@ const fs = require('fs');
22
22
  const os = require('os');
23
23
  const path = require('path');
24
24
 
25
+ const atomic = require('./atomic.js');
26
+
25
27
  const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
26
28
  const MANAGED_KEYS = ['effortLevel', 'model'];
27
29
  const DEFAULTS = { effortLevel: 'low' };
@@ -50,10 +52,7 @@ function readJson(file) {
50
52
  // Replace through a temporary file so an interrupted run cannot leave
51
53
  // settings.json half written.
52
54
  function writeJson(file, value) {
53
- fs.mkdirSync(path.dirname(file), { recursive: true });
54
- const temp = file + '.usage-limits-tmp';
55
- fs.writeFileSync(temp, JSON.stringify(value, null, 2) + '\n', 'utf8');
56
- fs.renameSync(temp, file);
55
+ atomic.writeFileAtomic(file, JSON.stringify(value, null, 2) + '\n');
57
56
  }
58
57
 
59
58
  function parseArgs(argv) {
@@ -31,6 +31,7 @@ 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');
36
37
 
@@ -480,10 +481,7 @@ function read() {
480
481
  }
481
482
 
482
483
  function writeAtomic(file, value) {
483
- fs.mkdirSync(path.dirname(file), { recursive: true });
484
- const tmp = file + '.' + process.pid + '.tmp';
485
- fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n');
486
- fs.renameSync(tmp, file);
484
+ atomic.writeFileAtomic(file, JSON.stringify(value, null, 2) + '\n');
487
485
  }
488
486
 
489
487
  // Never throws for the same reason read() does not.
@@ -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
- const tmp = file + '.' + process.pid + '.tmp';
106
- fs.writeFileSync(tmp, JSON.stringify(all));
107
- fs.renameSync(tmp, file);
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,6 +34,7 @@ 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');
39
40
 
@@ -353,9 +354,7 @@ function preflightPrompts(cwd, config, options) {
353
354
  // reader that lands between the truncate and the write of a plain writeFileSync
354
355
  // sees half a file; the rename is the only step another process can observe.
355
356
  function writeAtomic(file, text) {
356
- const tmp = file + '.' + process.pid + '.tmp';
357
- fs.writeFileSync(tmp, text);
358
- fs.renameSync(tmp, file);
357
+ atomic.writeFileAtomic(file, text);
359
358
  }
360
359
 
361
360
  function write(state) {
@@ -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
- fs.mkdirSync(path.dirname(file), { recursive: true });
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
- fs.mkdirSync(path.dirname(file), { recursive: true });
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
- try {
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
  }