@cortexkit/opencode-openai-auth 0.4.2 → 0.5.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 (62) hide show
  1. package/README.md +8 -4
  2. package/dist/cli.js +1 -1
  3. package/dist/codex-http.d.ts +1 -1
  4. package/dist/commands.d.ts +6 -0
  5. package/dist/core/accounts.d.ts +6 -1
  6. package/dist/core/background-quota-refresh.d.ts +46 -0
  7. package/dist/core/cachekeep.d.ts +4 -0
  8. package/dist/core/oauth.d.ts +1 -1
  9. package/dist/core/quota-manager.d.ts +18 -15
  10. package/dist/core/refresh-all-quota.d.ts +12 -1
  11. package/dist/core/sticky-routing.d.ts +43 -0
  12. package/dist/dump.d.ts +1 -0
  13. package/dist/{index-x1c39tzz.js → index-6rnw2cgs.js} +1 -1
  14. package/dist/index-89mh4ahn.js +89 -0
  15. package/dist/index-rfgyxxxq.js +1 -0
  16. package/dist/index-s5fbqqwx.js +3 -0
  17. package/dist/index-vrdc90yd.js +6 -0
  18. package/dist/index-wnxfzjfr.js +2 -0
  19. package/dist/{index-jpx7h5gc.js → index-wpd7mg3p.js} +1 -1
  20. package/dist/index-zkzb0twr.js +2 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.js +101 -83
  23. package/dist/{quota-normalize-02mn21p4.js → quota-normalize-n02dfdx0.js} +1 -1
  24. package/dist/raw-ws-bun.d.ts +2 -0
  25. package/dist/raw-ws-node.d.ts +2 -0
  26. package/dist/raw-ws-upgrade.d.ts +2 -0
  27. package/dist/rpc/port-file.d.ts +4 -0
  28. package/dist/rpc/port-file.js +1 -1
  29. package/dist/rpc/protocol.d.ts +1 -0
  30. package/dist/rpc/rpc-client.js +1 -1
  31. package/dist/rpc/rpc-dir.d.ts +6 -0
  32. package/dist/rpc/rpc-dir.js +1 -1
  33. package/dist/rpc/rpc-server.d.ts +2 -0
  34. package/dist/sidebar-state.d.ts +46 -5
  35. package/dist/sidebar-state.js +1 -1
  36. package/dist/{token-dab1ama0.js → token-5hdnazxp.js} +1 -1
  37. package/dist/token-util-3d2nj45d.js +1 -0
  38. package/dist/tui-preferences.js +1 -1
  39. package/dist/tui.d.ts +9 -0
  40. package/dist/version.d.ts +1 -1
  41. package/dist/ws-pool.d.ts +2 -11
  42. package/dist/ws.d.ts +7 -2
  43. package/package.json +2 -2
  44. package/src/rpc/port-file.ts +115 -8
  45. package/src/rpc/protocol.ts +1 -0
  46. package/src/rpc/rpc-dir.ts +31 -7
  47. package/src/rpc/rpc-server.ts +18 -2
  48. package/src/sidebar-state.ts +771 -23
  49. package/src/tui/command-dialogs.tsx +25 -3
  50. package/src/tui-compiled/rpc/port-file.ts +115 -8
  51. package/src/tui-compiled/rpc/protocol.ts +1 -0
  52. package/src/tui-compiled/rpc/rpc-dir.ts +31 -7
  53. package/src/tui-compiled/sidebar-state.ts +771 -23
  54. package/src/tui-compiled/tui/command-dialogs.tsx +17 -4
  55. package/src/tui-compiled/tui.tsx +50 -15
  56. package/src/tui.tsx +57 -19
  57. package/dist/index-72cas5ht.js +0 -7
  58. package/dist/index-dc7za61x.js +0 -89
  59. package/dist/index-hjgzqz1c.js +0 -1
  60. package/dist/index-qnd9x5jh.js +0 -2
  61. package/dist/index-wpk440dz.js +0 -2
  62. package/dist/token-util-41n40m0y.js +0 -1
@@ -1,11 +1,13 @@
1
1
  export interface QuotaWindow {
2
2
  usedPercent: number
3
3
  remainingPercent: number
4
+ checkedAt?: number
4
5
  resetsAt?: string
5
6
  windowMinutes?: number
6
7
  }
7
8
 
