claude-usage-limits 1.39.4 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/usage-limits/scripts/activity.js +4 -16
- package/skills/usage-limits/scripts/agy-hook.js +12 -5
- package/skills/usage-limits/scripts/atomic.js +173 -0
- package/skills/usage-limits/scripts/brief.js +65 -10
- package/skills/usage-limits/scripts/codex-lowpower.js +3 -3
- package/skills/usage-limits/scripts/codex.js +122 -21
- package/skills/usage-limits/scripts/drift.js +5 -6
- package/skills/usage-limits/scripts/install-antigravity.js +60 -13
- package/skills/usage-limits/scripts/live.js +5 -15
- package/skills/usage-limits/scripts/lowpower.js +3 -4
- package/skills/usage-limits/scripts/mode.js +2 -4
- package/skills/usage-limits/scripts/pulse.js +18 -8
- package/skills/usage-limits/scripts/reading.js +4 -4
- package/skills/usage-limits/scripts/relay.js +2 -3
- package/skills/usage-limits/scripts/statusline.js +3 -4
- package/skills/usage-limits/scripts/usage.js +3 -10
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "usage-limits",
|
|
3
3
|
"displayName": "Usage Limits",
|
|
4
|
-
"version": "1.39.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
72
|
-
|
|
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
|
}
|
|
@@ -157,9 +157,14 @@ async function run(now, input, argv) {
|
|
|
157
157
|
return {};
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
160
|
+
// Read stdin, answer, exit zero. A function rather than a bare block because
|
|
161
|
+
// the installer's launcher calls it: see install-antigravity.js, which writes a
|
|
162
|
+
// two-line launcher beside hooks.json so the hook command carries no path and
|
|
163
|
+
// therefore no quotes for `cmd /c` to mangle.
|
|
164
|
+
function main(argv) {
|
|
165
|
+
const args = argv || [];
|
|
166
|
+
return readInput()
|
|
167
|
+
.then((input) => run(Date.now(), input, args))
|
|
163
168
|
.then(
|
|
164
169
|
(result) => {
|
|
165
170
|
process.stdout.write(JSON.stringify(result || {}) + '\n');
|
|
@@ -169,11 +174,13 @@ if (require.main === module) {
|
|
|
169
174
|
// Hooks block the agent loop here, so a failure has to be silent and
|
|
170
175
|
// well formed rather than loud - and on PreToolUse, well formed means
|
|
171
176
|
// an explicit allow, never a bare {} that reads as a refusal.
|
|
172
|
-
const pre =
|
|
177
|
+
const pre = args.join(' ').includes('--event PreToolUse');
|
|
173
178
|
process.stdout.write(JSON.stringify(pre ? ALLOW : {}) + '\n');
|
|
174
179
|
process.exit(0);
|
|
175
180
|
}
|
|
176
181
|
);
|
|
177
182
|
}
|
|
178
183
|
|
|
179
|
-
|
|
184
|
+
if (require.main === module) main(process.argv.slice(2));
|
|
185
|
+
|
|
186
|
+
module.exports = { EVENTS, ALLOW, eventFrom, toolNameOf, percentNow, run, main };
|
|
@@ -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');
|
|
@@ -64,6 +65,10 @@ const DEFAULTS = {
|
|
|
64
65
|
// A hook has ten seconds; the reading gets four of them at most.
|
|
65
66
|
const REFRESH_TIMEOUT_MS = 4000;
|
|
66
67
|
|
|
68
|
+
// What the hook is actually given before it is killed. Claude Code and Codex
|
|
69
|
+
// both use ten seconds (hooks/hooks.json, install-codex-hook.js).
|
|
70
|
+
const HOOK_BUDGET_MS = 10000;
|
|
71
|
+
|
|
67
72
|
// The hook is given ten seconds, and a live reading may take four of them, so
|
|
68
73
|
// the transcript scan gets five and the last second is slack. A warm scan
|
|
69
74
|
// takes about a quarter of a second; this is the guard for the first run on a
|
|
@@ -71,6 +76,28 @@ const REFRESH_TIMEOUT_MS = 4000;
|
|
|
71
76
|
// killed and Claude being told nothing at all.
|
|
72
77
|
const SCAN_BUDGET_MS = 5000;
|
|
73
78
|
|
|
79
|
+
// How long the live reading may actually wait, given how much of the hook's ten
|
|
80
|
+
// seconds is already gone.
|
|
81
|
+
//
|
|
82
|
+
// Four seconds for the reading plus five for the scan plus a second of slack
|
|
83
|
+
// only adds up if the hook starts at zero, and it never does: node's own
|
|
84
|
+
// start-up, the requires, and reading the hook payload all come out of the same
|
|
85
|
+
// budget, and on a loaded machine that is most of a second before this line is
|
|
86
|
+
// reached. `process.uptime()` is the honest elapsed figure. Past the point
|
|
87
|
+
// where the scan would no longer fit, the reading on disk is used as it is
|
|
88
|
+
// rather than the hook being killed with nothing to say.
|
|
89
|
+
function refreshBudgetMs(options) {
|
|
90
|
+
const opts = options || {};
|
|
91
|
+
const configured = Number(
|
|
92
|
+
opts.hookBudgetMs !== undefined ? opts.hookBudgetMs : process.env.USAGE_LIMITS_HOOK_BUDGET_MS
|
|
93
|
+
);
|
|
94
|
+
const total = Number.isFinite(configured) && configured > 0 ? configured : HOOK_BUDGET_MS;
|
|
95
|
+
const elapsed = Number.isFinite(opts.elapsedMs) ? opts.elapsedMs : process.uptime() * 1000;
|
|
96
|
+
const reserve = Number.isFinite(opts.reserveMs) ? opts.reserveMs : SCAN_BUDGET_MS + 1000;
|
|
97
|
+
const most = Number.isFinite(opts.maxMs) ? opts.maxMs : REFRESH_TIMEOUT_MS;
|
|
98
|
+
return Math.max(0, Math.min(most, Math.round(total - elapsed - reserve)));
|
|
99
|
+
}
|
|
100
|
+
|
|
74
101
|
// How far into the hook arming may still start. See relayState: the task
|
|
75
102
|
// registration is about a second and the hook is allowed ten.
|
|
76
103
|
const ARM_DEADLINE_MS = 5000;
|
|
@@ -1424,6 +1451,20 @@ const GEMINI_UNREADABLE =
|
|
|
1424
1451
|
const LIMIT_REACHED =
|
|
1425
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.';
|
|
1426
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
|
+
|
|
1427
1468
|
async function run(now, hookInput, opts) {
|
|
1428
1469
|
if (String(process.env.USAGE_LIMITS_BRIEF || '').toLowerCase() === 'off') return '';
|
|
1429
1470
|
|
|
@@ -1437,6 +1478,8 @@ async function run(now, hookInput, opts) {
|
|
|
1437
1478
|
const budget = mode.forSession({ sessionId });
|
|
1438
1479
|
if (budget.policy.briefStyle === 'none') return guardLine(now, budget);
|
|
1439
1480
|
|
|
1481
|
+
sweepDebris();
|
|
1482
|
+
|
|
1440
1483
|
// Settle host here, before any file is read. A caller that already knows the
|
|
1441
1484
|
// host (agy-hook runs only inside Antigravity) says so, and that wins over
|
|
1442
1485
|
// detection: an empty or unparseable payload used to fall through to
|
|
@@ -1495,21 +1538,29 @@ async function run(now, hookInput, opts) {
|
|
|
1495
1538
|
} else if (usage.isCodex()) {
|
|
1496
1539
|
// Codex only writes its meter when it makes a request, so between turns
|
|
1497
1540
|
// the newest figure can be half an hour old. Ask it, the way /status
|
|
1498
|
-
// does, when the reading has aged.
|
|
1541
|
+
// does, when the reading has aged - but never inside this hook. The
|
|
1542
|
+
// reading means starting `codex app-server`, whose cold start alone has
|
|
1543
|
+
// been measured past four seconds, and Codex kills a hook at ten. So the
|
|
1544
|
+
// refresh is started detached and the next hook reads what it wrote.
|
|
1499
1545
|
await require('./codex.js').refreshIfStale({
|
|
1500
1546
|
now,
|
|
1501
1547
|
maxAgeMs: refreshSeconds * SECOND,
|
|
1502
|
-
|
|
1548
|
+
detach: true,
|
|
1503
1549
|
});
|
|
1504
1550
|
} else {
|
|
1505
1551
|
const cached = usage.collect(now);
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1552
|
+
const waitMs = refreshBudgetMs();
|
|
1553
|
+
// No room left in the hook for a network call: the figure on disk stands
|
|
1554
|
+
// and the next prompt tries again.
|
|
1555
|
+
if (waitMs > 0) {
|
|
1556
|
+
await live.refreshIfStale({
|
|
1557
|
+
now,
|
|
1558
|
+
maxAgeMs: refreshSeconds * SECOND,
|
|
1559
|
+
cacheFetchedAtMs: cached.snapshotFetchedAt,
|
|
1560
|
+
accountUuid: usage.accountUuid(),
|
|
1561
|
+
timeoutMs: waitMs,
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1513
1564
|
}
|
|
1514
1565
|
} catch (err) {
|
|
1515
1566
|
// The reading on disk is still there.
|
|
@@ -1770,9 +1821,13 @@ function withBugcheck(text) {
|
|
|
1770
1821
|
return text;
|
|
1771
1822
|
}
|
|
1772
1823
|
|
|
1773
|
-
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,
|
|
1774
1825
|
readSaid, standingSaid, markStanding, standingShortFor, STANDING_SHORT, cacheMissWhyFor, missReason, MISS_RECENT_MS,
|
|
1775
1826
|
DEFAULTS,
|
|
1827
|
+
HOOK_BUDGET_MS,
|
|
1828
|
+
REFRESH_TIMEOUT_MS,
|
|
1829
|
+
SCAN_BUDGET_MS,
|
|
1830
|
+
refreshBudgetMs,
|
|
1776
1831
|
aheadOfPace,
|
|
1777
1832
|
pacingMatters,
|
|
1778
1833
|
PACE_MIN_SPAN_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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -830,6 +827,72 @@ function attemptFile() {
|
|
|
830
827
|
return path.join(homeDir(), 'usage-limits-codex-fetch.json');
|
|
831
828
|
}
|
|
832
829
|
|
|
830
|
+
// How long a detached refresh is given before another hook may start one. The
|
|
831
|
+
// child is not waited on, so this claim is the only thing stopping every hook
|
|
832
|
+
// in a burst from launching its own app-server.
|
|
833
|
+
const DETACHED_GUARD_MS = 45 * 1000;
|
|
834
|
+
|
|
835
|
+
// A detached refresh sits outside everybody's hook budget, so it can afford to
|
|
836
|
+
// wait out a cold start instead of giving up at four seconds.
|
|
837
|
+
const DETACHED_TIMEOUT_MS = 25 * 1000;
|
|
838
|
+
|
|
839
|
+
// Start the refresh in a child nobody waits for.
|
|
840
|
+
//
|
|
841
|
+
// This is the answer to the hook timeout. Codex kills a hook at ten seconds,
|
|
842
|
+
// and `codex app-server` can take more than four to answer cold - so a hook
|
|
843
|
+
// that waits for the reading is a hook that gets killed, and a killed hook
|
|
844
|
+
// says nothing at all. Here the hook returns immediately and the reading lands
|
|
845
|
+
// in liveFile() a second or two later, where the NEXT hook reads it. One turn
|
|
846
|
+
// of latency for a figure that is refreshed every few minutes anyway.
|
|
847
|
+
function spawnRefresh(options) {
|
|
848
|
+
const opts = options || {};
|
|
849
|
+
const spawn = opts.spawn || require('child_process').spawn;
|
|
850
|
+
const args = [__filename, '--refresh-meter'];
|
|
851
|
+
if (opts.codexPath) args.push('--codex-path', String(opts.codexPath));
|
|
852
|
+
const child = spawn(opts.execPath || process.execPath, args, {
|
|
853
|
+
detached: true,
|
|
854
|
+
stdio: 'ignore',
|
|
855
|
+
windowsHide: true,
|
|
856
|
+
// The child reads CODEX_HOME to find the same home directory this process
|
|
857
|
+
// is using, so a test or a non-default install refreshes the right file.
|
|
858
|
+
env: Object.assign({}, opts.env || process.env, { CODEX_HOME: homeDir() }),
|
|
859
|
+
});
|
|
860
|
+
if (child && typeof child.unref === 'function') child.unref();
|
|
861
|
+
return child;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Take the reading and record it, with no staleness or backoff check. This is
|
|
865
|
+
// the half of refreshIfStale() that actually costs something, split out so the
|
|
866
|
+
// detached child can run it without tripping over the in-flight claim the
|
|
867
|
+
// parent just wrote.
|
|
868
|
+
async function fetchAndRecord(options) {
|
|
869
|
+
const opts = options || {};
|
|
870
|
+
const now = Number.isFinite(opts.now) ? opts.now : Date.now();
|
|
871
|
+
const maxAgeMs = Number.isFinite(opts.maxAgeMs) ? opts.maxAgeMs : 3 * MINUTE;
|
|
872
|
+
const claim = (delayMs, kind) => {
|
|
873
|
+
try {
|
|
874
|
+
fs.writeFileSync(attemptFile(), JSON.stringify({ attemptedAtMs: now, delayMs, kind }), 'utf8');
|
|
875
|
+
} catch (err) {
|
|
876
|
+
// One extra attempt is survivable.
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
try {
|
|
880
|
+
const reading = await refresh({ codexPath: opts.codexPath, timeoutMs: opts.timeoutMs });
|
|
881
|
+
if (!reading || !reading.meter) {
|
|
882
|
+
claim(maxAgeMs, 'empty');
|
|
883
|
+
return { reading: null, error: 'no meter' };
|
|
884
|
+
}
|
|
885
|
+
writeLiveMeter(reading);
|
|
886
|
+
claim(maxAgeMs, 'ok');
|
|
887
|
+
return { reading, error: null };
|
|
888
|
+
} catch (err) {
|
|
889
|
+
// Codex missing or not answering: back off rather than pay for it again on
|
|
890
|
+
// every prompt.
|
|
891
|
+
claim(Math.min(10 * MINUTE, Math.max(2 * MINUTE, maxAgeMs * 2)), (err && err.code) || 'error');
|
|
892
|
+
return { reading: null, error: (err && err.code) || 'error' };
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
833
896
|
// Ask Codex for the meter, but only when the newest reading on disk has aged,
|
|
834
897
|
// and never twice in quick succession. Codex writes its meter into a rollout
|
|
835
898
|
// only when it makes a request, so between turns the newest reading can be
|
|
@@ -869,23 +932,32 @@ async function refreshIfStale(options) {
|
|
|
869
932
|
// One extra attempt is survivable.
|
|
870
933
|
}
|
|
871
934
|
};
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
935
|
+
// The hook's own path: start the reading and get out of the way. Waiting for
|
|
936
|
+
// `codex app-server` inside a hook is what overran Codex's ten-second limit
|
|
937
|
+
// under load - the cold start alone has been measured past four seconds, and
|
|
938
|
+
// the brief still has a transcript scan to do after it. The child writes the
|
|
939
|
+
// meter file; the next hook reads it.
|
|
940
|
+
if (opts.detach) {
|
|
941
|
+
claim(DETACHED_GUARD_MS, 'detached');
|
|
942
|
+
try {
|
|
943
|
+
spawnRefresh({ codexPath: opts.codexPath, spawn: opts.spawn, execPath: opts.execPath, env: env });
|
|
944
|
+
} catch (err) {
|
|
945
|
+
claim(Math.max(2 * MINUTE, maxAgeMs), 'spawn-failed');
|
|
946
|
+
return { reading: live, skipped: null, error: 'spawn failed' };
|
|
879
947
|
}
|
|
880
|
-
|
|
881
|
-
claim(maxAgeMs, 'ok');
|
|
882
|
-
return { reading, skipped: null };
|
|
883
|
-
} catch (err) {
|
|
884
|
-
// Codex missing or not answering: back off rather than pay for it again on
|
|
885
|
-
// every prompt.
|
|
886
|
-
claim(Math.min(10 * MINUTE, Math.max(2 * MINUTE, maxAgeMs * 2)), (err && err.code) || 'error');
|
|
887
|
-
return { reading: live, skipped: null, error: (err && err.code) || 'error' };
|
|
948
|
+
return { reading: live, skipped: 'detached' };
|
|
888
949
|
}
|
|
950
|
+
|
|
951
|
+
claim(Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 8000, 'inflight');
|
|
952
|
+
|
|
953
|
+
const done = await fetchAndRecord({
|
|
954
|
+
now,
|
|
955
|
+
maxAgeMs,
|
|
956
|
+
timeoutMs: opts.timeoutMs,
|
|
957
|
+
codexPath: opts.codexPath,
|
|
958
|
+
});
|
|
959
|
+
if (done.error) return { reading: live, skipped: null, error: done.error };
|
|
960
|
+
return { reading: done.reading, skipped: null };
|
|
889
961
|
}
|
|
890
962
|
|
|
891
963
|
function collect(now, options) {
|
|
@@ -1093,10 +1165,39 @@ function refresh(options) {
|
|
|
1093
1165
|
});
|
|
1094
1166
|
}
|
|
1095
1167
|
|
|
1168
|
+
// The detached refresh, run as `node codex.js --refresh-meter`. Nothing waits
|
|
1169
|
+
// for this process, so it can spend the cold start a hook cannot.
|
|
1170
|
+
if (require.main === module) {
|
|
1171
|
+
const argv = process.argv.slice(2);
|
|
1172
|
+
if (argv[0] !== '--refresh-meter') {
|
|
1173
|
+
process.stderr.write('codex.js: only --refresh-meter is runnable\n');
|
|
1174
|
+
process.exitCode = 2;
|
|
1175
|
+
} else {
|
|
1176
|
+
const at = argv.indexOf('--codex-path');
|
|
1177
|
+
fetchAndRecord({
|
|
1178
|
+
now: Date.now(),
|
|
1179
|
+
timeoutMs: DETACHED_TIMEOUT_MS,
|
|
1180
|
+
codexPath: at !== -1 ? argv[at + 1] : null,
|
|
1181
|
+
})
|
|
1182
|
+
.catch(() => null)
|
|
1183
|
+
.then(() => {
|
|
1184
|
+
// The app-server is asked to stop by having its stdin closed, which
|
|
1185
|
+
// refresh() has already done. This timer is deliberately not unref'd:
|
|
1186
|
+
// it is the one thing that ends this process if the child it started
|
|
1187
|
+
// does not exit on its own.
|
|
1188
|
+
setTimeout(() => process.exit(0), 2000);
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1096
1193
|
module.exports = {
|
|
1097
1194
|
SLOTS,
|
|
1098
1195
|
WEIGHTS,
|
|
1099
1196
|
PLANS,
|
|
1197
|
+
DETACHED_GUARD_MS,
|
|
1198
|
+
DETACHED_TIMEOUT_MS,
|
|
1199
|
+
spawnRefresh,
|
|
1200
|
+
fetchAndRecord,
|
|
1100
1201
|
normalizeMeter,
|
|
1101
1202
|
homeDir,
|
|
1102
1203
|
sessionsDir,
|
|
@@ -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
|
-
|
|
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';
|
|
@@ -44,20 +45,66 @@ function pluginDir() {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
// Forward slashes on every platform. They work in Windows paths and keep the
|
|
47
|
-
// command free of escapes in both JSON and the shell that runs it.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
function quote(file) {
|
|
51
|
-
const normalized = String(file).replace(/\\/g, '/');
|
|
52
|
-
return normalized.includes(' ') ? '"' + normalized + '"' : normalized;
|
|
48
|
+
// command free of escapes in both JSON and the shell that runs it.
|
|
49
|
+
function slashes(file) {
|
|
50
|
+
return String(file).replace(/\\/g, '/');
|
|
53
51
|
}
|
|
54
52
|
|
|
55
53
|
function scriptPath(name) {
|
|
56
54
|
return path.join(__dirname, name);
|
|
57
55
|
}
|
|
58
56
|
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
// The name of the launcher written beside hooks.json.
|
|
58
|
+
const LAUNCHER = 'agy-hook.js';
|
|
59
|
+
|
|
60
|
+
// A hook command a shell cannot mangle.
|
|
61
|
+
//
|
|
62
|
+
// Antigravity runs hook commands through `cmd /c` on Windows. It does not pass
|
|
63
|
+
// /s, so cmd applies its own quote rule: the outer quotes are stripped only
|
|
64
|
+
// when the string carries exactly one pair. A path with a space in it needs a
|
|
65
|
+
// pair of its own, which makes three, and cmd then keeps them all - the command
|
|
66
|
+
// name becomes the whole quoted string and node is handed `C:/Users/Some` as
|
|
67
|
+
// its script. That is not a theory: a checkout under a directory with a space
|
|
68
|
+
// in its name installed cleanly and every hook then failed silently, because
|
|
69
|
+
// Antigravity reports nothing when a hook cannot start.
|
|
70
|
+
//
|
|
71
|
+
// So the command carries no quotes at all, which means it must carry no spaces
|
|
72
|
+
// either. Two ways to get there, and the one used depends on the path:
|
|
73
|
+
//
|
|
74
|
+
// - the plugin directory has no space: name the launcher absolutely, exactly
|
|
75
|
+
// as before, and nothing depends on the working directory
|
|
76
|
+
// - it does: name it relatively. Hooks run with their working directory set
|
|
77
|
+
// to the folder holding hooks.json, which is where the launcher is written
|
|
78
|
+
//
|
|
79
|
+
// Either way the real path into this checkout lives inside the launcher, as
|
|
80
|
+
// JavaScript, where no shell ever sees it.
|
|
81
|
+
function launcherPath() {
|
|
82
|
+
return path.join(pluginDir(), LAUNCHER);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function hookCommand(event, dir) {
|
|
86
|
+
const absolute = slashes(dir === undefined ? launcherPath() : path.join(dir, LAUNCHER));
|
|
87
|
+
const target = absolute.includes(' ') ? LAUNCHER : absolute;
|
|
88
|
+
return 'node ' + target + ' --event ' + event;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The launcher itself. It exists so the command above needs no path: requiring
|
|
92
|
+
// the real script by absolute path is a string in a JS file, which survives any
|
|
93
|
+
// amount of shell quoting, and calling main() is what `node agy-hook.js` would
|
|
94
|
+
// have done.
|
|
95
|
+
function launcherText() {
|
|
96
|
+
return [
|
|
97
|
+
"'use strict';",
|
|
98
|
+
'',
|
|
99
|
+
'// Written by install-antigravity.js. Do not edit: `on` rewrites it.',
|
|
100
|
+
'//',
|
|
101
|
+
'// The hook command that runs this file carries no path and no quotes,',
|
|
102
|
+
"// because Antigravity's `cmd /c` keeps the quotes around a path with a",
|
|
103
|
+
'// space in it and node is then handed a truncated script name. The path',
|
|
104
|
+
'// lives here instead, where only node reads it.',
|
|
105
|
+
'require(' + JSON.stringify(slashes(scriptPath('agy-hook.js'))) + ').main(process.argv.slice(2));',
|
|
106
|
+
'',
|
|
107
|
+
].join('\n');
|
|
61
108
|
}
|
|
62
109
|
|
|
63
110
|
function manifest() {
|
|
@@ -98,10 +145,7 @@ function rulesText() {
|
|
|
98
145
|
}
|
|
99
146
|
|
|
100
147
|
function writeFile(file, text) {
|
|
101
|
-
|
|
102
|
-
const tmp = file + '.' + process.pid + '.tmp';
|
|
103
|
-
fs.writeFileSync(tmp, text, 'utf8');
|
|
104
|
-
fs.renameSync(tmp, file);
|
|
148
|
+
atomic.writeFileAtomic(file, text);
|
|
105
149
|
}
|
|
106
150
|
|
|
107
151
|
function installed() {
|
|
@@ -155,6 +199,9 @@ function enable() {
|
|
|
155
199
|
}
|
|
156
200
|
const dir = pluginDir();
|
|
157
201
|
writeFile(path.join(dir, 'plugin.json'), JSON.stringify(manifest(), null, 2) + '\n');
|
|
202
|
+
// Before hooks.json, so the file the commands name is never missing while
|
|
203
|
+
// they are readable.
|
|
204
|
+
writeFile(path.join(dir, LAUNCHER), launcherText());
|
|
158
205
|
writeFile(path.join(dir, 'hooks.json'), JSON.stringify(hooks(), null, 2) + '\n');
|
|
159
206
|
const rules = rulesText();
|
|
160
207
|
if (rules) writeFile(path.join(dir, 'rules', 'AGENTS.md'), rules);
|
|
@@ -178,7 +225,7 @@ function disable() {
|
|
|
178
225
|
const dir = pluginDir();
|
|
179
226
|
if (!installed()) return 'Not installed. Nothing to remove.';
|
|
180
227
|
const removed = [];
|
|
181
|
-
for (const relative of ['plugin.json', 'hooks.json', path.join('rules', 'AGENTS.md')]) {
|
|
228
|
+
for (const relative of ['plugin.json', 'hooks.json', LAUNCHER, path.join('rules', 'AGENTS.md')]) {
|
|
182
229
|
const file = path.join(dir, relative);
|
|
183
230
|
try {
|
|
184
231
|
fs.unlinkSync(file);
|
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
@@ -312,16 +312,26 @@ async function run(now, hookInput) {
|
|
|
312
312
|
// against the account rather than against a guess from its own transcript.
|
|
313
313
|
try {
|
|
314
314
|
if (usage.isCodex()) {
|
|
315
|
-
|
|
315
|
+
// Never waited on inside the hook. `codex app-server` can take longer to
|
|
316
|
+
// answer cold than this hook is allowed to live, and a killed PostToolUse
|
|
317
|
+
// hook is a tool call that stalls. The child writes the meter file and
|
|
318
|
+
// the next pulse reads it.
|
|
319
|
+
await require('./codex.js').refreshIfStale({ now, maxAgeMs: every, detach: true });
|
|
316
320
|
} else {
|
|
317
321
|
const cached = usage.collect(now);
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
322
|
+
// Sized from what is left of the hook's ten seconds, not from a constant:
|
|
323
|
+
// this hook interrupts work in progress, so being late is worse here than
|
|
324
|
+
// anywhere else.
|
|
325
|
+
const waitMs = brief.refreshBudgetMs({ reserveMs: SCAN_BUDGET_MS + 1000 });
|
|
326
|
+
if (waitMs > 0) {
|
|
327
|
+
await live.refreshIfStale({
|
|
328
|
+
now,
|
|
329
|
+
maxAgeMs: every,
|
|
330
|
+
cacheFetchedAtMs: cached.snapshotFetchedAt,
|
|
331
|
+
accountUuid: usage.accountUuid(),
|
|
332
|
+
timeoutMs: waitMs,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
325
335
|
}
|
|
326
336
|
} catch (err) {
|
|
327
337
|
// The reading on disk is still there.
|
|
@@ -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,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
|
-
|
|
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
|
-
|
|
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
|
}
|