@cortexkit/opencode-openai-auth 0.11.1 → 1.0.1

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 (204) hide show
  1. package/README.md +15 -26
  2. package/dist/index-766sfrh5.js +1 -0
  3. package/dist/index-8kj7sa1p.js +12 -0
  4. package/dist/index-b3q3j39x.js +2 -0
  5. package/dist/index-bmd7x5aq.js +2 -0
  6. package/dist/index-eqh53k8v.js +2 -0
  7. package/dist/{index-sdt2d2ra.js → index-hy9030jh.js} +1 -1
  8. package/dist/index-jyr8y8k4.js +7 -0
  9. package/dist/index-rv08jmd4.js +2 -0
  10. package/dist/index-ww91hyzr.js +221 -0
  11. package/dist/index.d.ts +578 -145
  12. package/dist/index.js +1 -110
  13. package/dist/pool-lifecycle-DbDmgabd.d.ts +2430 -0
  14. package/dist/port-file-Do3lQIEk.d.ts +29 -0
  15. package/dist/protocol-BOBdkqqm.d.ts +198 -0
  16. package/dist/rpc/port-file.d.ts +4 -17
  17. package/dist/rpc/port-file.js +1 -1
  18. package/dist/rpc/protocol.d.ts +2 -2
  19. package/dist/rpc/protocol.js +1 -0
  20. package/dist/rpc/rpc-client.d.ts +5 -4
  21. package/dist/rpc/rpc-client.js +1 -1
  22. package/dist/rpc/rpc-dir.d.ts +2 -0
  23. package/dist/rpc/rpc-dir.js +1 -1
  24. package/dist/sidebar-state-D6cUKgkm.d.ts +385 -0
  25. package/dist/sidebar-state.d.ts +2 -2
  26. package/dist/sidebar-state.js +1 -1
  27. package/dist/token-util-wdza526f.js +1 -0
  28. package/dist/{token-9823y0pc.js → token-ypk61xj0.js} +1 -1
  29. package/dist/tui-preferences.js +5 -2
  30. package/dist/tui.d.ts +9 -4
  31. package/dist/v2/server.d.ts +54 -0
  32. package/dist/v2/server.js +4 -0
  33. package/package.json +21 -9
  34. package/src/tui/entry.mjs +26 -25
  35. package/src/tui-compiled/raw/logger.js +24 -0
  36. package/src/tui-compiled/raw/rpc/protocol.js +3 -0
  37. package/src/tui-compiled/raw/rpc/rpc-client.js +7 -0
  38. package/src/tui-compiled/raw/rpc/rpc-dir.js +27 -0
  39. package/src/tui-compiled/raw/selector.js +21 -0
  40. package/src/tui-compiled/raw/shared/0332701351b3eeec-format.js +138 -0
  41. package/src/tui-compiled/raw/shared/04a6357627de5ed0-errors.js +38 -0
  42. package/src/tui-compiled/raw/shared/053cc17b5419261e-codec.js +12 -0
  43. package/src/tui-compiled/raw/shared/0766d78c6f481f18-watcher.js +157 -0
  44. package/src/tui-compiled/raw/shared/08eef45626a94992-reserved.js +13 -0
  45. package/src/tui-compiled/raw/shared/39c3cb05738fcf6d-projection.js +134 -0
  46. package/src/tui-compiled/raw/shared/3c2d4169d00f907e-tui-support.js +5 -0
  47. package/src/tui-compiled/raw/shared/3e7d45a77c6ad4d5-index.js +4 -0
  48. package/src/tui-compiled/raw/shared/415792e85d80819e-capture-sink.js +13 -0
  49. package/src/tui-compiled/raw/shared/42ae93ab554fcec2-open-url.js +28 -0
  50. package/src/tui-compiled/raw/shared/43fc9e6b994519c7-refresh-lock.js +136 -0
  51. package/src/tui-compiled/raw/shared/458b934e92490331-mutate.js +500 -0
  52. package/src/tui-compiled/raw/shared/45bf5343bbbf9add-admission.js +142 -0
  53. package/src/tui-compiled/raw/shared/46f3dff1266160e8-client.js +2 -0
  54. package/src/tui-compiled/raw/shared/4825e03209d6ae3e-with-lock.js +48 -0
  55. package/src/tui-compiled/raw/shared/4f36d333d081d040-merge.js +162 -0
  56. package/src/tui-compiled/raw/shared/54eba94fd64c8c3c-index.js +7 -0
  57. package/src/tui-compiled/raw/shared/7211cfec06a298bc-atomic-write.js +80 -0
  58. package/src/tui-compiled/raw/shared/754d6017f8e419cf-builtins.js +534 -0
  59. package/src/tui-compiled/raw/shared/75bd8c3bd63b315f-identity.js +79 -0
  60. package/src/tui-compiled/raw/shared/7a91bb630e6f4804-index.js +1 -0
  61. package/src/tui-compiled/raw/shared/826342a4b6a8b89b-model.js +16 -0
  62. package/src/tui-compiled/raw/shared/83068c0dbe8a6f13-publication.js +304 -0
  63. package/src/tui-compiled/raw/shared/836fc615de6afbd9-settings.js +120 -0
  64. package/src/tui-compiled/raw/shared/83bc3ba9d0fe2761-runtime.js +68 -0
  65. package/src/tui-compiled/raw/shared/8a18abe4fa2977c8-menu.js +300 -0
  66. package/src/tui-compiled/raw/shared/901e149c5a0dbbff-sticky.js +339 -0
  67. package/src/tui-compiled/raw/shared/9020c407ce64f672-ordered.js +58 -0
  68. package/src/tui-compiled/raw/shared/905bffc0220f43d0-map.js +105 -0
  69. package/src/tui-compiled/raw/shared/94820e324f2b4c09-seam.js +301 -0
  70. package/src/tui-compiled/raw/shared/97484d9bb98c705b-provider-state.js +226 -0
  71. package/src/tui-compiled/raw/shared/9968e3cade6969a1-index.js +2 -0
  72. package/src/tui-compiled/raw/shared/9b4da614c45f9cd0-lock-constants.js +14 -0
  73. package/src/tui-compiled/raw/shared/9d3fc9e37456504e-refresh.js +225 -0
  74. package/src/tui-compiled/raw/shared/9deae3c4da4fd63b-hooks.js +34 -0
  75. package/src/tui-compiled/raw/shared/a09b6a533c11858f-tui-preferences.js +77 -0
  76. package/src/tui-compiled/raw/shared/a62a27a204266acd-attribution.js +60 -0
  77. package/src/tui-compiled/raw/shared/a910277ac43a8626-pull.js +143 -0
  78. package/src/tui-compiled/raw/shared/b85c5301fddcb98a-paths.js +5 -0
  79. package/src/tui-compiled/raw/shared/c14988621dc60a75-port-file.js +217 -0
  80. package/src/tui-compiled/raw/shared/c64d7afbc14ca125-sidebar-file.js +157 -0
  81. package/src/tui-compiled/raw/shared/c6b176708c446d6d-pins.js +34 -0
  82. package/src/tui-compiled/raw/shared/c7d252bedee01600-pi.js +206 -0
  83. package/src/tui-compiled/raw/shared/c852a15cfe489d12-pool.js +120 -0
  84. package/src/tui-compiled/raw/shared/cc9e0702a752cea7-index.js +3 -0
  85. package/src/tui-compiled/raw/shared/cd94316a888b0a8e-schema.js +754 -0
  86. package/src/tui-compiled/raw/shared/d319517e3ea6ad70-redact.js +83 -0
  87. package/src/tui-compiled/raw/shared/d5a8f1061ee2f114-index.js +5 -0
  88. package/src/tui-compiled/raw/shared/d669d72c40e9e116-logger.js +19 -0
  89. package/src/tui-compiled/raw/shared/d7dcba96fea1685c-engine.js +246 -0
  90. package/src/tui-compiled/raw/shared/dbef697d1df1cd84-index.js +5 -0
  91. package/src/tui-compiled/raw/shared/e4acc4cb713f300c-protocol.js +3 -0
  92. package/src/tui-compiled/raw/shared/e8cd4929507508d8-refresh-file-lock.js +501 -0
  93. package/src/tui-compiled/raw/shared/ec971142548df90e-index.js +6 -0
  94. package/src/tui-compiled/raw/shared/f37fcefc7aedbf25-error.js +11 -0
  95. package/src/tui-compiled/raw/shared/fbcdcd0f925e74c0-rpc-client.js +253 -0
  96. package/src/tui-compiled/raw/shared/fd45e82f2f9c0a0d-torn.js +294 -0
  97. package/src/tui-compiled/raw/shared/fe02abeadc535f77-rows.js +1258 -0
  98. package/src/tui-compiled/raw/sidebar-state.js +960 -0
  99. package/src/tui-compiled/raw/tui/command-dialogs.tsx +424 -0
  100. package/src/tui-compiled/raw/tui-preferences.js +135 -0
  101. package/src/tui-compiled/raw/tui.tsx +135 -143
  102. package/src/tui-compiled/runtime/logger.js +24 -0
  103. package/src/tui-compiled/runtime/rpc/protocol.js +3 -0
  104. package/src/tui-compiled/runtime/rpc/rpc-client.js +7 -0
  105. package/src/tui-compiled/runtime/rpc/rpc-dir.js +27 -0
  106. package/src/tui-compiled/runtime/selector.js +21 -0
  107. package/src/tui-compiled/runtime/shared/0332701351b3eeec-format.js +138 -0
  108. package/src/tui-compiled/runtime/shared/04a6357627de5ed0-errors.js +38 -0
  109. package/src/tui-compiled/runtime/shared/053cc17b5419261e-codec.js +12 -0
  110. package/src/tui-compiled/runtime/shared/0766d78c6f481f18-watcher.js +157 -0
  111. package/src/tui-compiled/runtime/shared/08eef45626a94992-reserved.js +13 -0
  112. package/src/tui-compiled/runtime/shared/39c3cb05738fcf6d-projection.js +134 -0
  113. package/src/tui-compiled/runtime/shared/3c2d4169d00f907e-tui-support.js +5 -0
  114. package/src/tui-compiled/runtime/shared/3e7d45a77c6ad4d5-index.js +4 -0
  115. package/src/tui-compiled/runtime/shared/415792e85d80819e-capture-sink.js +13 -0
  116. package/src/tui-compiled/runtime/shared/42ae93ab554fcec2-open-url.js +28 -0
  117. package/src/tui-compiled/runtime/shared/43fc9e6b994519c7-refresh-lock.js +136 -0
  118. package/src/tui-compiled/runtime/shared/458b934e92490331-mutate.js +500 -0
  119. package/src/tui-compiled/runtime/shared/45bf5343bbbf9add-admission.js +142 -0
  120. package/src/tui-compiled/runtime/shared/46f3dff1266160e8-client.js +2 -0
  121. package/src/tui-compiled/runtime/shared/4825e03209d6ae3e-with-lock.js +48 -0
  122. package/src/tui-compiled/runtime/shared/4f36d333d081d040-merge.js +162 -0
  123. package/src/tui-compiled/runtime/shared/54eba94fd64c8c3c-index.js +7 -0
  124. package/src/tui-compiled/runtime/shared/7211cfec06a298bc-atomic-write.js +80 -0
  125. package/src/tui-compiled/runtime/shared/754d6017f8e419cf-builtins.js +534 -0
  126. package/src/tui-compiled/runtime/shared/75bd8c3bd63b315f-identity.js +79 -0
  127. package/src/tui-compiled/runtime/shared/7a91bb630e6f4804-index.js +1 -0
  128. package/src/tui-compiled/runtime/shared/826342a4b6a8b89b-model.js +16 -0
  129. package/src/tui-compiled/runtime/shared/83068c0dbe8a6f13-publication.js +304 -0
  130. package/src/tui-compiled/runtime/shared/836fc615de6afbd9-settings.js +120 -0
  131. package/src/tui-compiled/runtime/shared/83bc3ba9d0fe2761-runtime.js +68 -0
  132. package/src/tui-compiled/runtime/shared/8a18abe4fa2977c8-menu.js +300 -0
  133. package/src/tui-compiled/runtime/shared/901e149c5a0dbbff-sticky.js +339 -0
  134. package/src/tui-compiled/runtime/shared/9020c407ce64f672-ordered.js +58 -0
  135. package/src/tui-compiled/runtime/shared/905bffc0220f43d0-map.js +105 -0
  136. package/src/tui-compiled/runtime/shared/94820e324f2b4c09-seam.js +301 -0
  137. package/src/tui-compiled/runtime/shared/97484d9bb98c705b-provider-state.js +226 -0
  138. package/src/tui-compiled/runtime/shared/9968e3cade6969a1-index.js +2 -0
  139. package/src/tui-compiled/runtime/shared/9b4da614c45f9cd0-lock-constants.js +14 -0
  140. package/src/tui-compiled/runtime/shared/9d3fc9e37456504e-refresh.js +225 -0
  141. package/src/tui-compiled/runtime/shared/9deae3c4da4fd63b-hooks.js +34 -0
  142. package/src/tui-compiled/runtime/shared/a09b6a533c11858f-tui-preferences.js +77 -0
  143. package/src/tui-compiled/runtime/shared/a62a27a204266acd-attribution.js +60 -0
  144. package/src/tui-compiled/runtime/shared/a910277ac43a8626-pull.js +143 -0
  145. package/src/tui-compiled/runtime/shared/b85c5301fddcb98a-paths.js +5 -0
  146. package/src/tui-compiled/runtime/shared/c14988621dc60a75-port-file.js +217 -0
  147. package/src/tui-compiled/runtime/shared/c64d7afbc14ca125-sidebar-file.js +157 -0
  148. package/src/tui-compiled/runtime/shared/c6b176708c446d6d-pins.js +34 -0
  149. package/src/tui-compiled/runtime/shared/c7d252bedee01600-pi.js +206 -0
  150. package/src/tui-compiled/runtime/shared/c852a15cfe489d12-pool.js +120 -0
  151. package/src/tui-compiled/runtime/shared/cc9e0702a752cea7-index.js +3 -0
  152. package/src/tui-compiled/runtime/shared/cd94316a888b0a8e-schema.js +754 -0
  153. package/src/tui-compiled/runtime/shared/d319517e3ea6ad70-redact.js +83 -0
  154. package/src/tui-compiled/runtime/shared/d5a8f1061ee2f114-index.js +5 -0
  155. package/src/tui-compiled/runtime/shared/d669d72c40e9e116-logger.js +19 -0
  156. package/src/tui-compiled/runtime/shared/d7dcba96fea1685c-engine.js +246 -0
  157. package/src/tui-compiled/runtime/shared/dbef697d1df1cd84-index.js +5 -0
  158. package/src/tui-compiled/runtime/shared/e4acc4cb713f300c-protocol.js +3 -0
  159. package/src/tui-compiled/runtime/shared/e8cd4929507508d8-refresh-file-lock.js +501 -0
  160. package/src/tui-compiled/runtime/shared/ec971142548df90e-index.js +6 -0
  161. package/src/tui-compiled/runtime/shared/f37fcefc7aedbf25-error.js +11 -0
  162. package/src/tui-compiled/runtime/shared/fbcdcd0f925e74c0-rpc-client.js +253 -0
  163. package/src/tui-compiled/runtime/shared/fd45e82f2f9c0a0d-torn.js +294 -0
  164. package/src/tui-compiled/runtime/shared/fe02abeadc535f77-rows.js +1258 -0
  165. package/src/tui-compiled/runtime/sidebar-state.js +960 -0
  166. package/src/tui-compiled/runtime/tui/command-dialogs.js +378 -0
  167. package/src/tui-compiled/runtime/tui-preferences.js +135 -0
  168. package/src/tui-compiled/runtime/{tui.tsx → tui.js} +288 -316
  169. package/dist/index-4effjmr6.js +0 -2
  170. package/dist/index-9ehhb051.js +0 -2
  171. package/dist/index-g3hsdjh4.js +0 -2
  172. package/dist/index-hh52v8sb.js +0 -2
  173. package/dist/index-jwn4r3sw.js +0 -1
  174. package/dist/index-sq36b120.js +0 -211
  175. package/dist/internal-BTndPmW2.d.ts +0 -914
  176. package/dist/quota-normalize-08hpp51v.js +0 -1
  177. package/dist/sidebar-state-CoKYKcLn.d.ts +0 -220
  178. package/dist/token-util-ypgz7rge.js +0 -1
  179. package/src/tui-compiled/raw/core-account-paths.ts +0 -114
  180. package/src/tui-compiled/raw/core-custody-state.ts +0 -243
  181. package/src/tui-compiled/raw/logger.ts +0 -53
  182. package/src/tui-compiled/raw/rpc-port-file.ts +0 -177
  183. package/src/tui-compiled/raw/rpc-protocol.ts +0 -12
  184. package/src/tui-compiled/raw/rpc-rpc-client.ts +0 -82
  185. package/src/tui-compiled/raw/rpc-rpc-dir.ts +0 -46
  186. package/src/tui-compiled/raw/sidebar-state.ts +0 -1659
  187. package/src/tui-compiled/raw/tui-command-dialogs.tsx +0 -1096
  188. package/src/tui-compiled/raw/tui-preferences.ts +0 -328
  189. package/src/tui-compiled/runtime/core-account-paths.ts +0 -114
  190. package/src/tui-compiled/runtime/core-custody-state.ts +0 -243
  191. package/src/tui-compiled/runtime/logger.ts +0 -53
  192. package/src/tui-compiled/runtime/rpc-port-file.ts +0 -177
  193. package/src/tui-compiled/runtime/rpc-protocol.ts +0 -12
  194. package/src/tui-compiled/runtime/rpc-rpc-client.ts +0 -82
  195. package/src/tui-compiled/runtime/rpc-rpc-dir.ts +0 -46
  196. package/src/tui-compiled/runtime/sidebar-state.ts +0 -1659
  197. package/src/tui-compiled/runtime/tui-command-dialogs.tsx +0 -855
  198. package/src/tui-compiled/runtime/tui-preferences.ts +0 -328
  199. package/src/tui-compiled/shared/internal.ts +0 -6
  200. package/src/tui-compiled/shared/logger.ts +0 -264
  201. package/src/tui-compiled/shared/paths.ts +0 -33
  202. package/src/tui-compiled/shared/refresh-file-lock.ts +0 -384
  203. package/src/tui-compiled/shared/util/error.ts +0 -9
  204. package/src/tui-compiled/shared/util/open-url.ts +0 -30
