@cortexkit/opencode-openai-auth 1.1.0 → 1.2.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.
Files changed (55) hide show
  1. package/dist/bin.js +8 -8
  2. package/dist/{codex-request-BXDXToyt.d.ts → codex-request-CjXETpTA.d.ts} +56 -24
  3. package/dist/index-03em8hv1.js +69 -0
  4. package/dist/index-6n4pnfkg.js +2 -0
  5. package/dist/{index-f2fmb596.js → index-bc89akak.js} +1 -1
  6. package/dist/{index-7c6sxtd6.js → index-ehjj89rx.js} +2 -2
  7. package/dist/index-f21addzm.js +90 -0
  8. package/dist/{index-k6cydkgt.js → index-ffg0sgg5.js} +2 -2
  9. package/dist/{index-zpm95mt5.js → index-ftkhhr6d.js} +1 -1
  10. package/dist/{index-4c8yw54k.js → index-htvcmtcx.js} +2 -2
  11. package/dist/{index-j8v2xwq6.js → index-qp5hdpw8.js} +1 -1
  12. package/dist/{index-msx2m78k.js → index-qxbchh2d.js} +2 -2
  13. package/dist/{index-m5qmvr6c.js → index-r2s51ac1.js} +2 -2
  14. package/dist/index-rwbrhkse.js +65 -0
  15. package/dist/{index-csky2xpg.js → index-xwvze96g.js} +1 -1
  16. package/dist/index.d.ts +5 -3
  17. package/dist/index.js +1 -1
  18. package/dist/{model-YRg7Uowo.d.ts → model-CQcw10oW.d.ts} +1 -1
  19. package/dist/{port-file-CxEpgSHY.d.ts → port-file-BDJnzgHH.d.ts} +1 -1
  20. package/dist/{protocol-CDV1Pc6h.d.ts → protocol-C-kagE6O.d.ts} +1 -1
  21. package/dist/rpc/port-file.d.ts +1 -1
  22. package/dist/rpc/port-file.js +1 -1
  23. package/dist/rpc/protocol.d.ts +1 -1
  24. package/dist/rpc/protocol.js +1 -1
  25. package/dist/rpc/rpc-client.d.ts +3 -3
  26. package/dist/rpc/rpc-client.js +1 -1
  27. package/dist/rpc/rpc-dir.js +1 -1
  28. package/dist/{sidebar-state-DjTWUbmf.d.ts → sidebar-state-BfQ4mpZO.d.ts} +98 -5
  29. package/dist/sidebar-state.d.ts +2 -2
  30. package/dist/sidebar-state.js +1 -1
  31. package/dist/token-util-cf0nyh7n.js +1 -0
  32. package/dist/{token-rz4t3ay6.js → token-z6qq7a1d.js} +1 -1
  33. package/dist/tui-preferences.js +1 -1
  34. package/dist/tui.d.ts +2 -2
  35. package/dist/v2/server.d.ts +109 -2
  36. package/dist/v2/server.js +3 -3
  37. package/package.json +3 -3
  38. package/src/tui-compiled/raw/shared/0332701351b3eeec-format.js +135 -18
  39. package/src/tui-compiled/raw/shared/0f77ebc3c914c9c6-custody.js +17 -0
  40. package/src/tui-compiled/raw/shared/169b2b4bf30909bc-consumer.js +38 -1
  41. package/src/tui-compiled/raw/shared/d5a8f1061ee2f114-index.js +1 -1
  42. package/src/tui-compiled/raw/sidebar-state.js +63 -1
  43. package/src/tui-compiled/raw/tui.tsx +82 -30
  44. package/src/tui-compiled/runtime/shared/0332701351b3eeec-format.js +135 -18
  45. package/src/tui-compiled/runtime/shared/0f77ebc3c914c9c6-custody.js +17 -0
  46. package/src/tui-compiled/runtime/shared/169b2b4bf30909bc-consumer.js +38 -1
  47. package/src/tui-compiled/runtime/shared/d5a8f1061ee2f114-index.js +1 -1
  48. package/src/tui-compiled/runtime/sidebar-state.js +63 -1
  49. package/src/tui-compiled/runtime/tui.js +245 -208
  50. package/dist/index-0yh85bwb.js +0 -69
  51. package/dist/index-7eb3dw37.js +0 -90
  52. package/dist/index-8re8rprk.js +0 -2
  53. package/dist/index-edbdscy7.js +0 -65
  54. package/dist/map-Buq-GW7l.d.ts +0 -56
  55. package/dist/token-util-tpy2c7h6.js +0 -1
