oc-codex-multi-auth 6.20.0 → 6.22.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 (215) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +729 -568
  3. package/assets/icon.svg +7 -7
  4. package/assets/opencode-logo-ornate-dark.svg +18 -18
  5. package/assets/readme-hero.svg +31 -31
  6. package/config/README.md +177 -177
  7. package/config/minimal-opencode.json +14 -14
  8. package/config/opencode-legacy.json +1496 -1496
  9. package/config/opencode-modern.json +555 -555
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +567 -102
  12. package/dist/index.js.map +1 -1
  13. package/dist/lib/account-display.d.ts +46 -0
  14. package/dist/lib/account-display.d.ts.map +1 -1
  15. package/dist/lib/account-display.js +263 -1
  16. package/dist/lib/account-display.js.map +1 -1
  17. package/dist/lib/accounts/persistence.d.ts +3 -1
  18. package/dist/lib/accounts/persistence.d.ts.map +1 -1
  19. package/dist/lib/accounts/persistence.js +27 -3
  20. package/dist/lib/accounts/persistence.js.map +1 -1
  21. package/dist/lib/accounts/stale-state.d.ts +1 -0
  22. package/dist/lib/accounts/stale-state.d.ts.map +1 -1
  23. package/dist/lib/accounts/stale-state.js +30 -0
  24. package/dist/lib/accounts/stale-state.js.map +1 -1
  25. package/dist/lib/accounts/warm-recovery.d.ts +8 -0
  26. package/dist/lib/accounts/warm-recovery.d.ts.map +1 -0
  27. package/dist/lib/accounts/warm-recovery.js +70 -0
  28. package/dist/lib/accounts/warm-recovery.js.map +1 -0
  29. package/dist/lib/accounts/warm-request.d.ts +2 -0
  30. package/dist/lib/accounts/warm-request.d.ts.map +1 -1
  31. package/dist/lib/accounts/warm-request.js +4 -2
  32. package/dist/lib/accounts/warm-request.js.map +1 -1
  33. package/dist/lib/accounts.d.ts +5 -1
  34. package/dist/lib/accounts.d.ts.map +1 -1
  35. package/dist/lib/accounts.js +22 -17
  36. package/dist/lib/accounts.js.map +1 -1
  37. package/dist/lib/auth/login-runner.d.ts.map +1 -1
  38. package/dist/lib/auth/login-runner.js +79 -2
  39. package/dist/lib/auth/login-runner.js.map +1 -1
  40. package/dist/lib/auto-update-checker.d.ts +17 -1
  41. package/dist/lib/auto-update-checker.d.ts.map +1 -1
  42. package/dist/lib/auto-update-checker.js +49 -3
  43. package/dist/lib/auto-update-checker.js.map +1 -1
  44. package/dist/lib/circuit-breaker.d.ts +5 -0
  45. package/dist/lib/circuit-breaker.d.ts.map +1 -1
  46. package/dist/lib/circuit-breaker.js +23 -6
  47. package/dist/lib/circuit-breaker.js.map +1 -1
  48. package/dist/lib/cli.d.ts +1 -0
  49. package/dist/lib/cli.d.ts.map +1 -1
  50. package/dist/lib/cli.js +9 -3
  51. package/dist/lib/cli.js.map +1 -1
  52. package/dist/lib/codex-reset.d.ts +7 -1
  53. package/dist/lib/codex-reset.d.ts.map +1 -1
  54. package/dist/lib/codex-reset.js +9 -2
  55. package/dist/lib/codex-reset.js.map +1 -1
  56. package/dist/lib/codex-usage.d.ts +7 -4
  57. package/dist/lib/codex-usage.d.ts.map +1 -1
  58. package/dist/lib/codex-usage.js +48 -13
  59. package/dist/lib/codex-usage.js.map +1 -1
  60. package/dist/lib/config.d.ts +77 -1
  61. package/dist/lib/config.d.ts.map +1 -1
  62. package/dist/lib/config.js +190 -15
  63. package/dist/lib/config.js.map +1 -1
  64. package/dist/lib/context-overflow.js +10 -10
  65. package/dist/lib/desktop-notifications.js +6 -6
  66. package/dist/lib/oauth-success.js +202 -202
  67. package/dist/lib/parallel-probe.d.ts.map +1 -1
  68. package/dist/lib/parallel-probe.js +2 -1
  69. package/dist/lib/parallel-probe.js.map +1 -1
  70. package/dist/lib/plan-allotment.d.ts +72 -0
  71. package/dist/lib/plan-allotment.d.ts.map +1 -0
  72. package/dist/lib/plan-allotment.js +142 -0
  73. package/dist/lib/plan-allotment.js.map +1 -0
  74. package/dist/lib/plugin-origin.d.ts +77 -0
  75. package/dist/lib/plugin-origin.d.ts.map +1 -0
  76. package/dist/lib/plugin-origin.js +301 -0
  77. package/dist/lib/plugin-origin.js.map +1 -0
  78. package/dist/lib/prompts/codex-opencode-bridge.js +67 -67
  79. package/dist/lib/prompts/codex.js +75 -75
  80. package/dist/lib/quota-display.d.ts +40 -0
  81. package/dist/lib/quota-display.d.ts.map +1 -0
  82. package/dist/lib/quota-display.js +45 -0
  83. package/dist/lib/quota-display.js.map +1 -0
  84. package/dist/lib/quota-notifications.d.ts +2 -1
  85. package/dist/lib/quota-notifications.d.ts.map +1 -1
  86. package/dist/lib/quota-notifications.js +43 -14
  87. package/dist/lib/quota-notifications.js.map +1 -1
  88. package/dist/lib/quota-overview.d.ts +253 -0
  89. package/dist/lib/quota-overview.d.ts.map +1 -0
  90. package/dist/lib/quota-overview.js +707 -0
  91. package/dist/lib/quota-overview.js.map +1 -0
  92. package/dist/lib/request/fetch-helpers.js +22 -18
  93. package/dist/lib/request/fetch-helpers.js.map +1 -1
  94. package/dist/lib/request/response-handler.d.ts.map +1 -1
  95. package/dist/lib/request/response-handler.js +85 -43
  96. package/dist/lib/request/response-handler.js.map +1 -1
  97. package/dist/lib/request/retry-budget.d.ts +26 -0
  98. package/dist/lib/request/retry-budget.d.ts.map +1 -1
  99. package/dist/lib/request/retry-budget.js +43 -0
  100. package/dist/lib/request/retry-budget.js.map +1 -1
  101. package/dist/lib/rotation.d.ts +1 -1
  102. package/dist/lib/schemas.d.ts +67 -0
  103. package/dist/lib/schemas.d.ts.map +1 -1
  104. package/dist/lib/schemas.js +53 -0
  105. package/dist/lib/schemas.js.map +1 -1
  106. package/dist/lib/storage/backup.d.ts +33 -0
  107. package/dist/lib/storage/backup.d.ts.map +1 -1
  108. package/dist/lib/storage/backup.js +43 -7
  109. package/dist/lib/storage/backup.js.map +1 -1
  110. package/dist/lib/storage/credential-snapshots.d.ts +78 -0
  111. package/dist/lib/storage/credential-snapshots.d.ts.map +1 -0
  112. package/dist/lib/storage/credential-snapshots.js +302 -0
  113. package/dist/lib/storage/credential-snapshots.js.map +1 -0
  114. package/dist/lib/storage/export-import.d.ts.map +1 -1
  115. package/dist/lib/storage/export-import.js +7 -0
  116. package/dist/lib/storage/export-import.js.map +1 -1
  117. package/dist/lib/storage/flagged.d.ts.map +1 -1
  118. package/dist/lib/storage/flagged.js +23 -1
  119. package/dist/lib/storage/flagged.js.map +1 -1
  120. package/dist/lib/storage/load-save.d.ts +5 -0
  121. package/dist/lib/storage/load-save.d.ts.map +1 -1
  122. package/dist/lib/storage/load-save.js +61 -0
  123. package/dist/lib/storage/load-save.js.map +1 -1
  124. package/dist/lib/storage/normalize.d.ts.map +1 -1
  125. package/dist/lib/storage/normalize.js +38 -2
  126. package/dist/lib/storage/normalize.js.map +1 -1
  127. package/dist/lib/storage/paths.d.ts +1 -0
  128. package/dist/lib/storage/paths.d.ts.map +1 -1
  129. package/dist/lib/storage/paths.js +1 -1
  130. package/dist/lib/storage/paths.js.map +1 -1
  131. package/dist/lib/storage/state.d.ts +1 -0
  132. package/dist/lib/storage/state.d.ts.map +1 -1
  133. package/dist/lib/storage/state.js +12 -0
  134. package/dist/lib/storage/state.js.map +1 -1
  135. package/dist/lib/storage/test-home-guard.d.ts +35 -0
  136. package/dist/lib/storage/test-home-guard.d.ts.map +1 -0
  137. package/dist/lib/storage/test-home-guard.js +59 -0
  138. package/dist/lib/storage/test-home-guard.js.map +1 -0
  139. package/dist/lib/tools/codex-dashboard.d.ts.map +1 -1
  140. package/dist/lib/tools/codex-dashboard.js +3 -2
  141. package/dist/lib/tools/codex-dashboard.js.map +1 -1
  142. package/dist/lib/tools/codex-doctor.d.ts.map +1 -1
  143. package/dist/lib/tools/codex-doctor.js +16 -0
  144. package/dist/lib/tools/codex-doctor.js.map +1 -1
  145. package/dist/lib/tools/codex-health.d.ts.map +1 -1
  146. package/dist/lib/tools/codex-health.js +10 -2
  147. package/dist/lib/tools/codex-health.js.map +1 -1
  148. package/dist/lib/tools/codex-label.d.ts.map +1 -1
  149. package/dist/lib/tools/codex-label.js +1 -0
  150. package/dist/lib/tools/codex-label.js.map +1 -1
  151. package/dist/lib/tools/codex-limits.d.ts.map +1 -1
  152. package/dist/lib/tools/codex-limits.js +26 -10
  153. package/dist/lib/tools/codex-limits.js.map +1 -1
  154. package/dist/lib/tools/codex-list.d.ts.map +1 -1
  155. package/dist/lib/tools/codex-list.js +28 -3
  156. package/dist/lib/tools/codex-list.js.map +1 -1
  157. package/dist/lib/tools/codex-note.d.ts.map +1 -1
  158. package/dist/lib/tools/codex-note.js +4 -1
  159. package/dist/lib/tools/codex-note.js.map +1 -1
  160. package/dist/lib/tools/codex-pool.d.ts.map +1 -1
  161. package/dist/lib/tools/codex-pool.js +6 -2
  162. package/dist/lib/tools/codex-pool.js.map +1 -1
  163. package/dist/lib/tools/codex-refresh.d.ts.map +1 -1
  164. package/dist/lib/tools/codex-refresh.js +4 -1
  165. package/dist/lib/tools/codex-refresh.js.map +1 -1
  166. package/dist/lib/tools/codex-remove.d.ts.map +1 -1
  167. package/dist/lib/tools/codex-remove.js +1 -0
  168. package/dist/lib/tools/codex-remove.js.map +1 -1
  169. package/dist/lib/tools/codex-reset.d.ts.map +1 -1
  170. package/dist/lib/tools/codex-reset.js +53 -8
  171. package/dist/lib/tools/codex-reset.js.map +1 -1
  172. package/dist/lib/tools/codex-status.d.ts.map +1 -1
  173. package/dist/lib/tools/codex-status.js +25 -2
  174. package/dist/lib/tools/codex-status.js.map +1 -1
  175. package/dist/lib/tools/codex-switch.d.ts.map +1 -1
  176. package/dist/lib/tools/codex-switch.js +1 -0
  177. package/dist/lib/tools/codex-switch.js.map +1 -1
  178. package/dist/lib/tools/codex-tag.d.ts.map +1 -1
  179. package/dist/lib/tools/codex-tag.js +4 -1
  180. package/dist/lib/tools/codex-tag.js.map +1 -1
  181. package/dist/lib/tools/codex-warm.d.ts +2 -1
  182. package/dist/lib/tools/codex-warm.d.ts.map +1 -1
  183. package/dist/lib/tools/codex-warm.js +49 -5
  184. package/dist/lib/tools/codex-warm.js.map +1 -1
  185. package/dist/lib/tools/index.d.ts +10 -1
  186. package/dist/lib/tools/index.d.ts.map +1 -1
  187. package/dist/lib/tools/index.js.map +1 -1
  188. package/dist/lib/tui-quota-cache.d.ts +35 -0
  189. package/dist/lib/tui-quota-cache.d.ts.map +1 -1
  190. package/dist/lib/tui-quota-cache.js +79 -12
  191. package/dist/lib/tui-quota-cache.js.map +1 -1
  192. package/dist/lib/tui-quota-overview.d.ts +55 -0
  193. package/dist/lib/tui-quota-overview.d.ts.map +1 -0
  194. package/dist/lib/tui-quota-overview.js +209 -0
  195. package/dist/lib/tui-quota-overview.js.map +1 -0
  196. package/dist/lib/tui-status.d.ts +55 -0
  197. package/dist/lib/tui-status.d.ts.map +1 -1
  198. package/dist/lib/tui-status.js +155 -14
  199. package/dist/lib/tui-status.js.map +1 -1
  200. package/dist/lib/ui/auth-menu.d.ts +2 -0
  201. package/dist/lib/ui/auth-menu.d.ts.map +1 -1
  202. package/dist/lib/ui/auth-menu.js +10 -6
  203. package/dist/lib/ui/auth-menu.js.map +1 -1
  204. package/dist/tui.d.ts +59 -1
  205. package/dist/tui.d.ts.map +1 -1
  206. package/dist/tui.js +535 -43
  207. package/dist/tui.js.map +1 -1
  208. package/package.json +155 -155
  209. package/scripts/audit-dev-allowlist.js +114 -114
  210. package/scripts/clean-dist.js +27 -27
  211. package/scripts/copy-oauth-success.js +47 -47
  212. package/scripts/install-oc-codex-multi-auth-core.js +2003 -1469
  213. package/scripts/install-oc-codex-multi-auth.js +37 -37
  214. package/scripts/test-all-models.sh +260 -260
  215. package/scripts/validate-model-map.sh +97 -97
