@bridge4dev/runner 0.65.0 → 0.66.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.
@@ -0,0 +1,44 @@
1
+ import type { AccountLoginCodeResult, AccountLoginTarget, LoginStartResult } from './auth-relay.js';
2
+ /**
3
+ * Several logins of one agent, on the wire (#422 S2, plan §8).
4
+ *
5
+ * Five commands, each a thin door over what `claude-homes.ts` already does on
6
+ * the machine. New NAMES rather than new arguments of `login_start`: an older
7
+ * runner silently ignores an argument it does not know and would sign in over
8
+ * the machine login while the API believed a saved account was being added; an
9
+ * unknown name it answers «Unknown command» at once.
10
+ *
11
+ * The API refuses to send any of these to a runner whose `capabilities.commands`
12
+ * does not name them, and `capabilities.accounts` says for which agents they
13
+ * work. Both lists come from here, so the announcement and the handler cannot
14
+ * disagree.
15
+ *
16
+ * The shapes are a hand-written copy of `@devbridge/shared` `agent-accounts.ts`
17
+ * (this package cannot import it); `account-commands.test.ts` parses real
18
+ * answers through the shared schemas.
19
+ */
20
+ export declare const ACCOUNT_COMMANDS: readonly ["agent_accounts", "agent_account_login_start", "agent_account_login_code", "agent_account_activate", "agent_account_forget"];
21
+ export type AccountCommand = (typeof ACCOUNT_COMMANDS)[number];
22
+ /**
23
+ * The agents this runner keeps several logins for (`capabilities.accounts`,
24
+ * R11). Codex joins in S4; until then its account commands are refused with a
25
+ * sentence rather than half-served.
26
+ */
27
+ export declare const ACCOUNT_AGENTS: readonly ["claude"];
28
+ /** What `supervisor.ts` sends back as `command_result`, minus the envelope. */
29
+ export type AccountCommandReply = {
30
+ ok: true;
31
+ result: unknown;
32
+ } | {
33
+ ok: false;
34
+ error: string;
35
+ result?: unknown;
36
+ };
37
+ /** The two sign-in steps the commands drive – `AuthRelay`, or a test double. */
38
+ export interface AccountSignIn {
39
+ startAccountLogin(target: AccountLoginTarget): Promise<LoginStartResult>;
40
+ submitAccountCode(code: string): Promise<AccountLoginCodeResult>;
41
+ }
42
+ export declare function isAccountCommand(name: string): name is AccountCommand;
43
+ export declare function runAccountCommand(name: AccountCommand, args: Record<string, unknown>, signIn: AccountSignIn): Promise<AccountCommandReply>;
44
+ //# sourceMappingURL=account-commands.d.ts.map
@@ -0,0 +1,99 @@
1
+ import { AccountError, forgetAccount, isAccountId, listAccounts, setActiveAccount, } from './claude-homes.js';
2
+ import { MACHINE_ACCOUNT_ID } from './login-marks.js';
3
+ import { maskString } from './policy.js';
4
+ /**
5
+ * Several logins of one agent, on the wire (#422 S2, plan §8).
6
+ *
7
+ * Five commands, each a thin door over what `claude-homes.ts` already does on
8
+ * the machine. New NAMES rather than new arguments of `login_start`: an older
9
+ * runner silently ignores an argument it does not know and would sign in over
10
+ * the machine login while the API believed a saved account was being added; an
11
+ * unknown name it answers «Unknown command» at once.
12
+ *
13
+ * The API refuses to send any of these to a runner whose `capabilities.commands`
14
+ * does not name them, and `capabilities.accounts` says for which agents they
15
+ * work. Both lists come from here, so the announcement and the handler cannot
16
+ * disagree.
17
+ *
18
+ * The shapes are a hand-written copy of `@devbridge/shared` `agent-accounts.ts`
19
+ * (this package cannot import it); `account-commands.test.ts` parses real
20
+ * answers through the shared schemas.
21
+ */
22
+ export const ACCOUNT_COMMANDS = [
23
+ 'agent_accounts',
24
+ 'agent_account_login_start',
25
+ 'agent_account_login_code',
26
+ 'agent_account_activate',
27
+ 'agent_account_forget',
28
+ ];
29
+ /**
30
+ * The agents this runner keeps several logins for (`capabilities.accounts`,
31
+ * R11). Codex joins in S4; until then its account commands are refused with a
32
+ * sentence rather than half-served.
33
+ */
34
+ export const ACCOUNT_AGENTS = ['claude'];
35
+ export function isAccountCommand(name) {
36
+ return ACCOUNT_COMMANDS.includes(name);
37
+ }
38
+ function refusal(error) {
39
+ // `AccountError` is written for a person and carries no path (S1); anything
40
+ // else is masked like every other command error on this wire.
41
+ const message = error instanceof AccountError
42
+ ? error.message
43
+ : maskString(String(error instanceof Error ? error.message : error));
44
+ return { ok: false, error: message.slice(0, 500) };
45
+ }
46
+ function accountIdArg(value) {
47
+ return value === MACHINE_ACCOUNT_ID || isAccountId(value) ? value : null;
48
+ }
49
+ export async function runAccountCommand(name, args, signIn) {
50
+ const agent = args['agent'];
51
+ if (agent === 'codex') {
52
+ return { ok: false, error: 'several Codex logins are not supported by this runner yet' };
53
+ }
54
+ if (agent !== 'claude')
55
+ return { ok: false, error: 'agent must be claude or codex' };
56
+ try {
57
+ switch (name) {
58
+ case 'agent_accounts':
59
+ // The one place `claude auth status` runs on request (R14 a) – home by
60
+ // home, only where the known identity is stale.
61
+ return { ok: true, result: await listAccounts({ probeIdentity: true }) };
62
+ case 'agent_account_login_start': {
63
+ const target = args['target'];
64
+ if (target !== 'machine' && target !== 'saved') {
65
+ return { ok: false, error: 'target must be machine or saved' };
66
+ }
67
+ return { ok: true, result: await signIn.startAccountLogin(target) };
68
+ }
69
+ case 'agent_account_login_code': {
70
+ const code = args['code'];
71
+ if (typeof code !== 'string' || !code.trim()) {
72
+ return { ok: false, error: 'code is required' };
73
+ }
74
+ const result = await signIn.submitAccountCode(code);
75
+ return result.ok ? { ok: true, result } : { ok: false, error: result.detail, result };
76
+ }
77
+ case 'agent_account_activate': {
78
+ const id = accountIdArg(args['id']);
79
+ if (!id)
80
+ return { ok: false, error: 'no such account on this server' };
81
+ // The usage cache is keyed by subscription, so a switch leaves it alone:
82
+ // the new row's figures are already its own (R16, §8).
83
+ return { ok: true, result: { active: setActiveAccount(id) } };
84
+ }
85
+ case 'agent_account_forget': {
86
+ const id = accountIdArg(args['id']);
87
+ if (!id)
88
+ return { ok: false, error: 'no such account on this server' };
89
+ // Drops the forgotten row's usage slot itself (`forgetAccount`).
90
+ const { active } = forgetAccount(id);
91
+ return { ok: true, result: { ok: true, active } };
92
+ }
93
+ }
94
+ }
95
+ catch (error) {
96
+ return refusal(error);
97
+ }
98
+ }
99
+ //# sourceMappingURL=account-commands.js.map
@@ -59,7 +59,9 @@ export declare function applyUsagePercentages(windows: AgentRateLimitWindow[], r
59
59
  /**
60
60
  * How long the probe may run before it is written off (#390).
61
61
  *
62
- * Ninety seconds, and the number is measured rather than chosen. `/usage` was
62
+ * Ninety seconds, and the number is measured rather than chosen. (The docblock
63
+ * said ninety from #390 on while the constant still read 20 000, and about half
64
+ * the probes died at the ceiling – owner's decision D5 of #422 closes that.) `/usage` was
63
65
  * 3.7s when the old ceiling of 20s was set on 2026-08-15; it is not that
64
66
  * command any more. Since then the CLI prints «What's contributing to your
65
67
  * limits usage?», computed from the machine's OWN session history — 1.4 GB and
@@ -79,7 +81,7 @@ export declare function applyUsagePercentages(windows: AgentRateLimitWindow[], r
79
81
  * runs in the background, and since #390 there is at most ONE of it per
80
82
  * machine. The cost of NOT waiting was the panel losing two of its three rows.
81
83
  */
82
- export declare const USAGE_PROBE_TIMEOUT_MS = 20000;
84
+ export declare const USAGE_PROBE_TIMEOUT_MS = 90000;
83
85
  /**
84
86
  * How long a successful reading is believed — the throttle for the whole box.
85
87
  *
@@ -100,7 +102,7 @@ export declare const USAGE_CACHE_MS: number;
100
102
  * the worst case is a single ~90s process a minute apart, not a pile.
101
103
  */
102
104
  export declare const USAGE_FAILURE_COOLDOWN_MS: number;
103
- export type UsageProbeRunner = (binary: string, cwd: string) => Promise<string | null>;
105
+ export type UsageProbeRunner = (binary: string, cwd: string, env?: NodeJS.ProcessEnv) => Promise<string | null>;
104
106
  /**
105
107
  * Run `/usage` in a throwaway process and return what it printed.
106
108
  *
@@ -128,17 +130,66 @@ export type UsageProbeRunner = (binary: string, cwd: string) => Promise<string |
128
130
  * usage at all, because it reads neither OAuth nor the keychain — on the
129
131
  * subscription this whole file exists to report, it has nothing to say.
130
132
  */
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 declare function probeUsageText(binary: string, cwd: string, timeoutMs?: number, env?: NodeJS.ProcessEnv): Promise<string | null>;
134
+ /**
135
+ * Whose numbers a reading is (#422 R16).
136
+ *
137
+ * The machine used to have one account, so «the machine's reading» was enough
138
+ * of a name. With several Claude homes it is not: a session measures under the
139
+ * home it STARTED with, and the cache is kept per subscription – `orgId` when
140
+ * the home's identity is known, the account id until then.
141
+ */
142
+ export interface UsageAccount {
143
+ id: string;
144
+ /** `CLAUDE_CONFIG_DIR` of a saved account; null for the machine login. */
145
+ home: string | null;
146
+ orgId?: string;
147
+ }
148
+ /** The machine login, for callers that predate #422 and name no account. */
149
+ export declare const MACHINE_USAGE_ACCOUNT: UsageAccount;
150
+ /** The cache slot a reading of this account lives in. */
151
+ export declare function usageCacheKey(account: {
152
+ id: string;
153
+ orgId?: string;
154
+ }): string;
155
+ /** Who a reading belongs to, read off the home right after it was measured. */
156
+ export interface UsageSignature {
157
+ accountUuid?: string;
158
+ orgId?: string;
159
+ }
160
+ /** A reading of one account's plan, with the moment it was taken. */
133
161
  export interface UsageReading {
134
162
  rows: UsageRow[];
135
163
  measuredAtMs: number;
164
+ /** The account the CLI measured – from the home's own `.claude.json`. */
165
+ accountUuid?: string;
166
+ /** The subscription the numbers belong to. */
167
+ orgId?: string;
136
168
  }
137
169
  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
170
  /**
141
- * What the machine last read, WITHOUT starting a measurement.
171
+ * Forget what was measured for one account (#422 R16).
172
+ *
173
+ * `forget` drops the forgotten row's slot, a replaced login drops the replaced
174
+ * row's, a new sign-in to the machine drops the machine's; `activate` drops
175
+ * nothing – the keys already keep accounts apart.
176
+ *
177
+ * Never cancels a probe that is running. Nulling «a probe is running» used to
178
+ * let the very next caller start a SECOND process beside the first, which then
179
+ * finished and wrote its figures under the account that had just been switched
180
+ * to. The running probe now finishes into the slot it was started for; a
181
+ * dropped slot is simply not where anybody reads any more, and the next probe
182
+ * waits in the queue behind it.
183
+ *
184
+ * Called with no account it forgets EVERYTHING, the queue included – a clean
185
+ * slate for a test. The product always names an account.
186
+ */
187
+ export declare function invalidateUsageCache(account?: {
188
+ id: string;
189
+ orgId?: string;
190
+ }): void;
191
+ /**
192
+ * What the machine last read for this account, WITHOUT starting a measurement.
142
193
  *
143
194
  * The reason this exists is the defect #390 was actually about. A session's
144
195
  * window map starts empty, and its first `rate_limit_event` arrives 12-28
@@ -152,7 +203,10 @@ export declare function invalidateUsageCache(): void;
152
203
  * the machine already knows in its very first snapshot, and the probe that
153
204
  * follows only refreshes it.
154
205
  */
155
- export declare function lastUsageRows(): UsageReading | null;
206
+ export declare function lastUsageRows(account?: {
207
+ id: string;
208
+ orgId?: string;
209
+ }): UsageReading | null;
156
210
  export interface ReadUsageOptions {
157
211
  binary: string;
158
212
  /**
@@ -166,21 +220,31 @@ export interface ReadUsageOptions {
166
220
  cwd?: string;
167
221
  /** Fixed clock for tests; both the throttle and the stamp read it. */
168
222
  now?: number;
223
+ /** Whose plan to measure. Absent: the machine login, as before #422. */
224
+ account?: UsageAccount;
225
+ /** The probe's environment – the account's home, without the operator token. */
226
+ env?: NodeJS.ProcessEnv;
227
+ /**
228
+ * Read who the home says it is, right after the probe. A reading whose
229
+ * subscription is not the account's own is thrown away rather than filed under
230
+ * the wrong name – the exact wrong statement #380 was about.
231
+ */
232
+ sign?: () => UsageSignature;
169
233
  }
170
234
  /**
171
- * The machine's plan percentages — measured at most once per {@link USAGE_CACHE_MS}.
235
+ * An account's plan percentages — measured at most once per {@link USAGE_CACHE_MS}.
172
236
  *
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
237
+ * Three guards, and they answer different questions. The throttle stops us
238
+ * measuring the same account again too SOON; the per-account join stops us
239
+ * measuring it twice AT ONCE; the queue stops two accounts measuring at once.
240
+ * Five sessions reaching a turn end together used to start five copies of a
177
241
  * 20-second CPU-bound process, each of which made the others slower — the same
178
242
  * self-sustaining shape `gitops.ts` writes about for `git_status`.
179
243
  *
180
244
  * Always resolves to the best reading we have, never to «nothing» just because
181
245
  * this attempt failed. A stale true number beats an empty panel — and the
182
246
  * 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}).
247
+ * measurement from the one it already folded in.
184
248
  */
185
249
  export declare function readUsageRows(options: ReadUsageOptions): Promise<UsageReading | null>;
186
250
  //# sourceMappingURL=claude-usage.d.ts.map
@@ -232,7 +232,9 @@ export function applyUsagePercentages(windows, rows, options = {}) {
232
232
  /**
233
233
  * How long the probe may run before it is written off (#390).
234
234
  *
235
- * Ninety seconds, and the number is measured rather than chosen. `/usage` was
235
+ * Ninety seconds, and the number is measured rather than chosen. (The docblock
236
+ * said ninety from #390 on while the constant still read 20 000, and about half
237
+ * the probes died at the ceiling – owner's decision D5 of #422 closes that.) `/usage` was
236
238
  * 3.7s when the old ceiling of 20s was set on 2026-08-15; it is not that
237
239
  * command any more. Since then the CLI prints «What's contributing to your
238
240
  * limits usage?», computed from the machine's OWN session history — 1.4 GB and
@@ -252,7 +254,7 @@ export function applyUsagePercentages(windows, rows, options = {}) {
252
254
  * runs in the background, and since #390 there is at most ONE of it per
253
255
  * machine. The cost of NOT waiting was the panel losing two of its three rows.
254
256
  */
255
- export const USAGE_PROBE_TIMEOUT_MS = 20_000;
257
+ export const USAGE_PROBE_TIMEOUT_MS = 90_000;
256
258
  /**
257
259
  * How long a successful reading is believed — the throttle for the whole box.
258
260
  *
@@ -333,7 +335,7 @@ function warnProbeFailure(error, timeoutMs, now) {
333
335
  * usage at all, because it reads neither OAuth nor the keychain — on the
334
336
  * subscription this whole file exists to report, it has nothing to say.
335
337
  */
336
- export function probeUsageText(binary, cwd, timeoutMs = USAGE_PROBE_TIMEOUT_MS) {
338
+ export function probeUsageText(binary, cwd, timeoutMs = USAGE_PROBE_TIMEOUT_MS, env) {
337
339
  return new Promise((resolve) => {
338
340
  let settled = false;
339
341
  const finish = (text) => {
@@ -345,6 +347,12 @@ export function probeUsageText(binary, cwd, timeoutMs = USAGE_PROBE_TIMEOUT_MS)
345
347
  };
346
348
  const child = execFile(binary, ['--strict-mcp-config', '-p', '/usage', '--output-format', 'text'], {
347
349
  cwd,
350
+ // The account's home and nothing that outranks it (#422 S1 item 5): a
351
+ // probe run under the daemon's environment measures whichever login the
352
+ // DAEMON has, and with a saved account active that is somebody else's
353
+ // plan printed under this account's name. Absent only for a caller
354
+ // that measures the machine login exactly as before.
355
+ ...(env ? { env } : {}),
348
356
  timeout: timeoutMs,
349
357
  maxBuffer: 1024 * 1024,
350
358
  // SIGTERM can be trapped, SIGKILL cannot. A probe that ignored the
@@ -411,33 +419,41 @@ function probeDir() {
411
419
  return null;
412
420
  }
413
421
  }
422
+ /** The machine login, for callers that predate #422 and name no account. */
423
+ export const MACHINE_USAGE_ACCOUNT = { id: 'machine', home: null };
424
+ /** The cache slot a reading of this account lives in. */
425
+ export function usageCacheKey(account) {
426
+ return account.orgId ? `org:${account.orgId}` : `account:${account.id}`;
427
+ }
428
+ const slots = new Map();
414
429
  /**
415
- * The last reading that SUCCEEDED. A failed probe never clears it.
430
+ * The tail of the one-at-a-time queue: «a probe is running» is ONE per machine.
416
431
  *
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».
432
+ * Not one per account. Two live sessions on two accounts would otherwise take
433
+ * turns throwing away each other's reading and starting their own — the pile-up
434
+ * of #390, rebuilt out of accounts instead of sessions (R16). A second account
435
+ * waits for the first probe to finish and then measures; nobody measures twice
436
+ * at once.
433
437
  */
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;
438
+ let probeQueue = Promise.resolve();
439
+ function slotFor(key) {
440
+ let slot = slots.get(key);
441
+ if (!slot) {
442
+ slot = { rows: null, measuredAtMs: 0, attemptedAtMs: 0, lastAttemptOk: false, pending: null };
443
+ slots.set(key, slot);
444
+ }
445
+ return slot;
446
+ }
447
+ function readingOf(slot) {
448
+ if (!slot?.rows)
449
+ return null;
450
+ return {
451
+ rows: slot.rows,
452
+ measuredAtMs: slot.measuredAtMs,
453
+ ...(slot.accountUuid ? { accountUuid: slot.accountUuid } : {}),
454
+ ...(slot.orgId ? { orgId: slot.orgId } : {}),
455
+ };
456
+ }
441
457
  /**
442
458
  * The probe the cache runs. Replaced only by tests.
443
459
  *
@@ -450,21 +466,41 @@ let inFlight = null;
450
466
  * `cwd` did not exist, and this change stopped the probe using the session's
451
467
  * `cwd` at all.
452
468
  */
453
- let probeRunner = (binary, cwd) => probeUsageText(binary, cwd);
469
+ const defaultProbeRunner = (binary, cwd, env) => probeUsageText(binary, cwd, USAGE_PROBE_TIMEOUT_MS, env);
470
+ let probeRunner = defaultProbeRunner;
454
471
  export function setUsageProbeRunner(run) {
455
- probeRunner = run ?? ((binary, cwd) => probeUsageText(binary, cwd));
472
+ probeRunner = run ?? defaultProbeRunner;
456
473
  }
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();
474
+ /**
475
+ * Forget what was measured for one account (#422 R16).
476
+ *
477
+ * `forget` drops the forgotten row's slot, a replaced login drops the replaced
478
+ * row's, a new sign-in to the machine drops the machine's; `activate` drops
479
+ * nothing – the keys already keep accounts apart.
480
+ *
481
+ * Never cancels a probe that is running. Nulling «a probe is running» used to
482
+ * let the very next caller start a SECOND process beside the first, which then
483
+ * finished and wrote its figures under the account that had just been switched
484
+ * to. The running probe now finishes into the slot it was started for; a
485
+ * dropped slot is simply not where anybody reads any more, and the next probe
486
+ * waits in the queue behind it.
487
+ *
488
+ * Called with no account it forgets EVERYTHING, the queue included – a clean
489
+ * slate for a test. The product always names an account.
490
+ */
491
+ export function invalidateUsageCache(account) {
492
+ if (account === undefined) {
493
+ slots.clear();
494
+ warnedAtMs.clear();
495
+ // A probe a previous test left hanging must not hold every later one.
496
+ // Production probes settle on their own (the watchdog in `probeUsageText`).
497
+ probeQueue = Promise.resolve();
498
+ return;
499
+ }
500
+ slots.delete(usageCacheKey(account));
465
501
  }
466
502
  /**
467
- * What the machine last read, WITHOUT starting a measurement.
503
+ * What the machine last read for this account, WITHOUT starting a measurement.
468
504
  *
469
505
  * The reason this exists is the defect #390 was actually about. A session's
470
506
  * window map starts empty, and its first `rate_limit_event` arrives 12-28
@@ -478,61 +514,101 @@ export function invalidateUsageCache() {
478
514
  * the machine already knows in its very first snapshot, and the probe that
479
515
  * follows only refreshes it.
480
516
  */
481
- export function lastUsageRows() {
482
- return lastRows ? { rows: lastRows, measuredAtMs } : null;
517
+ export function lastUsageRows(account = MACHINE_USAGE_ACCOUNT) {
518
+ return readingOf(slots.get(usageCacheKey(account)));
483
519
  }
484
520
  /**
485
- * The machine's plan percentages — measured at most once per {@link USAGE_CACHE_MS}.
521
+ * An account's plan percentages — measured at most once per {@link USAGE_CACHE_MS}.
486
522
  *
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
523
+ * Three guards, and they answer different questions. The throttle stops us
524
+ * measuring the same account again too SOON; the per-account join stops us
525
+ * measuring it twice AT ONCE; the queue stops two accounts measuring at once.
526
+ * Five sessions reaching a turn end together used to start five copies of a
491
527
  * 20-second CPU-bound process, each of which made the others slower — the same
492
528
  * self-sustaining shape `gitops.ts` writes about for `git_status`.
493
529
  *
494
530
  * Always resolves to the best reading we have, never to «nothing» just because
495
531
  * this attempt failed. A stale true number beats an empty panel — and the
496
532
  * 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}).
533
+ * measurement from the one it already folded in.
498
534
  */
499
535
  export function readUsageRows(options) {
536
+ const account = options.account ?? MACHINE_USAGE_ACCOUNT;
537
+ const key = usageCacheKey(account);
538
+ const slot = slotFor(key);
500
539
  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());
540
+ const interval = slot.lastAttemptOk ? USAGE_CACHE_MS : USAGE_FAILURE_COOLDOWN_MS;
541
+ if (slot.attemptedAtMs !== 0 && now - slot.attemptedAtMs < interval) {
542
+ return Promise.resolve(readingOf(slot));
504
543
  }
505
- if (inFlight)
506
- return inFlight;
544
+ if (slot.pending)
545
+ return slot.pending;
507
546
  const cwd = options.cwd ?? probeDir();
508
547
  if (cwd === null)
509
- return Promise.resolve(lastUsageRows());
510
- const pending = probeRunner(options.binary, cwd)
548
+ return Promise.resolve(readingOf(slot));
549
+ // After the slot was dropped (a forgotten or replaced account) the reading
550
+ // lands in the orphan and nobody sees it; whoever asks gets the slot that is
551
+ // current now.
552
+ const answer = () => slots.get(key) === slot ? readingOf(slot) : lastUsageRows(account);
553
+ const pending = probeQueue
554
+ .then(() =>
555
+ // Waited in the queue behind somebody else's probe: the account may have
556
+ // been forgotten or replaced meanwhile. A probe of a slot nobody reads, or
557
+ // under a home that is gone, would only let the CLI recreate that home.
558
+ slots.get(key) !== slot || (account.home !== null && !fs.existsSync(account.home))
559
+ ? null
560
+ : probeRunner(options.binary, cwd, options.env))
511
561
  .then((text) => {
512
562
  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;
563
+ const signature = rows.length > 0 ? signReading(options.sign) : {};
564
+ // The home answered for a different subscription than the account we
565
+ // measured for – a re-login under our feet. Not this account's numbers.
566
+ const foreign = account.orgId !== undefined &&
567
+ signature.orgId !== undefined &&
568
+ signature.orgId !== account.orgId;
569
+ slot.attemptedAtMs = options.now ?? Date.now();
570
+ slot.lastAttemptOk = rows.length > 0 && !foreign;
571
+ if (foreign) {
572
+ log.warn('claude: /usage measured a different subscription than the account — dropped', {
573
+ account: account.id,
574
+ });
575
+ }
576
+ else if (rows.length > 0) {
577
+ // Zero rows is not an answer worth keeping: it means the wording moved
578
+ // or the CLI said nothing, and the previous true reading is still the
579
+ // best thing we have to show.
580
+ slot.rows = rows;
581
+ slot.measuredAtMs = slot.attemptedAtMs;
582
+ slot.accountUuid = signature.accountUuid;
583
+ slot.orgId = account.orgId ?? signature.orgId;
521
584
  }
522
- return lastUsageRows();
585
+ return answer();
523
586
  })
524
587
  .catch((error) => {
525
588
  // `probeUsageText` resolves rather than rejects, so this is a seat that
526
589
  // threw or a parser that did — either way the reading is unchanged.
527
590
  log.debug('claude: /usage probe threw', { error: String(error) });
528
- attemptedAtMs = options.now ?? Date.now();
529
- lastAttemptOk = false;
530
- return lastUsageRows();
591
+ slot.attemptedAtMs = options.now ?? Date.now();
592
+ slot.lastAttemptOk = false;
593
+ return answer();
531
594
  })
532
595
  .finally(() => {
533
- inFlight = null;
596
+ slot.pending = null;
534
597
  });
535
- inFlight = pending;
598
+ slot.pending = pending;
599
+ probeQueue = pending.catch(() => undefined);
536
600
  return pending;
537
601
  }
602
+ function signReading(sign) {
603
+ if (!sign)
604
+ return {};
605
+ try {
606
+ return sign();
607
+ }
608
+ catch {
609
+ // A home we could not read signs nothing; the reading still belongs to the
610
+ // account it was measured for.
611
+ return {};
612
+ }
613
+ }
538
614
  //# sourceMappingURL=claude-usage.js.map
@@ -1,8 +1,37 @@
1
1
  import { query } from '@anthropic-ai/claude-agent-sdk';
2
- import { type AgentAdapter, type AgentQuestion, type AgentSession, type SessionSpec } from './types.js';
2
+ import { type AgentAdapter, type AgentMode, type AgentQuestion, type AgentSession, type SessionSpec } from './types.js';
3
+ import { type SessionAccount } from '../claude-homes.js';
3
4
  export declare function truncate(text: string, limit?: number): string;
4
5
  /** Exported for the one-shot commit-message run (session 14), same rules. */
5
6
  export declare function scrubbedEnv(): Record<string, string>;
7
+ /**
8
+ * The one env var the allowlist above refuses, added back for exactly one mode
9
+ * (ticket #156).
10
+ *
11
+ * The CLI's own guard, read out of the binary verbatim:
12
+ *
13
+ * ```js
14
+ * if (t === "bypassPermissions" || r) {
15
+ * if (typeof process.getuid === "function" && process.getuid() === 0
16
+ * && process.env.IS_SANDBOX !== "1" && !Z.CLAUDE_CODE_BUBBLEWRAP)
17
+ * console.error("--dangerously-skip-permissions cannot be used with root/sudo privileges …"),
18
+ * process.exit(1)
19
+ * }
20
+ * ```
21
+ *
22
+ * The runner's default install is root (`install.sh`), so on an ordinary
23
+ * machine «Unrestricted» did not degrade — it killed the session at launch with
24
+ * exit code 1. On the machine this was found on it happened to start, because
25
+ * `~/.claude/settings.json` carries `env: { IS_SANDBOX: "1" }` and this adapter
26
+ * loads user settings; that is a hole in the scrub covering a bug, not a fix.
27
+ *
28
+ * The condition here is the CLI's own, no wider: `full` AND uid 0. It is safe
29
+ * precisely where it is applied — `bypassPermissions` is the mode in which the
30
+ * SDK never calls `canUseTool` (it says so itself), so layer 1 is already inert
31
+ * and there is nothing left for `IS_SANDBOX` to weaken. In every other mode the
32
+ * scrub stands, which is what the comment on `ENV_ALLOWLIST` has always meant.
33
+ */
34
+ export declare function agentEnv(mode: AgentMode, sessionId: string, account?: Pick<SessionAccount, 'kind' | 'home'>): Record<string, string>;
6
35
  /**
7
36
  * The whole `AskUserQuestion` payload, not just its first line.
8
37
  *