@@ -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
  }
@@ -1,5 +1,5 @@
1
1
  export { quotaCodec } from "./053cc17b5419261e-codec.js";
2
- export { formatQuota, NO_LIMITS_REPORTED, NO_QUOTA_READING, QUOTA_STALE_AFTER_MS, quotaTextParts, quotaWindowName, quotaWindowParts, } from "./0332701351b3eeec-format.js";
2
+ export { formatQuota, isQuotaStale, NO_LIMITS_REPORTED, NO_QUOTA_READING, QUOTA_STALE_AFTER_MS, quotaTextParts, quotaWindowName, quotaWindowParts, } from "./0332701351b3eeec-format.js";
3
3
  export { ALL_SCOPE, DEFAULT_REQUIRED_LABELS, decodeQuotaMap, emptyQuotaMap, isQuotaMap, } from "./905bffc0220f43d0-map.js";
4
4
  export { isQuotaObservation, mergeQuotaObservation, QuotaCodecError, } from "./4f36d333d081d040-merge.js";
5
5
  export { budgetExhaustedResetAt, futureResetAt, projectQuota, readsExhausted, } from "./39c3cb05738fcf6d-projection.js";
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  formatQuota,
3
+ isQuotaStale,
3
4
  projectQuota,
4
5
  quotaWindowName
5
6
  } from "./shared/d5a8f1061ee2f114-index.js";
@@ -266,7 +267,7 @@ export function resolveSessionSidebarRouting(state, sessionId, now = Date.now())
266
267
  }
