@cortexkit/opencode-openai-auth 0.4.3 → 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 (57) 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 +11 -4
  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-xcf8tm40.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-xbq3aq6f.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 +99 -86
  23. package/dist/{quota-normalize-754rewnb.js → quota-normalize-n02dfdx0.js} +1 -1
  24. package/dist/rpc/port-file.d.ts +4 -0
  25. package/dist/rpc/port-file.js +1 -1
  26. package/dist/rpc/protocol.d.ts +1 -0
  27. package/dist/rpc/rpc-client.js +1 -1
  28. package/dist/rpc/rpc-dir.d.ts +6 -0
  29. package/dist/rpc/rpc-dir.js +1 -1
  30. package/dist/rpc/rpc-server.d.ts +2 -0
  31. package/dist/sidebar-state.d.ts +44 -5
  32. package/dist/sidebar-state.js +1 -1
  33. package/dist/{token-xsc39hv8.js → token-5hdnazxp.js} +1 -1
  34. package/dist/token-util-3d2nj45d.js +1 -0
  35. package/dist/tui-preferences.js +1 -1
  36. package/dist/tui.d.ts +9 -0
  37. package/dist/version.d.ts +1 -1
  38. package/package.json +2 -2
  39. package/src/rpc/port-file.ts +115 -8
  40. package/src/rpc/protocol.ts +1 -0
  41. package/src/rpc/rpc-dir.ts +31 -7
  42. package/src/rpc/rpc-server.ts +18 -2
  43. package/src/sidebar-state.ts +744 -27
  44. package/src/tui/command-dialogs.tsx +25 -3
  45. package/src/tui-compiled/rpc/port-file.ts +115 -8
  46. package/src/tui-compiled/rpc/protocol.ts +1 -0
  47. package/src/tui-compiled/rpc/rpc-dir.ts +31 -7
  48. package/src/tui-compiled/sidebar-state.ts +744 -27
  49. package/src/tui-compiled/tui/command-dialogs.tsx +17 -4
  50. package/src/tui-compiled/tui.tsx +50 -15
  51. package/src/tui.tsx +57 -19
  52. package/dist/index-9k2gxqc1.js +0 -89
  53. package/dist/index-ptsh66z7.js +0 -2
  54. package/dist/index-rckqt97a.js +0 -1
  55. package/dist/index-reagbdg3.js +0 -2
  56. package/dist/index-ya6hw803.js +0 -7
  57. package/dist/token-util-xzavn760.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
