@cortexkit/antigravity-auth-core 3.0.6 → 3.0.7

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.
Files changed (46) hide show
  1. package/dist/account-migration.d.ts +1 -1
  2. package/dist/account-migration.d.ts.map +1 -1
  3. package/dist/account-migration.js +228 -53
  4. package/dist/account-migration.js.map +1 -1
  5. package/dist/agy-transport.d.ts.map +1 -1
  6. package/dist/agy-transport.js +2 -0
  7. package/dist/agy-transport.js.map +1 -1
  8. package/dist/common-auth-embedded/claustrum/consumer.d.ts +11 -1
  9. package/dist/common-auth-embedded/claustrum/consumer.js +38 -1
  10. package/dist/common-auth-embedded/claustrum/custody.d.ts +29 -0
  11. package/dist/common-auth-embedded/claustrum/custody.js +17 -0
  12. package/dist/common-auth-embedded/claustrum/index.d.ts +1 -1
  13. package/dist/common-auth-embedded/fs/refresh-file-lock.d.ts +8 -2
  14. package/dist/common-auth-embedded/fs/refresh-file-lock.js +6 -1
  15. package/dist/common-auth-embedded/pi-slot/lock.js +7 -3
  16. package/dist/common-auth-embedded/quota/format.d.ts +14 -3
  17. package/dist/common-auth-embedded/quota/format.js +135 -18
  18. package/dist/common-auth-embedded/quota/index.d.ts +1 -1
  19. package/dist/common-auth-embedded/quota/index.js +1 -1
  20. package/dist/common-auth-embedded/source-output.json +54 -54
  21. package/dist/common-auth-embedded/store/pool.d.ts +1 -1
  22. package/dist/common-auth-embedded/store/pool.js +0 -1
  23. package/dist/common-auth-embedded/store/refresh-lock.d.ts +0 -1
  24. package/dist/common-auth-embedded/store/refresh-lock.js +0 -1
  25. package/dist/credential-redaction.d.ts +8 -4
  26. package/dist/credential-redaction.d.ts.map +1 -1
  27. package/dist/credential-redaction.js +55 -28
  28. package/dist/credential-redaction.js.map +1 -1
  29. package/dist/index.d.ts +3 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +3 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/pre-response-deadline.d.ts +41 -0
  34. package/dist/pre-response-deadline.d.ts.map +1 -0
  35. package/dist/pre-response-deadline.js +150 -0
  36. package/dist/pre-response-deadline.js.map +1 -0
  37. package/dist/vault-account-source.d.ts +21 -1
  38. package/dist/vault-account-source.d.ts.map +1 -1
  39. package/dist/vault-account-source.js +5 -0
  40. package/dist/vault-account-source.js.map +1 -1
  41. package/dist/vault-folder-refusal.js +1 -1
  42. package/dist/vault-grant-reach.d.ts +23 -0
  43. package/dist/vault-grant-reach.d.ts.map +1 -0
  44. package/dist/vault-grant-reach.js +15 -0
  45. package/dist/vault-grant-reach.js.map +1 -0
  46. package/package.json +2 -2
