@cortexkit/opencode-openai-auth 0.3.3 → 0.4.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 (57) hide show
  1. package/README.md +1 -0
  2. package/dist/cli.js +1 -1
  3. package/dist/codex-http.d.ts +3 -0
  4. package/dist/config.d.ts +3 -0
  5. package/dist/core/accounts.d.ts +9 -3
  6. package/dist/core/oauth.d.ts +1 -1
  7. package/dist/core/quota-manager.d.ts +2 -2
  8. package/dist/{index-k5zwf3j1.js → index-1anzb8jh.js} +1 -1
  9. package/dist/index-1r02s323.js +6 -0
  10. package/dist/index-2ng03cby.js +2 -0
  11. package/dist/{index-e7m7qbne.js → index-6y6ss1kc.js} +1 -1
  12. package/dist/{index-1xmasv0x.js → index-b6kp9ydc.js} +1 -1
  13. package/dist/index-cz1bkmjz.js +2 -0
  14. package/dist/index-dzg88nw2.js +89 -0
  15. package/dist/index-y67qn95a.js +2 -0
  16. package/dist/index.d.ts +8 -1
  17. package/dist/index.js +65 -65
  18. package/dist/model-costs.d.ts +29 -0
  19. package/dist/prompt-context.d.ts +1 -1
  20. package/dist/quota-normalize-8yzxqy78.js +1 -0
  21. package/dist/quota-normalize.d.ts +13 -4
  22. package/dist/rpc/port-file.js +1 -1
  23. package/dist/rpc/rpc-client.js +1 -1
  24. package/dist/rpc/rpc-dir.js +1 -1
  25. package/dist/sidebar-state.d.ts +47 -2
  26. package/dist/sidebar-state.js +1 -1
  27. package/dist/token-util-h46jb3eg.js +1 -0
  28. package/dist/token-yzcb1p3e.js +1 -0
  29. package/dist/tui/command-dialogs.d.ts +8 -1
  30. package/dist/tui-preferences.js +1 -1
  31. package/dist/tui.d.ts +18 -1
  32. package/dist/version.d.ts +1 -1
  33. package/dist/ws-pool.d.ts +1 -0
  34. package/package.json +11 -8
  35. package/src/core/refresh-file-lock.ts +293 -0
  36. package/src/sidebar-state.ts +392 -13
  37. package/src/tui/command-dialogs.tsx +43 -25
  38. package/src/tui/entry.mjs +37 -0
  39. package/src/tui-compiled/logger.ts +161 -0
  40. package/src/tui-compiled/rpc/port-file.ts +70 -0
  41. package/src/tui-compiled/rpc/protocol.ts +31 -0
  42. package/src/tui-compiled/rpc/rpc-client.ts +78 -0
  43. package/src/tui-compiled/rpc/rpc-dir.ts +22 -0
  44. package/src/tui-compiled/sidebar-state.ts +695 -0
  45. package/src/tui-compiled/tui/command-dialogs.tsx +645 -0
  46. package/src/tui-compiled/tui-preferences.ts +328 -0
  47. package/src/tui-compiled/tui.tsx +1098 -0
  48. package/src/tui-compiled/util/open-url.ts +30 -0
  49. package/src/tui.tsx +158 -77
  50. package/dist/index-5s67238f.js +0 -2
  51. package/dist/index-d9hq0tmc.js +0 -2
  52. package/dist/index-mrg2fms9.js +0 -2
  53. package/dist/index-phrx93zy.js +0 -92
  54. package/dist/index-qdh7pt5a.js +0 -3
  55. package/dist/quota-normalize-dfdpwwm3.js +0 -1
  56. package/dist/token-01dcf92a.js +0 -1
  57. package/dist/token-util-rkqd82q9.js +0 -1