@@ -300,12 +406,13 @@ export type SidebarRoutingAccount = {
300
406
 
301
407
  export function isUsableRoutingEntry(
302
408
  entry: ActiveRoutingEntry,
303
- accounts: readonly SidebarRoutingAccount[],
409
+ accounts: readonly SidebarRoutingAccount[] | undefined,
304
410
  now = Date.now(),
305
411
  ): boolean {
306
412
  const fresh =
307
413
  entry.updatedAt >= now - ACTIVE_ROUTING_MAX_AGE_MS && entry.updatedAt <= now
308
414
  if (!fresh) return false
415
+ if (accounts === undefined) return true
309
416
  return (
310
417
  entry.activeId === 'main' ||
311
418
  accounts.some(
@@ -317,20 +424,72 @@ export function isUsableRoutingEntry(
317
424
  )
318
425
  }
319
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
+
320
456
  export function isQuotaExhausted(
321
457
  quota: AccountQuota | null | undefined,
322
458
  now = Date.now(),
323
459
  ): boolean {
324
- const primary = quota?.primary
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)]
325
470
  if (
326
- typeof primary?.resetsAt !== 'string' ||
327
- !Number.isFinite(primary.usedPercent) ||
328
- primary.usedPercent < 100
471
+ !assignment ||
472
+ assignment.lastSeenAt < now - STICKY_ASSIGNMENT_MAX_AGE_MS
329
473
  ) {
330
- return false
474
+ return undefined
331
475
  }
332
- const resetsAt = Date.parse(primary.resetsAt)
333
- return Number.isFinite(resetsAt) && resetsAt > now
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
334
493
  }
335
494
 
336
495
  export function resolveSessionSidebarRouting(
@@ -354,6 +513,11 @@ export function resolveSessionSidebarRouting(
354
513
  return { activeId: own.activeId, route: own.route }
355
514
  }
356
515
 
516
+ const stickyAccountId = resolveSessionStickyAccount(state, sessionId, now)
517
+ if (stickyAccountId) {
518
+ return { activeId: stickyAccountId, route: state.route }
519
+ }
520
+
357
521
  const enabledFallbacks = state.fallbacks.filter(
358
522
  (account) => account.enabled && !account.killed,
359
523
  )
@@ -369,7 +533,7 @@ export function resolveSessionSidebarRouting(
369
533
 
370
534
  export function pruneActiveRouting(
371
535
  activeRouting: ActiveRoutingMap | undefined,
372
- accounts: readonly SidebarRoutingAccount[],
536
+ accounts: readonly SidebarRoutingAccount[] | undefined,
373
537
  now = Date.now(),
374
538
  removedSessionId?: string,
375
539
  ): ActiveRoutingMap | undefined {
@@ -388,6 +552,175 @@ export function pruneActiveRouting(
388
552
  return bounded.length > 0 ? Object.fromEntries(bounded) : undefined
389
553
  }
390
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
+
391
724
  // Serialization chain: concurrent calls are queued so a stale background
392
725
  // write cannot land after a newer one and corrupt the file.
393
726
  let sidebarWriteChain: Promise<void> = Promise.resolve()
@@ -450,7 +783,7 @@ function parseSidebarState(raw: string): SidebarState {
450
783
  }
451
784
 
452
785
  async function acquireSidebarWriteLock(file: string) {
453
- await mkdir(dirname(file), { recursive: true })
786
+ await ensureSidebarStateDirectory(file)
454
787
  const deadline = Date.now() + SIDEBAR_WRITE_LOCK_WAIT_MS
455
788
  while (Date.now() <= deadline) {
456
789
  const lock = await acquireRefreshFileLock({
@@ -465,9 +798,21 @@ async function acquireSidebarWriteLock(file: string) {
465
798
  throw new Error('Timed out waiting for the sidebar state write lock')
466
799
  }
467
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
+
468
813
  async function writeMergedSidebarState(
469
814
  file: string,
470
- merge: (latest: SidebarState) => SidebarState,
815
+ merge: (latest: SidebarState) => SidebarState | undefined,
471
816
  hooks?: SidebarMergeHooks,
472
817
  ): Promise<void> {
473
818
  const lock = await acquireSidebarWriteLock(file)
@@ -477,6 +822,7 @@ async function writeMergedSidebarState(
477
822
  for (let attempt = 0; attempt < MAX_MERGE_ATTEMPTS; attempt += 1) {
478
823
  const rawBefore = await readRawSidebar(file)
479
824
  const next = merge(parseSidebarState(rawBefore))
825
+ if (!next) return
480
826
  if (attempt === 0) await hooks?.beforeRecheck?.()
481
827
  const rawRecheck = await readRawSidebar(file)
482
828
  if (rawRecheck !== rawBefore) continue
@@ -485,7 +831,8 @@ async function writeMergedSidebarState(
485
831
  }
486
832
 
487
833
  const latest = await readSidebarState(file)
488
- await doWriteSidebarState(merge(latest), file)
834
+ const next = merge(latest)
835
+ if (next) await doWriteSidebarState(next, file)
489
836
  } finally {
490
837
  await lock.release()
491
838
  }
@@ -496,6 +843,156 @@ export type SidebarMachineState = Pick<
496
843
  'main' | 'fallbacks' | 'planType' | 'credits' | 'lastUpdated'
497
844
  > & { route: string }
498
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
+
499
996
  export function setSidebarMachineState(
500
997
  machineState: SidebarMachineState,
501
998
  file = getSidebarStateFile(),
@@ -504,13 +1001,70 @@ export function setSidebarMachineState(
504
1001
  return enqueueSidebarWrite(async () => {
505
1002
  await writeMergedSidebarState(
506
1003
  file,
507
- (latest) => ({
508
- ...latest,
509
- ...machineState,
510
- activeId: latest.activeId,
511
- activeRouting: latest.activeRouting,
512
- lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
513
- }),
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
+ },
514
1068
  hooks,
515
1069
  )
516
1070
  })
@@ -518,7 +1072,7 @@ export function setSidebarMachineState(
518
1072
 
519
1073
  export function upsertSidebarActiveRouting(
520
1074
  input: { sessionId: string } & ActiveRoutingEntry,
521
- accounts: readonly SidebarRoutingAccount[],
1075
+ accounts: readonly SidebarRoutingAccount[] | undefined,
522
1076
  file = getSidebarStateFile(),
523
1077
  hooks?: SidebarMergeHooks,
524
1078
  ): Promise<void> {
@@ -538,11 +1092,17 @@ export function upsertSidebarActiveRouting(
538
1092
  accounts,
539
1093
  Date.now(),
540
1094
  )
1095
+ const stickyAssignments = pruneStickyAssignments(
1096
+ latest.stickyAssignments,
1097
+ usableRoutingAccountIds(accounts),
1098
+ Date.now(),
1099
+ )
541
1100
  return {
542
1101
  ...latest,
543
1102
  activeId: input.activeId,
544
1103
  route: input.route,
545
1104
  activeRouting,
1105
+ stickyAssignments,
546
1106
  lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
547
1107
  }
548
1108
  },
@@ -567,7 +1127,7 @@ export function setSidebarLegacyRouting(
567
1127
 
568
1128
  export function removeSidebarActiveRouting(
569
1129
  sessionId: string,
570
- accounts: readonly SidebarRoutingAccount[],
1130
+ accounts: readonly SidebarRoutingAccount[] | undefined,
571
1131
  file = getSidebarStateFile(),
572
1132
  hooks?: SidebarMergeHooks,
573
1133
  ): Promise<void> {
@@ -582,9 +1142,16 @@ export function removeSidebarActiveRouting(
582
1142
  now,
583
1143
  sessionId,
584
1144
  )
1145
+ const stickyAssignments = pruneStickyAssignments(
1146
+ latest.stickyAssignments,
1147
+ usableRoutingAccountIds(accounts),
1148
+ now,
1149
+ hashSidebarSessionId(sessionId),
1150
+ )
585
1151
  return {
586
1152
  ...latest,
587
1153
  activeRouting,
1154
+ stickyAssignments,
588
1155
  lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
589
1156
  }
590
1157
  },
@@ -593,14 +1160,164 @@ export function removeSidebarActiveRouting(
593
1160
  })
594
1161
  }
595
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
+
596
1310
  async function doWriteSidebarState(
597
1311
  state: SidebarState,
598
1312
  file: string,
599
1313
  ): Promise<void> {
600
1314
  const tempPath = `${file}.${randomUUID()}.tmp`
601
1315
  try {
602
- await mkdir(dirname(file), { recursive: true })
603
- 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
+ })
604
1321
  await rename(tempPath, file)
605
1322
  } catch (e) {
606
1323
  await rm(tempPath, { force: true }).catch(() => {})