@@ -0,0 +1,707 @@
1
+ /**
2
+ * One constant line describing the whole account pool.
3
+ *
4
+ * The prompt status line names whichever account served the most recent
5
+ * request, so on a pool of several accounts it changes identity as rotation
6
+ * moves - and a reader who wants to know where the pool stands has to watch it
7
+ * long enough to see every account go past. This module renders the pool
8
+ * instead: every account at once, in a fixed order, so the line only changes
9
+ * when the underlying quota does.
10
+ *
11
+ * ```text
12
+ * 24%: #1 5x 13%, #2 20x 100% 3d 1r, #3 1x 12%
13
+ * ```
14
+ *
15
+ * The leading figure is the pool total, and it is a WEIGHTED mean rather than
16
+ * a plain one. A Pro seat spent to 50% has given up twenty times the capacity
17
+ * a Business Standard seat does at 50%, so averaging the percentages
18
+ * unweighted describes a pool nobody has; `lib/plan-allotment.ts` supplies the
19
+ * per-plan ratio the mean is taken over.
20
+ *
21
+ * Percentages follow `quotaDisplay` like every other surface, so the same pool
22
+ * reads `24%` as headroom or `76%` as consumption. Only the wording changes:
23
+ * every decision here - which window governs an account, which account is
24
+ * closest to recovering, whether a reset time is worth the characters - stays
25
+ * keyed on the percentage remaining.
26
+ *
27
+ * Everything below is pure string work over already-gathered readings, so the
28
+ * whole rendering can be exercised without a network, a clock or a terminal.
29
+ */
30
+ import { maskEmailForDisplay } from "./account-display.js";
31
+ import { formatPlanMultiplier, getPlanWeight } from "./plan-allotment.js";
32
+ import { formatQuotaPercent, toQuotaDisplayPercent, } from "./quota-display.js";
33
+ const MS_PER_MINUTE = 60_000;
34
+ const MS_PER_HOUR = 60 * MS_PER_MINUTE;
35
+ const MS_PER_DAY = 24 * MS_PER_HOUR;
36
+ /**
37
+ * Under `resetTimes: "low"`, only an account at or below this headroom gets
38
+ * its reset time printed.
39
+ *
40
+ * Every account has a reset, and printing all of them triples the length of
41
+ * the line to say "this account you are not waiting on recovers at some point
42
+ * too". The threshold matches the one the single-account status line already
43
+ * uses to decide the same question, so an account near exhaustion reads the
44
+ * same way in either mode. `resetTimes: "always"` opts out of the threshold,
45
+ * because 90% spent with an hour to go and 90% spent with six days to go are
46
+ * not the same situation.
47
+ */
48
+ export const OVERVIEW_RESET_LEFT_PERCENT = 25;
49
+ /** Smallest pool-total movement worth spending characters on. */
50
+ const MIN_RECOVERY_DELTA_PERCENT = 1;
51
+ function isPercent(value) {
52
+ return typeof value === "number" && Number.isFinite(value);
53
+ }
54
+ /**
55
+ * Render a duration the way a countdown reads: the largest unit that fits,
56
+ * floored, so `2d` never claims more time remains than actually does. A gap
57
+ * under a minute still reads `1m` rather than `0m`, because a reset that has
58
+ * not happened yet is not zero away.
59
+ */
60
+ export function formatCompactDuration(ms) {
61
+ if (!Number.isFinite(ms) || ms <= 0)
62
+ return undefined;
63
+ if (ms >= MS_PER_DAY)
64
+ return `${Math.floor(ms / MS_PER_DAY)}d`;
65
+ if (ms >= MS_PER_HOUR)
66
+ return `${Math.floor(ms / MS_PER_HOUR)}h`;
67
+ return `${Math.max(1, Math.floor(ms / MS_PER_MINUTE))}m`;
68
+ }
69
+ /**
70
+ * The window that decides what an account can still do.
71
+ *
72
+ * An account reports several windows at once - typically a 5-hour and a weekly
73
+ * one - and the one with the least headroom is the one that stops a request,
74
+ * so it is the one the account is described by. On a tie the window that
75
+ * blocks for longer governs: two windows both fully spent are not equally
76
+ * costly when one returns in four hours and the other in three days.
77
+ */
78
+ export function resolveGoverningWindow(account) {
79
+ let governing;
80
+ for (const window of account.windows) {
81
+ if (!isPercent(window.leftPercent))
82
+ continue;
83
+ if (!governing) {
84
+ governing = window;
85
+ continue;
86
+ }
87
+ const governingLeft = governing.leftPercent ?? 100;
88
+ if (window.leftPercent < governingLeft) {
89
+ governing = window;
90
+ continue;
91
+ }
92
+ if (window.leftPercent === governingLeft &&
93
+ isPercent(window.resetAtMs) &&
94
+ (!isPercent(governing.resetAtMs) || window.resetAtMs > governing.resetAtMs)) {
95
+ governing = window;
96
+ }
97
+ }
98
+ return governing;
99
+ }
100
+ /**
101
+ * Weighted mean headroom across the pool, or `undefined` when no account
102
+ * reported a readable window.
103
+ *
104
+ * Accounts with no readable window are left out rather than counted as full:
105
+ * a quota we could not read is not capacity we know we have.
106
+ */
107
+ export function computeWeightedLeftPercent(accounts) {
108
+ let weighted = 0;
109
+ let totalWeight = 0;
110
+ for (const account of accounts) {
111
+ const governing = resolveGoverningWindow(account);
112
+ if (!governing || !isPercent(governing.leftPercent))
113
+ continue;
114
+ const weight = getPlanWeight(account.planType);
115
+ if (!Number.isFinite(weight) || weight <= 0)
116
+ continue;
117
+ weighted += weight * governing.leftPercent;
118
+ totalWeight += weight;
119
+ }
120
+ return totalWeight > 0 ? Math.round(weighted / totalWeight) : undefined;
121
+ }
122
+ /**
123
+ * What the pool the percentage is taken over adds up to, in 1x seats.
124
+ *
125
+ * Deliberately the same sum {@link computeWeightedLeftPercent} divides by, and
126
+ * over the same accounts, so `66% of 65x` is one statement rather than two
127
+ * that can disagree. An account whose plan states no ratio therefore
128
+ * contributes its fallback weight here exactly as it does to the mean.
129
+ */
130
+ export function computePoolAllotment(accounts) {
131
+ let total = 0;
132
+ for (const account of accounts) {
133
+ const governing = resolveGoverningWindow(account);
134
+ if (!governing || !isPercent(governing.leftPercent))
135
+ continue;
136
+ const weight = getPlanWeight(account.planType);
137
+ if (!Number.isFinite(weight) || weight <= 0)
138
+ continue;
139
+ total += weight;
140
+ }
141
+ return total > 0 ? total : undefined;
142
+ }
143
+ /**
144
+ * The next moment the pool gets capacity back, and how much.
145
+ *
146
+ * Only the window that actually resets is refilled, and the account's
147
+ * governing window is then resolved again: an account whose 5-hour window
148
+ * resets while its weekly window is still spent gains nothing, and reporting
149
+ * the 5-hour refill as pool recovery would promise headroom that does not
150
+ * arrive. Movement below one point is dropped rather than rendered as `+0%`.
151
+ */
152
+ export function resolveQuotaOverviewRecovery(accounts, now = Date.now()) {
153
+ const current = computeWeightedLeftPercent(accounts);
154
+ if (current === undefined)
155
+ return undefined;
156
+ let earliest;
157
+ for (const account of accounts) {
158
+ for (const window of account.windows) {
159
+ if (!isPercent(window.leftPercent) || window.leftPercent >= 100)
160
+ continue;
161
+ const resetAtMs = window.resetAtMs;
162
+ if (!isPercent(resetAtMs) || resetAtMs <= now)
163
+ continue;
164
+ if (earliest === undefined || resetAtMs < earliest)
165
+ earliest = resetAtMs;
166
+ }
167
+ }
168
+ if (earliest === undefined)
169
+ return undefined;
170
+ const refilled = accounts.map((account) => ({
171
+ ...account,
172
+ windows: account.windows.map((window) => isPercent(window.resetAtMs) && window.resetAtMs <= earliest
173
+ ? { ...window, leftPercent: 100 }
174
+ : window),
175
+ }));
176
+ const recovered = computeWeightedLeftPercent(refilled);
177
+ if (recovered === undefined)
178
+ return undefined;
179
+ const deltaPercent = recovered - current;
180
+ if (deltaPercent < MIN_RECOVERY_DELTA_PERCENT)
181
+ return undefined;
182
+ return { deltaPercent, atMs: earliest };
183
+ }
184
+ /**
185
+ * Whether nothing in the pool has capacity left.
186
+ *
187
+ * This is the condition the reset-credit line exists for: while any account
188
+ * can still serve a request, which one recovers when is a detail, and once
189
+ * none can it is the only question left. Accounts whose quota could not be
190
+ * read do not count either way - an unknown reading is not evidence of
191
+ * exhaustion, but it is not capacity either, so a pool of nothing but
192
+ * unreadable accounts is reported as not spent rather than as dead.
193
+ */
194
+ export function isPoolFullySpent(accounts) {
195
+ let readable = 0;
196
+ for (const account of accounts) {
197
+ const governing = resolveGoverningWindow(account);
198
+ if (!governing || !isPercent(governing.leftPercent))
199
+ continue;
200
+ readable += 1;
201
+ if (governing.leftPercent > 0)
202
+ return false;
203
+ }
204
+ return readable > 0;
205
+ }
206
+ /**
207
+ * Sort key for the reset-time orders.
208
+ *
209
+ * An account with no known reset sorts after every account that has one, in
210
+ * both directions. Not knowing when something returns is a different statement
211
+ * from knowing it returns soon, and a different statement from knowing it
212
+ * returns last.
213
+ */
214
+ function governingResetAtMs(account) {
215
+ const governing = resolveGoverningWindow(account);
216
+ return governing && isPercent(governing.resetAtMs)
217
+ ? governing.resetAtMs
218
+ : undefined;
219
+ }
220
+ function governingLeftPercent(account) {
221
+ const governing = resolveGoverningWindow(account);
222
+ return governing && isPercent(governing.leftPercent)
223
+ ? governing.leftPercent
224
+ : undefined;
225
+ }
226
+ /**
227
+ * Arrange the accounts for display.
228
+ *
229
+ * Every comparison falls back to the account number, so two accounts reading
230
+ * the same percentage never trade places between renders. An order that let
231
+ * them would reintroduce exactly the movement this mode exists to remove.
232
+ */
233
+ export function orderOverviewAccounts(accounts, order) {
234
+ const sorted = [...accounts];
235
+ if (order === "most-used" || order === "least-used") {
236
+ const direction = order === "most-used" ? 1 : -1;
237
+ return sorted.sort((left, right) => {
238
+ const leftPercent = governingLeftPercent(left);
239
+ const rightPercent = governingLeftPercent(right);
240
+ if (leftPercent === undefined && rightPercent === undefined) {
241
+ return left.index - right.index;
242
+ }
243
+ if (leftPercent === undefined)
244
+ return 1;
245
+ if (rightPercent === undefined)
246
+ return -1;
247
+ if (leftPercent !== rightPercent) {
248
+ return direction * (leftPercent - rightPercent);
249
+ }
250
+ return left.index - right.index;
251
+ });
252
+ }
253
+ if (order === "renewing-earliest" || order === "renewing-latest") {
254
+ const direction = order === "renewing-earliest" ? 1 : -1;
255
+ return sorted.sort((left, right) => {
256
+ const leftReset = governingResetAtMs(left);
257
+ const rightReset = governingResetAtMs(right);
258
+ if (leftReset === undefined && rightReset === undefined) {
259
+ return left.index - right.index;
260
+ }
261
+ if (leftReset === undefined)
262
+ return 1;
263
+ if (rightReset === undefined)
264
+ return -1;
265
+ if (leftReset !== rightReset)
266
+ return direction * (leftReset - rightReset);
267
+ return left.index - right.index;
268
+ });
269
+ }
270
+ // `number` and anything unrecognized: an order nobody asked for must not
271
+ // silently become one of the sorted ones, which would move accounts around
272
+ // under a reader who configured nothing.
273
+ return sorted.sort((left, right) => left.index - right.index);
274
+ }
275
+ const EMAIL_LIKE = /^[^\s@]+@[^\s@]+$/;
276
+ /**
277
+ * The local part of an email, which is what a person calls the account.
278
+ *
279
+ * `damian@nowaker.net` -> `damian`. Masking is applied to the local part
280
+ * rather than to the whole address, because the domain is what
281
+ * {@link maskEmailForDisplay} keeps and there is no room for it here.
282
+ */
283
+ function formatEmailLocalPart(email, maskEmail) {
284
+ const trimmed = email.trim();
285
+ if (!trimmed)
286
+ return undefined;
287
+ const local = trimmed.split("@")[0]?.trim();
288
+ if (!local)
289
+ return undefined;
290
+ if (!maskEmail)
291
+ return local;
292
+ return `${Array.from(local).slice(0, 2).join("")}***`;
293
+ }
294
+ /**
295
+ * What this account is called on the line.
296
+ *
297
+ * Under `label` a user-set label wins, because it is the one name the user
298
+ * chose; an account that only knows its email falls back to that email's local
299
+ * part, and an account with neither falls back to its number rather than
300
+ * rendering nothing - a nameless segment in a named line reads as a missing
301
+ * account.
302
+ */
303
+ export function resolveAccountName(account, names, maskEmail = false) {
304
+ if (names === "none")
305
+ return undefined;
306
+ if (names === "number")
307
+ return `#${account.index}`;
308
+ const label = account.label?.trim();
309
+ if (label && !EMAIL_LIKE.test(label))
310
+ return label;
311
+ const email = label && EMAIL_LIKE.test(label) ? label : account.email;
312
+ const local = email ? formatEmailLocalPart(email, maskEmail) : undefined;
313
+ return local ?? `#${account.index}`;
314
+ }
315
+ /** The full email for the reset-credit line, masked when asked. */
316
+ function resolveAccountEmail(account, maskEmail) {
317
+ const label = account.label?.trim();
318
+ const email = account.email?.trim() || (label && EMAIL_LIKE.test(label) ? label : undefined);
319
+ if (!email)
320
+ return undefined;
321
+ return maskEmail ? maskEmailForDisplay(email) : email;
322
+ }
323
+ function resolveResetCredits(account) {
324
+ const credits = account.resetCredits;
325
+ return typeof credits === "number" && Number.isFinite(credits) && credits > 0
326
+ ? Math.trunc(credits)
327
+ : 0;
328
+ }
329
+ function shouldPrintReset(leftPercent, resetTimes) {
330
+ if (resetTimes === "never")
331
+ return false;
332
+ if (resetTimes === "always")
333
+ return true;
334
+ return leftPercent <= OVERVIEW_RESET_LEFT_PERCENT;
335
+ }
336
+ /** The part of a segment that is not the account's name or its percentage. */
337
+ function formatAccountAnnotations(account, governing, options) {
338
+ const parts = [];
339
+ const leftPercent = governing.leftPercent;
340
+ if (isPercent(leftPercent) &&
341
+ shouldPrintReset(leftPercent, options.resetTimes) &&
342
+ isPercent(governing.resetAtMs)) {
343
+ const reset = formatCompactDuration(governing.resetAtMs - options.now);
344
+ if (reset)
345
+ parts.push(reset);
346
+ }
347
+ if (options.resetCredits) {
348
+ const credits = resolveResetCredits(account);
349
+ if (credits > 0)
350
+ parts.push(`${credits}r`);
351
+ }
352
+ return parts;
353
+ }
354
+ function formatAccountSegment(account, options) {
355
+ const governing = resolveGoverningWindow(account);
356
+ if (!governing || !isPercent(governing.leftPercent))
357
+ return undefined;
358
+ const parts = [];
359
+ const name = resolveAccountName(account, options.names, options.maskEmail);
360
+ if (name)
361
+ parts.push(name);
362
+ if (options.multipliers) {
363
+ const multiplier = formatPlanMultiplier(account.planType);
364
+ if (multiplier)
365
+ parts.push(multiplier);
366
+ }
367
+ parts.push(formatQuotaPercent(governing.leftPercent, options.mode));
368
+ parts.push(...formatAccountAnnotations(account, governing, options));
369
+ return parts.join(" ");
370
+ }
371
+ /**
372
+ * Accounts reading the same percentage, collapsed into one segment.
373
+ *
374
+ * On a pool where several accounts are fully spent, `100% 3d, 100% 4d,
375
+ * 100% 5d` spends two thirds of its characters repeating a number that is the
376
+ * same every time. Grouping prints it once and keeps what differs:
377
+ *
378
+ * ```text
379
+ * 100% 3d 1r 4d 5d
380
+ * ```
381
+ *
382
+ * The group's size is stated explicitly - `100% x3 3d` - only when the
383
+ * annotations do not already reveal it, so a group of three accounts where one
384
+ * has no reset time to print cannot read as a group of two. A group of one is
385
+ * never counted, because there is nothing to count.
386
+ *
387
+ * Grouping discards identity by construction, so `names` has no effect here.
388
+ */
389
+ function formatAggregateSegments(accounts, options) {
390
+ const groups = new Map();
391
+ for (const account of accounts) {
392
+ const governing = resolveGoverningWindow(account);
393
+ if (!governing || !isPercent(governing.leftPercent))
394
+ continue;
395
+ const percent = formatQuotaPercent(governing.leftPercent, options.mode);
396
+ const group = groups.get(percent) ?? {
397
+ percent,
398
+ size: 0,
399
+ annotations: [],
400
+ };
401
+ group.size += 1;
402
+ const annotations = formatAccountAnnotations(account, governing, options);
403
+ if (annotations.length > 0)
404
+ group.annotations.push(annotations.join(" "));
405
+ groups.set(percent, group);
406
+ }
407
+ return [...groups.values()].map((group) => {
408
+ const parts = [group.percent];
409
+ if (group.size > 1 && group.annotations.length < group.size) {
410
+ parts.push(`x${group.size}`);
411
+ }
412
+ parts.push(...group.annotations);
413
+ return parts.join(" ");
414
+ });
415
+ }
416
+ /**
417
+ * `+12% in 3d` / `-12% 3d`.
418
+ *
419
+ * The sign describes the direction the number beside it moves, not the
420
+ * direction of the user's fortunes: under `used` the pool total falls as
421
+ * capacity returns, and a `+` there would contradict the figure it annotates.
422
+ * The word `in` is the first thing dropped when the line is short, because it
423
+ * is the only part of the clause a reader can supply themselves.
424
+ */
425
+ function formatRecovery(recovery, options) {
426
+ const at = formatCompactDuration(recovery.atMs - options.now);
427
+ if (!at)
428
+ return undefined;
429
+ const sign = options.mode === "used" ? "-" : "+";
430
+ return `${sign}${recovery.deltaPercent}% ${options.words ? "in " : ""}${at}`;
431
+ }
432
+ function formatAccountCount(count, style) {
433
+ if (style === "bare")
434
+ return `${count}`;
435
+ if (style === "short")
436
+ return `${count} acct.`;
437
+ return `${count} account${count === 1 ? "" : "s"}`;
438
+ }
439
+ /**
440
+ * Every annotation level to try, most informative first.
441
+ *
442
+ * One dimension is given up per rung and never restored within the ladder, so
443
+ * each rung is strictly shorter than the one above it. The order is by what a
444
+ * reader loses: the plan badge says nothing the account's own numbers do not,
445
+ * a long label can be replaced by the number that selects the same account,
446
+ * banked resets only matter once something is spent, and a reset countdown on
447
+ * a healthy account is the detail `resetTimes: "always"` opted into.
448
+ *
449
+ * Dropping the account's name entirely is the last rung, and it is offered
450
+ * only when position still identifies an account: under a sorted order, or
451
+ * with any account missing from the line, `39%, 8%, 91%` names nothing at all
452
+ * and a reader would attach those figures to the wrong seats.
453
+ */
454
+ function annotationRungs(options, positionsAreComplete) {
455
+ const rungs = [];
456
+ let current = {
457
+ names: options.names,
458
+ multipliers: options.multipliers,
459
+ resetTimes: options.resetTimes,
460
+ resetCredits: options.resetCredits,
461
+ };
462
+ rungs.push(current);
463
+ const step = (next) => {
464
+ current = { ...current, ...next };
465
+ rungs.push(current);
466
+ };
467
+ if (current.multipliers)
468
+ step({ multipliers: false });
469
+ if (current.names === "label")
470
+ step({ names: "number" });
471
+ if (current.resetCredits)
472
+ step({ resetCredits: false });
473
+ if (current.resetTimes === "always")
474
+ step({ resetTimes: "low" });
475
+ if (current.resetTimes !== "never")
476
+ step({ resetTimes: "never" });
477
+ if (current.names !== "none" && positionsAreComplete)
478
+ step({ names: "none" });
479
+ return rungs;
480
+ }
481
+ /**
482
+ * Every rendering of this pool, longest first.
483
+ *
484
+ * The caller takes the first that fits its width. Detail is dropped in the
485
+ * order that costs a reader the least: the recovery clause and then the
486
+ * annotations (badges, banked resets) that sit beside a figure which stays
487
+ * either way, then the per-account breakdown, leaving the pool total - the one
488
+ * thing the line exists to say - as the last to go.
489
+ *
490
+ * Order is preference, NOT length: a stripped-down rung is occasionally a
491
+ * character or two longer than the rung above it. Sorting by length instead
492
+ * would let a form win or lose by two characters as a percentage crosses from
493
+ * `9%` to `10%`, and the line would change shape while the reader watches -
494
+ * the flicker this whole mode exists to remove.
495
+ *
496
+ * Candidates never exceed what {@link QuotaOverviewOptions} asked for, so a
497
+ * switch left off cannot reappear because the terminal happened to be wide.
498
+ */
499
+ export function formatQuotaOverviewCandidates(accounts, options) {
500
+ const now = options.now ?? Date.now();
501
+ const maskEmail = options.maskEmail ?? false;
502
+ const total = computeWeightedLeftPercent(accounts);
503
+ if (total === undefined)
504
+ return [];
505
+ const totalText = formatQuotaPercent(total, options.mode);
506
+ const ordered = orderOverviewAccounts(accounts, options.order);
507
+ const usable = ordered.filter((account) => resolveGoverningWindow(account));
508
+ const recovery = options.recovery
509
+ ? resolveQuotaOverviewRecovery(accounts, now)
510
+ : undefined;
511
+ const recoveryForms = recovery
512
+ ? [true, false]
513
+ .map((words) => formatRecovery(recovery, { mode: options.mode, now, words }))
514
+ .filter((form) => Boolean(form))
515
+ : [];
516
+ const bodies = [];
517
+ if (options.layout !== "count") {
518
+ // Position identifies an account only when the accounts are in number
519
+ // order, none of them is missing from the line, and the numbers run
520
+ // 1..n with no gap - a deduplicated or disabled account leaves indices
521
+ // like #1, #3, where the second percentage is NOT account #2's.
522
+ const positionsAreComplete = options.order === "number" &&
523
+ usable.length === accounts.length &&
524
+ usable.every((account, position) => account.index === position + 1);
525
+ for (const rung of annotationRungs(options, positionsAreComplete)) {
526
+ const segmentOptions = {
527
+ ...rung,
528
+ mode: options.mode,
529
+ maskEmail,
530
+ now,
531
+ };
532
+ const segments = options.layout === "aggregate"
533
+ ? formatAggregateSegments(usable, segmentOptions)
534
+ : usable
535
+ .map((account) => formatAccountSegment(account, segmentOptions))
536
+ .filter((segment) => Boolean(segment));
537
+ if (segments.length === 0)
538
+ continue;
539
+ const text = segments.join(", ");
540
+ if (!bodies.includes(text))
541
+ bodies.push(text);
542
+ }
543
+ }
544
+ // `66% of 65x` before `66%`: the allotment is a small, near-static
545
+ // annotation, so it is given up early - but not before any account detail,
546
+ // which is what the line is read for.
547
+ const heads = [];
548
+ if (options.allotment) {
549
+ const allotment = computePoolAllotment(accounts);
550
+ if (allotment !== undefined)
551
+ heads.push(`${totalText} of ${allotment}x`);
552
+ }
553
+ if (!heads.includes(totalText))
554
+ heads.push(totalText);
555
+ const candidates = [];
556
+ const push = (head, ...tail) => {
557
+ const body = tail.filter((part) => Boolean(part));
558
+ const text = body.length > 0 ? `${head}: ${body.join(", ")}` : head;
559
+ if (!candidates.includes(text))
560
+ candidates.push(text);
561
+ };
562
+ for (const body of bodies) {
563
+ for (const head of heads) {
564
+ for (const form of recoveryForms)
565
+ push(head, body, form);
566
+ push(head, body);
567
+ }
568
+ }
569
+ for (const head of heads) {
570
+ // The count word is grammar, the recovery clause is information, so the
571
+ // word goes first. The count is the pool's size, not the number of
572
+ // accounts that could be read - `40%: 1 account` on a three-account
573
+ // pool would say a pool exists that does not.
574
+ if (recoveryForms.length > 0) {
575
+ for (const form of recoveryForms)
576
+ push(head, formatAccountCount(accounts.length, "long"), form);
577
+ const shortest = recoveryForms[recoveryForms.length - 1];
578
+ push(head, formatAccountCount(accounts.length, "short"), shortest);
579
+ push(head, formatAccountCount(accounts.length, "bare"), shortest);
580
+ }
581
+ push(head, formatAccountCount(accounts.length, "long"));
582
+ push(head, formatAccountCount(accounts.length, "short"));
583
+ push(head, formatAccountCount(accounts.length, "bare"));
584
+ }
585
+ for (const head of heads)
586
+ push(head);
587
+ return candidates;
588
+ }
589
+ /** The fullest rendering, for surfaces with a line to themselves. */
590
+ export function formatQuotaOverviewText(accounts, options) {
591
+ return formatQuotaOverviewCandidates(accounts, options)[0] ?? "";
592
+ }
593
+ /**
594
+ * Every rendering of the banked reset credits, longest first.
595
+ *
596
+ * ```text
597
+ * Free resets: 6d 1r damian@nowaker.net, 4d 2r work@example.com
598
+ * ```
599
+ *
600
+ * Sorted by the LATEST reset first, which is redemption order rather than
601
+ * reading order: redeeming a credit on an account that renews by itself
602
+ * tomorrow throws the credit away, while the account six days out is the one
603
+ * worth spending it on.
604
+ *
605
+ * Returns nothing at all unless the pool is spent and something is redeemable.
606
+ * A reset credit is only an answer to "everything is used up"; offered while
607
+ * accounts still have headroom it is an invitation to waste it.
608
+ */
609
+ export function formatQuotaResetsCandidates(accounts, options) {
610
+ if (!isPoolFullySpent(accounts))
611
+ return [];
612
+ const now = options.now ?? Date.now();
613
+ const maskEmail = options.maskEmail ?? false;
614
+ const redeemable = orderOverviewAccounts(accounts, "renewing-latest").filter((account) => resolveResetCredits(account) > 0);
615
+ if (redeemable.length === 0)
616
+ return [];
617
+ const durations = redeemable.map((account) => {
618
+ const resetAtMs = governingResetAtMs(account);
619
+ return resetAtMs === undefined
620
+ ? undefined
621
+ : formatCompactDuration(resetAtMs - now);
622
+ });
623
+ const credits = redeemable.map((account) => resolveResetCredits(account));
624
+ const everyAccountHasOneCredit = credits.every((count) => count === 1);
625
+ // The identity follows `accountNames` like the pool line does: the full
626
+ // address is the longest form of the `label` name and appears only there,
627
+ // `number` gives `#n`, and `none` names no account at all.
628
+ const names = options.names ?? "label";
629
+ const unnamed = redeemable.map(() => undefined);
630
+ const identities = names === "none"
631
+ ? [unnamed]
632
+ : names === "number"
633
+ ? [
634
+ redeemable.map((account) => resolveAccountName(account, "number", maskEmail)),
635
+ ]
636
+ : [
637
+ redeemable.map((account) => resolveAccountEmail(account, maskEmail)),
638
+ redeemable.map((account) => resolveAccountName(account, "label", maskEmail)),
639
+ redeemable.map((account) => resolveAccountName(account, "number", maskEmail)),
640
+ ];
641
+ const candidates = [];
642
+ const add = (text) => {
643
+ if (!candidates.includes(text))
644
+ candidates.push(text);
645
+ };
646
+ const push = (prefix, identity, withDuration, withCredits) => {
647
+ const segments = redeemable.map((_account, position) => {
648
+ const parts = [];
649
+ const duration = durations[position];
650
+ if (withDuration && duration)
651
+ parts.push(duration);
652
+ if (withCredits)
653
+ parts.push(`${credits[position]}r`);
654
+ const name = identity[position];
655
+ if (name)
656
+ parts.push(name);
657
+ return parts.join(" ");
658
+ });
659
+ // A form that would render one account as an empty segment is not a
660
+ // shorter rendering of this line, it is a different and wrong one.
661
+ if (segments.some((segment) => segment.length === 0))
662
+ return;
663
+ add(`${prefix} ${segments.join(", ")}`);
664
+ };
665
+ // The word `Free` is given up before any account detail is, and the
666
+ // identity then shortens from the fullest configured form - the full
667
+ // address under `label` - down to the number `codex-reset` takes.
668
+ push("Free resets:", identities[0] ?? unnamed, true, true);
669
+ for (const identity of identities)
670
+ push("Resets:", identity, true, true);
671
+ // Only once every identity form has been tried does the line start giving
672
+ // up facts: the countdown is the reason one account is a better redemption
673
+ // than another, and the credit count stops being news when every account
674
+ // holds exactly one.
675
+ for (const identity of identities) {
676
+ if (everyAccountHasOneCredit)
677
+ push("Resets:", identity, true, false);
678
+ push("Resets:", identity, false, true);
679
+ push("Resets:", identity, false, false);
680
+ }
681
+ add(`Resets: ${redeemable.length}`);
682
+ return candidates;
683
+ }
684
+ /**
685
+ * Headroom of the account with the most room left, for the caller that
686
+ * colours the line.
687
+ *
688
+ * Deliberately the best account rather than the worst: a pool is only in
689
+ * trouble when nothing in it has room left, and keying on the worst account
690
+ * would paint the line red for one spent seat that rotation has already
691
+ * stopped selecting while every other account serves requests normally.
692
+ */
693
+ export function resolveQuotaOverviewTonePercent(accounts) {
694
+ let best;
695
+ for (const account of accounts) {
696
+ const governing = resolveGoverningWindow(account);
697
+ if (!governing || !isPercent(governing.leftPercent))
698
+ continue;
699
+ if (best === undefined || governing.leftPercent > best) {
700
+ best = governing.leftPercent;
701
+ }
702
+ }
703
+ return best;
704
+ }
705
+ /** Re-exported so callers rendering a bare total need only this module. */
706
+ export { toQuotaDisplayPercent };
707
+ //# sourceMappingURL=quota-overview.js.map