267
268
  function vaultAccountsInPlacementOrder(state) {
268
269
  const accounts = state.vaultAccounts ?? [], byId = new Map(accounts.map((account) => [account.id, account]));
269
- return orderForPlacement(accounts.map((account) => account.id), state.route === "sticky-balanced" ? "roster" : orderedPlacement(state.route), vaultMainRouteId(accounts) ?? "").flatMap((id) => {
270
+ return orderForPlacement(accounts.map((account) => account.id), placementOf(state), vaultMainRouteId(accounts) ?? "").flatMap((id) => {
270
271
  const account = byId.get(id);
271
272
  return account ? [account] : [];
272
273
  });
@@ -962,6 +963,67 @@ export function getCollapsedQuotaSummary(quota, now = Date.now()) {
962
963
  text: !projection || projection.limits.length === 0 && !projection.budget ? null : formatQuota(projection, { now, form: "compact" })
963
964
  };
964
965
  }
966
+ function placementOf(state) {
967
+ return state.route === "sticky-balanced" ? "roster" : orderedPlacement(state.route);
968
+ }
969
+ export function getCollapsedAccountLines(state, options = {}) {
970
+ const now = options.now ?? Date.now(), activeId = resolveActiveAccount(state).id;
971
+ return (state.vaultAccounts !== void 0 ? vaultAccountsInPlacementOrder(state).map((account) => ({
972
+ id: account.id,
973
+ name: account.label ?? account.id,
974
+ quota: account.quota,
975
+ killed: account.killed
976
+ })) : localAccountsInPlacementOrder(state, activeId, options.showFallbacks ?? !0)).map((account) => {
977
+ const projection = sidebarQuotaProjection(account.quota, now), reading = projection && (projection.limits.length > 0 || projection.budget) ? projection : void 0;
978
+ return {
979
+ id: account.id,
980
+ name: account.name,
981
+ killed: account.killed,
982
+ active: account.id === activeId,
983
+ stale: reading ? isQuotaStale(reading, { now }) : !1,
984
+ text: reading ? formatQuota(reading, { now, form: "narrow" }) : null
985
+ };
986
+ });
987
+ }
988
+ function localAccountsInPlacementOrder(state, activeId, showFallbacks) {
989
+ const accounts = [
990
+ {
991
+ id: "main",
992
+ name: "main",
993
+ quota: state.main.quota,
994
+ killed: state.main.killed
995
+ },
996
+ ...state.fallbacks.filter((account) => account.enabled && (showFallbacks || account.id === activeId)).map((account) => ({
997
+ id: account.id,
998
+ name: account.label ?? account.id,
999
+ quota: account.quota,
1000
+ killed: account.killed
1001
+ }))
1002
+ ], byId = new Map(accounts.map((account) => [account.id, account]));
1003
+ return orderForPlacement(accounts.map((account) => account.id), placementOf(state), "main").flatMap((id) => {
1004
+ const account = byId.get(id);
1005
+ return account ? [account] : [];
1006
+ });
1007
+ }
1008
+ export function displayWidth(text) {
1009
+ const bun = globalThis.Bun;
1010
+ return bun?.stringWidth ? bun.stringWidth(text) : Array.from(text).length;
1011
+ }
1012
+ export function fitCollapsedLabel(label, columns) {
1013
+ if (displayWidth(label) <= columns)
1014
+ return label;
1015
+ if (columns < 1)
1016
+ return "";
1017
+ let kept = "", used = 0;
1018
+ for (const char of label) {
1019
+ const width = displayWidth(char);
1020
+ if (used + width > columns - 1)
1021
+ break;
1022
+ kept += char;
1023
+ used += width;
1024
+ }
1025
+ return `${kept}\u2026`;
1026
+ }
965
1027
  const PACING_MIN_ELAPSED_MS = 300000, PACING_MIN_ELAPSED_FRACTION = 0.01, ON_PACE_DELTA = 1;
966
1028
  export function computeQuotaPacing(window, windowMs, now) {
967
1029
  if (!window.resetsAt)
@@ -18,7 +18,7 @@ import {
18
18
  createEffect,
19
19
  createSignal,
20
20
  For,
21
- type JSX,
21
+ Index,
22
22
  onCleanup,
23
23
  Show,
24
24
  } from "solid-js"
@@ -28,8 +28,12 @@ import { createRpcClient } from "./rpc/rpc-client.js"
28
28
  import { resolveRpcDir } from "./rpc/rpc-dir.js"
29
29
  import {
30
30
  type AccountQuota,
31
+ type CollapsedAccountLine,
31
32
  computeQuotaPacing,
32
33
  DEFAULT_SIDEBAR_STATE,
34
+ displayWidth,
35
+ fitCollapsedLabel,
36
+ getCollapsedAccountLines,
33
37
  getCollapsedQuotaSummary,
34
38
  getPresentQuotaWindows,
35
39
  getSidebarState,
@@ -250,17 +254,72 @@ function StatRow(props: {
250
254
  )
251
255
  }
252
256
 
253
- // Compact row for the collapsed view: muted label left, caller-provided value
254
- // right. Mirrors StatRow's layout so columns line up.
255
- function CollapsedRow(props: {
257
+ // One account in the collapsed view: label left; narrow quota text and the
258
+ // status dot right (red ⊘ when killed). The session's account keeps the
259
+ // usage tone; other accounts, and any account whose reading is stale, are
260
+ // drawn muted. The row measures its own width so only the label is cut (with
261
+ // …) when the line does not fit; the quota text is never cut.
262
+ function CollapsedAccountRow(props: {
256
263
  theme: ThemeCurrent
257
- label: string
258
- children: JSX.Element
264
+ line: CollapsedAccountLine
265
+ tone: Tone
259
266
  }) {
267
+ const [width, setWidth] = createSignal(0)
268
+ let row: { width: number } | undefined
269
+ const dim = () => props.line.stale || !props.line.active
270
+ const label = () => {
271
+ const columns = width()
272
+ if (columns <= 0) return props.line.name
273
+ // The value side is the text plus ` ●`, or a lone `—`; one column
274
+ // separates it from the label.
275
+ const value =
276
+ props.line.text === null ? 1 : displayWidth(props.line.text) + 2
277
+ return fitCollapsedLabel(props.line.name, columns - value - 1)
278
+ }
260
279
  return (
261
- <box width='100%' flexDirection='row' justifyContent='space-between'>
262
- <text fg={props.theme.textMuted}>{props.label}</text>
263
- {props.children}
280
+ <box
281
+ ref={(element: { width: number }) => {
282
+ row = element
283
+ }}
284
+ onSizeChange={() => setWidth(row?.width ?? 0)}
285
+ width='100%'
286
+ flexDirection='row'
287
+ justifyContent='space-between'
288
+ >
289
+ <text
290
+ fg={toneColor(props.theme, dim() ? 'muted' : 'text')}
291
+ wrapMode='none'
292
+ flexShrink={1}
293
+ >
294
+ {label()}
295
+ </text>
296
+ <Show
297
+ when={props.line.text !== null}
298
+ fallback={<text fg={props.theme.textMuted}>{'\u2014'}</text>}
299
+ >
300
+ <box flexDirection='row' flexShrink={0}>
301
+ <Show
302
+ when={!dim()}
303
+ fallback={
304
+ <text fg={toneColor(props.theme, 'muted')}>
305
+ {props.line.text}
306
+ </text>
307
+ }
308
+ >
309
+ <text fg={toneColor(props.theme, props.tone)}>
310
+ <b>{props.line.text}</b>
311
+ </text>
312
+ </Show>
313
+ <text
314
+ fg={toneColor(
315
+ props.theme,
316
+ props.line.killed ? 'err' : dim() ? 'muted' : props.tone,
317
+ )}
318
+ >
319
+ {props.line.killed ? ' \u2298' : ' \u25cf'}
320
+ </text>
321
+ </box>
322
+ </Show>
264
323
  </box>
265
324
  )
266
325
  }
@@ -823,6 +882,10 @@ function QuotaSidebar(props: {
823
882
  route: sessionRouting().route,
824
883
  })
825
884
  const activeAccount = () => resolveActiveAccount(sessionState())
885
+ const collapsedLines = () =>
886
+ getCollapsedAccountLines(sessionState(), {
887
+ showFallbacks: prefs().sections.fallbackAccounts,
888
+ })
826
889
  const quotaLabelWidth = () => computeQuotaLabelWidth(renderedQuotas(state()))
827
890
  const activeQuotaSummary = () =>
828
891
  getCollapsedQuotaSummary(activeAccount().quota)
@@ -910,28 +973,17 @@ function QuotaSidebar(props: {
910
973
  </Show>
911
974
  </box>
912
975
 
913
- {/* Collapsed: active account's present quota windows + dot (red ⊘ when killed) */}
976
+ {/* Collapsed: one line per account, narrow quota text + dot (red ⊘ when killed) */}
914
977
  <Show when={collapsed() && hasData()}>
915
- <CollapsedRow theme={theme()} label={activeAccount().name}>
916
- <Show
917
- when={activeQuotaSummary().text != null}
918
- fallback={<text fg={theme().textMuted}>{'\u2014'}</text>}
919
- >
920
- <box flexDirection='row'>
921
- <text fg={toneColor(theme(), activeQuotaTone())}>
922
- <b>{activeQuotaSummary().text}</b>
923
- </text>
924
- <text
925
- fg={toneColor(
926
- theme(),
927
- activeAccount().killed ? 'err' : activeQuotaTone(),
928
- )}
929
- >
930
- {activeAccount().killed ? ' \u2298' : ' \u25cf'}
931
- </text>
932
- </box>
933
- </Show>
934
- </CollapsedRow>
978
+ <Index each={collapsedLines()}>
979
+ {(line) => (
980
+ <CollapsedAccountRow
981
+ theme={theme()}
982
+ line={line()}
983
+ tone={activeQuotaTone()}
984
+ />
985
+ )}
986
+ </Index>
935
987
  </Show>
936
988
 
937
989
  {/* Expanded: full sections. Also render when there's no data so the
@@ -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
  */
@@ -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
  /**
@@ -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
  }
@@ -1,5 +1,5 @@
1
1
  export { quotaCodec } from "./053cc17b5419261e-codec.js";
2
- export { formatQuota, NO_LIMITS_REPORTED, NO_QUOTA_READING, QUOTA_STALE_AFTER_MS, quotaTextParts, quotaWindowName, quotaWindowParts, } from "./0332701351b3eeec-format.js";
2
+ export { formatQuota, isQuotaStale, NO_LIMITS_REPORTED, NO_QUOTA_READING, QUOTA_STALE_AFTER_MS, quotaTextParts, quotaWindowName, quotaWindowParts, } from "./0332701351b3eeec-format.js";
3
3
  export { ALL_SCOPE, DEFAULT_REQUIRED_LABELS, decodeQuotaMap, emptyQuotaMap, isQuotaMap, } from "./905bffc0220f43d0-map.js";
4
4
  export { isQuotaObservation, mergeQuotaObservation, QuotaCodecError, } from "./4f36d333d081d040-merge.js";
5
5
  export { budgetExhaustedResetAt, futureResetAt, projectQuota, readsExhausted, } from "./39c3cb05738fcf6d-projection.js";