@@ -0,0 +1,695 @@
1
+ export interface QuotaWindow {
2
+ usedPercent: number
3
+ remainingPercent: number
4
+ resetsAt?: string
5
+ windowMinutes?: number
6
+ }
7
+
8
+ export interface AccountQuota {
9
+ primary?: QuotaWindow
10
+ secondary?: QuotaWindow
11
+ resetCreditsAvailable?: number
12
+ }
13
+
14
+ export type QuotaWindowKey = 'primary' | 'secondary'
15
+
16
+ const QUOTA_WINDOW_KEYS: readonly QuotaWindowKey[] = ['primary', 'secondary']
17
+ const LEGACY_WINDOW_MINUTES: Record<QuotaWindowKey, number> = {
18
+ primary: 300,
19
+ secondary: 10_080,
20
+ }
21
+
22
+ function compactUnit(value: number): string {
23
+ return Number.isInteger(value)
24
+ ? String(value)
25
+ : String(Math.round(value * 10) / 10)
26
+ }
27
+
28
+ // Derives a short human label ("5h", "1d", "7d") from a window length in
29
+ // minutes. Snapshots written before dynamic windows carry no length, so retain
30
+ // their historical primary=5h and secondary=7d meanings.
31
+ export function formatWindowLabel(
32
+ windowMinutes: number | undefined,
33
+ fallbackKey: QuotaWindowKey,
34
+ ): string {
35
+ const minutes =
36
+ windowMinutes !== undefined &&
37
+ Number.isFinite(windowMinutes) &&
38
+ windowMinutes > 0
39
+ ? windowMinutes
40
+ : LEGACY_WINDOW_MINUTES[fallbackKey]
41
+ if (minutes < 60) return `${compactUnit(minutes)}m`
42
+ if (minutes < 1_440) return `${compactUnit(minutes / 60)}h`
43
+ return `${compactUnit(minutes / 1_440)}d`
44
+ }
45
+
46
+ export interface PresentQuotaWindow {
47
+ key: QuotaWindowKey
48
+ label: string
49
+ window: QuotaWindow
50
+ windowMs: number | null
51
+ }
52
+
53
+ // Present windows only — an absent slot means "not applicable", not
54
+ // "unknown", so it must never synthesize a placeholder row here.
55
+ export function getPresentQuotaWindows(
56
+ quota: AccountQuota | null,
57
+ ): PresentQuotaWindow[] {
58
+ if (!quota) return []
59
+ const rows: PresentQuotaWindow[] = []
60
+ for (const key of QUOTA_WINDOW_KEYS) {
61
+ const window = quota[key]
62
+ if (!window) continue
63
+ const configuredMinutes = window.windowMinutes
64
+ const windowMinutes =
65
+ configuredMinutes !== undefined &&
66
+ Number.isFinite(configuredMinutes) &&
67
+ configuredMinutes > 0
68
+ ? configuredMinutes
69
+ : LEGACY_WINDOW_MINUTES[key]
70
+ rows.push({
71
+ key,
72
+ label: formatWindowLabel(windowMinutes, key),
73
+ window,
74
+ windowMs: windowMinutes * 60_000,
75
+ })
76
+ }
77
+ return rows
78
+ }
79
+
80
+ export interface SidebarAccountState {
81
+ id: string
82
+ label: string | undefined
83
+ quota: AccountQuota | null
84
+ killed: boolean
85
+ enabled: boolean
86
+ resetCredits?: number
87
+ }
88
+
89
+ export interface ActiveRoutingEntry {
90
+ activeId: string
91
+ route: string
92
+ updatedAt: number
93
+ }
94
+
95
+ export type ActiveRoutingMap = Record<string, ActiveRoutingEntry>
96
+
97
+ export interface SidebarState {
98
+ main: {
99
+ quota: AccountQuota | null
100
+ killed: boolean
101
+ quotaBackedOff?: boolean
102
+ quotaBackoffUntil?: number
103
+ refreshBackedOff?: boolean
104
+ refreshBackoffUntil?: number
105
+ resetCredits?: number
106
+ }
107
+ fallbacks: SidebarAccountState[]
108
+ /** @deprecated Compatibility field for readers that do not consume activeRouting. */
109
+ activeId: string | undefined
110
+ /** Machine-global routing mode and compatibility value for older readers. */
111
+ route: string
112
+ activeRouting?: ActiveRoutingMap
113
+ planType?: string
114
+ credits?: number
115
+ lastUpdated: number
116
+ }
117
+
118
+ import { randomUUID } from 'node:crypto'
119
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
120
+ import { tmpdir } from 'node:os'
121
+ import { dirname, join } from 'node:path'
122
+ import { acquireRefreshFileLock } from './core/refresh-file-lock'
123
+ import { createLogger } from './logger'
124
+
125
+ const logSb = createLogger('sidebar')
126
+
127
+ const STATE_FILE_ENV = 'OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE'
128
+ const DEFAULT_STATE_DIR = join(tmpdir(), 'opencode-openai-auth')
129
+ const DEFAULT_STATE_FILE = join(DEFAULT_STATE_DIR, 'sidebar-state.json')
130
+
131
+ function normalizeResetCredits(value: unknown): number | undefined {
132
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0
133
+ ? value
134
+ : undefined
135
+ }
136
+
137
+ function resetCreditsField(value: unknown): { resetCredits?: number } {
138
+ const credits = normalizeResetCredits(value)
139
+ return credits !== undefined ? { resetCredits: credits } : {}
140
+ }
141
+
142
+ function normalizeActiveRouting(value: unknown): ActiveRoutingMap | undefined {
143
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
144
+ return undefined
145
+ }
146
+
147
+ const normalized: ActiveRoutingMap = {}
148
+ for (const [sessionId, rawEntry] of Object.entries(value)) {
149
+ if (
150
+ rawEntry === null ||
151
+ typeof rawEntry !== 'object' ||
152
+ Array.isArray(rawEntry)
153
+ ) {
154
+ continue
155
+ }
156
+ const entry = rawEntry as Record<string, unknown>
157
+ if (
158
+ typeof entry.activeId !== 'string' ||
159
+ typeof entry.route !== 'string' ||
160
+ typeof entry.updatedAt !== 'number' ||
161
+ !Number.isFinite(entry.updatedAt)
162
+ ) {
163
+ continue
164
+ }
165
+ normalized[sessionId] = {
166
+ activeId: entry.activeId,
167
+ route: entry.route,
168
+ updatedAt: entry.updatedAt,
169
+ }
170
+ }
171
+
172
+ return Object.keys(normalized).length > 0 ? normalized : undefined
173
+ }
174
+
175
+ export function getSidebarStateFile(): string {
176
+ return process.env[STATE_FILE_ENV] || DEFAULT_STATE_FILE
177
+ }
178
+
179
+ export const DEFAULT_SIDEBAR_STATE: SidebarState = {
180
+ main: { quota: null, killed: false },
181
+ fallbacks: [],
182
+ activeId: undefined,
183
+ route: 'main',
184
+ lastUpdated: 0,
185
+ }
186
+
187
+ /**
188
+ * Normalize an arbitrary parsed value into a well-formed SidebarState.
189
+ *
190
+ * JSON.parse + `as SidebarState` is an unchecked cast — a partial, old, or
191
+ * malformed state file passes through and the TUI's `state().main.quota` /
192
+ * `state().fallbacks.filter(...)` throw at runtime. This helper guarantees
193
+ * every required field is present and correctly typed before the value leaves
194
+ * the I/O boundary, so a bad file can never crash the host TUI.
195
+ */
196
+ export function normalizeSidebarState(raw: unknown): SidebarState {
197
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
198
+ return DEFAULT_SIDEBAR_STATE
199
+ }
200
+
201
+ const r = raw as Record<string, unknown>
202
+
203
+ // main — must be an object with at least quota and killed
204
+ const rawMain = r.main
205
+ let main: SidebarState['main']
206
+ if (
207
+ rawMain !== null &&
208
+ typeof rawMain === 'object' &&
209
+ !Array.isArray(rawMain)
210
+ ) {
211
+ const m = rawMain as Record<string, unknown>
212
+ main = {
213
+ quota: ('quota' in m ? m.quota : null) as AccountQuota | null,
214
+ killed: typeof m.killed === 'boolean' ? m.killed : false,
215
+ // Preserve optional backoff fields if present
216
+ ...(typeof m.quotaBackedOff === 'boolean'
217
+ ? { quotaBackedOff: m.quotaBackedOff }
218
+ : {}),
219
+ ...(typeof m.quotaBackoffUntil === 'number'
220
+ ? { quotaBackoffUntil: m.quotaBackoffUntil }
221
+ : {}),
222
+ ...(typeof m.refreshBackedOff === 'boolean'
223
+ ? { refreshBackedOff: m.refreshBackedOff }
224
+ : {}),
225
+ ...(typeof m.refreshBackoffUntil === 'number'
226
+ ? { refreshBackoffUntil: m.refreshBackoffUntil }
227
+ : {}),
228
+ ...resetCreditsField(m.resetCredits),
229
+ }
230
+ } else {
231
+ main = { quota: null, killed: false }
232
+ }
233
+
234
+ // fallbacks — must be an array; keep entries that are objects with a string
235
+ // id, and normalize each entry's inner fields so the TUI never reads a
236
+ // wrong-typed value (e.g. a string `enabled`) off a malformed file.
237
+ const rawFallbacks = r.fallbacks
238
+ const fallbacks: SidebarAccountState[] = Array.isArray(rawFallbacks)
239
+ ? rawFallbacks
240
+ .filter(
241
+ (entry): entry is Record<string, unknown> =>
242
+ entry !== null &&
243
+ typeof entry === 'object' &&
244
+ !Array.isArray(entry) &&
245
+ typeof (entry as Record<string, unknown>).id === 'string',
246
+ )
247
+ .map((e) => ({
248
+ id: e.id as string,
249
+ label: typeof e.label === 'string' ? e.label : undefined,
250
+ quota: ('quota' in e ? e.quota : null) as AccountQuota | null,
251
+ killed: typeof e.killed === 'boolean' ? e.killed : false,
252
+ enabled: typeof e.enabled === 'boolean' ? e.enabled : true,
253
+ ...resetCreditsField(e.resetCredits),
254
+ }))
255
+ : []
256
+
257
+ // activeId — string or undefined
258
+ const activeId = typeof r.activeId === 'string' ? r.activeId : undefined
259
+
260
+ // route — string, default 'main'
261
+ const route =
262
+ typeof r.route === 'string' ? r.route : DEFAULT_SIDEBAR_STATE.route
263
+
264
+ // lastUpdated — number, default 0
265
+ const lastUpdated = typeof r.lastUpdated === 'number' ? r.lastUpdated : 0
266
+
267
+ // Optional top-level fields
268
+ const planType = typeof r.planType === 'string' ? r.planType : undefined
269
+ const credits = typeof r.credits === 'number' ? r.credits : undefined
270
+ const activeRouting = normalizeActiveRouting(r.activeRouting)
271
+ return {
272
+ main,
273
+ fallbacks,
274
+ activeId,
275
+ route,
276
+ lastUpdated,
277
+ ...(activeRouting !== undefined ? { activeRouting } : {}),
278
+ ...(planType !== undefined ? { planType } : {}),
279
+ ...(credits !== undefined ? { credits } : {}),
280
+ }
281
+ }
282
+
283
+ export async function getSidebarState(): Promise<SidebarState> {
284
+ try {
285
+ const raw = await readFile(getSidebarStateFile(), 'utf8')
286
+ return normalizeSidebarState(JSON.parse(raw))
287
+ } catch {
288
+ return DEFAULT_SIDEBAR_STATE
289
+ }
290
+ }
291
+
292
+ export const ACTIVE_ROUTING_MAX_AGE_MS = 60 * 60 * 1000
293
+ export const ACTIVE_ROUTING_MAX_ENTRIES = 128
294
+
295
+ export type SidebarRoutingAccount = {
296
+ id: string
297
+ enabled?: boolean
298
+ }
299
+
300
+ export function isUsableRoutingEntry(
301
+ entry: ActiveRoutingEntry,
302
+ accounts: readonly SidebarRoutingAccount[],
303
+ now = Date.now(),
304
+ ): boolean {
305
+ const fresh =
306
+ entry.updatedAt >= now - ACTIVE_ROUTING_MAX_AGE_MS && entry.updatedAt <= now
307
+ if (!fresh) return false
308
+ return (
309
+ entry.activeId === 'main' ||
310
+ accounts.some(
311
+ (account) => account.enabled !== false && account.id === entry.activeId,
312
+ )
313
+ )
314
+ }
315
+
316
+ export function resolveSessionSidebarRouting(
317
+ state: SidebarState,
318
+ sessionId?: string,
319
+ now = Date.now(),
320
+ ): { activeId: string; route: string } {
321
+ if (!sessionId) {
322
+ return { activeId: state.activeId ?? 'main', route: state.route }
323
+ }
324
+ const own = sessionId ? state.activeRouting?.[sessionId] : undefined
325
+ if (own && isUsableRoutingEntry(own, state.fallbacks, now)) {
326
+ return { activeId: own.activeId, route: own.route }
327
+ }
328
+
329
+ const fallback = state.fallbacks.find(
330
+ (account) => account.enabled && !account.killed,
331
+ )
332
+ return {
333
+ activeId:
334
+ state.route === 'fallback-first' && fallback ? fallback.id : 'main',
335
+ route: state.route,
336
+ }
337
+ }
338
+
339
+ export function pruneActiveRouting(
340
+ activeRouting: ActiveRoutingMap | undefined,
341
+ accounts: readonly SidebarRoutingAccount[],
342
+ now = Date.now(),
343
+ removedSessionId?: string,
344
+ ): ActiveRoutingMap | undefined {
345
+ if (!activeRouting) return undefined
346
+ const kept = Object.entries(activeRouting).filter(
347
+ ([sessionId, entry]) =>
348
+ sessionId !== removedSessionId &&
349
+ isUsableRoutingEntry(entry, accounts, now),
350
+ )
351
+ const bounded =
352
+ kept.length <= ACTIVE_ROUTING_MAX_ENTRIES
353
+ ? kept
354
+ : kept
355
+ .sort((left, right) => right[1].updatedAt - left[1].updatedAt)
356
+ .slice(0, ACTIVE_ROUTING_MAX_ENTRIES)
357
+ return bounded.length > 0 ? Object.fromEntries(bounded) : undefined
358
+ }
359
+
360
+ // Serialization chain: concurrent calls are queued so a stale background
361
+ // write cannot land after a newer one and corrupt the file.
362
+ let sidebarWriteChain: Promise<void> = Promise.resolve()
363
+ const MAX_MERGE_ATTEMPTS = 3
364
+ const SIDEBAR_WRITE_LOCK_TTL_MS = 10_000
365
+ const SIDEBAR_WRITE_LOCK_WAIT_MS = 15_000
366
+
367
+ interface SidebarMergeHooks {
368
+ beforeRecheck?: () => void | Promise<void>
369
+ }
370
+
371
+ function enqueueSidebarWrite(operation: () => Promise<void>): Promise<void> {
372
+ const result = sidebarWriteChain.then(operation)
373
+ sidebarWriteChain = result.catch(() => {})
374
+ return result
375
+ }
376
+
377
+ /**
378
+ * Write sidebar state to disk, serialized through a promise chain so
379
+ * concurrent callers never interleave or let a stale write land last.
380
+ *
381
+ * @param state The state to persist.
382
+ * @param file Explicit path override — callers that bind the path at init
383
+ * time (e.g. the index.ts loader) pass this so late callbacks
384
+ * always write to the path that was current when the loader ran,
385
+ * even if the env changes underneath them during tests.
386
+ * Defaults to getSidebarStateFile() (per-call resolution).
387
+ */
388
+ export function setSidebarState(
389
+ state: SidebarState,
390
+ file = getSidebarStateFile(),
391
+ ): Promise<void> {
392
+ return enqueueSidebarWrite(() => doWriteSidebarState(state, file))
393
+ }
394
+
395
+ async function readSidebarState(file: string): Promise<SidebarState> {
396
+ try {
397
+ return parseSidebarState(await readRawSidebar(file))
398
+ } catch {
399
+ return DEFAULT_SIDEBAR_STATE
400
+ }
401
+ }
402
+
403
+ async function readRawSidebar(file: string): Promise<string> {
404
+ try {
405
+ return await readFile(file, 'utf8')
406
+ } catch (error) {
407
+ if ((error as { code?: string }).code === 'ENOENT') return ''
408
+ throw error
409
+ }
410
+ }
411
+
412
+ function parseSidebarState(raw: string): SidebarState {
413
+ if (raw === '') return DEFAULT_SIDEBAR_STATE
414
+ try {
415
+ return normalizeSidebarState(JSON.parse(raw))
416
+ } catch {
417
+ return DEFAULT_SIDEBAR_STATE
418
+ }
419
+ }
420
+
421
+ async function acquireSidebarWriteLock(file: string) {
422
+ const deadline = Date.now() + SIDEBAR_WRITE_LOCK_WAIT_MS
423
+ while (Date.now() <= deadline) {
424
+ const lock = await acquireRefreshFileLock({
425
+ name: 'sidebar-write',
426
+ path: file,
427
+ ttlMs: SIDEBAR_WRITE_LOCK_TTL_MS,
428
+ renew: true,
429
+ })
430
+ if (lock) return lock
431
+ await new Promise((resolve) => setTimeout(resolve, 10))
432
+ }
433
+ throw new Error('Timed out waiting for the sidebar state write lock')
434
+ }
435
+
436
+ async function writeMergedSidebarState(
437
+ file: string,
438
+ merge: (latest: SidebarState) => SidebarState,
439
+ hooks?: SidebarMergeHooks,
440
+ ): Promise<void> {
441
+ const lock = await acquireSidebarWriteLock(file)
442
+ try {
443
+ // The file lock serializes current writers. Rechecking also preserves data
444
+ // from older plugin processes that do not participate in this lock.
445
+ for (let attempt = 0; attempt < MAX_MERGE_ATTEMPTS; attempt += 1) {
446
+ const rawBefore = await readRawSidebar(file)
447
+ const next = merge(parseSidebarState(rawBefore))
448
+ if (attempt === 0) await hooks?.beforeRecheck?.()
449
+ const rawRecheck = await readRawSidebar(file)
450
+ if (rawRecheck !== rawBefore) continue
451
+ await doWriteSidebarState(next, file)
452
+ return
453
+ }
454
+
455
+ const latest = await readSidebarState(file)
456
+ await doWriteSidebarState(merge(latest), file)
457
+ } finally {
458
+ await lock.release()
459
+ }
460
+ }
461
+
462
+ export type SidebarMachineState = Pick<
463
+ SidebarState,
464
+ 'main' | 'fallbacks' | 'planType' | 'credits' | 'lastUpdated'
465
+ > & { route: string }
466
+
467
+ export function setSidebarMachineState(
468
+ machineState: SidebarMachineState,
469
+ file = getSidebarStateFile(),
470
+ hooks?: SidebarMergeHooks,
471
+ ): Promise<void> {
472
+ return enqueueSidebarWrite(async () => {
473
+ await writeMergedSidebarState(
474
+ file,
475
+ (latest) => ({
476
+ ...latest,
477
+ ...machineState,
478
+ activeId: latest.activeId,
479
+ activeRouting: latest.activeRouting,
480
+ lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
481
+ }),
482
+ hooks,
483
+ )
484
+ })
485
+ }
486
+
487
+ export function upsertSidebarActiveRouting(
488
+ input: { sessionId: string } & ActiveRoutingEntry,
489
+ accounts: readonly SidebarRoutingAccount[],
490
+ file = getSidebarStateFile(),
491
+ hooks?: SidebarMergeHooks,
492
+ ): Promise<void> {
493
+ return enqueueSidebarWrite(async () => {
494
+ await writeMergedSidebarState(
495
+ file,
496
+ (latest) => {
497
+ const activeRouting = pruneActiveRouting(
498
+ {
499
+ ...latest.activeRouting,
500
+ [input.sessionId]: {
501
+ activeId: input.activeId,
502
+ route: input.route,
503
+ updatedAt: input.updatedAt,
504
+ },
505
+ },
506
+ accounts,
507
+ Date.now(),
508
+ )
509
+ return {
510
+ ...latest,
511
+ activeId: input.activeId,
512
+ route: input.route,
513
+ activeRouting,
514
+ lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
515
+ }
516
+ },
517
+ hooks,
518
+ )
519
+ })
520
+ }
521
+
522
+ export function setSidebarLegacyRouting(
523
+ input: ActiveRoutingEntry,
524
+ file = getSidebarStateFile(),
525
+ ): Promise<void> {
526
+ return enqueueSidebarWrite(async () => {
527
+ await writeMergedSidebarState(file, (latest) => ({
528
+ ...latest,
529
+ activeId: input.activeId,
530
+ route: input.route,
531
+ lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
532
+ }))
533
+ })
534
+ }
535
+
536
+ export function removeSidebarActiveRouting(
537
+ sessionId: string,
538
+ accounts: readonly SidebarRoutingAccount[],
539
+ file = getSidebarStateFile(),
540
+ hooks?: SidebarMergeHooks,
541
+ ): Promise<void> {
542
+ return enqueueSidebarWrite(async () => {
543
+ await writeMergedSidebarState(
544
+ file,
545
+ (latest) => {
546
+ const now = Date.now()
547
+ const activeRouting = pruneActiveRouting(
548
+ latest.activeRouting,
549
+ accounts,
550
+ now,
551
+ sessionId,
552
+ )
553
+ return {
554
+ ...latest,
555
+ activeRouting,
556
+ lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
557
+ }
558
+ },
559
+ hooks,
560
+ )
561
+ })
562
+ }
563
+
564
+ async function doWriteSidebarState(
565
+ state: SidebarState,
566
+ file: string,
567
+ ): Promise<void> {
568
+ const tempPath = `${file}.${randomUUID()}.tmp`
569
+ try {
570
+ await mkdir(dirname(file), { recursive: true })
571
+ await writeFile(tempPath, JSON.stringify(state), 'utf8')
572
+ await rename(tempPath, file)
573
+ } catch (e) {
574
+ await rm(tempPath, { force: true }).catch(() => {})
575
+ logSb.warn('sidebar write failed', {
576
+ pid: process.pid,
577
+ error: e instanceof Error ? e.message : String(e),
578
+ })
579
+ throw e
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Await all pending sidebar writes. Tests call this before restoring env
585
+ * vars in teardown so no in-flight write can re-resolve getSidebarStateFile()
586
+ * after the env is changed.
587
+ */
588
+ export function drainSidebarWrites(): Promise<void> {
589
+ return sidebarWriteChain
590
+ }
591
+
592
+ // Resolve the currently-active account from activeId for the collapsed sidebar
593
+ // view. activeId === 'main' (or undefined/unmatched/disabled) → the main
594
+ // account; otherwise the enabled fallback whose id matches.
595
+ export function resolveActiveAccount(state: SidebarState): {
596
+ id: string
597
+ name: string
598
+ quota: AccountQuota | null
599
+ killed: boolean
600
+ } {
601
+ const activeId = state.activeId
602
+ if (activeId && activeId !== 'main') {
603
+ const fallback = state.fallbacks.find(
604
+ (account) => account.enabled && account.id === activeId,
605
+ )
606
+ if (fallback) {
607
+ return {
608
+ id: fallback.id,
609
+ name: fallback.label ?? fallback.id,
610
+ quota: fallback.quota,
611
+ killed: fallback.killed,
612
+ }
613
+ }
614
+ }
615
+ return {
616
+ id: 'main',
617
+ name: 'main',
618
+ quota: state.main.quota,
619
+ killed: state.main.killed,
620
+ }
621
+ }
622
+
623
+ export function getCollapsedQuotaSummary(quota: AccountQuota | null): {
624
+ primaryUsedPercent: number | null
625
+ secondaryUsedPercent: number | null
626
+ text: string | null
627
+ } {
628
+ const primaryUsedPercent = quota?.primary?.usedPercent ?? null
629
+ const secondaryUsedPercent = quota?.secondary?.usedPercent ?? null
630
+ const rows = getPresentQuotaWindows(quota)
631
+ return {
632
+ primaryUsedPercent,
633
+ secondaryUsedPercent,
634
+ text:
635
+ rows.length === 0
636
+ ? null
637
+ : rows
638
+ .map(
639
+ ({ label, window }) =>
640
+ `${label}: ${Math.round(window.usedPercent)}%`,
641
+ )
642
+ .join(' '),
643
+ }
644
+ }
645
+
646
+ const PACING_MIN_ELAPSED_MS = 5 * 60 * 1000
647
+ const PACING_MIN_ELAPSED_FRACTION = 0.01
648
+ const ON_PACE_DELTA = 1
649
+
650
+ export interface QuotaPacing {
651
+ pacePercent: number
652
+ deltaPercent: number
653
+ state: 'deficit' | 'reserve' | 'on-pace'
654
+ runsOutAt: string | null
655
+ }
656
+
657
+ // Even-burn pacing for a quota window. The window start is inferred from the
658
+ // reset timestamp minus the window length. Two metrics: deltaPercent compares
659
+ // usage against a uniform burn-down (positive = deficit), and runsOutAt
660
+ // projects the current average burn rate forward — null means the window
661
+ // lasts until reset at that rate. Returns null when there is no reset
662
+ // timestamp or the elapsed time is too small to give a meaningful rate.
663
+ export function computeQuotaPacing(
664
+ window: QuotaWindow,
665
+ windowMs: number,
666
+ now: number,
667
+ ): QuotaPacing | null {
668
+ if (!window.resetsAt) return null
669
+ const resetsAt = new Date(window.resetsAt).getTime()
670
+ if (!Number.isFinite(resetsAt)) return null
671
+ const start = resetsAt - windowMs
672
+ const elapsed = now - start
673
+ if (elapsed < PACING_MIN_ELAPSED_MS) return null
674
+ if (elapsed < windowMs * PACING_MIN_ELAPSED_FRACTION) return null
675
+ if (elapsed >= windowMs) return null
676
+
677
+ const used = window.usedPercent
678
+ const pacePercent = Math.min(Math.max((elapsed / windowMs) * 100, 0), 100)
679
+ const deltaPercent = used - pacePercent
680
+ const state =
681
+ Math.abs(deltaPercent) < ON_PACE_DELTA
682
+ ? 'on-pace'
683
+ : deltaPercent > 0
684
+ ? 'deficit'
685
+ : 'reserve'
686
+
687
+ let runsOutAt: string | null = null
688
+ if (used > 0) {
689
+ const msToFull = (elapsed * 100) / used
690
+ const runOut = start + msToFull
691
+ if (runOut < resetsAt) runsOutAt = new Date(runOut).toISOString()
692
+ }
693
+
694
+ return { pacePercent, deltaPercent, state, runsOutAt }
695
+ }