@@ -1,1659 +0,0 @@
1
- export interface QuotaWindow {
2
- usedPercent: number
3
- remainingPercent: number
4
- checkedAt?: number
5
- resetsAt?: string
6
- windowMinutes?: number
7
- }
8
-
9
- export interface SpendControlReading {
10
- limit: number
11
- used: number
12
- remaining: number
13
- usedPercent: number
14
- remainingPercent: number
15
- resetsAt?: string
16
- unit?: string
17
- source?: string
18
- reached: boolean
19
- }
20
-
21
- export interface AccountQuota {
22
- checkedAt?: number
23
- primary?: QuotaWindow
24
- secondary?: QuotaWindow
25
- spendControl?: SpendControlReading
26
- // The source reported that no credit budget exists; see OAuthQuotaSnapshot.
27
- spendControlCleared?: true
28
- resetCreditsAvailable?: number
29
- }
30
-
31
- export type QuotaWindowKey = 'primary' | 'secondary'
32
-
33
- const QUOTA_WINDOW_KEYS: readonly QuotaWindowKey[] = ['primary', 'secondary']
34
- const LEGACY_WINDOW_MINUTES: Record<QuotaWindowKey, number> = {
35
- primary: 300,
36
- secondary: 10_080,
37
- }
38
-
39
- function compactUnit(value: number): string {
40
- return Number.isInteger(value)
41
- ? String(value)
42
- : String(Math.round(value * 10) / 10)
43
- }
44
-
45
- // Derives a short human label ("5h", "1d", "7d") from a window length in
46
- // minutes. Snapshots written before dynamic windows carry no length, so retain
47
- // their historical primary=5h and secondary=7d meanings.
48
- export function formatWindowLabel(
49
- windowMinutes: number | undefined,
50
- fallbackKey: QuotaWindowKey,
51
- ): string {
52
- const minutes =
53
- windowMinutes !== undefined &&
54
- Number.isFinite(windowMinutes) &&
55
- windowMinutes > 0
56
- ? windowMinutes
57
- : LEGACY_WINDOW_MINUTES[fallbackKey]
58
- if (minutes < 60) return `${compactUnit(minutes)}m`
59
- if (minutes < 1_440) return `${compactUnit(minutes / 60)}h`
60
- return `${compactUnit(minutes / 1_440)}d`
61
- }
62
-
63
- export interface PresentQuotaWindow {
64
- key: QuotaWindowKey
65
- label: string
66
- window: QuotaWindow
67
- windowMs: number | null
68
- }
69
-
70
- // Present windows only — an absent slot means "not applicable", not
71
- // "unknown", so it must never synthesize a placeholder row here.
72
- export function getPresentQuotaWindows(
73
- quota: AccountQuota | null,
74
- ): PresentQuotaWindow[] {
75
- if (!quota) return []
76
- const rows: PresentQuotaWindow[] = []
77
- for (const key of QUOTA_WINDOW_KEYS) {
78
- const window = quota[key]
79
- if (!window) continue
80
- const configuredMinutes = window.windowMinutes
81
- const windowMinutes =
82
- configuredMinutes !== undefined &&
83
- Number.isFinite(configuredMinutes) &&
84
- configuredMinutes > 0
85
- ? configuredMinutes
86
- : LEGACY_WINDOW_MINUTES[key]
87
- rows.push({
88
- key,
89
- label: formatWindowLabel(windowMinutes, key),
90
- window,
91
- windowMs: windowMinutes * 60_000,
92
- })
93
- }
94
- return rows
95
- }
96
-
97
- export interface SidebarAccountState {
98
- id: string
99
- label: string | undefined
100
- /** ChatGPT identity of the account this quota belongs to. */
101
- accountId?: string
102
- quota: AccountQuota | null
103
- killed: boolean
104
- enabled: boolean
105
- resetCredits?: number
106
- custody?: SidebarAccountCustody
107
- }
108
-
109
- export type SidebarCustodyState = 'vault' | 'needsLogin' | 'local' | 'inert'
110
-
111
- export type SidebarCustodyReason = CustodyInertReason | 'corrupt'
112
-
113
- export interface SidebarAccountCustody {
114
- state: SidebarCustodyState
115
- reason?: SidebarCustodyReason
116
- recordVersion?: number
117
- }
118
-
119
- export interface ActiveRoutingEntry {
120
- activeId: string
121
- route: string
122
- updatedAt: number
123
- }
124
-
125
- export type ActiveRoutingMap = Record<string, ActiveRoutingEntry>
126
-
127
- export interface StickyAssignment {
128
- accountId: string
129
- wireAccountId?: string
130
- assignedAt: number
131
- lastSeenAt: number
132
- inputBytes: number
133
- quotaCheckedAt?: number
134
- }
135
-
136
- export type StickyAssignmentMap = Record<string, StickyAssignment>
137
-
138
- export interface StickyAssignmentChoice {
139
- accountId: string
140
- quotaCheckedAt?: number
141
- }
142
-
143
- export interface ResolveStickyAssignmentInput {
144
- sessionId: string
145
- requestBytes: number
146
- now: number
147
- validPinnedAccountIds: readonly string[]
148
- excludeAccountIds?: readonly string[]
149
- quotaCheckedAtByAccount: Readonly<Record<string, number | undefined>>
150
- wireAccountIdByAccount?: Readonly<Record<string, string | undefined>>
151
- choose: (
152
- pendingBytes: ReadonlyMap<string, number>,
153
- ) => StickyAssignmentChoice | undefined
154
- }
155
-
156
- export interface SidebarState {
157
- main: {
158
- quota: AccountQuota | null
159
- /** ChatGPT identity of the main account this quota belongs to. */
160
- mainAccountId?: string
161
- custody?: SidebarAccountCustody
162
- killed: boolean
163
- quotaBackedOff?: boolean
164
- quotaBackoffUntil?: number
165
- refreshBackedOff?: boolean
166
- refreshBackoffUntil?: number
167
- resetCredits?: number
168
- }
169
- fallbacks: SidebarAccountState[]
170
- /** @deprecated Compatibility field for readers that do not consume activeRouting. */
171
- activeId: string | undefined
172
- /** Machine-global routing mode and compatibility value for older readers. */
173
- route: string
174
- activeRouting?: ActiveRoutingMap
175
- stickyAssignments?: StickyAssignmentMap
176
- planType?: string
177
- credits?: number
178
- lastUpdated: number
179
- }
180
-
181
- import { createHash, randomUUID } from 'node:crypto'
182
- import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
183
- import { tmpdir } from 'node:os'
184
- import { dirname, join } from 'node:path'
185
- import { acquireRefreshFileLock } from '../shared/internal.ts'
186
- import {
187
- CUSTODY_INERT_REASONS,
188
- type CustodyInertReason,
189
- type CustodyVerdict,
190
- } from './core-custody-state.ts'
191
- import { createLogger } from './logger.ts'
192
-
193
- const logSb = createLogger('sidebar')
194
-
195
- const STATE_FILE_ENV = 'OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE'
196
- const DEFAULT_STATE_DIR = join(tmpdir(), 'opencode-openai-auth')
197
- const DEFAULT_STATE_FILE = join(DEFAULT_STATE_DIR, 'sidebar-state.json')
198
- const SESSION_HASH_PATTERN = /^[a-f0-9]{64}$/
199
- export const STICKY_ASSIGNMENT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
200
- export const STICKY_ASSIGNMENT_MAX_ENTRIES = 256
201
- const STICKY_ASSIGNMENT_LAST_SEEN_TOUCH_MS = 60 * 60 * 1000
202
-
203
- export function hashSidebarSessionId(sessionId: string): string {
204
- return createHash('sha256').update(sessionId).digest('hex')
205
- }
206
-
207
- function normalizeResetCredits(value: unknown): number | undefined {
208
- return typeof value === 'number' && Number.isFinite(value) && value >= 0
209
- ? value
210
- : undefined
211
- }
212
-
213
- function resetCreditsField(value: unknown): { resetCredits?: number } {
214
- const credits = normalizeResetCredits(value)
215
- return credits !== undefined ? { resetCredits: credits } : {}
216
- }
217
-
218
- const CUSTODY_STATES = new Set<SidebarCustodyState>([
219
- 'vault',
220
- 'needsLogin',
221
- 'local',
222
- 'inert',
223
- ])
224
-
225
- const CUSTODY_REASONS = new Set<SidebarCustodyReason>([
226
- ...CUSTODY_INERT_REASONS,
227
- 'corrupt',
228
- ])
229
-
230
- /**
231
- * Tolerant reader for the per-fallback `custody` projection. Unknown state
232
- * or reason values are dropped (NOT replaced with a default — a stale state
233
- * file with an experimental `vaultHealing` value must not silently render
234
- * as `local`, it must render as no-projection-at-all). Valid values round-
235
- * trip byte-identical. The output contains only `state` and `reason`.
236
- */
237
- function normalizeSidebarCustody(
238
- value: unknown,
239
- ): SidebarAccountCustody | undefined {
240
- if (value === null || typeof value !== 'object' || Array.isArray(value)) {
241
- return undefined
242
- }
243
- const c = value as Record<string, unknown>
244
- if (
245
- typeof c.state !== 'string' ||
246
- !CUSTODY_STATES.has(c.state as SidebarCustodyState)
247
- ) {
248
- return undefined
249
- }
250
- const state = c.state as SidebarCustodyState
251
- const out: SidebarAccountCustody = { state }
252
- if (
253
- typeof c.reason === 'string' &&
254
- CUSTODY_REASONS.has(c.reason as SidebarCustodyReason)
255
- ) {
256
- out.reason = c.reason as SidebarCustodyReason
257
- }
258
- return out
259
- }
260
-
261
- function normalizeActiveRouting(value: unknown): ActiveRoutingMap | undefined {
262
- if (value === null || typeof value !== 'object' || Array.isArray(value)) {
263
- return undefined
264
- }
265
-
266
- const normalized: ActiveRoutingMap = {}
267
- for (const [sessionId, rawEntry] of Object.entries(value)) {
268
- if (
269
- rawEntry === null ||
270
- typeof rawEntry !== 'object' ||
271
- Array.isArray(rawEntry)
272
- ) {
273
- continue
274
- }
275
- const entry = rawEntry as Record<string, unknown>
276
- if (
277
- typeof entry.activeId !== 'string' ||
278
- typeof entry.route !== 'string' ||
279
- typeof entry.updatedAt !== 'number' ||
280
- !Number.isFinite(entry.updatedAt)
281
- ) {
282
- continue
283
- }
284
- normalized[sessionId] = {
285
- activeId: entry.activeId,
286
- route: entry.route,
287
- updatedAt: entry.updatedAt,
288
- }
289
- }
290
-
291
- return Object.keys(normalized).length > 0 ? normalized : undefined
292
- }
293
-
294
- function normalizeStickyAssignments(
295
- value: unknown,
296
- ): StickyAssignmentMap | undefined {
297
- if (value === null || typeof value !== 'object' || Array.isArray(value)) {
298
- return undefined
299
- }
300
-
301
- const normalized: StickyAssignmentMap = {}
302
- for (const [sessionHash, rawAssignment] of Object.entries(value)) {
303
- if (
304
- !SESSION_HASH_PATTERN.test(sessionHash) ||
305
- rawAssignment === null ||
306
- typeof rawAssignment !== 'object' ||
307
- Array.isArray(rawAssignment)
308
- ) {
309
- continue
310
- }
311
- const assignment = rawAssignment as Record<string, unknown>
312
- if (
313
- typeof assignment.accountId !== 'string' ||
314
- assignment.accountId.length === 0 ||
315
- typeof assignment.assignedAt !== 'number' ||
316
- !Number.isFinite(assignment.assignedAt) ||
317
- assignment.assignedAt < 0 ||
318
- typeof assignment.lastSeenAt !== 'number' ||
319
- !Number.isFinite(assignment.lastSeenAt) ||
320
- assignment.lastSeenAt < 0 ||
321
- typeof assignment.inputBytes !== 'number' ||
322
- !Number.isFinite(assignment.inputBytes) ||
323
- assignment.inputBytes < 0
324
- ) {
325
- continue
326
- }
327
- const quotaCheckedAt = assignment.quotaCheckedAt
328
- if (
329
- quotaCheckedAt !== undefined &&
330
- (typeof quotaCheckedAt !== 'number' ||
331
- !Number.isFinite(quotaCheckedAt) ||
332
- quotaCheckedAt < 0)
333
- ) {
334
- continue
335
- }
336
- const wireAccountId = assignment.wireAccountId
337
- normalized[sessionHash] = {
338
- accountId: assignment.accountId,
339
- assignedAt: assignment.assignedAt,
340
- lastSeenAt: assignment.lastSeenAt,
341
- inputBytes: assignment.inputBytes,
342
- ...(quotaCheckedAt === undefined ? {} : { quotaCheckedAt }),
343
- ...(typeof wireAccountId === 'string' ? { wireAccountId } : {}),
344
- }
345
- }
346
-
347
- return Object.keys(normalized).length > 0 ? normalized : undefined
348
- }
349
-
350
- export function getSidebarStateFile(): string {
351
- return process.env[STATE_FILE_ENV] || DEFAULT_STATE_FILE
352
- }
353
-
354
- export function projectCustodyForSidebar(
355
- verdict: CustodyVerdict,
356
- ): SidebarAccountCustody {
357
- switch (verdict.kind) {
358
- case 'LOCAL':
359
- return { state: 'local' }
360
- case 'VAULT':
361
- return { state: 'vault' }
362
- case 'INERT':
363
- return { state: 'inert', reason: verdict.reason }
364
- case 'NEEDS_LOGIN':
365
- return verdict.reason === 'corrupt'
366
- ? { state: 'needsLogin', reason: 'corrupt' }
367
- : { state: 'needsLogin' }
368
- }
369
- }
370
-
371
- export const DEFAULT_SIDEBAR_STATE: SidebarState = {
372
- main: { quota: null, killed: false },
373
- fallbacks: [],
374
- activeId: undefined,
375
- route: 'main',
376
- lastUpdated: 0,
377
- }
378
-
379
- /**
380
- * Normalize an arbitrary parsed value into a well-formed SidebarState.
381
- *
382
- * JSON.parse + `as SidebarState` is an unchecked cast — a partial, old, or
383
- * malformed state file passes through and the TUI's `state().main.quota` /
384
- * `state().fallbacks.filter(...)` throw at runtime. This helper guarantees
385
- * every required field is present and correctly typed before the value leaves
386
- * the I/O boundary, so a bad file can never crash the host TUI.
387
- */
388
- export function normalizeSidebarState(raw: unknown): SidebarState {
389
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
390
- return DEFAULT_SIDEBAR_STATE
391
- }
392
-
393
- const r = raw as Record<string, unknown>
394
-
395
- // main — must be an object with at least quota and killed
396
- const rawMain = r.main
397
- let main: SidebarState['main']
398
- if (
399
- rawMain !== null &&
400
- typeof rawMain === 'object' &&
401
- !Array.isArray(rawMain)
402
- ) {
403
- const m = rawMain as Record<string, unknown>
404
- main = {
405
- quota: ('quota' in m ? m.quota : null) as AccountQuota | null,
406
- killed: typeof m.killed === 'boolean' ? m.killed : false,
407
- ...(typeof m.mainAccountId === 'string'
408
- ? { mainAccountId: m.mainAccountId }
409
- : {}),
410
- ...(() => {
411
- const custody = normalizeSidebarCustody(m.custody)
412
- return custody ? { custody } : {}
413
- })(),
414
- // Preserve optional backoff fields if present
415
- ...(typeof m.quotaBackedOff === 'boolean'
416
- ? { quotaBackedOff: m.quotaBackedOff }
417
- : {}),
418
- ...(typeof m.quotaBackoffUntil === 'number'
419
- ? { quotaBackoffUntil: m.quotaBackoffUntil }
420
- : {}),
421
- ...(typeof m.refreshBackedOff === 'boolean'
422
- ? { refreshBackedOff: m.refreshBackedOff }
423
- : {}),
424
- ...(typeof m.refreshBackoffUntil === 'number'
425
- ? { refreshBackoffUntil: m.refreshBackoffUntil }
426
- : {}),
427
- ...resetCreditsField(m.resetCredits),
428
- }
429
- } else {
430
- main = { quota: null, killed: false }
431
- }
432
-
433
- // fallbacks — must be an array; keep entries that are objects with a string
434
- // id, and normalize each entry's inner fields so the TUI never reads a
435
- // wrong-typed value (e.g. a string `enabled`) off a malformed file.
436
- const rawFallbacks = r.fallbacks
437
- const fallbacks: SidebarAccountState[] = Array.isArray(rawFallbacks)
438
- ? rawFallbacks
439
- .filter(
440
- (entry): entry is Record<string, unknown> =>
441
- entry !== null &&
442
- typeof entry === 'object' &&
443
- !Array.isArray(entry) &&
444
- typeof (entry as Record<string, unknown>).id === 'string',
445
- )
446
- .map((e) => {
447
- const custody = normalizeSidebarCustody(e.custody)
448
- return {
449
- id: e.id as string,
450
- label: typeof e.label === 'string' ? e.label : undefined,
451
- ...(typeof e.accountId === 'string'
452
- ? { accountId: e.accountId }
453
- : {}),
454
- quota: ('quota' in e ? e.quota : null) as AccountQuota | null,
455
- killed: typeof e.killed === 'boolean' ? e.killed : false,
456
- enabled: typeof e.enabled === 'boolean' ? e.enabled : true,
457
- ...resetCreditsField(e.resetCredits),
458
- ...(custody ? { custody } : {}),
459
- }
460
- })
461
- : []
462
-
463
- // activeId — string or undefined
464
- const activeId = typeof r.activeId === 'string' ? r.activeId : undefined
465
-
466
- // route — string, default 'main'
467
- const route =
468
- typeof r.route === 'string' ? r.route : DEFAULT_SIDEBAR_STATE.route
469
-
470
- // lastUpdated — number, default 0
471
- const lastUpdated = typeof r.lastUpdated === 'number' ? r.lastUpdated : 0
472
-
473
- // Optional top-level fields
474
- const planType = typeof r.planType === 'string' ? r.planType : undefined
475
- const credits = typeof r.credits === 'number' ? r.credits : undefined
476
- const activeRouting = normalizeActiveRouting(r.activeRouting)
477
- const stickyAssignments = normalizeStickyAssignments(r.stickyAssignments)
478
- return {
479
- main,
480
- fallbacks,
481
- activeId,
482
- route,
483
- lastUpdated,
484
- ...(activeRouting !== undefined ? { activeRouting } : {}),
485
- ...(stickyAssignments !== undefined ? { stickyAssignments } : {}),
486
- ...(planType !== undefined ? { planType } : {}),
487
- ...(credits !== undefined ? { credits } : {}),
488
- }
489
- }
490
-
491
- export async function getSidebarState(
492
- stateFile = getSidebarStateFile(),
493
- ): Promise<SidebarState> {
494
- try {
495
- const raw = await readFile(stateFile, 'utf8')
496
- return normalizeSidebarState(JSON.parse(raw))
497
- } catch {
498
- return DEFAULT_SIDEBAR_STATE
499
- }
500
- }
501
-
502
- export const ACTIVE_ROUTING_MAX_AGE_MS = 60 * 60 * 1000
503
- export const ACTIVE_ROUTING_MAX_ENTRIES = 128
504
-
505
- export type SidebarRoutingAccount = {
506
- id: string
507
- enabled?: boolean
508
- killed?: boolean
509
- }
510
-
511
- export function isUsableRoutingEntry(
512
- entry: ActiveRoutingEntry,
513
- accounts: readonly SidebarRoutingAccount[] | undefined,
514
- now = Date.now(),
515
- ): boolean {
516
- const fresh =
517
- entry.updatedAt >= now - ACTIVE_ROUTING_MAX_AGE_MS && entry.updatedAt <= now
518
- if (!fresh) return false
519
- if (accounts === undefined) return true
520
- return (
521
- entry.activeId === 'main' ||
522
- accounts.some(
523
- (account) =>
524
- account.enabled !== false &&
525
- account.killed !== true &&
526
- account.id === entry.activeId,
527
- )
528
- )
529
- }
530
-
531
- // The credit budget's own exhaustion signal, shared by admission and sticky
532
- // migration so both agree on what "spent" means. `reached` is the provider's
533
- // authoritative boolean — the percentage is only a display approximation — and
534
- // the check fails open on a missing or lapsed reset exactly like a rate-limit
535
- // window, so a stale or corrupt reading never blocks.
536
- export function spendControlExhaustedResetAt(
537
- quota: AccountQuota | null | undefined,
538
- now = Date.now(),
539
- ): { resetsAt: string; resetAtMs: number } | undefined {
540
- const spendControl = quota?.spendControl
541
- if (
542
- spendControl?.reached !== true ||
543
- typeof spendControl.resetsAt !== 'string'
544
- ) {
545
- return undefined
546
- }
547
- const resetAtMs = Date.parse(spendControl.resetsAt)
548
- if (!Number.isFinite(resetAtMs) || resetAtMs <= now) return undefined
549
- return { resetsAt: spendControl.resetsAt, resetAtMs }
550
- }
551
-
552
- // Earliest future reset among the quota's exhausted windows, or undefined when
553
- // no present window is exhausted. Every present window is evaluated — matching
554
- // the admission policy, which rejects an account when ANY live window is below
555
- // its threshold — and each check fails open: a missing/malformed usage, a
556
- // missing/unparsable reset, or a reset already in the past never counts as
557
- // exhausted, so a stale or corrupt snapshot can never block routing.
558
- export function exhaustedQuotaResetAt(
559
- quota: AccountQuota | null | undefined,
560
- now = Date.now(),
561
- ): { resetsAt: string; resetAtMs: number } | undefined {
562
- let earliest: { resetsAt: string; resetAtMs: number } | undefined
563
- for (const key of QUOTA_WINDOW_KEYS) {
564
- const window = quota?.[key]
565
- if (
566
- typeof window?.resetsAt !== 'string' ||
567
- !Number.isFinite(window.usedPercent) ||
568
- window.usedPercent < 100
569
- ) {
570
- continue
571
- }
572
- const resetAtMs = Date.parse(window.resetsAt)
573
- if (!Number.isFinite(resetAtMs) || resetAtMs <= now) continue
574
- if (!earliest || resetAtMs < earliest.resetAtMs) {
575
- earliest = { resetsAt: window.resetsAt, resetAtMs }
576
- }
577
- }
578
- // The credit budget is a third axis on its own reset clock (a month, not
579
- // 5h/7d), judged by the same shared signal sticky migration uses.
580
- const spendReset = spendControlExhaustedResetAt(quota, now)
581
- if (spendReset && (!earliest || spendReset.resetAtMs < earliest.resetAtMs)) {
582
- earliest = spendReset
583
- }
584
- return earliest
585
- }
586
-
587
- export function isQuotaExhausted(
588
- quota: AccountQuota | null | undefined,
589
- now = Date.now(),
590
- ): boolean {
591
- return exhaustedQuotaResetAt(quota, now) !== undefined
592
- }
593
-
594
- export function resolveSessionStickyAccount(
595
- state: SidebarState,
596
- sessionId: string | undefined,
597
- now = Date.now(),
598
- ): string | undefined {
599
- if (!sessionId || state.route !== 'sticky-balanced') return undefined
600
- const assignment = state.stickyAssignments?.[hashSidebarSessionId(sessionId)]
601
- if (
602
- !assignment ||
603
- assignment.lastSeenAt < now - STICKY_ASSIGNMENT_MAX_AGE_MS
604
- ) {
605
- return undefined
606
- }
607
- if (assignment.accountId === 'main') {
608
- return state.main?.killed || isQuotaExhausted(state.main?.quota, now)
609
- ? undefined
610
- : 'main'
611
- }
612
- const fallback = state.fallbacks?.find(
613
- (account) => account.id === assignment.accountId,
614
- )
615
- if (
616
- !fallback ||
617
- fallback.enabled === false ||
618
- fallback.killed === true ||
619
- isQuotaExhausted(fallback.quota, now)
620
- ) {
621
- return undefined
622
- }
623
- return fallback.id
624
- }
625
-
626
- export function resolveSessionSidebarRouting(
627
- state: SidebarState,
628
- sessionId?: string,
629
- now = Date.now(),
630
- ): { activeId: string; route: string } {
631
- if (!sessionId) {
632
- return { activeId: state.activeId ?? 'main', route: state.route }
633
- }
634
- const own = sessionId ? state.activeRouting?.[sessionId] : undefined
635
- const ownQuota =
636
- own?.activeId === 'main'
637
- ? state.main.quota
638
- : state.fallbacks.find((account) => account.id === own?.activeId)?.quota
639
- if (
640
- own &&
641
- isUsableRoutingEntry(own, state.fallbacks, now) &&
642
- !isQuotaExhausted(ownQuota, now)
643
- ) {
644
- return { activeId: own.activeId, route: own.route }
645
- }
646
-
647
- const stickyAccountId = resolveSessionStickyAccount(state, sessionId, now)
648
- if (stickyAccountId) {
649
- return { activeId: stickyAccountId, route: state.route }
650
- }
651
-
652
- const enabledFallbacks = state.fallbacks.filter(
653
- (account) => account.enabled && !account.killed,
654
- )
655
- const fallback =
656
- enabledFallbacks.find((account) => !isQuotaExhausted(account.quota, now)) ??
657
- enabledFallbacks[0]
658
- return {
659
- activeId:
660
- state.route === 'fallback-first' && fallback ? fallback.id : 'main',
661
- route: state.route,
662
- }
663
- }
664
-
665
- export function pruneActiveRouting(
666
- activeRouting: ActiveRoutingMap | undefined,
667
- accounts: readonly SidebarRoutingAccount[] | undefined,
668
- now = Date.now(),
669
- removedSessionId?: string,
670
- ): ActiveRoutingMap | undefined {
671
- if (!activeRouting) return undefined
672
- const kept = Object.entries(activeRouting).filter(
673
- ([sessionId, entry]) =>
674
- sessionId !== removedSessionId &&
675
- isUsableRoutingEntry(entry, accounts, now),
676
- )
677
- const bounded =
678
- kept.length <= ACTIVE_ROUTING_MAX_ENTRIES
679
- ? kept
680
- : kept
681
- .sort((left, right) => right[1].updatedAt - left[1].updatedAt)
682
- .slice(0, ACTIVE_ROUTING_MAX_ENTRIES)
683
- return bounded.length > 0 ? Object.fromEntries(bounded) : undefined
684
- }
685
-
686
- export function pruneStickyAssignments(
687
- assignments: StickyAssignmentMap | undefined,
688
- validAccountIds: ReadonlySet<string> | undefined,
689
- now = Date.now(),
690
- removedSessionHash?: string,
691
- ): StickyAssignmentMap | undefined {
692
- if (!assignments) return undefined
693
- let accountNotInRoster = 0
694
- let expired = 0
695
- let explicitRemoval = 0
696
- const kept: [string, StickyAssignment][] = []
697
- for (const [sessionHash, assignment] of Object.entries(assignments)) {
698
- if (sessionHash === removedSessionHash) {
699
- explicitRemoval += 1
700
- continue
701
- }
702
- if (assignment.lastSeenAt < now - STICKY_ASSIGNMENT_MAX_AGE_MS) {
703
- expired += 1
704
- continue
705
- }
706
- if (
707
- validAccountIds !== undefined &&
708
- !validAccountIds.has(assignment.accountId)
709
- ) {
710
- accountNotInRoster += 1
711
- continue
712
- }
713
- kept.push([sessionHash, assignment])
714
- }
715
- const removed = accountNotInRoster + expired + explicitRemoval
716
- if (removed > 0) {
717
- logSb.debug('pruned sticky assignments', {
718
- pid: process.pid,
719
- removed,
720
- reasons: {
721
- 'account-not-in-roster': accountNotInRoster,
722
- expired,
723
- 'explicit-removal': explicitRemoval,
724
- },
725
- })
726
- }
727
- return kept.length > 0 ? Object.fromEntries(kept) : undefined
728
- }
729
-
730
- function limitStickyAssignments(
731
- assignments: StickyAssignmentMap,
732
- protectedSessionHash: string,
733
- ): StickyAssignmentMap {
734
- const overflow =
735
- Object.keys(assignments).length - STICKY_ASSIGNMENT_MAX_ENTRIES
736
- if (overflow <= 0) return assignments
737
- const evicted = new Set(
738
- Object.entries(assignments)
739
- .filter(([sessionHash]) => sessionHash !== protectedSessionHash)
740
- .sort(
741
- ([leftHash, left], [rightHash, right]) =>
742
- left.lastSeenAt - right.lastSeenAt ||
743
- leftHash.localeCompare(rightHash),
744
- )
745
- .slice(0, overflow)
746
- .map(([sessionHash]) => sessionHash),
747
- )
748
- return Object.fromEntries(
749
- Object.entries(assignments).filter(
750
- ([sessionHash]) => !evicted.has(sessionHash),
751
- ),
752
- )
753
- }
754
-
755
- function stickyAssignmentsEqual(
756
- left: StickyAssignmentMap | undefined,
757
- right: StickyAssignmentMap | undefined,
758
- ): boolean {
759
- if (left === right) return true
760
- if (!left || !right) return false
761
- const leftEntries = Object.entries(left)
762
- if (leftEntries.length !== Object.keys(right).length) return false
763
- return leftEntries.every(([sessionHash, assignment]) =>
764
- Object.is(right[sessionHash], assignment),
765
- )
766
- }
767
-
768
- function isValidStickyAssignment(
769
- assignment: StickyAssignment | undefined,
770
- validPinnedAccountIds: ReadonlySet<string>,
771
- excludedAccountIds: ReadonlySet<string>,
772
- now: number,
773
- ): assignment is StickyAssignment {
774
- return (
775
- assignment !== undefined &&
776
- validPinnedAccountIds.has(assignment.accountId) &&
777
- !excludedAccountIds.has(assignment.accountId) &&
778
- assignment.lastSeenAt >= now - STICKY_ASSIGNMENT_MAX_AGE_MS
779
- )
780
- }
781
-
782
- function stickyAssignmentNeedsMetadataUpdate(
783
- assignment: StickyAssignment,
784
- requestBytes: number,
785
- now: number,
786
- wireAccountId: string | undefined,
787
- ): boolean {
788
- return (
789
- requestBytes > assignment.inputBytes ||
790
- now - assignment.lastSeenAt >= STICKY_ASSIGNMENT_LAST_SEEN_TOUCH_MS ||
791
- (assignment.wireAccountId === undefined && wireAccountId !== undefined)
792
- )
793
- }
794
-
795
- function hasStickyIdentityMismatch(
796
- assignment: StickyAssignment,
797
- wireAccountId: string | undefined,
798
- ): boolean {
799
- // True only when both sides know their ChatGPT account and those accounts
800
- // differ, which means the slot this session was placed on now holds a
801
- // different account than it did at placement.
802
- //
803
- // An unknown on either side deliberately reports no mismatch. A session is
804
- // pinned to keep its prompt cache warm, and treating missing data as a
805
- // change would place sessions again for no reason and throw those caches
806
- // away — worse than leaving a rare wrong pin in place until real evidence
807
- // arrives.
808
- return (
809
- typeof assignment.wireAccountId === 'string' &&
810
- typeof wireAccountId === 'string' &&
811
- assignment.wireAccountId !== wireAccountId
812
- )
813
- }
814
-
815
- function readonlyPendingBytes(
816
- pendingBytes: Map<string, number>,
817
- ): ReadonlyMap<string, number> {
818
- const snapshot = new Map(pendingBytes)
819
- const view: ReadonlyMap<string, number> = {
820
- get size() {
821
- return snapshot.size
822
- },
823
- has: (key: string) => snapshot.has(key),
824
- get: (key: string) => snapshot.get(key),
825
- entries: () => snapshot.entries(),
826
- keys: () => snapshot.keys(),
827
- values: () => snapshot.values(),
828
- forEach: (
829
- callback: (
830
- value: number,
831
- key: string,
832
- map: ReadonlyMap<string, number>,
833
- ) => void,
834
- thisArg?: unknown,
835
- ) => {
836
- snapshot.forEach((value, key) => {
837
- callback.call(thisArg, value, key, view)
838
- })
839
- },
840
- [Symbol.iterator]: () => snapshot[Symbol.iterator](),
841
- }
842
- return Object.freeze(view)
843
- }
844
-
845
- function pendingBytesForAssignments(
846
- assignments: StickyAssignmentMap | undefined,
847
- quotaCheckedAtByAccount: Readonly<Record<string, number | undefined>>,
848
- excludedSessionHash?: string,
849
- ): ReadonlyMap<string, number> {
850
- const pendingBytes = new Map<string, number>()
851
- for (const [sessionHash, assignment] of Object.entries(assignments ?? {})) {
852
- if (sessionHash === excludedSessionHash) continue
853
- if (
854
- assignment.quotaCheckedAt !==
855
- quotaCheckedAtByAccount[assignment.accountId]
856
- ) {
857
- continue
858
- }
859
- pendingBytes.set(
860
- assignment.accountId,
861
- (pendingBytes.get(assignment.accountId) ?? 0) + assignment.inputBytes,
862
- )
863
- }
864
- return readonlyPendingBytes(pendingBytes)
865
- }
866
-
867
- function usableRoutingAccountIds(
868
- accounts: readonly SidebarRoutingAccount[] | undefined,
869
- ): ReadonlySet<string> | undefined {
870
- if (accounts === undefined) return undefined
871
- return new Set([
872
- 'main',
873
- ...accounts
874
- .filter((account) => account.enabled !== false && account.killed !== true)
875
- .map((account) => account.id),
876
- ])
877
- }
878
-
879
- // Serialization chain: concurrent calls are queued so a stale background
880
- // write cannot land after a newer one and corrupt the file.
881
- let sidebarWriteChain: Promise<void> = Promise.resolve()
882
- const MAX_MERGE_ATTEMPTS = 3
883
- const SIDEBAR_WRITE_LOCK_TTL_MS = 10_000
884
- const SIDEBAR_WRITE_LOCK_WAIT_MS = 15_000
885
-
886
- interface SidebarMergeHooks {
887
- beforeRecheck?: () => void | Promise<void>
888
- }
889
-
890
- function enqueueSidebarWrite(operation: () => Promise<void>): Promise<void> {
891
- const result = sidebarWriteChain.then(operation)
892
- sidebarWriteChain = result.catch(() => {})
893
- return result
894
- }
895
-
896
- /**
897
- * Write sidebar state to disk, serialized through a promise chain so
898
- * concurrent callers never interleave or let a stale write land last.
899
- *
900
- * @param state The state to persist.
901
- * @param file Explicit path override — callers that bind the path at init
902
- * time (e.g. the index.ts loader) pass this so late callbacks
903
- * always write to the path that was current when the loader ran,
904
- * even if the env changes underneath them during tests.
905
- * Defaults to getSidebarStateFile() (per-call resolution).
906
- */
907
- export function setSidebarState(
908
- state: SidebarState,
909
- file = getSidebarStateFile(),
910
- ): Promise<void> {
911
- return enqueueSidebarWrite(() => doWriteSidebarState(state, file))
912
- }
913
-
914
- async function readSidebarState(file: string): Promise<SidebarState> {
915
- try {
916
- return parseSidebarState(await readRawSidebar(file))
917
- } catch {
918
- return DEFAULT_SIDEBAR_STATE
919
- }
920
- }
921
-
922
- async function readRawSidebar(file: string): Promise<string> {
923
- try {
924
- return await readFile(file, 'utf8')
925
- } catch (error) {
926
- if ((error as { code?: string }).code === 'ENOENT') return ''
927
- throw error
928
- }
929
- }
930
-
931
- function parseSidebarState(raw: string): SidebarState {
932
- if (raw === '') return DEFAULT_SIDEBAR_STATE
933
- try {
934
- return normalizeSidebarState(JSON.parse(raw))
935
- } catch {
936
- return DEFAULT_SIDEBAR_STATE
937
- }
938
- }
939
-
940
- async function acquireSidebarWriteLock(file: string) {
941
- await ensureSidebarStateDirectory(file)
942
- const deadline = Date.now() + SIDEBAR_WRITE_LOCK_WAIT_MS
943
- while (Date.now() <= deadline) {
944
- const lock = await acquireRefreshFileLock({
945
- name: 'sidebar-write',
946
- path: file,
947
- ttlMs: SIDEBAR_WRITE_LOCK_TTL_MS,
948
- renew: true,
949
- })
950
- if (lock) return lock
951
- await new Promise((resolve) => setTimeout(resolve, 10))
952
- }
953
- throw new Error('Timed out waiting for the sidebar state write lock')
954
- }
955
-
956
- async function ensureSidebarStateDirectory(file: string): Promise<void> {
957
- const dir = dirname(file)
958
- await mkdir(dir, { recursive: true, mode: 0o700 })
959
- if (file !== DEFAULT_STATE_FILE) return
960
- await chmod(dir, 0o700).catch((error) => {
961
- logSb.warn('sidebar directory permission remediation failed', {
962
- pid: process.pid,
963
- error: error instanceof Error ? error.message : String(error),
964
- })
965
- })
966
- }
967
-
968
- async function writeMergedSidebarState(
969
- file: string,
970
- merge: (latest: SidebarState) => SidebarState | undefined,
971
- hooks?: SidebarMergeHooks,
972
- ): Promise<void> {
973
- const lock = await acquireSidebarWriteLock(file)
974
- try {
975
- // The file lock serializes current writers. Rechecking also preserves data
976
- // from older plugin processes that do not participate in this lock.
977
- for (let attempt = 0; attempt < MAX_MERGE_ATTEMPTS; attempt += 1) {
978
- const rawBefore = await readRawSidebar(file)
979
- const next = merge(parseSidebarState(rawBefore))
980
- if (!next) return
981
- if (attempt === 0) await hooks?.beforeRecheck?.()
982
- const rawRecheck = await readRawSidebar(file)
983
- if (rawRecheck !== rawBefore) continue
984
- await doWriteSidebarState(next, file)
985
- return
986
- }
987
-
988
- const latest = await readSidebarState(file)
989
- const next = merge(latest)
990
- if (next) await doWriteSidebarState(next, file)
991
- } finally {
992
- await lock.release()
993
- }
994
- }
995
-
996
- export type SidebarMachineState = Pick<
997
- SidebarState,
998
- 'main' | 'fallbacks' | 'planType' | 'credits' | 'lastUpdated'
999
- > & { route: string }
1000
-
1001
- // The freshest signal across every timestamp a snapshot carries: either window
1002
- // (primary/secondary) or the legacy top-level stamp. A retired primary window
1003
- // (null) with a fresh secondary must still outrank an older incoming primary, so
1004
- // the comparison takes the max rather than the first present value.
1005
- function latestQuotaCheckedAt(quota: AccountQuota | null): number | undefined {
1006
- let latest: number | undefined
1007
- for (const checkedAt of [
1008
- quota?.primary?.checkedAt,
1009
- quota?.secondary?.checkedAt,
1010
- quota?.checkedAt,
1011
- ]) {
1012
- if (typeof checkedAt === 'number' && Number.isFinite(checkedAt)) {
1013
- latest = latest === undefined ? checkedAt : Math.max(latest, checkedAt)
1014
- }
1015
- }
1016
- return latest
1017
- }
1018
-
1019
- function freshestQuota(
1020
- incoming: AccountQuota | null,
1021
- existing: AccountQuota | null,
1022
- ): AccountQuota | null {
1023
- const incomingCheckedAt = latestQuotaCheckedAt(incoming)
1024
- const existingCheckedAt = latestQuotaCheckedAt(existing)
1025
- if (
1026
- existingCheckedAt !== undefined &&
1027
- (incomingCheckedAt === undefined || existingCheckedAt > incomingCheckedAt)
1028
- ) {
1029
- return existing
1030
- }
1031
- return incoming
1032
- }
1033
-
1034
- // True when both sides assert the same stable account identity. An unknown
1035
- // identity on either side is NOT a confirmed match — merging windows across an
1036
- // unconfirmed identity could combine two accounts' quota, so the caller
1037
- // whole-picks instead.
1038
- function sameAccountIdentity(
1039
- incoming: string | undefined,
1040
- existing: string | undefined,
1041
- ): boolean {
1042
- return (
1043
- incoming !== undefined && existing !== undefined && incoming === existing
1044
- )
1045
- }
1046
-
1047
- // A window's checkedAt when it is a usable timestamp, else undefined — so an
1048
- // absent or invalid stamp sorts oldest and a timestamped window always wins
1049
- // over an untimestamped one. The optional fallback is the enclosing snapshot's
1050
- // checkedAt, used when the window itself carries no usable stamp (files written
1051
- // by versions that did not propagate the entry timestamp onto each present
1052
- // window): a present window with no stamp is still "live", so it must sort by
1053
- // SOME timestamp — the snapshot's is the next-best signal.
1054
- function finiteWindowCheckedAt(
1055
- window: QuotaWindow | undefined,
1056
- fallback?: number,
1057
- ): number | undefined {
1058
- const checkedAt = window?.checkedAt
1059
- if (typeof checkedAt === 'number' && Number.isFinite(checkedAt)) {
1060
- return checkedAt
1061
- }
1062
- if (typeof fallback === 'number' && Number.isFinite(fallback)) {
1063
- return fallback
1064
- }
1065
- return undefined
1066
- }
1067
-
1068
- // Fresher of two same-slot windows. When both sides report the window, the
1069
- // window's own stamp decides (falling back to each side's snapshot stamp when
1070
- // the window itself has none). When the slots disagree on presence, the FRESHER
1071
- // snapshot's slot is authoritative — a quota snapshot reports every live window,
1072
- // so an absent slot there means the wire retired it, which a stale window on the
1073
- // other side must not resurrect (and a fresher snapshot's present window must
1074
- // not be dropped by a stale window-less write).
1075
- function freshestWindow(
1076
- incoming: QuotaWindow | undefined,
1077
- existing: QuotaWindow | undefined,
1078
- existingSnapshotIsFresher: boolean,
1079
- incomingSnapshotCheckedAt: number | undefined,
1080
- existingSnapshotCheckedAt: number | undefined,
1081
- ): QuotaWindow | undefined {
1082
- if (incoming && existing) {
1083
- const incomingAt = finiteWindowCheckedAt(
1084
- incoming,
1085
- incomingSnapshotCheckedAt,
1086
- )
1087
- const existingAt = finiteWindowCheckedAt(
1088
- existing,
1089
- existingSnapshotCheckedAt,
1090
- )
1091
- if (
1092
- existingAt !== undefined &&
1093
- (incomingAt === undefined || existingAt > incomingAt)
1094
- ) {
1095
- return existing
1096
- }
1097
- return incoming
1098
- }
1099
- return existingSnapshotIsFresher ? existing : incoming
1100
- }
1101
-
1102
- // Merge two same-account snapshots window-by-window: each slot keeps the
1103
- // fresher of the two sides, so a newer primary on one side and a newer
1104
- // secondary on the other both survive instead of one side's whole snapshot
1105
- // replacing the other's. The snapshot stamp becomes the freshest window stamp
1106
- // of the merged result. Only safe when both snapshots share an account identity
1107
- // (sameAccountIdentity) — an identity switch must whole-pick (freshestQuota) so
1108
- // windows from two accounts are never combined.
1109
- // The fresher side decides the budget when it knows anything about it: its own
1110
- // reading, or its report that no budget exists. Only when it says nothing (a
1111
- // header or WebSocket push, which never carries spend control) does the other
1112
- // side's reading survive, and even then not if that side reported none.
1113
- function mergeSpendControl(
1114
- fresher: AccountQuota,
1115
- older: AccountQuota,
1116
- ): SpendControlReading | undefined {
1117
- if (fresher.spendControl !== undefined) return fresher.spendControl
1118
- if (fresher.spendControlCleared === true) return undefined
1119
- return older.spendControl
1120
- }
1121
-
1122
- function mergeQuotaByWindow(
1123
- incoming: AccountQuota | null,
1124
- existing: AccountQuota | null,
1125
- ): AccountQuota | null {
1126
- if (!incoming) return existing
1127
- if (!existing) return incoming
1128
- const incomingAt = latestQuotaCheckedAt(incoming)
1129
- const existingAt = latestQuotaCheckedAt(existing)
1130
- const existingSnapshotIsFresher =
1131
- existingAt !== undefined &&
1132
- (incomingAt === undefined || existingAt > incomingAt)
1133
- const primary = freshestWindow(
1134
- incoming.primary,
1135
- existing.primary,
1136
- existingSnapshotIsFresher,
1137
- incoming.checkedAt,
1138
- existing.checkedAt,
1139
- )
1140
- const secondary = freshestWindow(
1141
- incoming.secondary,
1142
- existing.secondary,
1143
- existingSnapshotIsFresher,
1144
- incoming.checkedAt,
1145
- existing.checkedAt,
1146
- )
1147
- const spendControl = existingSnapshotIsFresher
1148
- ? mergeSpendControl(existing, incoming)
1149
- : mergeSpendControl(incoming, existing)
1150
- const spendControlCleared =
1151
- spendControl === undefined &&
1152
- (incoming.spendControlCleared === true ||
1153
- existing.spendControlCleared === true)
1154
- let checkedAt: number | undefined
1155
- for (const stamp of [
1156
- finiteWindowCheckedAt(primary),
1157
- finiteWindowCheckedAt(secondary),
1158
- ]) {
1159
- if (stamp !== undefined) {
1160
- checkedAt = checkedAt === undefined ? stamp : Math.max(checkedAt, stamp)
1161
- }
1162
- }
1163
- // Both budget fields are decided above, so neither may leak in from the
1164
- // spread: an older incoming budget must not survive a fresher "no budget".
1165
- const {
1166
- spendControl: _incomingSpendControl,
1167
- spendControlCleared: _incomingCleared,
1168
- ...incomingRest
1169
- } = incoming
1170
- return {
1171
- ...incomingRest,
1172
- primary,
1173
- secondary,
1174
- ...(spendControl !== undefined ? { spendControl } : {}),
1175
- ...(spendControlCleared ? { spendControlCleared: true as const } : {}),
1176
- checkedAt: checkedAt ?? incoming.checkedAt,
1177
- }
1178
- }
1179
-
1180
- export function setSidebarMachineState(
1181
- machineState: SidebarMachineState,
1182
- file = getSidebarStateFile(),
1183
- hooks?: SidebarMergeHooks,
1184
- ): Promise<void> {
1185
- return enqueueSidebarWrite(async () => {
1186
- await writeMergedSidebarState(
1187
- file,
1188
- (latest) => {
1189
- const latestFallbacks = new Map(
1190
- latest.fallbacks.map((account) => [account.id, account]),
1191
- )
1192
- // A same-identity merge combines the freshest of each window across the
1193
- // two sides; a differing or unknown identity whole-picks the fresher
1194
- // snapshot so windows from two accounts are never combined.
1195
- const mainSameIdentity = sameAccountIdentity(
1196
- machineState.main.mainAccountId,
1197
- latest.main.mainAccountId,
1198
- )
1199
- const mergedMainQuota = mainSameIdentity
1200
- ? mergeQuotaByWindow(machineState.main.quota, latest.main.quota)
1201
- : freshestQuota(machineState.main.quota, latest.main.quota)
1202
- const now = Date.now()
1203
- const stickyAssignments = pruneStickyAssignments(
1204
- latest.stickyAssignments,
1205
- usableRoutingAccountIds(machineState.fallbacks),
1206
- now,
1207
- )
1208
- return {
1209
- ...latest,
1210
- ...machineState,
1211
- main: {
1212
- ...machineState.main,
1213
- quota: mergedMainQuota,
1214
- // On a whole-pick the identity follows the winning snapshot, so a
1215
- // reader never pairs one account's id with another account's quota
1216
- // (a re-login race would otherwise resurrect the stale-account bug).
1217
- // On a same-identity merge both sides already agree, so the incoming
1218
- // id is the shared one.
1219
- mainAccountId: mainSameIdentity
1220
- ? machineState.main.mainAccountId
1221
- : mergedMainQuota === latest.main.quota &&
1222
- mergedMainQuota !== machineState.main.quota
1223
- ? latest.main.mainAccountId
1224
- : machineState.main.mainAccountId,
1225
- },
1226
- fallbacks: machineState.fallbacks.map((account) => {
1227
- const existing = latestFallbacks.get(account.id)
1228
- const fallbackSameIdentity = sameAccountIdentity(
1229
- account.accountId,
1230
- existing?.accountId,
1231
- )
1232
- const mergedQuota = fallbackSameIdentity
1233
- ? mergeQuotaByWindow(account.quota, existing?.quota ?? null)
1234
- : freshestQuota(account.quota, existing?.quota ?? null)
1235
- return {
1236
- ...account,
1237
- quota: mergedQuota,
1238
- accountId: fallbackSameIdentity
1239
- ? account.accountId
1240
- : mergedQuota === existing?.quota &&
1241
- mergedQuota !== account.quota
1242
- ? existing?.accountId
1243
- : account.accountId,
1244
- }
1245
- }),
1246
- activeId: latest.activeId,
1247
- activeRouting: latest.activeRouting,
1248
- stickyAssignments,
1249
- lastUpdated: Math.max(now, latest.lastUpdated + 1),
1250
- }
1251
- },
1252
- hooks,
1253
- )
1254
- })
1255
- }
1256
-
1257
- export function upsertSidebarActiveRouting(
1258
- input: { sessionId: string } & ActiveRoutingEntry,
1259
- accounts: readonly SidebarRoutingAccount[] | undefined,
1260
- file = getSidebarStateFile(),
1261
- hooks?: SidebarMergeHooks,
1262
- ): Promise<void> {
1263
- return enqueueSidebarWrite(async () => {
1264
- await writeMergedSidebarState(
1265
- file,
1266
- (latest) => {
1267
- const activeRouting = pruneActiveRouting(
1268
- {
1269
- ...latest.activeRouting,
1270
- [input.sessionId]: {
1271
- activeId: input.activeId,
1272
- route: input.route,
1273
- updatedAt: input.updatedAt,
1274
- },
1275
- },
1276
- accounts,
1277
- Date.now(),
1278
- )
1279
- const stickyAssignments = pruneStickyAssignments(
1280
- latest.stickyAssignments,
1281
- usableRoutingAccountIds(accounts),
1282
- Date.now(),
1283
- )
1284
- return {
1285
- ...latest,
1286
- activeId: input.activeId,
1287
- route: input.route,
1288
- activeRouting,
1289
- stickyAssignments,
1290
- lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
1291
- }
1292
- },
1293
- hooks,
1294
- )
1295
- })
1296
- }
1297
-
1298
- export function setSidebarLegacyRouting(
1299
- input: ActiveRoutingEntry,
1300
- file = getSidebarStateFile(),
1301
- ): Promise<void> {
1302
- return enqueueSidebarWrite(async () => {
1303
- await writeMergedSidebarState(file, (latest) => ({
1304
- ...latest,
1305
- activeId: input.activeId,
1306
- route: input.route,
1307
- lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
1308
- }))
1309
- })
1310
- }
1311
-
1312
- export function removeSidebarActiveRouting(
1313
- sessionId: string,
1314
- accounts: readonly SidebarRoutingAccount[] | undefined,
1315
- file = getSidebarStateFile(),
1316
- hooks?: SidebarMergeHooks,
1317
- ): Promise<void> {
1318
- return enqueueSidebarWrite(async () => {
1319
- await writeMergedSidebarState(
1320
- file,
1321
- (latest) => {
1322
- const now = Date.now()
1323
- const activeRouting = pruneActiveRouting(
1324
- latest.activeRouting,
1325
- accounts,
1326
- now,
1327
- sessionId,
1328
- )
1329
- const stickyAssignments = pruneStickyAssignments(
1330
- latest.stickyAssignments,
1331
- usableRoutingAccountIds(accounts),
1332
- now,
1333
- hashSidebarSessionId(sessionId),
1334
- )
1335
- return {
1336
- ...latest,
1337
- activeRouting,
1338
- stickyAssignments,
1339
- lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
1340
- }
1341
- },
1342
- hooks,
1343
- )
1344
- })
1345
- }
1346
-
1347
- export async function resolveSidebarStickyAssignment(
1348
- input: ResolveStickyAssignmentInput,
1349
- file = getSidebarStateFile(),
1350
- hooks?: SidebarMergeHooks,
1351
- ): Promise<StickyAssignment | undefined> {
1352
- const sessionHash = hashSidebarSessionId(input.sessionId)
1353
- const validPinnedAccountIds = new Set(input.validPinnedAccountIds)
1354
- const excludedAccountIds = new Set(input.excludeAccountIds)
1355
- const existing = (await readSidebarState(file)).stickyAssignments?.[
1356
- sessionHash
1357
- ]
1358
- if (
1359
- isValidStickyAssignment(
1360
- existing,
1361
- validPinnedAccountIds,
1362
- excludedAccountIds,
1363
- input.now,
1364
- ) &&
1365
- !hasStickyIdentityMismatch(
1366
- existing,
1367
- input.wireAccountIdByAccount?.[existing.accountId],
1368
- ) &&
1369
- !stickyAssignmentNeedsMetadataUpdate(
1370
- existing,
1371
- input.requestBytes,
1372
- input.now,
1373
- input.wireAccountIdByAccount?.[existing.accountId],
1374
- )
1375
- ) {
1376
- return existing
1377
- }
1378
-
1379
- let resolved: StickyAssignment | undefined
1380
- await enqueueSidebarWrite(async () => {
1381
- await writeMergedSidebarState(
1382
- file,
1383
- (latest) => {
1384
- const stickyAssignments = pruneStickyAssignments(
1385
- latest.stickyAssignments,
1386
- validPinnedAccountIds,
1387
- input.now,
1388
- )
1389
- const assignmentsPruned = !stickyAssignmentsEqual(
1390
- latest.stickyAssignments,
1391
- stickyAssignments,
1392
- )
1393
- const current = stickyAssignments?.[sessionHash]
1394
- const currentIdentityMismatch =
1395
- current !== undefined &&
1396
- hasStickyIdentityMismatch(
1397
- current,
1398
- input.wireAccountIdByAccount?.[current.accountId],
1399
- )
1400
- if (
1401
- isValidStickyAssignment(
1402
- current,
1403
- validPinnedAccountIds,
1404
- excludedAccountIds,
1405
- input.now,
1406
- ) &&
1407
- !currentIdentityMismatch
1408
- ) {
1409
- const metadataNeedsUpdate = stickyAssignmentNeedsMetadataUpdate(
1410
- current,
1411
- input.requestBytes,
1412
- input.now,
1413
- input.wireAccountIdByAccount?.[current.accountId],
1414
- )
1415
- const assignment = metadataNeedsUpdate
1416
- ? {
1417
- ...current,
1418
- inputBytes: Math.max(current.inputBytes, input.requestBytes),
1419
- ...(current.wireAccountId === undefined &&
1420
- input.wireAccountIdByAccount?.[current.accountId] !== undefined
1421
- ? {
1422
- wireAccountId:
1423
- input.wireAccountIdByAccount?.[current.accountId],
1424
- }
1425
- : {}),
1426
- ...(input.now - current.lastSeenAt >=
1427
- STICKY_ASSIGNMENT_LAST_SEEN_TOUCH_MS
1428
- ? { lastSeenAt: input.now }
1429
- : {}),
1430
- }
1431
- : current
1432
- resolved = assignment
1433
- if (!assignmentsPruned && !metadataNeedsUpdate) return undefined
1434
- return {
1435
- ...latest,
1436
- stickyAssignments: {
1437
- ...stickyAssignments,
1438
- [sessionHash]: assignment,
1439
- },
1440
- lastUpdated: Math.max(input.now, latest.lastUpdated + 1),
1441
- }
1442
- }
1443
-
1444
- const choice = input.choose(
1445
- pendingBytesForAssignments(
1446
- stickyAssignments,
1447
- input.quotaCheckedAtByAccount,
1448
- // Pending bytes weigh how much traffic each account is already
1449
- // committed to, so the session being placed must not weigh its own
1450
- // stale entry: that entry belongs to the account it is moving off,
1451
- // and counting it would bias placement away from a perfectly good
1452
- // destination. Other sessions' entries still count.
1453
- currentIdentityMismatch ? sessionHash : undefined,
1454
- ),
1455
- )
1456
- if (!choice) {
1457
- resolved = undefined
1458
- if (!assignmentsPruned) return undefined
1459
- return {
1460
- ...latest,
1461
- stickyAssignments,
1462
- lastUpdated: Math.max(input.now, latest.lastUpdated + 1),
1463
- }
1464
- }
1465
-
1466
- resolved = {
1467
- accountId: choice.accountId,
1468
- assignedAt: input.now,
1469
- lastSeenAt: input.now,
1470
- inputBytes: input.requestBytes,
1471
- ...(choice.quotaCheckedAt === undefined
1472
- ? {}
1473
- : { quotaCheckedAt: choice.quotaCheckedAt }),
1474
- ...(input.wireAccountIdByAccount?.[choice.accountId] === undefined
1475
- ? {}
1476
- : {
1477
- wireAccountId: input.wireAccountIdByAccount?.[choice.accountId],
1478
- }),
1479
- }
1480
- return {
1481
- ...latest,
1482
- stickyAssignments: limitStickyAssignments(
1483
- {
1484
- ...stickyAssignments,
1485
- [sessionHash]: resolved,
1486
- },
1487
- sessionHash,
1488
- ),
1489
- lastUpdated: Math.max(input.now, latest.lastUpdated + 1),
1490
- }
1491
- },
1492
- hooks,
1493
- )
1494
- })
1495
- return resolved
1496
- }
1497
-
1498
- export async function clearSidebarStickyAssignment(
1499
- sessionId: string,
1500
- file = getSidebarStateFile(),
1501
- ): Promise<boolean> {
1502
- const sessionHash = hashSidebarSessionId(sessionId)
1503
- let removed = false
1504
- await enqueueSidebarWrite(async () => {
1505
- await writeMergedSidebarState(file, (latest) => {
1506
- const assignments = latest.stickyAssignments
1507
- if (assignments?.[sessionHash] === undefined) {
1508
- removed = false
1509
- return undefined
1510
- }
1511
- removed = true
1512
- const { [sessionHash]: _removed, ...remaining } = assignments
1513
- return {
1514
- ...latest,
1515
- ...(Object.keys(remaining).length > 0
1516
- ? { stickyAssignments: remaining }
1517
- : { stickyAssignments: undefined }),
1518
- lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
1519
- }
1520
- })
1521
- })
1522
- return removed
1523
- }
1524
-
1525
- async function doWriteSidebarState(
1526
- state: SidebarState,
1527
- file: string,
1528
- ): Promise<void> {
1529
- const tempPath = `${file}.${randomUUID()}.tmp`
1530
- try {
1531
- await ensureSidebarStateDirectory(file)
1532
- await writeFile(tempPath, JSON.stringify(normalizeSidebarState(state)), {
1533
- encoding: 'utf8',
1534
- mode: 0o600,
1535
- })
1536
- await rename(tempPath, file)
1537
- } catch (e) {
1538
- await rm(tempPath, { force: true }).catch(() => {})
1539
- logSb.warn('sidebar write failed', {
1540
- pid: process.pid,
1541
- error: e instanceof Error ? e.message : String(e),
1542
- })
1543
- throw e
1544
- }
1545
- }
1546
-
1547
- /**
1548
- * Await all pending sidebar writes. Tests call this before restoring env
1549
- * vars in teardown so no in-flight write can re-resolve getSidebarStateFile()
1550
- * after the env is changed.
1551
- */
1552
- export function drainSidebarWrites(): Promise<void> {
1553
- return sidebarWriteChain
1554
- }
1555
-
1556
- // Resolve the currently-active account from activeId for the collapsed sidebar
1557
- // view. activeId === 'main' (or undefined/unmatched/disabled) → the main
1558
- // account; otherwise the enabled fallback whose id matches.
1559
- export function resolveActiveAccount(state: SidebarState): {
1560
- id: string
1561
- name: string
1562
- quota: AccountQuota | null
1563
- killed: boolean
1564
- } {
1565
- const activeId = state.activeId
1566
- if (activeId && activeId !== 'main') {
1567
- const fallback = state.fallbacks.find(
1568
- (account) => account.enabled && account.id === activeId,
1569
- )
1570
- if (fallback) {
1571
- return {
1572
- id: fallback.id,
1573
- name: fallback.label ?? fallback.id,
1574
- quota: fallback.quota,
1575
- killed: fallback.killed,
1576
- }
1577
- }
1578
- }
1579
- return {
1580
- id: 'main',
1581
- name: 'main',
1582
- quota: state.main.quota,
1583
- killed: state.main.killed,
1584
- }
1585
- }
1586
-
1587
- export function getCollapsedQuotaSummary(quota: AccountQuota | null): {
1588
- primaryUsedPercent: number | null
1589
- secondaryUsedPercent: number | null
1590
- text: string | null
1591
- } {
1592
- const primaryUsedPercent = quota?.primary?.usedPercent ?? null
1593
- const secondaryUsedPercent = quota?.secondary?.usedPercent ?? null
1594
- const rows = getPresentQuotaWindows(quota)
1595
- return {
1596
- primaryUsedPercent,
1597
- secondaryUsedPercent,
1598
- text:
1599
- rows.length === 0
1600
- ? null
1601
- : rows
1602
- .map(
1603
- ({ label, window }) =>
1604
- `${label}: ${Math.round(window.usedPercent)}%`,
1605
- )
1606
- .join(' '),
1607
- }
1608
- }
1609
-
1610
- const PACING_MIN_ELAPSED_MS = 5 * 60 * 1000
1611
- const PACING_MIN_ELAPSED_FRACTION = 0.01
1612
- const ON_PACE_DELTA = 1
1613
-
1614
- export interface QuotaPacing {
1615
- pacePercent: number
1616
- deltaPercent: number
1617
- state: 'deficit' | 'reserve' | 'on-pace'
1618
- runsOutAt: string | null
1619
- }
1620
-
1621
- // Even-burn pacing for a quota window. The window start is inferred from the
1622
- // reset timestamp minus the window length. Two metrics: deltaPercent compares
1623
- // usage against a uniform burn-down (positive = deficit), and runsOutAt
1624
- // projects the current average burn rate forward — null means the window
1625
- // lasts until reset at that rate. Returns null when there is no reset
1626
- // timestamp or the elapsed time is too small to give a meaningful rate.
1627
- export function computeQuotaPacing(
1628
- window: QuotaWindow,
1629
- windowMs: number,
1630
- now: number,
1631
- ): QuotaPacing | null {
1632
- if (!window.resetsAt) return null
1633
- const resetsAt = new Date(window.resetsAt).getTime()
1634
- if (!Number.isFinite(resetsAt)) return null
1635
- const start = resetsAt - windowMs
1636
- const elapsed = now - start
1637
- if (elapsed < PACING_MIN_ELAPSED_MS) return null
1638
- if (elapsed < windowMs * PACING_MIN_ELAPSED_FRACTION) return null
1639
- if (elapsed >= windowMs) return null
1640
-
1641
- const used = window.usedPercent
1642
- const pacePercent = Math.min(Math.max((elapsed / windowMs) * 100, 0), 100)
1643
- const deltaPercent = used - pacePercent
1644
- const state =
1645
- Math.abs(deltaPercent) < ON_PACE_DELTA
1646
- ? 'on-pace'
1647
- : deltaPercent > 0
1648
- ? 'deficit'
1649
- : 'reserve'
1650
-
1651
- let runsOutAt: string | null = null
1652
- if (used > 0) {
1653
- const msToFull = (elapsed * 100) / used
1654
- const runOut = start + msToFull
1655
- if (runOut < resetsAt) runsOutAt = new Date(runOut).toISOString()
1656
- }
1657
-
1658
- return { pacePercent, deltaPercent, state, runsOutAt }
1659
- }