@bridge4dev/runner 0.55.1 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/claude-usage.d.ts +122 -1
- package/dist/adapters/claude-usage.js +310 -7
- package/dist/adapters/claude.js +68 -28
- package/dist/adapters/questions.d.ts +15 -0
- package/dist/adapters/questions.js +32 -0
- package/dist/auth-relay.js +19 -0
- package/dist/checkpoints.d.ts +12 -1
- package/dist/checkpoints.js +272 -42
- package/dist/supervisor.d.ts +37 -10
- package/dist/supervisor.js +385 -190
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -53,13 +53,134 @@ export declare function parseUsageText(text: string, now?: Date): UsageRow[];
|
|
|
53
53
|
* reworded CLI must never be able to move a wake-up — therefore still holds.
|
|
54
54
|
* `claude-usage.test.ts` pins it.
|
|
55
55
|
*/
|
|
56
|
-
export declare function applyUsagePercentages(windows: AgentRateLimitWindow[], rows: UsageRow[]
|
|
56
|
+
export declare function applyUsagePercentages(windows: AgentRateLimitWindow[], rows: UsageRow[], options?: {
|
|
57
|
+
onlyFillGaps?: boolean;
|
|
58
|
+
}): AgentRateLimitWindow[];
|
|
59
|
+
/**
|
|
60
|
+
* How long the probe may run before it is written off (#390).
|
|
61
|
+
*
|
|
62
|
+
* Ninety seconds, and the number is measured rather than chosen. `/usage` was
|
|
63
|
+
* 3.7s when the old ceiling of 20s was set on 2026-08-15; it is not that
|
|
64
|
+
* command any more. Since then the CLI prints «What's contributing to your
|
|
65
|
+
* limits usage?», computed from the machine's OWN session history — 1.4 GB and
|
|
66
|
+
* ~4900 files on the busiest dev server — so the work grows with the history
|
|
67
|
+
* and the load rather than with anything we ask for. It is also CPU-bound, not
|
|
68
|
+
* network-bound: 23.7s wall against 32s of CPU.
|
|
69
|
+
*
|
|
70
|
+
* Measured 2026-09-07 on a six-core box under load 4-15:
|
|
71
|
+
*
|
|
72
|
+
* plain, /tmp 17.7s
|
|
73
|
+
* plain, a project checkout 19.0 20.8 22.2 24.0 27.5s
|
|
74
|
+
* in the reporter's own runs 15.0 15.3 16.5 17.1 20.8 23.7 28.3s
|
|
75
|
+
*
|
|
76
|
+
* So 20s was not a margin, it was the middle of the distribution, and a failed
|
|
77
|
+
* probe was an ordinary event rather than an exception. The cost of waiting is
|
|
78
|
+
* nil — the probe is free (0 tokens, 0 turns, 0 dollars, verified 2026-08-15),
|
|
79
|
+
* runs in the background, and since #390 there is at most ONE of it per
|
|
80
|
+
* machine. The cost of NOT waiting was the panel losing two of its three rows.
|
|
81
|
+
*/
|
|
82
|
+
export declare const USAGE_PROBE_TIMEOUT_MS = 20000;
|
|
83
|
+
/**
|
|
84
|
+
* How long a successful reading is believed — the throttle for the whole box.
|
|
85
|
+
*
|
|
86
|
+
* Three minutes, unchanged from when this was a per-session throttle. What
|
|
87
|
+
* changed is the scope: five sessions used to keep five independent clocks and
|
|
88
|
+
* run five copies of a 20-second process, all of them measuring the same
|
|
89
|
+
* account. The windows on screen are five hours and a week; three minutes is
|
|
90
|
+
* already far finer than they move.
|
|
91
|
+
*/
|
|
92
|
+
export declare const USAGE_CACHE_MS: number;
|
|
93
|
+
/**
|
|
94
|
+
* How long we wait before retrying after a probe that produced nothing.
|
|
95
|
+
*
|
|
96
|
+
* Shorter than the success interval, because a machine that has just failed
|
|
97
|
+
* once is the machine most worth asking again — but not so short that a box
|
|
98
|
+
* with no subscription at all (own API key, Bedrock, Vertex) spends its life
|
|
99
|
+
* spawning CLIs. One at a time is guaranteed by the in-flight join below, so
|
|
100
|
+
* the worst case is a single ~90s process a minute apart, not a pile.
|
|
101
|
+
*/
|
|
102
|
+
export declare const USAGE_FAILURE_COOLDOWN_MS: number;
|
|
103
|
+
export type UsageProbeRunner = (binary: string, cwd: string) => Promise<string | null>;
|
|
57
104
|
/**
|
|
58
105
|
* Run `/usage` in a throwaway process and return what it printed.
|
|
59
106
|
*
|
|
60
107
|
* `--output-format text` rather than the stream: we want the rendering, and the
|
|
61
108
|
* JSON wrapper would only have to be unwrapped again. Never throws — a probe
|
|
62
109
|
* that fails is a percentage we do not show, not a session that breaks.
|
|
110
|
+
*
|
|
111
|
+
* Three things about HOW it is run, all of them measured (#390):
|
|
112
|
+
*
|
|
113
|
+
* - `--strict-mcp-config` stops the CLI starting the project's MCP servers,
|
|
114
|
+
* which a question about the plan has no use for. Measured in a project
|
|
115
|
+
* checkout: 13.8 and 18.5s with the flag against 20.8 and 22.2s without, and
|
|
116
|
+
* the output is identical — the same three rows. It narrows the spread; it
|
|
117
|
+
* does not remove it, which is why the ceiling above moved as well.
|
|
118
|
+
* - the process is niced like every other thing we spawn for an agent. It used
|
|
119
|
+
* to be the one child of the runner that was neither caged nor renice'd, and
|
|
120
|
+
* at a 90-second ceiling that would have made it a real neighbour on a busy
|
|
121
|
+
* box (#387 is the same lesson from the other side).
|
|
122
|
+
* - stdin is closed. `-p` does not read it, but `agent-versions.ts` was bitten
|
|
123
|
+
* by a CLI that did, and a probe that hangs on an open pipe would now hang
|
|
124
|
+
* for a minute and a half. (Not the cause of the slowness here: measured
|
|
125
|
+
* with and without, the times overlap.)
|
|
126
|
+
*
|
|
127
|
+
* `--bare` was measured too and rejected: it finishes in 2.5s and prints no
|
|
128
|
+
* usage at all, because it reads neither OAuth nor the keychain — on the
|
|
129
|
+
* subscription this whole file exists to report, it has nothing to say.
|
|
63
130
|
*/
|
|
64
131
|
export declare function probeUsageText(binary: string, cwd: string, timeoutMs?: number): Promise<string | null>;
|
|
132
|
+
/** A reading of the machine's plan, with the moment it was taken. */
|
|
133
|
+
export interface UsageReading {
|
|
134
|
+
rows: UsageRow[];
|
|
135
|
+
measuredAtMs: number;
|
|
136
|
+
}
|
|
137
|
+
export declare function setUsageProbeRunner(run: UsageProbeRunner | null): void;
|
|
138
|
+
/** Forget everything measured. For tests, and for a re-login that changes account. */
|
|
139
|
+
export declare function invalidateUsageCache(): void;
|
|
140
|
+
/**
|
|
141
|
+
* What the machine last read, WITHOUT starting a measurement.
|
|
142
|
+
*
|
|
143
|
+
* The reason this exists is the defect #390 was actually about. A session's
|
|
144
|
+
* window map starts empty, and its first `rate_limit_event` arrives 12-28
|
|
145
|
+
* seconds before any probe of its own could answer. That event describes ONE
|
|
146
|
+
* window, the snapshot built from it therefore has one row, and the API stores
|
|
147
|
+
* the newest snapshot as the state of the whole machine — so a session that had
|
|
148
|
+
* only just started would overwrite three good rows with one, every time, on a
|
|
149
|
+
* machine with a single session as much as on a busy one.
|
|
150
|
+
*
|
|
151
|
+
* Seeding from the box's last reading closes that: a new session publishes what
|
|
152
|
+
* the machine already knows in its very first snapshot, and the probe that
|
|
153
|
+
* follows only refreshes it.
|
|
154
|
+
*/
|
|
155
|
+
export declare function lastUsageRows(): UsageReading | null;
|
|
156
|
+
export interface ReadUsageOptions {
|
|
157
|
+
binary: string;
|
|
158
|
+
/**
|
|
159
|
+
* Where to run it. Defaults to {@link probeDir} — a directory of the runner's
|
|
160
|
+
* own, never a shared one and never a session's working copy. The reading is
|
|
161
|
+
* identical in every directory (verified), so the choice is made entirely on
|
|
162
|
+
* safety and tidiness: see `probeDir` for why a shared directory is a hole,
|
|
163
|
+
* and note that `-p` leaves a transcript behind, which in a checkout would
|
|
164
|
+
* grow the very local history that makes `/usage` slow.
|
|
165
|
+
*/
|
|
166
|
+
cwd?: string;
|
|
167
|
+
/** Fixed clock for tests; both the throttle and the stamp read it. */
|
|
168
|
+
now?: number;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The machine's plan percentages — measured at most once per {@link USAGE_CACHE_MS}.
|
|
172
|
+
*
|
|
173
|
+
* Two guards, and they answer different questions. The throttle stops us
|
|
174
|
+
* measuring again too SOON; the in-flight join stops us measuring twice AT
|
|
175
|
+
* ONCE. Only the second one fixes the pile-up this change is about: five
|
|
176
|
+
* sessions reaching a turn end together used to start five copies of a
|
|
177
|
+
* 20-second CPU-bound process, each of which made the others slower — the same
|
|
178
|
+
* self-sustaining shape `gitops.ts` writes about for `git_status`.
|
|
179
|
+
*
|
|
180
|
+
* Always resolves to the best reading we have, never to «nothing» just because
|
|
181
|
+
* this attempt failed. A stale true number beats an empty panel — and the
|
|
182
|
+
* reading carries the moment it was TAKEN, so the caller can tell a fresh
|
|
183
|
+
* measurement from the one it already folded in (see {@link measuredAtMs}).
|
|
184
|
+
*/
|
|
185
|
+
export declare function readUsageRows(options: ReadUsageOptions): Promise<UsageReading | null>;
|
|
65
186
|
//# sourceMappingURL=claude-usage.d.ts.map
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
2
4
|
import { log } from '../log.js';
|
|
5
|
+
import { stateDir } from '../paths.js';
|
|
6
|
+
import { lowerPriority } from '../process-priority.js';
|
|
3
7
|
/**
|
|
4
8
|
* How much of the plan is spent — read from the CLI's own `/usage` (#279).
|
|
5
9
|
*
|
|
@@ -191,7 +195,7 @@ export function parseUsageText(text, now = new Date()) {
|
|
|
191
195
|
* reworded CLI must never be able to move a wake-up — therefore still holds.
|
|
192
196
|
* `claude-usage.test.ts` pins it.
|
|
193
197
|
*/
|
|
194
|
-
export function applyUsagePercentages(windows, rows) {
|
|
198
|
+
export function applyUsagePercentages(windows, rows, options = {}) {
|
|
195
199
|
// Keyed by window AND label: a per-model weekly row is its own line, not an
|
|
196
200
|
// overwrite of the plan-wide one.
|
|
197
201
|
const id = (window) => `${window.key}:${window.label ?? ''}`;
|
|
@@ -202,7 +206,19 @@ export function applyUsagePercentages(windows, rows) {
|
|
|
202
206
|
merged.set(key, {
|
|
203
207
|
key: row.key,
|
|
204
208
|
windowMinutes: existing?.windowMinutes ?? (row.key === 'five_hour' ? 300 : 10_080),
|
|
205
|
-
|
|
209
|
+
// `onlyFillGaps` is what a STALE reading gets (#390, QA MAJOR). The
|
|
210
|
+
// reading belongs to the machine and is handed to a session at the end of
|
|
211
|
+
// every turn; meanwhile a `rate_limit_event` may have put a newer
|
|
212
|
+
// percentage into this very window. Overwriting it rolled 95% back to the
|
|
213
|
+
// 60% measured three minutes earlier, once per turn, in both directions
|
|
214
|
+
// (too low inside a window, too high just after it resets). A fresh
|
|
215
|
+
// measurement still wins outright — it is the authority for percentages,
|
|
216
|
+
// which is the whole premise of #279.
|
|
217
|
+
usedPercent: options.onlyFillGaps &&
|
|
218
|
+
existing?.usedPercent !== null &&
|
|
219
|
+
existing?.usedPercent !== undefined
|
|
220
|
+
? existing.usedPercent
|
|
221
|
+
: row.percent,
|
|
206
222
|
// Event first, text second, nothing third. See the docblock above for why
|
|
207
223
|
// this ordering is the whole safety argument.
|
|
208
224
|
resetsAt: existing?.resetsAt ?? row.resetsAt ?? null,
|
|
@@ -213,23 +229,310 @@ export function applyUsagePercentages(windows, rows) {
|
|
|
213
229
|
return [...merged.values()].sort((a, b) => (a.windowMinutes ?? Number.MAX_SAFE_INTEGER) - (b.windowMinutes ?? Number.MAX_SAFE_INTEGER) ||
|
|
214
230
|
(a.label ?? '').localeCompare(b.label ?? ''));
|
|
215
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* How long the probe may run before it is written off (#390).
|
|
234
|
+
*
|
|
235
|
+
* Ninety seconds, and the number is measured rather than chosen. `/usage` was
|
|
236
|
+
* 3.7s when the old ceiling of 20s was set on 2026-08-15; it is not that
|
|
237
|
+
* command any more. Since then the CLI prints «What's contributing to your
|
|
238
|
+
* limits usage?», computed from the machine's OWN session history — 1.4 GB and
|
|
239
|
+
* ~4900 files on the busiest dev server — so the work grows with the history
|
|
240
|
+
* and the load rather than with anything we ask for. It is also CPU-bound, not
|
|
241
|
+
* network-bound: 23.7s wall against 32s of CPU.
|
|
242
|
+
*
|
|
243
|
+
* Measured 2026-09-07 on a six-core box under load 4-15:
|
|
244
|
+
*
|
|
245
|
+
* plain, /tmp 17.7s
|
|
246
|
+
* plain, a project checkout 19.0 20.8 22.2 24.0 27.5s
|
|
247
|
+
* in the reporter's own runs 15.0 15.3 16.5 17.1 20.8 23.7 28.3s
|
|
248
|
+
*
|
|
249
|
+
* So 20s was not a margin, it was the middle of the distribution, and a failed
|
|
250
|
+
* probe was an ordinary event rather than an exception. The cost of waiting is
|
|
251
|
+
* nil — the probe is free (0 tokens, 0 turns, 0 dollars, verified 2026-08-15),
|
|
252
|
+
* runs in the background, and since #390 there is at most ONE of it per
|
|
253
|
+
* machine. The cost of NOT waiting was the panel losing two of its three rows.
|
|
254
|
+
*/
|
|
255
|
+
export const USAGE_PROBE_TIMEOUT_MS = 20_000;
|
|
256
|
+
/**
|
|
257
|
+
* How long a successful reading is believed — the throttle for the whole box.
|
|
258
|
+
*
|
|
259
|
+
* Three minutes, unchanged from when this was a per-session throttle. What
|
|
260
|
+
* changed is the scope: five sessions used to keep five independent clocks and
|
|
261
|
+
* run five copies of a 20-second process, all of them measuring the same
|
|
262
|
+
* account. The windows on screen are five hours and a week; three minutes is
|
|
263
|
+
* already far finer than they move.
|
|
264
|
+
*/
|
|
265
|
+
export const USAGE_CACHE_MS = 3 * 60 * 1_000;
|
|
266
|
+
/**
|
|
267
|
+
* How long we wait before retrying after a probe that produced nothing.
|
|
268
|
+
*
|
|
269
|
+
* Shorter than the success interval, because a machine that has just failed
|
|
270
|
+
* once is the machine most worth asking again — but not so short that a box
|
|
271
|
+
* with no subscription at all (own API key, Bedrock, Vertex) spends its life
|
|
272
|
+
* spawning CLIs. One at a time is guaranteed by the in-flight join below, so
|
|
273
|
+
* the worst case is a single ~90s process a minute apart, not a pile.
|
|
274
|
+
*/
|
|
275
|
+
export const USAGE_FAILURE_COOLDOWN_MS = 60 * 1_000;
|
|
276
|
+
/** At most one line per reason per this long. See `warnProbeFailure`. */
|
|
277
|
+
const WARN_THROTTLE_MS = 15 * 60 * 1_000;
|
|
278
|
+
/**
|
|
279
|
+
* A probe that failed is worth SAYING (#390).
|
|
280
|
+
*
|
|
281
|
+
* It used to be `log.debug` while the runner logs at `info`, so the one thing
|
|
282
|
+
* that explains a panel losing two of its three rows was invisible from
|
|
283
|
+
* outside: the percentages simply stopped, and every other signal said the
|
|
284
|
+
* machine was healthy.
|
|
285
|
+
*
|
|
286
|
+
* Throttled per reason rather than said every time, because the failure that
|
|
287
|
+
* repeats forever is the boring one — a machine with no subscription probes,
|
|
288
|
+
* fails, and would otherwise write a line into journald every minute until the
|
|
289
|
+
* log is useless. The first line of a new failure is the one that carries the
|
|
290
|
+
* information; the hundredth only buries it.
|
|
291
|
+
*/
|
|
292
|
+
const warnedAtMs = new Map();
|
|
293
|
+
function warnProbeFailure(error, timeoutMs, now) {
|
|
294
|
+
const killed = typeof error === 'object' &&
|
|
295
|
+
error !== null &&
|
|
296
|
+
'killed' in error &&
|
|
297
|
+
error.killed === true;
|
|
298
|
+
const reason = killed ? 'timeout' : 'error';
|
|
299
|
+
const last = warnedAtMs.get(reason);
|
|
300
|
+
if (last !== undefined && now - last < WARN_THROTTLE_MS) {
|
|
301
|
+
log.debug('claude: /usage probe failed again', { reason, error: String(error) });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
warnedAtMs.set(reason, now);
|
|
305
|
+
log.warn(killed
|
|
306
|
+
? 'claude: /usage did not finish in time — plan percentages will go stale'
|
|
307
|
+
: 'claude: /usage could not be read — plan percentages will go stale', { reason, timeoutMs, error: String(error) });
|
|
308
|
+
}
|
|
216
309
|
/**
|
|
217
310
|
* Run `/usage` in a throwaway process and return what it printed.
|
|
218
311
|
*
|
|
219
312
|
* `--output-format text` rather than the stream: we want the rendering, and the
|
|
220
313
|
* JSON wrapper would only have to be unwrapped again. Never throws — a probe
|
|
221
314
|
* that fails is a percentage we do not show, not a session that breaks.
|
|
315
|
+
*
|
|
316
|
+
* Three things about HOW it is run, all of them measured (#390):
|
|
317
|
+
*
|
|
318
|
+
* - `--strict-mcp-config` stops the CLI starting the project's MCP servers,
|
|
319
|
+
* which a question about the plan has no use for. Measured in a project
|
|
320
|
+
* checkout: 13.8 and 18.5s with the flag against 20.8 and 22.2s without, and
|
|
321
|
+
* the output is identical — the same three rows. It narrows the spread; it
|
|
322
|
+
* does not remove it, which is why the ceiling above moved as well.
|
|
323
|
+
* - the process is niced like every other thing we spawn for an agent. It used
|
|
324
|
+
* to be the one child of the runner that was neither caged nor renice'd, and
|
|
325
|
+
* at a 90-second ceiling that would have made it a real neighbour on a busy
|
|
326
|
+
* box (#387 is the same lesson from the other side).
|
|
327
|
+
* - stdin is closed. `-p` does not read it, but `agent-versions.ts` was bitten
|
|
328
|
+
* by a CLI that did, and a probe that hangs on an open pipe would now hang
|
|
329
|
+
* for a minute and a half. (Not the cause of the slowness here: measured
|
|
330
|
+
* with and without, the times overlap.)
|
|
331
|
+
*
|
|
332
|
+
* `--bare` was measured too and rejected: it finishes in 2.5s and prints no
|
|
333
|
+
* usage at all, because it reads neither OAuth nor the keychain — on the
|
|
334
|
+
* subscription this whole file exists to report, it has nothing to say.
|
|
222
335
|
*/
|
|
223
|
-
export function probeUsageText(binary, cwd, timeoutMs =
|
|
336
|
+
export function probeUsageText(binary, cwd, timeoutMs = USAGE_PROBE_TIMEOUT_MS) {
|
|
224
337
|
return new Promise((resolve) => {
|
|
225
|
-
|
|
338
|
+
let settled = false;
|
|
339
|
+
const finish = (text) => {
|
|
340
|
+
if (settled)
|
|
341
|
+
return;
|
|
342
|
+
settled = true;
|
|
343
|
+
clearTimeout(watchdog);
|
|
344
|
+
resolve(text);
|
|
345
|
+
};
|
|
346
|
+
const child = execFile(binary, ['--strict-mcp-config', '-p', '/usage', '--output-format', 'text'], {
|
|
347
|
+
cwd,
|
|
348
|
+
timeout: timeoutMs,
|
|
349
|
+
maxBuffer: 1024 * 1024,
|
|
350
|
+
// SIGTERM can be trapped, SIGKILL cannot. A probe that ignored the
|
|
351
|
+
// polite signal would never call this callback, and since the cache
|
|
352
|
+
// above clears its in-flight slot only when the callback runs, ONE such
|
|
353
|
+
// process would stop the machine ever measuring again (QA-390 MINOR).
|
|
354
|
+
// Nothing here needs a graceful exit: it reads and prints.
|
|
355
|
+
killSignal: 'SIGKILL',
|
|
356
|
+
}, (error, stdout) => {
|
|
226
357
|
if (error) {
|
|
227
|
-
|
|
228
|
-
|
|
358
|
+
warnProbeFailure(error, timeoutMs, Date.now());
|
|
359
|
+
finish(null);
|
|
229
360
|
return;
|
|
230
361
|
}
|
|
231
|
-
|
|
362
|
+
finish(stdout);
|
|
232
363
|
});
|
|
364
|
+
// Belt as well as braces: the callback also waits for the output streams to
|
|
365
|
+
// close, and a grandchild holding the pipe can keep them open after the CLI
|
|
366
|
+
// itself is gone. This promise must settle no matter what, because the
|
|
367
|
+
// in-flight slot is released from it.
|
|
368
|
+
const watchdog = setTimeout(() => {
|
|
369
|
+
log.warn('claude: /usage probe left its pipes open — giving up on it', { timeoutMs });
|
|
370
|
+
finish(null);
|
|
371
|
+
}, timeoutMs + 15_000);
|
|
372
|
+
watchdog.unref?.();
|
|
373
|
+
child.stdin?.end();
|
|
374
|
+
lowerPriority(child.pid);
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Where the probe runs — a directory only this runner can write to.
|
|
379
|
+
*
|
|
380
|
+
* NOT a shared directory, and this is a security property rather than a
|
|
381
|
+
* preference (QA-390 BLOCKER). For Claude Code the working directory is the
|
|
382
|
+
* root of a project's SETTINGS: it reads `<cwd>/.claude/settings.json`, and
|
|
383
|
+
* that file may define hooks — commands the CLI then runs. `-p` asks for no
|
|
384
|
+
* trust confirmation at all, verified live. The runner is root by default
|
|
385
|
+
* (`--user <name>` is opt-in), so a world-writable `cwd` means any local user
|
|
386
|
+
* can drop `/tmp/.claude/settings.json` and have it executed as root within
|
|
387
|
+
* three minutes. Demonstrated end to end during review: a non-root user's hook
|
|
388
|
+
* wrote `uid=0(root)`.
|
|
389
|
+
*
|
|
390
|
+
* Before this ticket the probe inherited the session's own working copy, which
|
|
391
|
+
* is inside the project's trust boundary; `os.tmpdir()` was the accident that
|
|
392
|
+
* this comment exists to prevent someone re-introducing.
|
|
393
|
+
*
|
|
394
|
+
* Second, smaller reason for a directory of our own: with `cwd` on a busy
|
|
395
|
+
* `/tmp` the CLI walks it (`rg --files --hidden`) — 162,595 files and 1.16s on
|
|
396
|
+
* this machine, spent for nothing.
|
|
397
|
+
*/
|
|
398
|
+
function probeDir() {
|
|
399
|
+
const dir = path.join(stateDir(), 'usage-probe');
|
|
400
|
+
try {
|
|
401
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
402
|
+
return dir;
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
// No private directory, no probe. Falling back to a shared one is exactly
|
|
406
|
+
// the hole above, and a missing percentage is a far smaller loss.
|
|
407
|
+
log.warn('claude: no private directory for the /usage probe — skipping it', {
|
|
408
|
+
dir,
|
|
409
|
+
error: String(error),
|
|
410
|
+
});
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* The last reading that SUCCEEDED. A failed probe never clears it.
|
|
416
|
+
*
|
|
417
|
+
* This is the whole difference between «we could not measure just now» and «we
|
|
418
|
+
* know nothing»: the first must keep showing the last true numbers, and only
|
|
419
|
+
* the second is allowed to leave the panel empty.
|
|
420
|
+
*/
|
|
421
|
+
let lastRows = null;
|
|
422
|
+
/**
|
|
423
|
+
* When {@link lastRows} was MEASURED — not when it was last handed out.
|
|
424
|
+
*
|
|
425
|
+
* Load-bearing rather than diagnostic (QA-390 MAJOR). A reading may be up to
|
|
426
|
+
* {@link USAGE_CACHE_MS} old, and it is handed to a session at the end of every
|
|
427
|
+
* turn. Meanwhile a `rate_limit_event` can have put a NEWER percentage into
|
|
428
|
+
* that session's window, and `applyUsagePercentages` overwrites percentages
|
|
429
|
+
* without asking how old they are — so an unqualified reading rolled 95% back
|
|
430
|
+
* to the 60% the machine measured three minutes ago, on every turn. The stamp
|
|
431
|
+
* is what lets the adapter tell «this is news» from «this is what you already
|
|
432
|
+
* folded in».
|
|
433
|
+
*/
|
|
434
|
+
let measuredAtMs = 0;
|
|
435
|
+
/** When the last attempt FINISHED, successful or not — the throttle's clock. */
|
|
436
|
+
let attemptedAtMs = 0;
|
|
437
|
+
/** Did that attempt produce rows? Decides which of the two intervals applies. */
|
|
438
|
+
let lastAttemptOk = false;
|
|
439
|
+
/** The probe currently running, so overlapping callers share it. */
|
|
440
|
+
let inFlight = null;
|
|
441
|
+
/**
|
|
442
|
+
* The probe the cache runs. Replaced only by tests.
|
|
443
|
+
*
|
|
444
|
+
* A module-level seat rather than an argument, because the caller that needs it
|
|
445
|
+
* is a session buried inside the adapter and threading a test-only parameter
|
|
446
|
+
* through 126 constructions of `ClaudeAdapter` would put it on the product's
|
|
447
|
+
* own seam. It has to exist at all for a sharper reason than tidiness: until
|
|
448
|
+
* #390 the only thing keeping the adapter's own test file from spawning a real
|
|
449
|
+
* `claude` on this machine — which also hosts production — was that its fake
|
|
450
|
+
* `cwd` did not exist, and this change stopped the probe using the session's
|
|
451
|
+
* `cwd` at all.
|
|
452
|
+
*/
|
|
453
|
+
let probeRunner = (binary, cwd) => probeUsageText(binary, cwd);
|
|
454
|
+
export function setUsageProbeRunner(run) {
|
|
455
|
+
probeRunner = run ?? ((binary, cwd) => probeUsageText(binary, cwd));
|
|
456
|
+
}
|
|
457
|
+
/** Forget everything measured. For tests, and for a re-login that changes account. */
|
|
458
|
+
export function invalidateUsageCache() {
|
|
459
|
+
lastRows = null;
|
|
460
|
+
measuredAtMs = 0;
|
|
461
|
+
attemptedAtMs = 0;
|
|
462
|
+
lastAttemptOk = false;
|
|
463
|
+
inFlight = null;
|
|
464
|
+
warnedAtMs.clear();
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* What the machine last read, WITHOUT starting a measurement.
|
|
468
|
+
*
|
|
469
|
+
* The reason this exists is the defect #390 was actually about. A session's
|
|
470
|
+
* window map starts empty, and its first `rate_limit_event` arrives 12-28
|
|
471
|
+
* seconds before any probe of its own could answer. That event describes ONE
|
|
472
|
+
* window, the snapshot built from it therefore has one row, and the API stores
|
|
473
|
+
* the newest snapshot as the state of the whole machine — so a session that had
|
|
474
|
+
* only just started would overwrite three good rows with one, every time, on a
|
|
475
|
+
* machine with a single session as much as on a busy one.
|
|
476
|
+
*
|
|
477
|
+
* Seeding from the box's last reading closes that: a new session publishes what
|
|
478
|
+
* the machine already knows in its very first snapshot, and the probe that
|
|
479
|
+
* follows only refreshes it.
|
|
480
|
+
*/
|
|
481
|
+
export function lastUsageRows() {
|
|
482
|
+
return lastRows ? { rows: lastRows, measuredAtMs } : null;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* The machine's plan percentages — measured at most once per {@link USAGE_CACHE_MS}.
|
|
486
|
+
*
|
|
487
|
+
* Two guards, and they answer different questions. The throttle stops us
|
|
488
|
+
* measuring again too SOON; the in-flight join stops us measuring twice AT
|
|
489
|
+
* ONCE. Only the second one fixes the pile-up this change is about: five
|
|
490
|
+
* sessions reaching a turn end together used to start five copies of a
|
|
491
|
+
* 20-second CPU-bound process, each of which made the others slower — the same
|
|
492
|
+
* self-sustaining shape `gitops.ts` writes about for `git_status`.
|
|
493
|
+
*
|
|
494
|
+
* Always resolves to the best reading we have, never to «nothing» just because
|
|
495
|
+
* this attempt failed. A stale true number beats an empty panel — and the
|
|
496
|
+
* reading carries the moment it was TAKEN, so the caller can tell a fresh
|
|
497
|
+
* measurement from the one it already folded in (see {@link measuredAtMs}).
|
|
498
|
+
*/
|
|
499
|
+
export function readUsageRows(options) {
|
|
500
|
+
const now = options.now ?? Date.now();
|
|
501
|
+
const interval = lastAttemptOk ? USAGE_CACHE_MS : USAGE_FAILURE_COOLDOWN_MS;
|
|
502
|
+
if (attemptedAtMs !== 0 && now - attemptedAtMs < interval) {
|
|
503
|
+
return Promise.resolve(lastUsageRows());
|
|
504
|
+
}
|
|
505
|
+
if (inFlight)
|
|
506
|
+
return inFlight;
|
|
507
|
+
const cwd = options.cwd ?? probeDir();
|
|
508
|
+
if (cwd === null)
|
|
509
|
+
return Promise.resolve(lastUsageRows());
|
|
510
|
+
const pending = probeRunner(options.binary, cwd)
|
|
511
|
+
.then((text) => {
|
|
512
|
+
const rows = text ? parseUsageText(text) : [];
|
|
513
|
+
attemptedAtMs = options.now ?? Date.now();
|
|
514
|
+
lastAttemptOk = rows.length > 0;
|
|
515
|
+
// Zero rows is not an answer worth keeping: it means the wording moved or
|
|
516
|
+
// the CLI said nothing, and the previous true reading is still the best
|
|
517
|
+
// thing we have to show.
|
|
518
|
+
if (rows.length > 0) {
|
|
519
|
+
lastRows = rows;
|
|
520
|
+
measuredAtMs = attemptedAtMs;
|
|
521
|
+
}
|
|
522
|
+
return lastUsageRows();
|
|
523
|
+
})
|
|
524
|
+
.catch((error) => {
|
|
525
|
+
// `probeUsageText` resolves rather than rejects, so this is a seat that
|
|
526
|
+
// threw or a parser that did — either way the reading is unchanged.
|
|
527
|
+
log.debug('claude: /usage probe threw', { error: String(error) });
|
|
528
|
+
attemptedAtMs = options.now ?? Date.now();
|
|
529
|
+
lastAttemptOk = false;
|
|
530
|
+
return lastUsageRows();
|
|
531
|
+
})
|
|
532
|
+
.finally(() => {
|
|
533
|
+
inFlight = null;
|
|
233
534
|
});
|
|
535
|
+
inFlight = pending;
|
|
536
|
+
return pending;
|
|
234
537
|
}
|
|
235
538
|
//# sourceMappingURL=claude-usage.js.map
|
package/dist/adapters/claude.js
CHANGED
|
@@ -10,10 +10,8 @@ import { cageSpawn, memoryDeathSentence, releaseSessionScope } from '../session-
|
|
|
10
10
|
import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
|
|
11
11
|
import { availableModes, cardDescription, DIRECT_BRANCH_RULE, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
|
|
12
12
|
import { percentFromUtilization, RATE_WINDOW_MINUTES, rateWindowKey } from './rate-limits.js';
|
|
13
|
-
import { applyUsagePercentages,
|
|
13
|
+
import { applyUsagePercentages, lastUsageRows, readUsageRows } from './claude-usage.js';
|
|
14
14
|
import { claudeExecutableOption, sessionClaudePath } from '../agent-binary.js';
|
|
15
|
-
/** How often `/usage` may be read. Free, but still a process. */
|
|
16
|
-
const USAGE_PROBE_INTERVAL_MS = 3 * 60 * 1000;
|
|
17
15
|
/** Same 2KB the SDK keeps: enough for the CLI's last words, not a log sink. */
|
|
18
16
|
const STDERR_TAIL_LIMIT = 2048;
|
|
19
17
|
import { answerSummary, answerValue, discussMessage, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
|
|
@@ -832,8 +830,6 @@ class ClaudeSession {
|
|
|
832
830
|
rateLimitsAvailable = false;
|
|
833
831
|
/** A refusal seen since the last turn ended, waiting to be reported with it. */
|
|
834
832
|
limitBlockPending = false;
|
|
835
|
-
/** When `/usage` was last read, so a busy session does not spawn a process a second. */
|
|
836
|
-
usageProbedAt = 0;
|
|
837
833
|
/**
|
|
838
834
|
* The one key the window map is written under, from every source.
|
|
839
835
|
*
|
|
@@ -870,41 +866,85 @@ class ClaudeSession {
|
|
|
870
866
|
return providerName === 'five_hour' || providerName === 'seven_day';
|
|
871
867
|
}
|
|
872
868
|
/**
|
|
873
|
-
* Read the percentages out of the CLI's own `/usage` (#279).
|
|
869
|
+
* Read the percentages out of the CLI's own `/usage` (#279, #390).
|
|
874
870
|
*
|
|
875
|
-
* Fire-and-forget
|
|
876
|
-
*
|
|
877
|
-
*
|
|
878
|
-
*
|
|
871
|
+
* Fire-and-forget and free — a local command, not a request (probe
|
|
872
|
+
* 2026-08-15: zero tokens, zero turns, zero dollars). The live event gives us
|
|
873
|
+
* the clock and almost never the number; this gives the number and never
|
|
874
|
+
* touches the clock.
|
|
875
|
+
*
|
|
876
|
+
* Two steps, and the FIRST one is the fix for #390. The reading belongs to
|
|
877
|
+
* the machine, not to this session — one account, one set of numbers — so
|
|
878
|
+
* before anything is measured we take what the box already knows. Without it
|
|
879
|
+
* a session that started thirty seconds ago publishes a snapshot built from
|
|
880
|
+
* its single `rate_limit_event`, and because the API stores the newest
|
|
881
|
+
* snapshot as the state of the whole server, that one row replaced three good
|
|
882
|
+
* ones. Seeding costs nothing, needs no binary, and is why raising the
|
|
883
|
+
* timeout alone would not have fixed the report.
|
|
884
|
+
*
|
|
885
|
+
* The second step refreshes it. The throttle, the ceiling and the guarantee
|
|
886
|
+
* that only one probe runs per machine all live in `readUsageRows`.
|
|
879
887
|
*/
|
|
880
888
|
refreshUsage() {
|
|
881
889
|
if (this.stopped)
|
|
882
890
|
return;
|
|
883
|
-
if (this.
|
|
884
|
-
|
|
885
|
-
}
|
|
891
|
+
if (this.absorbUsageReading(lastUsageRows()))
|
|
892
|
+
this.emitRateLimits();
|
|
886
893
|
const binary = sessionClaudePath();
|
|
887
894
|
if (!binary)
|
|
888
895
|
return;
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
.
|
|
892
|
-
if (!text || this.stopped)
|
|
896
|
+
void readUsageRows({ binary })
|
|
897
|
+
.then((reading) => {
|
|
898
|
+
if (this.stopped)
|
|
893
899
|
return;
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
return;
|
|
897
|
-
// A percentage proves a plan as surely as the event does.
|
|
898
|
-
this.rateLimitsAvailable = true;
|
|
899
|
-
const merged = applyUsagePercentages([...this.rateLimitWindows.values()], rows);
|
|
900
|
-
this.rateLimitWindows.clear();
|
|
901
|
-
for (const window of merged) {
|
|
902
|
-
this.rateLimitWindows.set(ClaudeSession.windowKey(window.key, window.label), window);
|
|
903
|
-
}
|
|
904
|
-
this.emitRateLimits();
|
|
900
|
+
if (this.absorbUsageReading(reading))
|
|
901
|
+
this.emitRateLimits();
|
|
905
902
|
})
|
|
906
903
|
.catch((error) => log.debug('claude: usage probe failed', { error: String(error) }));
|
|
907
904
|
}
|
|
905
|
+
/** The measurement already folded in, so an older one cannot undo a newer event. */
|
|
906
|
+
usageAppliedAtMs = 0;
|
|
907
|
+
/**
|
|
908
|
+
* Fold a `/usage` reading into this session's window map.
|
|
909
|
+
*
|
|
910
|
+
* The reading belongs to the MACHINE and arrives at both ends of every turn,
|
|
911
|
+
* so most of the time it is something this session has already applied. That
|
|
912
|
+
* distinction is load-bearing rather than an optimisation (found by review):
|
|
913
|
+
* between two turns a `rate_limit_event` can put a NEWER percentage into a
|
|
914
|
+
* window, and re-applying a three-minute-old measurement over it rolled 95%
|
|
915
|
+
* back to 60% — every turn, and in both directions, because usage only climbs
|
|
916
|
+
* inside a window and drops to nothing when it resets. The warning badge is
|
|
917
|
+
* driven off that number.
|
|
918
|
+
*
|
|
919
|
+
* So a reading we have not seen before is applied outright — it is the
|
|
920
|
+
* authority for percentages, which is #279's whole premise — and one we have
|
|
921
|
+
* only FILLS what is still missing.
|
|
922
|
+
*
|
|
923
|
+
* Returns whether anything actually moved, so a repeat costs no frame. The
|
|
924
|
+
* supervisor's level gate would drop the duplicate anyway, but the cheapest
|
|
925
|
+
* frame is the one never built.
|
|
926
|
+
*/
|
|
927
|
+
absorbUsageReading(reading) {
|
|
928
|
+
if (!reading || reading.rows.length === 0)
|
|
929
|
+
return false;
|
|
930
|
+
const fresh = reading.measuredAtMs > this.usageAppliedAtMs;
|
|
931
|
+
const before = this.rateLimitsFingerprint();
|
|
932
|
+
// A percentage proves a plan as surely as the event does.
|
|
933
|
+
this.rateLimitsAvailable = true;
|
|
934
|
+
const merged = applyUsagePercentages([...this.rateLimitWindows.values()], reading.rows, {
|
|
935
|
+
onlyFillGaps: !fresh,
|
|
936
|
+
});
|
|
937
|
+
this.rateLimitWindows.clear();
|
|
938
|
+
for (const window of merged) {
|
|
939
|
+
this.rateLimitWindows.set(ClaudeSession.windowKey(window.key, window.label), window);
|
|
940
|
+
}
|
|
941
|
+
if (fresh)
|
|
942
|
+
this.usageAppliedAtMs = reading.measuredAtMs;
|
|
943
|
+
return this.rateLimitsFingerprint() !== before;
|
|
944
|
+
}
|
|
945
|
+
rateLimitsFingerprint() {
|
|
946
|
+
return JSON.stringify([this.rateLimitsAvailable, [...this.rateLimitWindows.values()]]);
|
|
947
|
+
}
|
|
908
948
|
/** Read the flag and clear it: one refusal marks exactly one turn end. */
|
|
909
949
|
consumeLimitBlock() {
|
|
910
950
|
const blocked = this.limitBlockPending;
|
|
@@ -28,6 +28,21 @@ export declare function mirrorOptions(questions: AgentQuestion[]): string[];
|
|
|
28
28
|
export declare function answerValue(answer: AgentQuestionAnswer): string;
|
|
29
29
|
/** One short line for the resolved card: «Postgres · Auth, Search». */
|
|
30
30
|
export declare function answerSummary(answers: AgentQuestionAnswer[]): string;
|
|
31
|
+
/**
|
|
32
|
+
* The same answer as something to SAY, when the card that asked is gone (#401).
|
|
33
|
+
*
|
|
34
|
+
* Not `answerSummary`: that one is a label for a resolved card, so it is
|
|
35
|
+
* clipped to an option's width and drops the notes. This is the person's reply
|
|
36
|
+
* being handed to the agent as an ordinary message, and nothing they typed may
|
|
37
|
+
* be shortened away on the path. The questions themselves cannot be named —
|
|
38
|
+
* their text lived in the process that asked and is gone with it — so the
|
|
39
|
+
* answer is given as the words it was made of, which is what the person
|
|
40
|
+
* actually chose.
|
|
41
|
+
*
|
|
42
|
+
* Empty when there is nothing in it: the caller uses that to tell «the reply
|
|
43
|
+
* was lost» from «there was no reply to lose».
|
|
44
|
+
*/
|
|
45
|
+
export declare function answersAsMessage(answers: AgentQuestionAnswer[]): string;
|
|
31
46
|
/**
|
|
32
47
|
* The «discuss instead» exit.
|
|
33
48
|
*
|