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