@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.
@@ -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[]): AgentRateLimitWindow[];
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
- usedPercent: row.percent,
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 = 20_000) {
336
+ export function probeUsageText(binary, cwd, timeoutMs = USAGE_PROBE_TIMEOUT_MS) {
224
337
  return new Promise((resolve) => {
225
- execFile(binary, ['-p', '/usage', '--output-format', 'text'], { cwd, timeout: timeoutMs, maxBuffer: 1024 * 1024 }, (error, stdout) => {
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
- log.debug('claude: /usage probe failed', { error: String(error) });
228
- resolve(null);
358
+ warnProbeFailure(error, timeoutMs, Date.now());
359
+ finish(null);
229
360
  return;
230
361
  }
231
- resolve(stdout);
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
@@ -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, parseUsageText, probeUsageText } from './claude-usage.js';
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, throttled, and free — a local command, not a request
876
- * (probe 2026-08-15: zero tokens, zero turns, zero dollars). The live event
877
- * gives us the clock and almost never the number; this gives the number and
878
- * never touches the clock.
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.usageProbedAt !== 0 && Date.now() - this.usageProbedAt < USAGE_PROBE_INTERVAL_MS) {
884
- return;
885
- }
891
+ if (this.absorbUsageReading(lastUsageRows()))
892
+ this.emitRateLimits();
886
893
  const binary = sessionClaudePath();
887
894
  if (!binary)
888
895
  return;
889
- this.usageProbedAt = Date.now();
890
- void probeUsageText(binary, this.spec.cwd)
891
- .then((text) => {
892
- if (!text || this.stopped)
896
+ void readUsageRows({ binary })
897
+ .then((reading) => {
898
+ if (this.stopped)
893
899
  return;
894
- const rows = parseUsageText(text);
895
- if (rows.length === 0)
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
  *