8
9
  export interface AccountQuota {
10
+ checkedAt?: number
9
11
  primary?: QuotaWindow
10
12
  secondary?: QuotaWindow
11
13
  resetCreditsAvailable?: number
@@ -80,6 +82,8 @@ export function getPresentQuotaWindows(
80
82
  export interface SidebarAccountState {
81
83
  id: string
82
84
  label: string | undefined
85
+ /** ChatGPT identity of the account this quota belongs to. */
86
+ accountId?: string
83
87
  quota: AccountQuota | null
84
88
  killed: boolean
85
89
  enabled: boolean
@@ -94,9 +98,38 @@ export interface ActiveRoutingEntry {
94
98
 
95
99
  export type ActiveRoutingMap = Record<string, ActiveRoutingEntry>
96
100
 
101
+ export interface StickyAssignment {
102
+ accountId: string
103
+ assignedAt: number
104
+ lastSeenAt: number
105
+ inputBytes: number
106
+ quotaCheckedAt?: number
107
+ }
108
+
109
+ export type StickyAssignmentMap = Record<string, StickyAssignment>
110
+
111
+ export interface StickyAssignmentChoice {
112
+ accountId: string
113
+ quotaCheckedAt?: number
114
+ }
115
+
116
+ export interface ResolveStickyAssignmentInput {
117
+ sessionId: string
118
+ requestBytes: number
119
+ now: number
120
+ validPinnedAccountIds: readonly string[]
121
+ excludeAccountIds?: readonly string[]
122
+ quotaCheckedAtByAccount: Readonly<Record<string, number | undefined>>
123
+ choose: (
124
+ pendingBytes: ReadonlyMap<string, number>,
125
+ ) => StickyAssignmentChoice | undefined
126
+ }
127
+
97
128
  export interface SidebarState {
98
129
  main: {
99
130
  quota: AccountQuota | null
131
+ /** ChatGPT identity of the main account this quota belongs to. */
132
+ mainAccountId?: string
100
133
  killed: boolean
101
134
  quotaBackedOff?: boolean
102
135
  quotaBackoffUntil?: number
@@ -110,13 +143,14 @@ export interface SidebarState {
110
143
  /** Machine-global routing mode and compatibility value for older readers. */
111
144
  route: string
112
145
  activeRouting?: ActiveRoutingMap
146
+ stickyAssignments?: StickyAssignmentMap
113
147
  planType?: string
114
148
  credits?: number
115
149
  lastUpdated: number
116
150
  }
117
151
 
118
- import { randomUUID } from 'node:crypto'
119
- import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
152
+ import { createHash, randomUUID } from 'node:crypto'
153
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
120
154
  import { tmpdir } from 'node:os'
121
155
  import { dirname, join } from 'node:path'
122
156
  import { acquireRefreshFileLock } from './core/refresh-file-lock'
@@ -127,6 +161,14 @@ const logSb = createLogger('sidebar')
127
161
  const STATE_FILE_ENV = 'OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE'
128
162
  const DEFAULT_STATE_DIR = join(tmpdir(), 'opencode-openai-auth')
129
163
  const DEFAULT_STATE_FILE = join(DEFAULT_STATE_DIR, 'sidebar-state.json')
164
+ const SESSION_HASH_PATTERN = /^[a-f0-9]{64}$/
165
+ export const STICKY_ASSIGNMENT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
166
+ export const STICKY_ASSIGNMENT_MAX_ENTRIES = 256
167
+ const STICKY_ASSIGNMENT_LAST_SEEN_TOUCH_MS = 60 * 60 * 1000
168
+
169
+ export function hashSidebarSessionId(sessionId: string): string {
170
+ return createHash('sha256').update(sessionId).digest('hex')
171
+ }
130
172
 
131
173
  function normalizeResetCredits(value: unknown): number | undefined {
132
174
  return typeof value === 'number' && Number.isFinite(value) && value >= 0
@@ -172,6 +214,60 @@ function normalizeActiveRouting(value: unknown): ActiveRoutingMap | undefined {
172
214
  return Object.keys(normalized).length > 0 ? normalized : undefined
173
215
  }
174
216
 
217
+ function normalizeStickyAssignments(
218
+ value: unknown,
219
+ ): StickyAssignmentMap | undefined {
220
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
221
+ return undefined
222
+ }
223
+
224
+ const normalized: StickyAssignmentMap = {}
225
+ for (const [sessionHash, rawAssignment] of Object.entries(value)) {
226
+ if (
227
+ !SESSION_HASH_PATTERN.test(sessionHash) ||
228
+ rawAssignment === null ||
229
+ typeof rawAssignment !== 'object' ||
230
+ Array.isArray(rawAssignment)
231
+ ) {
232
+ continue
233
+ }
234
+ const assignment = rawAssignment as Record<string, unknown>
235
+ if (
236
+ typeof assignment.accountId !== 'string' ||
237
+ assignment.accountId.length === 0 ||
238
+ typeof assignment.assignedAt !== 'number' ||
239
+ !Number.isFinite(assignment.assignedAt) ||
240
+ assignment.assignedAt < 0 ||
241
+ typeof assignment.lastSeenAt !== 'number' ||
242
+ !Number.isFinite(assignment.lastSeenAt) ||
243
+ assignment.lastSeenAt < 0 ||
244
+ typeof assignment.inputBytes !== 'number' ||
245
+ !Number.isFinite(assignment.inputBytes) ||
246
+ assignment.inputBytes < 0
247
+ ) {
248
+ continue
249
+ }
250
+ const quotaCheckedAt = assignment.quotaCheckedAt
251
+ if (
252
+ quotaCheckedAt !== undefined &&
253
+ (typeof quotaCheckedAt !== 'number' ||
254
+ !Number.isFinite(quotaCheckedAt) ||
255
+ quotaCheckedAt < 0)
256
+ ) {
257
+ continue
258
+ }
259
+ normalized[sessionHash] = {
260
+ accountId: assignment.accountId,
261
+ assignedAt: assignment.assignedAt,
262
+ lastSeenAt: assignment.lastSeenAt,
263
+ inputBytes: assignment.inputBytes,
264
+ ...(quotaCheckedAt === undefined ? {} : { quotaCheckedAt }),
265
+ }
266
+ }
267
+
268
+ return Object.keys(normalized).length > 0 ? normalized : undefined
269
+ }
270
+
175
271
  export function getSidebarStateFile(): string {
176
272
  return process.env[STATE_FILE_ENV] || DEFAULT_STATE_FILE
177
273
  }
@@ -212,6 +308,9 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
212
308
  main = {
213
309
  quota: ('quota' in m ? m.quota : null) as AccountQuota | null,
214
310
  killed: typeof m.killed === 'boolean' ? m.killed : false,
311
+ ...(typeof m.mainAccountId === 'string'
312
+ ? { mainAccountId: m.mainAccountId }
313
+ : {}),
215
314
  // Preserve optional backoff fields if present
216
315
  ...(typeof m.quotaBackedOff === 'boolean'
217
316
  ? { quotaBackedOff: m.quotaBackedOff }
@@ -247,6 +346,9 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
247
346
  .map((e) => ({
248
347
  id: e.id as string,
249
348
  label: typeof e.label === 'string' ? e.label : undefined,
349
+ ...(typeof e.accountId === 'string'
350
+ ? { accountId: e.accountId }
351
+ : {}),
250
352
  quota: ('quota' in e ? e.quota : null) as AccountQuota | null,
251
353
  killed: typeof e.killed === 'boolean' ? e.killed : false,
252
354
  enabled: typeof e.enabled === 'boolean' ? e.enabled : true,
@@ -268,6 +370,7 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
268
370
  const planType = typeof r.planType === 'string' ? r.planType : undefined
269
371
  const credits = typeof r.credits === 'number' ? r.credits : undefined
270
372
  const activeRouting = normalizeActiveRouting(r.activeRouting)
373
+ const stickyAssignments = normalizeStickyAssignments(r.stickyAssignments)
271
374
  return {
272
375
  main,
273
376
  fallbacks,
@@ -275,14 +378,17 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
275
378
  route,
276
379
  lastUpdated,
277
380
  ...(activeRouting !== undefined ? { activeRouting } : {}),
381
+ ...(stickyAssignments !== undefined ? { stickyAssignments } : {}),
278
382
  ...(planType !== undefined ? { planType } : {}),
279
383
  ...(credits !== undefined ? { credits } : {}),
280
384
  }
281
385
  }
282
386
 
283
- export async function getSidebarState(): Promise<SidebarState> {
387
+ export async function getSidebarState(
388
+ stateFile = getSidebarStateFile(),
389
+ ): Promise<SidebarState> {
284
390
  try {
285
- const raw = await readFile(getSidebarStateFile(), 'utf8')
391
+ const raw = await readFile(stateFile, 'utf8')
286
392
  return normalizeSidebarState(JSON.parse(raw))
287
393
  } catch {
288
394
  return DEFAULT_SIDEBAR_STATE
@@ -295,24 +401,97 @@ export const ACTIVE_ROUTING_MAX_ENTRIES = 128
295
401
  export type SidebarRoutingAccount = {
296
402
  id: string
297
403
  enabled?: boolean
404
+ killed?: boolean
298
405
  }
299
406
 
300
407
  export function isUsableRoutingEntry(
301
408
  entry: ActiveRoutingEntry,
302
- accounts: readonly SidebarRoutingAccount[],
409
+ accounts: readonly SidebarRoutingAccount[] | undefined,
303
410
  now = Date.now(),
304
411
  ): boolean {
305
412
  const fresh =
306
413
  entry.updatedAt >= now - ACTIVE_ROUTING_MAX_AGE_MS && entry.updatedAt <= now
307
414
  if (!fresh) return false
415
+ if (accounts === undefined) return true
308
416
  return (
309
417
  entry.activeId === 'main' ||
310
418
  accounts.some(
311
- (account) => account.enabled !== false && account.id === entry.activeId,
419
+ (account) =>
420
+ account.enabled !== false &&
421
+ account.killed !== true &&
422
+ account.id === entry.activeId,
312
423
  )
313
424
  )
314
425
  }
315
426
 
427
+ // Earliest future reset among the quota's exhausted windows, or undefined when
428
+ // no present window is exhausted. Every present window is evaluated — matching
429
+ // the admission policy, which rejects an account when ANY live window is below
430
+ // its threshold — and each check fails open: a missing/malformed usage, a
431
+ // missing/unparsable reset, or a reset already in the past never counts as
432
+ // exhausted, so a stale or corrupt snapshot can never block routing.
433
+ export function exhaustedQuotaResetAt(
434
+ quota: AccountQuota | null | undefined,
435
+ now = Date.now(),
436
+ ): { resetsAt: string; resetAtMs: number } | undefined {
437
+ let earliest: { resetsAt: string; resetAtMs: number } | undefined
438
+ for (const key of QUOTA_WINDOW_KEYS) {
439
+ const window = quota?.[key]
440
+ if (
441
+ typeof window?.resetsAt !== 'string' ||
442
+ !Number.isFinite(window.usedPercent) ||
443
+ window.usedPercent < 100
444
+ ) {
445
+ continue
446
+ }
447
+ const resetAtMs = Date.parse(window.resetsAt)
448
+ if (!Number.isFinite(resetAtMs) || resetAtMs <= now) continue
449
+ if (!earliest || resetAtMs < earliest.resetAtMs) {
450
+ earliest = { resetsAt: window.resetsAt, resetAtMs }
451
+ }
452
+ }
453
+ return earliest
454
+ }
455
+
456
+ export function isQuotaExhausted(
457
+ quota: AccountQuota | null | undefined,
458
+ now = Date.now(),
459
+ ): boolean {
460
+ return exhaustedQuotaResetAt(quota, now) !== undefined
461
+ }
462
+
463
+ export function resolveSessionStickyAccount(
464
+ state: SidebarState,
465
+ sessionId: string | undefined,
466
+ now = Date.now(),
467
+ ): string | undefined {
468
+ if (!sessionId || state.route !== 'sticky-balanced') return undefined
469
+ const assignment = state.stickyAssignments?.[hashSidebarSessionId(sessionId)]
470
+ if (
471
+ !assignment ||
472
+ assignment.lastSeenAt < now - STICKY_ASSIGNMENT_MAX_AGE_MS
473
+ ) {
474
+ return undefined
475
+ }
476
+ if (assignment.accountId === 'main') {
477
+ return state.main?.killed || isQuotaExhausted(state.main?.quota, now)
478
+ ? undefined
479
+ : 'main'
480
+ }
481
+ const fallback = state.fallbacks?.find(
482
+ (account) => account.id === assignment.accountId,
483
+ )
484
+ if (
485
+ !fallback ||
486
+ fallback.enabled === false ||
487
+ fallback.killed === true ||
488
+ isQuotaExhausted(fallback.quota, now)
489
+ ) {
490
+ return undefined
491
+ }
492
+ return fallback.id
493
+ }
494
+
316
495
  export function resolveSessionSidebarRouting(
317
496
  state: SidebarState,
318
497
  sessionId?: string,
@@ -322,13 +501,29 @@ export function resolveSessionSidebarRouting(
322
501
  return { activeId: state.activeId ?? 'main', route: state.route }
323
502
  }
324
503
  const own = sessionId ? state.activeRouting?.[sessionId] : undefined
325
- if (own && isUsableRoutingEntry(own, state.fallbacks, now)) {
504
+ const ownQuota =
505
+ own?.activeId === 'main'
506
+ ? state.main.quota
507
+ : state.fallbacks.find((account) => account.id === own?.activeId)?.quota
508
+ if (
509
+ own &&
510
+ isUsableRoutingEntry(own, state.fallbacks, now) &&
511
+ !isQuotaExhausted(ownQuota, now)
512
+ ) {
326
513
  return { activeId: own.activeId, route: own.route }
327
514
  }
328
515
 
329
- const fallback = state.fallbacks.find(
516
+ const stickyAccountId = resolveSessionStickyAccount(state, sessionId, now)
517
+ if (stickyAccountId) {
518
+ return { activeId: stickyAccountId, route: state.route }
519
+ }
520
+
521
+ const enabledFallbacks = state.fallbacks.filter(
330
522
  (account) => account.enabled && !account.killed,
331
523
  )
524
+ const fallback =
525
+ enabledFallbacks.find((account) => !isQuotaExhausted(account.quota, now)) ??
526
+ enabledFallbacks[0]
332
527
  return {
333
528
  activeId:
334
529
  state.route === 'fallback-first' && fallback ? fallback.id : 'main',
@@ -338,7 +533,7 @@ export function resolveSessionSidebarRouting(
338
533
 
339
534
  export function pruneActiveRouting(
340
535
  activeRouting: ActiveRoutingMap | undefined,
341
- accounts: readonly SidebarRoutingAccount[],
536
+ accounts: readonly SidebarRoutingAccount[] | undefined,
342
537
  now = Date.now(),
343
538
  removedSessionId?: string,
344
539
  ): ActiveRoutingMap | undefined {
@@ -357,6 +552,175 @@ export function pruneActiveRouting(
357
552
  return bounded.length > 0 ? Object.fromEntries(bounded) : undefined
358
553
  }
359
554
 
555
+ export function pruneStickyAssignments(
556
+ assignments: StickyAssignmentMap | undefined,
557
+ validAccountIds: ReadonlySet<string> | undefined,
558
+ now = Date.now(),
559
+ removedSessionHash?: string,
560
+ ): StickyAssignmentMap | undefined {
561
+ if (!assignments) return undefined
562
+ let accountNotInRoster = 0
563
+ let expired = 0
564
+ let explicitRemoval = 0
565
+ const kept: [string, StickyAssignment][] = []
566
+ for (const [sessionHash, assignment] of Object.entries(assignments)) {
567
+ if (sessionHash === removedSessionHash) {
568
+ explicitRemoval += 1
569
+ continue
570
+ }
571
+ if (assignment.lastSeenAt < now - STICKY_ASSIGNMENT_MAX_AGE_MS) {
572
+ expired += 1
573
+ continue
574
+ }
575
+ if (
576
+ validAccountIds !== undefined &&
577
+ !validAccountIds.has(assignment.accountId)
578
+ ) {
579
+ accountNotInRoster += 1
580
+ continue
581
+ }
582
+ kept.push([sessionHash, assignment])
583
+ }
584
+ const removed = accountNotInRoster + expired + explicitRemoval
585
+ if (removed > 0) {
586
+ logSb.debug('pruned sticky assignments', {
587
+ pid: process.pid,
588
+ removed,
589
+ reasons: {
590
+ 'account-not-in-roster': accountNotInRoster,
591
+ expired,
592
+ 'explicit-removal': explicitRemoval,
593
+ },
594
+ })
595
+ }
596
+ return kept.length > 0 ? Object.fromEntries(kept) : undefined
597
+ }
598
+
599
+ function limitStickyAssignments(
600
+ assignments: StickyAssignmentMap,
601
+ protectedSessionHash: string,
602
+ ): StickyAssignmentMap {
603
+ const overflow =
604
+ Object.keys(assignments).length - STICKY_ASSIGNMENT_MAX_ENTRIES
605
+ if (overflow <= 0) return assignments
606
+ const evicted = new Set(
607
+ Object.entries(assignments)
608
+ .filter(([sessionHash]) => sessionHash !== protectedSessionHash)
609
+ .sort(
610
+ ([leftHash, left], [rightHash, right]) =>
611
+ left.lastSeenAt - right.lastSeenAt ||
612
+ leftHash.localeCompare(rightHash),
613
+ )
614
+ .slice(0, overflow)
615
+ .map(([sessionHash]) => sessionHash),
616
+ )
617
+ return Object.fromEntries(
618
+ Object.entries(assignments).filter(
619
+ ([sessionHash]) => !evicted.has(sessionHash),
620
+ ),
621
+ )
622
+ }
623
+
624
+ function stickyAssignmentsEqual(
625
+ left: StickyAssignmentMap | undefined,
626
+ right: StickyAssignmentMap | undefined,
627
+ ): boolean {
628
+ if (left === right) return true
629
+ if (!left || !right) return false
630
+ const leftEntries = Object.entries(left)
631
+ if (leftEntries.length !== Object.keys(right).length) return false
632
+ return leftEntries.every(([sessionHash, assignment]) =>
633
+ Object.is(right[sessionHash], assignment),
634
+ )
635
+ }
636
+
637
+ function isValidStickyAssignment(
638
+ assignment: StickyAssignment | undefined,
639
+ validPinnedAccountIds: ReadonlySet<string>,
640
+ excludedAccountIds: ReadonlySet<string>,
641
+ now: number,
642
+ ): assignment is StickyAssignment {
643
+ return (
644
+ assignment !== undefined &&
645
+ validPinnedAccountIds.has(assignment.accountId) &&
646
+ !excludedAccountIds.has(assignment.accountId) &&
647
+ assignment.lastSeenAt >= now - STICKY_ASSIGNMENT_MAX_AGE_MS
648
+ )
649
+ }
650
+
651
+ function stickyAssignmentNeedsMetadataUpdate(
652
+ assignment: StickyAssignment,
653
+ requestBytes: number,
654
+ now: number,
655
+ ): boolean {
656
+ return (
657
+ requestBytes > assignment.inputBytes ||
658
+ now - assignment.lastSeenAt >= STICKY_ASSIGNMENT_LAST_SEEN_TOUCH_MS
659
+ )
660
+ }
661
+
662
+ function readonlyPendingBytes(
663
+ pendingBytes: Map<string, number>,
664
+ ): ReadonlyMap<string, number> {
665
+ const snapshot = new Map(pendingBytes)
666
+ const view: ReadonlyMap<string, number> = {
667
+ get size() {
668
+ return snapshot.size
669
+ },
670
+ has: (key: string) => snapshot.has(key),
671
+ get: (key: string) => snapshot.get(key),
672
+ entries: () => snapshot.entries(),
673
+ keys: () => snapshot.keys(),
674
+ values: () => snapshot.values(),
675
+ forEach: (
676
+ callback: (
677
+ value: number,
678
+ key: string,
679
+ map: ReadonlyMap<string, number>,
680
+ ) => void,
681
+ thisArg?: unknown,
682
+ ) => {
683
+ snapshot.forEach((value, key) => {
684
+ callback.call(thisArg, value, key, view)
685
+ })
686
+ },
687
+ [Symbol.iterator]: () => snapshot[Symbol.iterator](),
688
+ }
689
+ return Object.freeze(view)
690
+ }
691
+
692
+ function pendingBytesForAssignments(
693
+ assignments: StickyAssignmentMap | undefined,
694
+ quotaCheckedAtByAccount: Readonly<Record<string, number | undefined>>,
695
+ ): ReadonlyMap<string, number> {
696
+ const pendingBytes = new Map<string, number>()
697
+ for (const assignment of Object.values(assignments ?? {})) {
698
+ if (
699
+ assignment.quotaCheckedAt !==
700
+ quotaCheckedAtByAccount[assignment.accountId]
701
+ ) {
702
+ continue
703
+ }
704
+ pendingBytes.set(
705
+ assignment.accountId,
706
+ (pendingBytes.get(assignment.accountId) ?? 0) + assignment.inputBytes,
707
+ )
708
+ }
709
+ return readonlyPendingBytes(pendingBytes)
710
+ }
711
+
712
+ function usableRoutingAccountIds(
713
+ accounts: readonly SidebarRoutingAccount[] | undefined,
714
+ ): ReadonlySet<string> | undefined {
715
+ if (accounts === undefined) return undefined
716
+ return new Set([
717
+ 'main',
718
+ ...accounts
719
+ .filter((account) => account.enabled !== false && account.killed !== true)
720
+ .map((account) => account.id),
721
+ ])
722
+ }
723
+
360
724
  // Serialization chain: concurrent calls are queued so a stale background
361
725
  // write cannot land after a newer one and corrupt the file.
362
726
  let sidebarWriteChain: Promise<void> = Promise.resolve()
@@ -419,7 +783,7 @@ function parseSidebarState(raw: string): SidebarState {
419
783
  }
420
784
 
421
785
  async function acquireSidebarWriteLock(file: string) {
422
- await mkdir(dirname(file), { recursive: true })
786
+ await ensureSidebarStateDirectory(file)
423
787
  const deadline = Date.now() + SIDEBAR_WRITE_LOCK_WAIT_MS
424
788
  while (Date.now() <= deadline) {
425
789
  const lock = await acquireRefreshFileLock({
@@ -434,9 +798,21 @@ async function acquireSidebarWriteLock(file: string) {
434
798
  throw new Error('Timed out waiting for the sidebar state write lock')
435
799
  }
436
800
 
801
+ async function ensureSidebarStateDirectory(file: string): Promise<void> {
802
+ const dir = dirname(file)
803
+ await mkdir(dir, { recursive: true, mode: 0o700 })
804
+ if (file !== DEFAULT_STATE_FILE) return
805
+ await chmod(dir, 0o700).catch((error) => {
806
+ logSb.warn('sidebar directory permission remediation failed', {
807
+ pid: process.pid,
808
+ error: error instanceof Error ? error.message : String(error),
809
+ })
810
+ })
811
+ }
812
+
437
813
  async function writeMergedSidebarState(
438
814
  file: string,
439
- merge: (latest: SidebarState) => SidebarState,
815
+ merge: (latest: SidebarState) => SidebarState | undefined,
440
816
  hooks?: SidebarMergeHooks,
441
817
  ): Promise<void> {
442
818
  const lock = await acquireSidebarWriteLock(file)
@@ -446,6 +822,7 @@ async function writeMergedSidebarState(
446
822
  for (let attempt = 0; attempt < MAX_MERGE_ATTEMPTS; attempt += 1) {
447
823
  const rawBefore = await readRawSidebar(file)
448
824
  const next = merge(parseSidebarState(rawBefore))
825
+ if (!next) return
449
826
  if (attempt === 0) await hooks?.beforeRecheck?.()
450
827
  const rawRecheck = await readRawSidebar(file)
451
828
  if (rawRecheck !== rawBefore) continue
@@ -454,7 +831,8 @@ async function writeMergedSidebarState(
454
831
  }
455
832
 
456
833
  const latest = await readSidebarState(file)
457
- await doWriteSidebarState(merge(latest), file)
834
+ const next = merge(latest)
835
+ if (next) await doWriteSidebarState(next, file)
458
836
  } finally {
459
837
  await lock.release()
460
838
  }
@@ -465,6 +843,156 @@ export type SidebarMachineState = Pick<
465
843
  'main' | 'fallbacks' | 'planType' | 'credits' | 'lastUpdated'
466
844
  > & { route: string }
467
845
 
846
+ // The freshest signal across every timestamp a snapshot carries: either window
847
+ // (primary/secondary) or the legacy top-level stamp. A retired primary window
848
+ // (null) with a fresh secondary must still outrank an older incoming primary, so
849
+ // the comparison takes the max rather than the first present value.
850
+ function latestQuotaCheckedAt(quota: AccountQuota | null): number | undefined {
851
+ let latest: number | undefined
852
+ for (const checkedAt of [
853
+ quota?.primary?.checkedAt,
854
+ quota?.secondary?.checkedAt,
855
+ quota?.checkedAt,
856
+ ]) {
857
+ if (typeof checkedAt === 'number' && Number.isFinite(checkedAt)) {
858
+ latest = latest === undefined ? checkedAt : Math.max(latest, checkedAt)
859
+ }
860
+ }
861
+ return latest
862
+ }
863
+
864
+ function freshestQuota(
865
+ incoming: AccountQuota | null,
866
+ existing: AccountQuota | null,
867
+ ): AccountQuota | null {
868
+ const incomingCheckedAt = latestQuotaCheckedAt(incoming)
869
+ const existingCheckedAt = latestQuotaCheckedAt(existing)
870
+ if (
871
+ existingCheckedAt !== undefined &&
872
+ (incomingCheckedAt === undefined || existingCheckedAt > incomingCheckedAt)
873
+ ) {
874
+ return existing
875
+ }
876
+ return incoming
877
+ }
878
+
879
+ // True when both sides assert the same stable account identity. An unknown
880
+ // identity on either side is NOT a confirmed match — merging windows across an
881
+ // unconfirmed identity could combine two accounts' quota, so the caller
882
+ // whole-picks instead.
883
+ function sameAccountIdentity(
884
+ incoming: string | undefined,
885
+ existing: string | undefined,
886
+ ): boolean {
887
+ return (
888
+ incoming !== undefined && existing !== undefined && incoming === existing
889
+ )
890
+ }
891
+
892
+ // A window's checkedAt when it is a usable timestamp, else undefined — so an
893
+ // absent or invalid stamp sorts oldest and a timestamped window always wins
894
+ // over an untimestamped one. The optional fallback is the enclosing snapshot's
895
+ // checkedAt, used when the window itself carries no usable stamp (files written
896
+ // by versions that did not propagate the entry timestamp onto each present
897
+ // window): a present window with no stamp is still "live", so it must sort by
898
+ // SOME timestamp — the snapshot's is the next-best signal.
899
+ function finiteWindowCheckedAt(
900
+ window: QuotaWindow | undefined,
901
+ fallback?: number,
902
+ ): number | undefined {
903
+ const checkedAt = window?.checkedAt
904
+ if (typeof checkedAt === 'number' && Number.isFinite(checkedAt)) {
905
+ return checkedAt
906
+ }
907
+ if (typeof fallback === 'number' && Number.isFinite(fallback)) {
908
+ return fallback
909
+ }
910
+ return undefined
911
+ }
912
+
913
+ // Fresher of two same-slot windows. When both sides report the window, the
914
+ // window's own stamp decides (falling back to each side's snapshot stamp when
915
+ // the window itself has none). When the slots disagree on presence, the FRESHER
916
+ // snapshot's slot is authoritative — a quota snapshot reports every live window,
917
+ // so an absent slot there means the wire retired it, which a stale window on the
918
+ // other side must not resurrect (and a fresher snapshot's present window must
919
+ // not be dropped by a stale window-less write).
920
+ function freshestWindow(
921
+ incoming: QuotaWindow | undefined,
922
+ existing: QuotaWindow | undefined,
923
+ existingSnapshotIsFresher: boolean,
924
+ incomingSnapshotCheckedAt: number | undefined,
925
+ existingSnapshotCheckedAt: number | undefined,
926
+ ): QuotaWindow | undefined {
927
+ if (incoming && existing) {
928
+ const incomingAt = finiteWindowCheckedAt(
929
+ incoming,
930
+ incomingSnapshotCheckedAt,
931
+ )
932
+ const existingAt = finiteWindowCheckedAt(
933
+ existing,
934
+ existingSnapshotCheckedAt,
935
+ )
936
+ if (
937
+ existingAt !== undefined &&
938
+ (incomingAt === undefined || existingAt > incomingAt)
939
+ ) {
940
+ return existing
941
+ }
942
+ return incoming
943
+ }
944
+ return existingSnapshotIsFresher ? existing : incoming
945
+ }
946
+
947
+ // Merge two same-account snapshots window-by-window: each slot keeps the
948
+ // fresher of the two sides, so a newer primary on one side and a newer
949
+ // secondary on the other both survive instead of one side's whole snapshot
950
+ // replacing the other's. The snapshot stamp becomes the freshest window stamp
951
+ // of the merged result. Only safe when both snapshots share an account identity
952
+ // (sameAccountIdentity) — an identity switch must whole-pick (freshestQuota) so
953
+ // windows from two accounts are never combined.
954
+ function mergeQuotaByWindow(
955
+ incoming: AccountQuota | null,
956
+ existing: AccountQuota | null,
957
+ ): AccountQuota | null {
958
+ if (!incoming) return existing
959
+ if (!existing) return incoming
960
+ const incomingAt = latestQuotaCheckedAt(incoming)
961
+ const existingAt = latestQuotaCheckedAt(existing)
962
+ const existingSnapshotIsFresher =
963
+ existingAt !== undefined &&
964
+ (incomingAt === undefined || existingAt > incomingAt)
965
+ const primary = freshestWindow(
966
+ incoming.primary,
967
+ existing.primary,
968
+ existingSnapshotIsFresher,
969
+ incoming.checkedAt,
970
+ existing.checkedAt,
971
+ )
972
+ const secondary = freshestWindow(
973
+ incoming.secondary,
974
+ existing.secondary,
975
+ existingSnapshotIsFresher,
976
+ incoming.checkedAt,
977
+ existing.checkedAt,
978
+ )
979
+ let checkedAt: number | undefined
980
+ for (const stamp of [
981
+ finiteWindowCheckedAt(primary),
982
+ finiteWindowCheckedAt(secondary),
983
+ ]) {
984
+ if (stamp !== undefined) {
985
+ checkedAt = checkedAt === undefined ? stamp : Math.max(checkedAt, stamp)
986
+ }
987
+ }
988
+ return {
989
+ ...incoming,
990
+ primary,
991
+ secondary,
992
+ checkedAt: checkedAt ?? incoming.checkedAt,
993
+ }
994
+ }
995
+
468
996
  export function setSidebarMachineState(
469
997
  machineState: SidebarMachineState,
470
998
  file = getSidebarStateFile(),
@@ -473,13 +1001,70 @@ export function setSidebarMachineState(
473
1001
  return enqueueSidebarWrite(async () => {
474
1002
  await writeMergedSidebarState(
475
1003
  file,
476
- (latest) => ({
477
- ...latest,
478
- ...machineState,
479
- activeId: latest.activeId,
480
- activeRouting: latest.activeRouting,
481
- lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
482
- }),
1004
+ (latest) => {
1005
+ const latestFallbacks = new Map(
1006
+ latest.fallbacks.map((account) => [account.id, account]),
1007
+ )
1008
+ // A same-identity merge combines the freshest of each window across the
1009
+ // two sides; a differing or unknown identity whole-picks the fresher
1010
+ // snapshot so windows from two accounts are never combined.
1011
+ const mainSameIdentity = sameAccountIdentity(
1012
+ machineState.main.mainAccountId,
1013
+ latest.main.mainAccountId,
1014
+ )
1015
+ const mergedMainQuota = mainSameIdentity
1016
+ ? mergeQuotaByWindow(machineState.main.quota, latest.main.quota)
1017
+ : freshestQuota(machineState.main.quota, latest.main.quota)
1018
+ const now = Date.now()
1019
+ const stickyAssignments = pruneStickyAssignments(
1020
+ latest.stickyAssignments,
1021
+ usableRoutingAccountIds(machineState.fallbacks),
1022
+ now,
1023
+ )
1024
+ return {
1025
+ ...latest,
1026
+ ...machineState,
1027
+ main: {
1028
+ ...machineState.main,
1029
+ quota: mergedMainQuota,
1030
+ // On a whole-pick the identity follows the winning snapshot, so a
1031
+ // reader never pairs one account's id with another account's quota
1032
+ // (a re-login race would otherwise resurrect the stale-account bug).
1033
+ // On a same-identity merge both sides already agree, so the incoming
1034
+ // id is the shared one.
1035
+ mainAccountId: mainSameIdentity
1036
+ ? machineState.main.mainAccountId
1037
+ : mergedMainQuota === latest.main.quota &&
1038
+ mergedMainQuota !== machineState.main.quota
1039
+ ? latest.main.mainAccountId
1040
+ : machineState.main.mainAccountId,
1041
+ },
1042
+ fallbacks: machineState.fallbacks.map((account) => {
1043
+ const existing = latestFallbacks.get(account.id)
1044
+ const fallbackSameIdentity = sameAccountIdentity(
1045
+ account.accountId,
1046
+ existing?.accountId,
1047
+ )
1048
+ const mergedQuota = fallbackSameIdentity
1049
+ ? mergeQuotaByWindow(account.quota, existing?.quota ?? null)
1050
+ : freshestQuota(account.quota, existing?.quota ?? null)
1051
+ return {
1052
+ ...account,
1053
+ quota: mergedQuota,
1054
+ accountId: fallbackSameIdentity
1055
+ ? account.accountId
1056
+ : mergedQuota === existing?.quota &&
1057
+ mergedQuota !== account.quota
1058
+ ? existing?.accountId
1059
+ : account.accountId,
1060
+ }
1061
+ }),
1062
+ activeId: latest.activeId,
1063
+ activeRouting: latest.activeRouting,
1064
+ stickyAssignments,
1065
+ lastUpdated: Math.max(now, latest.lastUpdated + 1),
1066
+ }
1067
+ },
483
1068
  hooks,
484
1069
  )
485
1070
  })
@@ -487,7 +1072,7 @@ export function setSidebarMachineState(
487
1072
 
488
1073
  export function upsertSidebarActiveRouting(
489
1074
  input: { sessionId: string } & ActiveRoutingEntry,
490
- accounts: readonly SidebarRoutingAccount[],
1075
+ accounts: readonly SidebarRoutingAccount[] | undefined,
491
1076
  file = getSidebarStateFile(),
492
1077
  hooks?: SidebarMergeHooks,
493
1078
  ): Promise<void> {
@@ -507,11 +1092,17 @@ export function upsertSidebarActiveRouting(
507
1092
  accounts,
508
1093
  Date.now(),
509
1094
  )
1095
+ const stickyAssignments = pruneStickyAssignments(
1096
+ latest.stickyAssignments,
1097
+ usableRoutingAccountIds(accounts),
1098
+ Date.now(),
1099
+ )
510
1100
  return {
511
1101
  ...latest,
512
1102
  activeId: input.activeId,
513
1103
  route: input.route,
514
1104
  activeRouting,
1105
+ stickyAssignments,
515
1106
  lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
516
1107
  }
517
1108
  },
@@ -536,7 +1127,7 @@ export function setSidebarLegacyRouting(
536
1127
 
537
1128
  export function removeSidebarActiveRouting(
538
1129
  sessionId: string,
539
- accounts: readonly SidebarRoutingAccount[],
1130
+ accounts: readonly SidebarRoutingAccount[] | undefined,
540
1131
  file = getSidebarStateFile(),
541
1132
  hooks?: SidebarMergeHooks,
542
1133
  ): Promise<void> {
@@ -551,9 +1142,16 @@ export function removeSidebarActiveRouting(
551
1142
  now,
552
1143
  sessionId,
553
1144
  )
1145
+ const stickyAssignments = pruneStickyAssignments(
1146
+ latest.stickyAssignments,
1147
+ usableRoutingAccountIds(accounts),
1148
+ now,
1149
+ hashSidebarSessionId(sessionId),
1150
+ )
554
1151
  return {
555
1152
  ...latest,
556
1153
  activeRouting,
1154
+ stickyAssignments,
557
1155
  lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
558
1156
  }
559
1157
  },
@@ -562,14 +1160,164 @@ export function removeSidebarActiveRouting(
562
1160
  })
563
1161
  }
564
1162
 
1163
+ export async function resolveSidebarStickyAssignment(
1164
+ input: ResolveStickyAssignmentInput,
1165
+ file = getSidebarStateFile(),
1166
+ hooks?: SidebarMergeHooks,
1167
+ ): Promise<StickyAssignment | undefined> {
1168
+ const sessionHash = hashSidebarSessionId(input.sessionId)
1169
+ const validPinnedAccountIds = new Set(input.validPinnedAccountIds)
1170
+ const excludedAccountIds = new Set(input.excludeAccountIds)
1171
+ const existing = (await readSidebarState(file)).stickyAssignments?.[
1172
+ sessionHash
1173
+ ]
1174
+ if (
1175
+ isValidStickyAssignment(
1176
+ existing,
1177
+ validPinnedAccountIds,
1178
+ excludedAccountIds,
1179
+ input.now,
1180
+ ) &&
1181
+ !stickyAssignmentNeedsMetadataUpdate(
1182
+ existing,
1183
+ input.requestBytes,
1184
+ input.now,
1185
+ )
1186
+ ) {
1187
+ return existing
1188
+ }
1189
+
1190
+ let resolved: StickyAssignment | undefined
1191
+ await enqueueSidebarWrite(async () => {
1192
+ await writeMergedSidebarState(
1193
+ file,
1194
+ (latest) => {
1195
+ const stickyAssignments = pruneStickyAssignments(
1196
+ latest.stickyAssignments,
1197
+ validPinnedAccountIds,
1198
+ input.now,
1199
+ )
1200
+ const assignmentsPruned = !stickyAssignmentsEqual(
1201
+ latest.stickyAssignments,
1202
+ stickyAssignments,
1203
+ )
1204
+ const current = stickyAssignments?.[sessionHash]
1205
+ if (
1206
+ isValidStickyAssignment(
1207
+ current,
1208
+ validPinnedAccountIds,
1209
+ excludedAccountIds,
1210
+ input.now,
1211
+ )
1212
+ ) {
1213
+ const metadataNeedsUpdate = stickyAssignmentNeedsMetadataUpdate(
1214
+ current,
1215
+ input.requestBytes,
1216
+ input.now,
1217
+ )
1218
+ const assignment = metadataNeedsUpdate
1219
+ ? {
1220
+ ...current,
1221
+ inputBytes: Math.max(current.inputBytes, input.requestBytes),
1222
+ ...(input.now - current.lastSeenAt >=
1223
+ STICKY_ASSIGNMENT_LAST_SEEN_TOUCH_MS
1224
+ ? { lastSeenAt: input.now }
1225
+ : {}),
1226
+ }
1227
+ : current
1228
+ resolved = assignment
1229
+ if (!assignmentsPruned && !metadataNeedsUpdate) return undefined
1230
+ return {
1231
+ ...latest,
1232
+ stickyAssignments: {
1233
+ ...stickyAssignments,
1234
+ [sessionHash]: assignment,
1235
+ },
1236
+ lastUpdated: Math.max(input.now, latest.lastUpdated + 1),
1237
+ }
1238
+ }
1239
+
1240
+ const choice = input.choose(
1241
+ pendingBytesForAssignments(
1242
+ stickyAssignments,
1243
+ input.quotaCheckedAtByAccount,
1244
+ ),
1245
+ )
1246
+ if (!choice) {
1247
+ resolved = undefined
1248
+ if (!assignmentsPruned) return undefined
1249
+ return {
1250
+ ...latest,
1251
+ stickyAssignments,
1252
+ lastUpdated: Math.max(input.now, latest.lastUpdated + 1),
1253
+ }
1254
+ }
1255
+
1256
+ resolved = {
1257
+ accountId: choice.accountId,
1258
+ assignedAt: input.now,
1259
+ lastSeenAt: input.now,
1260
+ inputBytes: input.requestBytes,
1261
+ ...(choice.quotaCheckedAt === undefined
1262
+ ? {}
1263
+ : { quotaCheckedAt: choice.quotaCheckedAt }),
1264
+ }
1265
+ return {
1266
+ ...latest,
1267
+ stickyAssignments: limitStickyAssignments(
1268
+ {
1269
+ ...stickyAssignments,
1270
+ [sessionHash]: resolved,
1271
+ },
1272
+ sessionHash,
1273
+ ),
1274
+ lastUpdated: Math.max(input.now, latest.lastUpdated + 1),
1275
+ }
1276
+ },
1277
+ hooks,
1278
+ )
1279
+ })
1280
+ return resolved
1281
+ }
1282
+
1283
+ export async function clearSidebarStickyAssignment(
1284
+ sessionId: string,
1285
+ file = getSidebarStateFile(),
1286
+ ): Promise<boolean> {
1287
+ const sessionHash = hashSidebarSessionId(sessionId)
1288
+ let removed = false
1289
+ await enqueueSidebarWrite(async () => {
1290
+ await writeMergedSidebarState(file, (latest) => {
1291
+ const assignments = latest.stickyAssignments
1292
+ if (assignments?.[sessionHash] === undefined) {
1293
+ removed = false
1294
+ return undefined
1295
+ }
1296
+ removed = true
1297
+ const { [sessionHash]: _removed, ...remaining } = assignments
1298
+ return {
1299
+ ...latest,
1300
+ ...(Object.keys(remaining).length > 0
1301
+ ? { stickyAssignments: remaining }
1302
+ : { stickyAssignments: undefined }),
1303
+ lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
1304
+ }
1305
+ })
1306
+ })
1307
+ return removed
1308
+ }
1309
+
565
1310
  async function doWriteSidebarState(
566
1311
  state: SidebarState,
567
1312
  file: string,
568
1313
  ): Promise<void> {
569
1314
  const tempPath = `${file}.${randomUUID()}.tmp`
570
1315
  try {
571
- await mkdir(dirname(file), { recursive: true })
572
- await writeFile(tempPath, JSON.stringify(state), 'utf8')
1316
+ await ensureSidebarStateDirectory(file)
1317
+ await writeFile(tempPath, JSON.stringify(normalizeSidebarState(state)), {
1318
+ encoding: 'utf8',
1319
+ mode: 0o600,
1320
+ })
573
1321
  await rename(tempPath, file)
574
1322
  } catch (e) {
575
1323
  await rm(tempPath, { force: true }).catch(() => {})