@@ -21,6 +21,11 @@ export class ClaustrumConsumer {
21
21
  #connecting;
22
22
  #refreshing;
23
23
  #roster;
24
+ /**
25
+ * The grants named by this instance's latest discovery, with the view that
26
+ * discovery committed. Memory only: the roster file never carries it.
27
+ */
28
+ #grantReach;
24
29
  #timer;
25
30
  #started = false;
26
31
  #insula;
@@ -102,6 +107,21 @@ export class ClaustrumConsumer {
102
107
  snapshot() {
103
108
  return this.#roster;
104
109
  }
110
+ /**
111
+ * What this consumer is granted, from its latest discovery, without any I/O.
112
+ * It explains an empty roster: `grants === 0` means no grant at all, while
113
+ * `grants > 0` means a grant that reaches no credential (`tuples` names its
114
+ * selector). Undefined means unknown, never zero: the vault or client is too
115
+ * old to send it, nothing was discovered yet, or the roster now in memory is
116
+ * a different view than the one this instance discovered (a peer process
117
+ * held the discovery lease and its newer roster was served instead).
118
+ */
119
+ grantReach() {
120
+ const latest = this.#grantReach;
121
+ return latest && this.#roster?.view === latest.view
122
+ ? latest.reach
123
+ : undefined;
124
+ }
105
125
  /** Rows for `/routing`, from the last committed roster. */
106
126
  routingRows() {
107
127
  return vaultRoutingRows(this.#roster);
@@ -113,6 +133,7 @@ export class ClaustrumConsumer {
113
133
  this.#refreshing = (async () => {
114
134
  if (!(await this.#active())) {
115
135
  this.#roster = undefined;
136
+ this.#grantReach = undefined;
116
137
  this.#custody?.close();
117
138
  this.#custody = undefined;
118
139
  this.#insula?.release();
@@ -120,9 +141,17 @@ export class ClaustrumConsumer {
120
141
  return undefined;
121
142
  }
122
143
  const custody = await this.#getCustody();
144
+ // Capture the inventory so its grant fields can be kept beside the roster;
145
+ // the roster file itself never stores them.
146
+ let discovered;
123
147
  const roster = await refreshVaultRoster({
124
148
  path: this.#options.rosterPath,
125
- custody,
149
+ custody: {
150
+ discover: async (signal) => {
151
+ discovered = await custody.discover(signal);
152
+ return discovered;
153
+ },
154
+ },
126
155
  isActive: () => this.#active(),
127
156
  signal: this.#shutdown.signal,
128
157
  projection: () => ({
@@ -135,6 +164,13 @@ export class ClaustrumConsumer {
135
164
  this.#assertOpen();
136
165
  const changed = roster?.view !== this.#roster?.view;
137
166
  this.#roster = roster;
167
+ // Each discovery replaces the previous value, including with unknown. A
168
+ // refresh that served a peer's roster discovered nothing and leaves it;
169
+ // grantReach() hides it once the roster's view no longer matches.
170
+ if (discovered && roster?.view !== undefined)
171
+ this.#grantReach = discovered.grantReach
172
+ ? { view: roster.view, reach: discovered.grantReach }
173
+ : undefined;
138
174
  // Notify on view changes only: subscribers rewrite shared state on each
139
175
  // notification, and the poll runs every few seconds.
140
176
  if (roster && changed)
@@ -389,5 +425,6 @@ export class ClaustrumConsumer {
389
425
  this.#insula?.release();
390
426
  this.#insula = undefined;
391
427
  this.#roster = undefined;
428
+ this.#grantReach = undefined;
392
429
  }
393
430
  }
@@ -44,6 +44,30 @@ export interface SkippedVaultRecord {
44
44
  readonly credentialId?: string;
45
45
  readonly reason: SkippedVaultReason;
46
46
  }
47
+ /**
48
+ * One grant this consumer's enrollment holds, as the vault's list reply names
49
+ * it: for example `{ selectorKind: 'category', selector: 'openai-native',
50
+ * operation: 'read' }`. `selectorKind` is `exact` (the selector is a credential
51
+ * id) or `category` (a bare category name); both open strings.
52
+ */
53
+ export interface VaultGrantTuple {
54
+ readonly selectorKind: string;
55
+ readonly selector: string;
56
+ readonly operation: string;
57
+ }
58
+ /**
59
+ * What this consumer is granted, independent of what those grants currently
60
+ * reach. It is what tells two empty listings apart: no rows with `grants === 0`
61
+ * means no grant at all (the fix is `ck auth grant`), while no rows with
62
+ * `grants > 0` means a grant that reaches no credential, usually logins that
63
+ * lack the category (`ck auth reclassify --from-registry`); `tuples` names the
64
+ * selector that matched nothing. Never written to the roster file.
65
+ */
66
+ export interface VaultGrantReach {
67
+ /** How many grants the consumer holds, deposit grants excluded. */
68
+ readonly grants: number;
69
+ readonly tuples: readonly VaultGrantTuple[];
70
+ }
47
71
  export interface VaultInventory {
48
72
  /**
49
73
  * The vault's change cursor: a digest over exactly what this consumer can see
@@ -53,6 +77,11 @@ export interface VaultInventory {
53
77
  readonly view: string;
54
78
  readonly credentials: readonly VaultCredential[];
55
79
  readonly skipped: readonly SkippedVaultRecord[];
80
+ /**
81
+ * The consumer's grants, as the vault's list reply named them. Undefined means unknown
82
+ * (the vault or client predates the fields), never zero grants.
83
+ */
84
+ readonly grantReach?: VaultGrantReach;
56
85
  }
57
86
  export interface ClaustrumScopedIdentity {
58
87
  readonly credentialId: string;
@@ -83,6 +83,21 @@ function credentialTypeOf(row, family) {
83
83
  return row.refreshAdapter === undefined ? 'api_key' : undefined;
84
84
  return undefined;
85
85
  }
86
+ /**
87
+ * The grants named in the vault's list reply, or undefined when it names none.
88
+ * Both fields must be present: a reply carrying only one of them is treated as
89
+ * unknown rather than completed with a guess. claustrum-client already refuses
90
+ * a malformed value, so nothing here validates it again.
91
+ */
92
+ function grantReachOf(listed) {
93
+ const { grants, grantTuples } = listed;
94
+ if (grants === undefined || grantTuples === undefined)
95
+ return undefined;
96
+ return Object.freeze({
97
+ grants,
98
+ tuples: Object.freeze(grantTuples.map(({ selectorKind, selector, operation }) => Object.freeze({ selectorKind, selector, operation }))),
99
+ });
100
+ }
86
101
  /**
87
102
  * Reads this consumer's vault credentials (list, fetch, 401 report), with the
88
103
  * enrollment token as authorization. Used by every host of a plugin. There is
@@ -217,10 +232,12 @@ export class ClaustrumScopedCustody {
217
232
  }
218
233
  for (const record of skipped)
219
234
  this.#logger.warn('skipped malformed vault record', record);
235
+ const grantReach = grantReachOf(inventory);
220
236
  return {
221
237
  view: inventory.view,
222
238
  credentials: Object.freeze(credentials),
223
239
  skipped: Object.freeze(skipped),
240
+ ...(grantReach && { grantReach }),
224
241
  };
225
242
  }
226
243
  /**
@@ -2,7 +2,7 @@ import { type ClaustrumClientOptions } from '@cortexkit/claustrum-client';
2
2
  import type { ClaustrumScopedClient } from './custody.js';
3
3
  export { ClaustrumCredentialError, type ClaustrumReporterSource, type EnrollmentTokenFile, } from '@cortexkit/claustrum-client';
4
4
  export { ClaustrumConsumer, type ClaustrumConsumerOptions, type SendOptions, } from './consumer.js';
5
- export { type AccountIdentitySource, type ClaustrumFamily, type ClaustrumScopedAttempt, type ClaustrumScopedClient, ClaustrumScopedCustody, type ClaustrumScopedIdentity, decideScopedRetryAfter401, type IdentityParser, isScopedCredentialRotation, type ScopedRetryReason, SERVING_MARGIN_MS, type SkippedVaultReason, type SkippedVaultRecord, type VaultCredential, type VaultCredentialType, type VaultInventory, } from './custody.js';
5
+ export { type AccountIdentitySource, type ClaustrumFamily, type ClaustrumScopedAttempt, type ClaustrumScopedClient, ClaustrumScopedCustody, type ClaustrumScopedIdentity, decideScopedRetryAfter401, type IdentityParser, isScopedCredentialRotation, type ScopedRetryReason, SERVING_MARGIN_MS, type SkippedVaultReason, type SkippedVaultRecord, type VaultCredential, type VaultCredentialType, type VaultGrantReach, type VaultGrantTuple, type VaultInventory, } from './custody.js';
6
6
  export { type ClaustrumEnrollmentClient, type ClaustrumEnrollmentConnection, type ClaustrumEnrollmentDisconnectResult, ClaustrumEnrollmentManager, type ClaustrumEnrollmentPaths, type ClaustrumEnrollmentResetResult, type ClaustrumEnrollmentStatus, classifyEnrollmentError, connectClaustrumEnrollmentClient, disconnectClaustrumEnrollment, type EnrollmentDisposition, enrollmentAuthority, enrollmentName, getClaustrumEnrollmentPaths, hostEnrollmentPaths, RETRYABLE_ENROLLMENT_CODES, readClaustrumEnrollmentStatus, readClaustrumEnrollmentToken, resetClaustrumEnrollmentState, TERMINAL_ENROLLMENT_CODES, } from './enrollment.js';
7
7
  export { ClaustrumConsumerError, type ClaustrumConsumerFailureKind, type ClaustrumLogger, } from './errors.js';
8
8
  export { assertHostSlotMatchesMode, assertNotCustodyPlaceholder, CUSTODY_PLACEHOLDER_PREFIX, classifyHostSlot, custodyPlaceholder, custodyPlaceholderKey, type HostSlotContent, isCustodyPlaceholder, isCustodyPlaceholderValue, } from './host-slot.js';
@@ -13,7 +13,7 @@ export interface RefreshFileLock {
13
13
  whenLost(): Promise<LockLoss>;
14
14
  hasLost(): boolean;
15
15
  }
16
- export declare function acquireRefreshFileLock(options: {
16
+ type RefreshFileLockOptions = {
17
17
  name: string;
18
18
  ttlMs: number;
19
19
  /**
@@ -22,7 +22,6 @@ export declare function acquireRefreshFileLock(options: {
22
22
  * be one host's path.
23
23
  */
24
24
  path: string;
25
- now?: () => number;
26
25
  renew?: boolean;
27
26
  renewIntervalMs?: number;
28
27
  /** Notifies a live-owner refusal without awaiting; observer failures are ignored. */
@@ -30,4 +29,11 @@ export declare function acquireRefreshFileLock(options: {
30
29
  /** Synchronous, never awaited; throws and returned promise/thenable rejections are isolated. */
31
30
  onRenewalTimer?: (event: 'scheduled' | 'cancelled') => void;
32
31
  onStep?: (step: 'stale-marker-stat' | 'stale-marker-claimed' | 'stale-lock-observed' | 'lock-read-ready' | 'lock-missing' | 'stale-lock-confirmed' | 'stale-lock-removed' | 'stale-lock-recreated' | 'eviction-marker-acquired' | 'renewal-owner-confirmed' | 'renewal-marker-unavailable' | 'renewal-write-fenced' | 'renewal-write-ready' | 'renewal-write-finished' | 'relinquish-read' | 'renewal-finished' | 'release-owner-confirmed') => void | Promise<void>;
32
+ };
33
+ /** Filesystem leases share wall time across all callers and processes. */
34
+ export declare function acquireRefreshFileLock(options: RefreshFileLockOptions): Promise<RefreshFileLock | null>;
35
+ /** @internal Clock seam for lease tests; not exported by the package's fs entry point. */
36
+ export declare function acquireRefreshFileLockWithClock(options: RefreshFileLockOptions & {
37
+ now?: () => number;
33
38
  }): Promise<RefreshFileLock | null>;
39
+ export {};
@@ -31,7 +31,12 @@ export function isLostMarkerRaceError(error) {
31
31
  return (code === 'ENOENT' ||
32
32
  (!windowsPrivateFiles && (code === 'EINVAL' || code === 'ENOTDIR')));
33
33
  }
34
- export async function acquireRefreshFileLock(options) {
34
+ /** Filesystem leases share wall time across all callers and processes. */
35
+ export function acquireRefreshFileLock(options) {
36
+ return acquireRefreshFileLockWithClock({ ...options, now: Date.now });
37
+ }
38
+ /** @internal Clock seam for lease tests; not exported by the package's fs entry point. */
39
+ export async function acquireRefreshFileLockWithClock(options) {
35
40
  const lockPath = lockPathFor(options.path, options.name);
36
41
  const legacyOwnerPath = join(lockPath, 'owner.json');
37
42
  const ownerId = randomUUID();
@@ -1,4 +1,4 @@
1
- import { rmdirSync, statSync } from 'node:fs';
1
+ import { rmdirSync, statSync, utimesSync } from 'node:fs';
2
2
  import { mkdir, rmdir, stat, utimes } from 'node:fs/promises';
3
3
  // Protocol source: proper-lockfile 4.1.2 lib/lockfile.js and lib/mtime-precision.js;
4
4
  // line-level citations and the Pi lock options are recorded in docs/sources.md.
@@ -135,7 +135,9 @@ export async function acquirePiLock(authPath, onCompromised, onRenew) {
135
135
  return;
136
136
  let observed;
137
137
  try {
138
- observed = await stat(path);
138
+ // Durable writes can occupy every async filesystem worker in fsync.
139
+ // Renew on the calling thread so their queue cannot consume the lease.
140
+ observed = statSync(path);
139
141
  }
140
142
  catch (error) {
141
143
  retry(error);
@@ -151,12 +153,14 @@ export async function acquirePiLock(authPath, onCompromised, onRenew) {
151
153
  }
152
154
  const next = new Date(precision === 's' ? Math.ceil(Date.now() / 1_000) * 1_000 : Date.now());
153
155
  try {
154
- await utimes(path, next, next);
156
+ utimesSync(path, next, next);
155
157
  }
156
158
  catch (error) {
157
159
  retry(error);
158
160
  return;
159
161
  }
162
+ // A metadata syscall can itself stall; never confirm an expired lease.
163
+ assertFresh();
160
164
  if (released)
161
165
  return;
162
166
  mtime = next.getTime();
@@ -11,8 +11,11 @@ export declare const NO_LIMITS_REPORTED = "no limits reported";
11
11
  /**
12
12
  * `compact` fits a footer or a sidebar row: each window's figure, the credit
13
13
  * budget and the stale marker, without reset times. `full` adds each reset.
14
+ * `narrow` fits one line per account in a narrow sidebar: `5h 92% · 7d 66%
15
+ * · Fa 98% left`, the account-wide windows plus at most one model-scoped
16
+ * window or the budget (as `cr`), and no stale marker (see `isQuotaStale`).
14
17
  */
15
- export type QuotaTextForm = 'compact' | 'full';
18
+ export type QuotaTextForm = 'compact' | 'full' | 'narrow';
16
19
  export interface QuotaTextOptions {
17
20
  /** The current time in ms, for reset countdowns and the stale marker. */
18
21
  now: number;
@@ -55,17 +58,25 @@ export declare function quotaWindowName(limit: Pick<ProjectedLimit, 'scope' | 'l
55
58
  * and the optional age keep the shared formatter's wording.
56
59
  */
57
60
  export declare function quotaWindowParts(quota: ProjectedQuota | undefined, options: QuotaTextOptions): QuotaParts;
61
+ /**
62
+ * Whether the reading is stale: true exactly when the compact and full forms
63
+ * would end with `checked … ago`. The narrow form leaves that age out to stay
64
+ * short, so a caller drawing a narrow line uses this to dim it instead.
65
+ */
66
+ export declare function isQuotaStale(quota: ProjectedQuota | undefined, options: QuotaTextOptions): boolean;
58
67
  /**
59
68
  * The pieces of a quota text, in order: one per reported window
60
69
  * (`5h 58% left, resets 2h`), then the credit budget when there is one, then
61
70
  * `checked 3h ago` when the oldest piece shown is stale. Empty when nothing
62
71
  * was reported; `formatQuota` turns that into a sentence. A caller printing
63
- * one line per window (the terminal auth menu) uses these directly.
72
+ * one line per window (the terminal auth menu) uses these directly. In the
73
+ * narrow form the pieces are the narrow line's (`5h 92%`, `7d 66% left`).
64
74
  */
65
75
  export declare function quotaTextParts(quota: ProjectedQuota | undefined, options: QuotaTextOptions): string[];
66
76
  /**
67
77
  * A row's quota as one line: `5h 58% left · 7d 90% left` in the compact
68
- * form, with `, resets 2h` after each window in the full form. With nothing
78
+ * form, with `, resets 2h` after each window in the full form, and
79
+ * `5h 58% · 7d 90% left` in the narrow form. With nothing
69
80
  * reported it is `NO_QUOTA_READING`, or `NO_LIMITS_REPORTED` when the
70
81
  * provider answered without a limit for any window.
71
82
  */
@@ -63,8 +63,12 @@ export function quotaWindowName(limit) {
63
63
  function clampPercent(remaining) {
64
64
  return Math.min(100, Math.max(0, remaining));
65
65
  }
66
+ /** A remaining percentage as the whole number every form shows. */
67
+ function wholePercent(remaining) {
68
+ return Math.round(clampPercent(remaining));
69
+ }
66
70
  function percentLeft(remaining) {
67
- return `${Math.round(clampPercent(remaining))}% left`;
71
+ return `${wholePercent(remaining)}% left`;
68
72
  }
69
73
  function resetDetails(resetsAt, checkedAt, now) {
70
74
  const at = resetsAt === undefined ? Number.NaN : Date.parse(resetsAt);
@@ -98,6 +102,16 @@ function displayOrder(left, right) {
98
102
  * and the optional age keep the shared formatter's wording.
99
103
  */
100
104
  export function quotaWindowParts(quota, options) {
105
+ const { windows, age } = scopedWindowParts(quota, options);
106
+ const parts = windows.map(({ part }) => part);
107
+ return age === undefined ? { windows: parts } : { windows: parts, age };
108
+ }
109
+ /**
110
+ * `quotaWindowParts` with each window's scope kept beside it. The narrow form
111
+ * needs the scope to name and pick model-scoped windows; the public parts do
112
+ * not carry it, so their shape stays as it was.
113
+ */
114
+ function scopedWindowParts(quota, options) {
101
115
  if (!quota)
102
116
  return { windows: [] };
103
117
  const { now } = options;
@@ -107,12 +121,15 @@ export function quotaWindowParts(quota, options) {
107
121
  const windows = readings.map((limit) => {
108
122
  const remainingPercent = clampPercent(limit.remainingPercent ?? 0);
109
123
  return {
110
- kind: 'window',
111
- name: quotaWindowName(limit),
112
- figure: percentLeft(limit.remainingPercent ?? 0),
113
- remainingPercent,
114
- checkedAt: limit.checkedAt,
115
- ...resetDetails(limit.resetsAt, limit.checkedAt, now),
124
+ scope: limit.scope,
125
+ part: {
126
+ kind: 'window',
127
+ name: quotaWindowName(limit),
128
+ figure: percentLeft(limit.remainingPercent ?? 0),
129
+ remainingPercent,
130
+ checkedAt: limit.checkedAt,
131
+ ...resetDetails(limit.resetsAt, limit.checkedAt, now),
132
+ },
116
133
  };
117
134
  });
118
135
  const budget = quota.budget;
@@ -124,32 +141,131 @@ export function quotaWindowParts(quota, options) {
124
141
  ? `credits ${percentLeft(remainingPercent)}`
125
142
  : 'credits available';
126
143
  windows.push({
127
- kind: 'budget',
128
- name: 'credits',
129
- figure,
130
- ...(remainingPercent === undefined
131
- ? {}
132
- : { remainingPercent: clampPercent(remainingPercent) }),
133
- checkedAt: budget.checkedAt,
134
- ...resetDetails(budget.resetsAt, budget.checkedAt, now),
144
+ scope: ALL_SCOPE,
145
+ spent: budget.reached,
146
+ part: {
147
+ kind: 'budget',
148
+ name: 'credits',
149
+ figure,
150
+ ...(remainingPercent === undefined
151
+ ? {}
152
+ : { remainingPercent: clampPercent(remainingPercent) }),
153
+ checkedAt: budget.checkedAt,
154
+ ...resetDetails(budget.resetsAt, budget.checkedAt, now),
155
+ },
135
156
  });
136
157
  }
137
158
  if (windows.length > 0) {
138
- const age = now - Math.min(...windows.map((part) => part.checkedAt));
159
+ const age = now - Math.min(...windows.map(({ part }) => part.checkedAt));
139
160
  if (age > (options.staleAfterMs ?? QUOTA_STALE_AFTER_MS)) {
140
161
  return { windows, age: `checked ${shortSpan(age)} ago` };
141
162
  }
142
163
  }
143
164
  return { windows };
144
165
  }
166
+ /**
167
+ * Whether the reading is stale: true exactly when the compact and full forms
168
+ * would end with `checked … ago`. The narrow form leaves that age out to stay
169
+ * short, so a caller drawing a narrow line uses this to dim it instead.
170
+ */
171
+ export function isQuotaStale(quota, options) {
172
+ return scopedWindowParts(quota, options).age !== undefined;
173
+ }
174
+ /**
175
+ * The model a scope names, as a display name: any namespace before a colon
176
+ * is dropped (`model:claude-opus-4` → `claude-opus-4`) and the first letter is
177
+ * capitalized (`fable` → `Fable`); the rest stays as written.
178
+ */
179
+ function scopeDisplayName(scope) {
180
+ const model = scope.slice(scope.indexOf(':') + 1);
181
+ return model.charAt(0).toUpperCase() + model.slice(1);
182
+ }
183
+ /**
184
+ * Short names for the model scopes in one row: the first two letters of each
185
+ * display name. Names that would read the same for different models are each
186
+ * extended one letter at a time until they differ (`Fable` and `Fabric` read
187
+ * `Fabl` and `Fabr`); a name that runs out of letters stays whole.
188
+ */
189
+ function scopeAbbreviations(scopes) {
190
+ const names = [...new Set(scopes.map(scopeDisplayName))];
191
+ const lengths = new Map(names.map((name) => [name, 2]));
192
+ const short = (name) => name.slice(0, lengths.get(name));
193
+ for (;;) {
194
+ const byShort = new Map();
195
+ for (const name of names) {
196
+ byShort.set(short(name), [...(byShort.get(short(name)) ?? []), name]);
197
+ }
198
+ let extended = false;
199
+ for (const group of byShort.values()) {
200
+ if (group.length < 2)
201
+ continue;
202
+ for (const name of group) {
203
+ const length = lengths.get(name) ?? 2;
204
+ if (length < name.length) {
205
+ lengths.set(name, length + 1);
206
+ extended = true;
207
+ }
208
+ }
209
+ }
210
+ if (!extended)
211
+ break;
212
+ }
213
+ return new Map(scopes.map((scope) => [scope, short(scopeDisplayName(scope))]));
214
+ }
215
+ /**
216
+ * The narrow form's pieces: every account-wide window as `5h 92%`, then at
217
+ * most one more item, with ` left` added once to the last piece. That item is
218
+ * the tightest (least remaining) of the model-scoped windows and the credit
219
+ * budget, so the line stays bounded whatever the account carries: a scoped
220
+ * window reads under its short model name (`Fa 98%`), the budget as `cr 75%`
221
+ * (`cr 0%` when spent). On a tie a scoped window wins over the budget, and
222
+ * among scoped windows the first in display order wins. The choice depends
223
+ * only on the reading, never on a width. A budget without a percentage cannot
224
+ * be chosen; when nothing at all is shown it keeps its compact wording
225
+ * (`credits available`). No age is added; `isQuotaStale` says whether the
226
+ * reading is stale. A window whose reset arrived after the reading keeps its
227
+ * figure, as in the other forms.
228
+ */
229
+ function narrowTextParts(quota, options) {
230
+ const { windows } = scopedWindowParts(quota, options);
231
+ const general = windows.filter(({ part, scope }) => part.kind === 'window' && scope === ALL_SCOPE);
232
+ const scoped = windows.filter(({ part, scope }) => part.kind === 'window' && scope !== ALL_SCOPE);
233
+ const abbreviations = scopeAbbreviations(scoped.map(({ scope }) => scope));
234
+ // The items competing for the one slot after the account-wide windows, in
235
+ // tie-break order: scoped windows in display order, then the budget. A
236
+ // spent budget counts as 0% left; one without a percentage cannot compete.
237
+ const budget = windows.find(({ part }) => part.kind === 'budget');
238
+ const budgetLeft = budget?.spent ? 0 : budget?.part.remainingPercent;
239
+ const extras = [
240
+ ...scoped.map(({ scope, part }) => ({
241
+ label: abbreviations.get(scope) ?? scope,
242
+ remaining: part.remainingPercent ?? 0,
243
+ })),
244
+ ...(budgetLeft === undefined
245
+ ? []
246
+ : [{ label: 'cr', remaining: budgetLeft }]),
247
+ ];
248
+ // Strictly lower wins, so on a tie the earlier item keeps the slot.
249
+ const tightest = extras.reduce((best, item) => best === undefined || item.remaining < best.remaining ? item : best, undefined);
250
+ const pieces = general.map(({ part }) => `${part.name} ${wholePercent(part.remainingPercent ?? 0)}%`);
251
+ if (tightest) {
252
+ pieces.push(`${tightest.label} ${wholePercent(tightest.remaining)}%`);
253
+ }
254
+ if (pieces.length === 0)
255
+ return windows.map(({ part }) => part.figure);
256
+ return pieces.map((piece, index) => index === pieces.length - 1 ? `${piece} left` : piece);
257
+ }
145
258
  /**
146
259
  * The pieces of a quota text, in order: one per reported window
147
260
  * (`5h 58% left, resets 2h`), then the credit budget when there is one, then
148
261
  * `checked 3h ago` when the oldest piece shown is stale. Empty when nothing
149
262
  * was reported; `formatQuota` turns that into a sentence. A caller printing
150
- * one line per window (the terminal auth menu) uses these directly.
263
+ * one line per window (the terminal auth menu) uses these directly. In the
264
+ * narrow form the pieces are the narrow line's (`5h 92%`, `7d 66% left`).
151
265
  */
152
266
  export function quotaTextParts(quota, options) {
267
+ if (options.form === 'narrow')
268
+ return narrowTextParts(quota, options);
153
269
  const parts = quotaWindowParts(quota, options);
154
270
  const full = (options.form ?? 'full') === 'full';
155
271
  const text = parts.windows.map((part) => {
@@ -160,7 +276,8 @@ export function quotaTextParts(quota, options) {
160
276
  }
161
277
  /**
162
278
  * A row's quota as one line: `5h 58% left · 7d 90% left` in the compact
163
- * form, with `, resets 2h` after each window in the full form. With nothing
279
+ * form, with `, resets 2h` after each window in the full form, and
280
+ * `5h 58% · 7d 90% left` in the narrow form. With nothing
164
281
  * reported it is `NO_QUOTA_READING`, or `NO_LIMITS_REPORTED` when the
165
282
  * provider answered without a limit for any window.
166
283
  */
@@ -1,7 +1,7 @@
1
1
  export type { QuotaCodec } from './codec.js';
2
2
  export { quotaCodec } from './codec.js';
3
3
  export type { QuotaParts, QuotaTextForm, QuotaTextOptions, QuotaWindowPart, } from './format.js';
4
- export { formatQuota, NO_LIMITS_REPORTED, NO_QUOTA_READING, QUOTA_STALE_AFTER_MS, quotaTextParts, quotaWindowName, quotaWindowParts, } from './format.js';
4
+ export { formatQuota, isQuotaStale, NO_LIMITS_REPORTED, NO_QUOTA_READING, QUOTA_STALE_AFTER_MS, quotaTextParts, quotaWindowName, quotaWindowParts, } from './format.js';
5
5
  export type { CreditBudgetCleared, CreditBudgetEntry, CreditBudgetReading, QuotaAbsentEntry, QuotaCounter, QuotaEntry, QuotaMap, QuotaReadingEntry, QuotaRetiredEntry, } from './map.js';
6
6
  export { ALL_SCOPE, DEFAULT_REQUIRED_LABELS, decodeQuotaMap, emptyQuotaMap, isQuotaMap, } from './map.js';
7
7
  export type { ObservedBudget, ObservedPair, ObservedReading, QuotaObservation, } from './merge.js';
@@ -1,5 +1,5 @@
1
1
  export { quotaCodec } from './codec.js';
2
- export { formatQuota, NO_LIMITS_REPORTED, NO_QUOTA_READING, QUOTA_STALE_AFTER_MS, quotaTextParts, quotaWindowName, quotaWindowParts, } from './format.js';
2
+ export { formatQuota, isQuotaStale, NO_LIMITS_REPORTED, NO_QUOTA_READING, QUOTA_STALE_AFTER_MS, quotaTextParts, quotaWindowName, quotaWindowParts, } from './format.js';
3
3
  export { ALL_SCOPE, DEFAULT_REQUIRED_LABELS, decodeQuotaMap, emptyQuotaMap, isQuotaMap, } from './map.js';
4
4
  export { isQuotaObservation, mergeQuotaObservation, QuotaCodecError, } from './merge.js';
5
5
  export { budgetExhaustedResetAt, futureResetAt, projectQuota, readsExhausted, } from './projection.js';