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.
@@ -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');
@@ -23,6 +24,7 @@ const reading = require('./reading.js');
23
24
  const voice = require('./voice.js');
24
25
  const mode = require('./mode.js');
25
26
  const feed = require('./feed.js');
27
+ const lowpri = require('./lowpri.js');
26
28
 
27
29
  const SECOND = 1000;
28
30
  const DAY = 24 * 60 * 60 * 1000;
@@ -510,6 +512,11 @@ const CACHED_BINDING_FIELDS = [
510
512
  'msToReset',
511
513
  'refusedAt',
512
514
  'refusedResetsAt',
515
+ // What the room that is left buys, per window. Needed because the binding
516
+ // window can be swapped for the weekly once low-priority is acknowledged, and
517
+ // a swapped window with the old window's turn count would be the brief
518
+ // quoting two different budgets in one sentence.
519
+ 'turnsLeft',
513
520
  ];
514
521
 
515
522
  function cacheableBinding(binding) {
@@ -1002,6 +1009,74 @@ function briefText(input) {
1002
1009
  );
1003
1010
  }
1004
1011
  }
1012
+ // /low-priority: the other thing that is not stopping.
1013
+ //
1014
+ // Three rules this block exists to keep. It says nothing at all unless the
1015
+ // account is provisioned for the command, because advertising a slash command
1016
+ // an account does not have is worse than silence. It never implies that the
1017
+ // plugin or the model can switch it on - Claude cannot type a slash command,
1018
+ // and the command is declared supportsNonInteractive:false, so it is unusable
1019
+ // in a headless or relayed run as well. And it words the offer as a
1020
+ // possibility, because the real gate is an experiment arm in a response
1021
+ // header that nothing here will ever see.
1022
+ //
1023
+ // No wait time is printed. The retry and the ceiling come from
1024
+ // lowPriorityRetryAfterSeconds and lowPriorityMaxWaitSeconds on each
1025
+ // response, so any fixed number would be invented.
1026
+ // Kept in a variable as well as pushed, for the same reason as the escape: it
1027
+ // is the difference between stopping at the 5-hour wall and not, and the terse
1028
+ // style drops only what reads the same every turn. This does not.
1029
+ const lp = parts.lowPriority || null;
1030
+ let lowPrioritySentence = null;
1031
+ if (lp && lp.state === 'acknowledged') {
1032
+ lowPrioritySentence = (
1033
+ 'You have said /low-priority is on, so the brake is the weekly window and not the 5-hour one: ' +
1034
+ (Number.isFinite(lp.fiveHourPercent)
1035
+ ? 'the 5-hour limit is at ' + lp.fiveHourPercent + '% and no longer stops this session, '
1036
+ : 'the 5-hour limit no longer stops this session, ') +
1037
+ 'the figures above are the weekly, and replies may pause while lower priority waits for spare ' +
1038
+ 'capacity. Whether it is still on cannot be read from here, so if it has ended say so with ' +
1039
+ 'usage-mode --low-priority off and the 5-hour wall counts again. It also draws on a separate ' +
1040
+ 'weekly lower-priority allowance that is exposed to no hook and no file, so nothing here can ' +
1041
+ 'track how much of that is left.'
1042
+ );
1043
+ } else if (lp && lp.advise && lp.advise.kind === 'offer') {
1044
+ lowPrioritySentence = (
1045
+ 'If the wall offers it, /low-priority carries this session past the 5-hour limit at lower ' +
1046
+ 'priority instead of stopping: it spends the weekly limit, which is at ' +
1047
+ lp.advise.weeklyPercent + '% and so has room, and replies may pause while it waits for spare ' +
1048
+ 'capacity - the wait and its ceiling are set by the server per request. It is a toggle: you ' +
1049
+ 'type it yourself and run it again to stop, and nothing here can switch it on for you. Say ' +
1050
+ 'in one line that it is there; if it is taken, usage-mode --low-priority on is what makes ' +
1051
+ 'this line brake on the weekly instead.'
1052
+ );
1053
+ } else if (lp && lp.advise && lp.advise.kind === 'hold') {
1054
+ lowPrioritySentence = (
1055
+ 'The wall may offer /low-priority here, and it is not worth taking: it spends the weekly limit, ' +
1056
+ 'which is already at ' + lp.advise.weeklyPercent + '% - past the ' + lp.advise.threshold +
1057
+ ' per cent this plugin will recommend it at - and it draws on a weekly lower-priority ' +
1058
+ 'allowance as well, which has been measured emptying most of a week in a couple of hours. ' +
1059
+ 'Say in one line that the answer is no and why; waiting out the 5-hour reset is the cheaper move.'
1060
+ );
1061
+ }
1062
+ if (lowPrioritySentence) sentences.push(lowPrioritySentence);
1063
+ // A manual session reset, if this account ever gets one.
1064
+ //
1065
+ // /limit-reset refills the 5-hour window, works only AT a limit, and is once a
1066
+ // week - and the work it unlocks still spends the weekly, which is the part
1067
+ // worth saying out loud. This account holds no grant today
1068
+ // (tengu_cedar_ember absent, cachedUsageUtilization.cedar_ember null), so the
1069
+ // sentence is a detector rather than a feature: nothing is claimed about how
1070
+ // many resets are left, because resets_left is served by a live endpoint and
1071
+ // appears in no file a hook can read.
1072
+ if (lp && lp.resetGrant && (parts.pressure === 'tight' || parts.pressure === 'gone')) {
1073
+ sentences.push(
1074
+ 'A once-weekly manual session reset appears to be available on this account (/limit-reset). It ' +
1075
+ 'only works while you are actually AT a limit, and the work it unlocks still spends the weekly, ' +
1076
+ 'so it moves the 5-hour wall rather than adding budget. How many are left is not readable from ' +
1077
+ 'here - the CLI asks the server for that. It is yours to type, like /low-priority.'
1078
+ );
1079
+ }
1005
1080
  if (parts.session) {
1006
1081
  sentences.push(
1007
1082
  'This session: ' + parts.session.turns + ' turns, ' +
@@ -1101,6 +1176,23 @@ function briefText(input) {
1101
1176
  );
1102
1177
  }
1103
1178
  }
1179
+ // The relay was built for a CLI that stopped dead at the wall. Since 2.1.234
1180
+ // Claude Code waits out the reset and continues the same open session by
1181
+ // itself, and the setting that does it is ON BY DEFAULT
1182
+ // (autoContinueAtUsageLimit, code.claude.com/docs/en/settings-reference). In
1183
+ // place, with the context intact, that is strictly better than a wake: no
1184
+ // hand-off file to re-read and nothing lost. So the wake is the route for a
1185
+ // session that will be CLOSED, and saying so is what stops both firing for the
1186
+ // same reset - which would run the work twice and spend the weekly twice.
1187
+ if (carry && carry.armed && carry.armed.mode === 'resume' && parts.autoContinue && parts.autoContinue.value) {
1188
+ relaySentences.push(
1189
+ 'Claude Code\'s own "Continue automatically at usage limit" is on (' + parts.autoContinue.source +
1190
+ '), so if this terminal is still open at the reset the CLI carries THIS session across by ' +
1191
+ 'itself, in place, with its context intact - better than any wake. The wake is the route for a ' +
1192
+ 'session that is closed by then. Both firing for the same reset would start the work twice and ' +
1193
+ 'spend the weekly twice, so if the terminal is staying open, one of the two is worth standing down.'
1194
+ );
1195
+ }
1104
1196
  if (carry && carry.last) {
1105
1197
  relaySentences.push(
1106
1198
  'The last relay ' +
@@ -1123,7 +1215,25 @@ function briefText(input) {
1123
1215
  // near the wall is to make being cut off cheap - order the work, save as you
1124
1216
  // go, keep a note of where things stand - not to shrink the work until it is
1125
1217
  // guaranteed to fit.
1126
- const instruction =
1218
+ // Whether Claude Code itself is giving the wrap-up instruction at this wall.
1219
+ //
1220
+ // Confirmed mechanism, gated on a flag this plugin reads rather than assumed:
1221
+ // the CLI injects its own "finish up" note when tengu_lantern_wick_mode is
1222
+ // "wrap-up" or "next-steps". On this machine that flag reads "off", so the
1223
+ // plugin's own instruction stands and nothing changes. When it flips, two
1224
+ // agents telling the model to wind down in different words is worse than one,
1225
+ // so this line stands down and says who is speaking instead.
1226
+ //
1227
+ // Not when an escape is live: the escape says the budget is still there, and
1228
+ // deferring to a note that says the opposite would be the plugin handing over
1229
+ // at the one moment it disagrees.
1230
+ const hostWrapsUpNow =
1231
+ Boolean(parts.hostWrapsUp) && !escapeText && (parts.pressure === 'tight' || parts.pressure === 'gone');
1232
+ const instruction = hostWrapsUpNow
1233
+ ? 'Claude Code is injecting its own wrap-up note at this wall, so it gives the instruction and this ' +
1234
+ 'line does not repeat it. Follow that note; the figures above are the ones to quote, and the ' +
1235
+ 'session total below is what to close with.'
1236
+ :
1127
1237
  // The escape outranks everything below it. A session that stops while a
1128
1238
  // command would have carried it on has not been careful, it has quit - and
1129
1239
  // that is a real session: the Fable weekly hit 89 per cent, the line said
@@ -1251,6 +1361,7 @@ function briefText(input) {
1251
1361
  return (
1252
1362
  sentences[0] + caveat + (parts.tier ? ' ' + parts.tier : '') + (bounded ? ' ' + bounded : '') + adviceText +
1253
1363
  (escapeSentence ? ' ' + escapeSentence : '') +
1364
+ (lowPrioritySentence ? ' ' + lowPrioritySentence : '') +
1254
1365
  (parts.pressure !== 'roomy' && relaySentences.length ? ' ' + relaySentences.join(' ') : '') +
1255
1366
  '\n' + instruction + directive
1256
1367
  );
@@ -1376,6 +1487,41 @@ function relayState(now, hookInput, binding, sessionId) {
1376
1487
  }
1377
1488
  }
1378
1489
 
1490
+ // What Claude Code's own wall-time features change about this brief.
1491
+ //
1492
+ // Only one of them changes a number. An acknowledged /low-priority retires the
1493
+ // 5-hour wall - the session carries straight past that reset at lower priority,
1494
+ // spending the weekly - so the window that actually stops the work becomes the
1495
+ // weekly, and the brief has to brake on that one instead. The swap happens here,
1496
+ // once, so the pressure, the relay and the sentence all describe the same
1497
+ // window; doing it in three places is how they come to describe two.
1498
+ //
1499
+ // Never on a guess. `acknowledged` means the user said so through
1500
+ // usage-mode --low-priority on, and that record lapses at the reset of the
1501
+ // window it was recorded against. Whether low-priority is really running is in
1502
+ // the CLI's process memory and readable nowhere; see lowpri.js.
1503
+ function wallFeatures(now, binding, windows) {
1504
+ const out = { binding, lowPriority: null, hostWrapsUp: false, swapped: false };
1505
+ try {
1506
+ const account = lowpri.snapshot();
1507
+ const info = lowpri.forBrief({ account, now, binding, windows });
1508
+ out.lowPriority = info;
1509
+ out.hostWrapsUp = lowpri.wrapUp(account).hostWrapsUp;
1510
+ out.autoContinue = info.autoContinue;
1511
+ if (info.state === 'acknowledged' && binding && binding.key === 'five_hour') {
1512
+ const weekly = lowpri.weeklyOf(windows);
1513
+ if (weekly && Number.isFinite(weekly.percentUsed) && !weekly.stale && Number.isFinite(weekly.resetsAt)) {
1514
+ out.binding = weekly;
1515
+ out.swapped = true;
1516
+ out.lowPriority = Object.assign({}, info, { weeklyBinding: true });
1517
+ }
1518
+ }
1519
+ } catch (err) {
1520
+ // Nothing about these features is worth a failed prompt.
1521
+ }
1522
+ return out;
1523
+ }
1524
+
1379
1525
  // The one line `off` will ever say, and only when it has been asked for.
1380
1526
  //
1381
1527
  // `off` means off, including at 100 per cent: that is what was asked and it is
@@ -1450,6 +1596,20 @@ const GEMINI_UNREADABLE =
1450
1596
  const LIMIT_REACHED =
1451
1597
  '[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
1598
 
1599
+ // Temporary files a killed process left behind: a hook that ran into its
1600
+ // timeout between writing one and renaming it. Nothing else can clean them,
1601
+ // and on 2026-09-25 about seventy had piled up in ~/.claude. Once per prompt
1602
+ // is often enough and costs one directory listing per home. The clock here
1603
+ // is always the real one, never the run's `now`: a stale file is judged by
1604
+ // its mtime, which is real time too. Never throws.
1605
+ function sweepDebris() {
1606
+ try {
1607
+ return atomic.sweep([configDir(), host.codexHome()], Date.now());
1608
+ } catch (err) {
1609
+ return 0;
1610
+ }
1611
+ }
1612
+
1453
1613
  async function run(now, hookInput, opts) {
1454
1614
  if (String(process.env.USAGE_LIMITS_BRIEF || '').toLowerCase() === 'off') return '';
1455
1615
 
@@ -1463,6 +1623,8 @@ async function run(now, hookInput, opts) {
1463
1623
  const budget = mode.forSession({ sessionId });
1464
1624
  if (budget.policy.briefStyle === 'none') return guardLine(now, budget);
1465
1625
 
1626
+ sweepDebris();
1627
+
1466
1628
  // Settle host here, before any file is read. A caller that already knows the
1467
1629
  // host (agy-hook runs only inside Antigravity) says so, and that wins over
1468
1630
  // detection: an empty or unparseable payload used to fall through to
@@ -1612,10 +1774,35 @@ async function run(now, hookInput, opts) {
1612
1774
  writeCache(mergeCache(all, sessionId, view, KEEP_SESSIONS));
1613
1775
  }
1614
1776
 
1615
- const binding = view.binding;
1777
+ // Settled before the pressure, the relay and the line are decided, so all
1778
+ // three describe the same window.
1779
+ const wall = wallFeatures(now, view.binding, view.windows);
1780
+ const binding = wall.binding;
1781
+ // The turn count belongs to whichever window is now binding. Carrying the
1782
+ // 5-hour count onto a weekly window would put two budgets in one sentence.
1783
+ const turnsLeftNow = wall.swapped
1784
+ ? (Number.isFinite(binding.turnsLeft) ? binding.turnsLeft : null)
1785
+ : view.turnsLeft;
1786
+ const othersSummaryNow = wall.swapped
1787
+ ? summariseOthers(view.windows || [], binding.key)
1788
+ : view.othersSummary;
1789
+ // Everything derived from the window that WAS binding has to go with it.
1790
+ //
1791
+ // The escape route was worked out for the 5-hour window - "this window is
1792
+ // scoped to one model, so switching model retires it" - and the sentence that
1793
+ // prints it is gated on the binding window's percentage. Left in place after
1794
+ // the swap it would describe one window while the figures beside it described
1795
+ // another, which is the exact confusion the binding window exists to prevent.
1796
+ // Same for the critical list, which excludes whichever window was binding: the
1797
+ // weekly would otherwise be both the binding window and a "note that" warning
1798
+ // about itself.
1799
+ const escapeNow = wall.swapped ? null : view.escape || null;
1800
+ const criticalNow = wall.swapped
1801
+ ? (view.critical || []).filter((other) => other.label !== binding.label)
1802
+ : view.critical || [];
1616
1803
  const { active, share } = activeShare(view.sessions, all, now, sessionId);
1617
- const yourTurnsLeft = Number.isFinite(view.turnsLeft)
1618
- ? Math.max(1, Math.round(view.turnsLeft * share))
1804
+ const yourTurnsLeft = Number.isFinite(turnsLeftNow)
1805
+ ? Math.max(1, Math.round(turnsLeftNow * share))
1619
1806
  : null;
1620
1807
  // Only a short runway is worth saying. Quoting it when there are hours left
1621
1808
  // would make the line longer without making it more useful.
@@ -1664,7 +1851,7 @@ async function run(now, hookInput, opts) {
1664
1851
  const offering = advice.ok && !advice.alreadyOffered;
1665
1852
  if (offering) mode.adviceOffer(advice.id, sessionId, now);
1666
1853
 
1667
- const pressureNow = pressure(binding, now, config, Number.isFinite(yourTurnsLeft) ? yourTurnsLeft : view.turnsLeft);
1854
+ const pressureNow = pressure(binding, now, config, Number.isFinite(yourTurnsLeft) ? yourTurnsLeft : turnsLeftNow);
1668
1855
  // Fast mode changes what the window's figures mean, so a toggle is a change
1669
1856
  // worth saying even when nothing else has moved.
1670
1857
  const fastMode = fastModeFor(sessionId);
@@ -1679,12 +1866,17 @@ async function run(now, hookInput, opts) {
1679
1866
  budget.name,
1680
1867
  pressureNow,
1681
1868
  binding && Number.isFinite(binding.percentUsed) ? Math.round(binding.percentUsed / 5) * 5 : 'x',
1682
- view.escape ? view.escape.kind : '-',
1869
+ escapeNow ? escapeNow.kind : '-',
1683
1870
  tier || '-',
1684
1871
  active > 1 ? 'shared' : 'solo',
1685
1872
  carry && carry.armed ? 'relay' : '-',
1686
1873
  offering ? 'advice' : '-',
1687
1874
  fastMode ? 'fast' : '-',
1875
+ // An acknowledgement, or the wall starting to offer the toggle, changes what
1876
+ // the line says and which window it brakes on. Leaving it out of the digest
1877
+ // would let `max` swallow exactly the prompt on which that changed.
1878
+ wall.lowPriority ? wall.lowPriority.state + (wall.lowPriority.advise ? ':' + wall.lowPriority.advise.kind : '') : '-',
1879
+ wall.hostWrapsUp ? 'wrapup' : '-',
1688
1880
  ].join('|');
1689
1881
  if (!budget.policy.briefWhenUnchanged && pressureNow === 'roomy') {
1690
1882
  const slots = readCache();
@@ -1724,10 +1916,10 @@ async function run(now, hookInput, opts) {
1724
1916
  binding && binding.family && Number.isFinite(binding.percentUsed) && binding.percentUsed >= HALF_SPENT
1725
1917
  ? familyLabel(binding.family)
1726
1918
  : null,
1727
- othersSummary: view.othersSummary,
1728
- escape: view.escape || null,
1919
+ othersSummary: othersSummaryNow,
1920
+ escape: escapeNow,
1729
1921
  host: usage.currentHost(),
1730
- turnsLeft: view.turnsLeft,
1922
+ turnsLeft: turnsLeftNow,
1731
1923
  effortWarning: view.effortWarning || null,
1732
1924
  // Once per setting, and once per session, and never after a decline: the
1733
1925
  // advice rules above decide, and the sentence itself is unchanged.
@@ -1743,13 +1935,17 @@ async function run(now, hookInput, opts) {
1743
1935
  rebuilt: Boolean(binding && binding.estimated),
1744
1936
  staleWindows: view.staleWindows || 0,
1745
1937
  planChanged: Boolean(view.planChanged),
1746
- critical: view.critical || [],
1938
+ critical: criticalNow,
1747
1939
  pointsSinceSnapshot: (binding && binding.pointsSinceSnapshot) || 0,
1748
1940
  correctionUnreliable: Boolean(binding && binding.correctionUnreliable),
1749
1941
  pointsBeyondSnapshot: (binding && binding.pointsBeyondSnapshot) || 0,
1750
1942
  snapshotAge: view.snapshotAge,
1751
1943
  snapshotStale: Number.isFinite(view.snapshotAgeMs) && view.snapshotAgeMs >= SNAPSHOT_TRUST_MS,
1752
1944
  fastMode,
1945
+ // Claude Code's own wall-time features, as far as they are readable.
1946
+ lowPriority: wall.lowPriority,
1947
+ hostWrapsUp: wall.hostWrapsUp,
1948
+ autoContinue: wall.autoContinue || null,
1753
1949
  // The turn count that matters for this session is its share of a shared
1754
1950
  // budget, not the whole window's. Escalating on the whole window meant a
1755
1951
  // count that looked comfortable while the part actually available here was
@@ -1804,7 +2000,7 @@ function withBugcheck(text) {
1804
2000
  return text;
1805
2001
  }
1806
2002
 
1807
- module.exports = { withBugcheck, sayOnce, shapeOf, saidFile, REPEAT_MS, staleVersionFor, relayNewsFor, installedVersion, runningVersion,
2003
+ module.exports = { sweepDebris, withBugcheck, sayOnce, shapeOf, saidFile, REPEAT_MS, staleVersionFor, relayNewsFor, installedVersion, runningVersion,
1808
2004
  readSaid, standingSaid, markStanding, standingShortFor, STANDING_SHORT, cacheMissWhyFor, missReason, MISS_RECENT_MS,
1809
2005
  DEFAULTS,
1810
2006
  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) {