@cortexkit/opencode-anthropic-auth 1.15.0 → 1.16.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.
@@ -65,6 +65,17 @@ export function openCommandDialog(
65
65
  value: 'fallback-first',
66
66
  description: 'Prefer fallback accounts, preserve main',
67
67
  },
68
+ {
69
+ title: 'Sticky balanced',
70
+ value: 'sticky-balanced',
71
+ description:
72
+ 'Balance new sessions by quota and keep each account sticky',
73
+ },
74
+ {
75
+ title: 'Reset this session',
76
+ value: 'reset',
77
+ description: 'Reassign this session on its next request',
78
+ },
68
79
  ]}
69
80
  onSelect={(option) => {
70
81
  void apply('claude-routing', String(option.value)).then((r) => {
@@ -158,9 +169,9 @@ export function openCommandDialog(
158
169
  api.ui.dialog.setSize('xlarge')
159
170
  api.ui.dialog.replace(() => (
160
171
  <DialogPrompt
161
- title='Claude cachekeep window'
172
+ title='Claude cachekeep schedule'
162
173
  description={() => <text>{payload.text}</text>}
163
- placeholder="HH-HH (e.g. 08-20) or 'off'"
174
+ placeholder="'always', HH-HH (e.g. 08-20), or 'off'"
164
175
  value={seed}
165
176
  onConfirm={(value: string) => {
166
177
  void apply('claude-cachekeep', value.trim()).then((r) => {
@@ -0,0 +1,37 @@
1
+ // Prefer the host OpenTUI runtime registry when it exists. OpenTUI 0.4.x
2
+ // registers these virtual modules process-wide, allowing the precompiled TUI to
3
+ // share the host's single Solid/OpenTUI runtime when loaded from node_modules.
4
+ const runtimeProbe = `opentui:runtime-module:${encodeURIComponent('@opentui/solid')}`
5
+
6
+ function isMissingRuntimeRegistry(error) {
7
+ const message = error instanceof Error ? error.message : String(error)
8
+ return (
9
+ /Cannot find|Could not resolve|Module not found|Unable to resolve/.test(
10
+ message,
11
+ ) && message.includes('opentui:runtime-module:')
12
+ )
13
+ }
14
+
15
+ let mod
16
+ try {
17
+ await import(runtimeProbe)
18
+ } catch (error) {
19
+ if (!isMissingRuntimeRegistry(error)) {
20
+ console.error('Anthropic Auth TUI runtime registry probe failed', error)
21
+ throw error
22
+ }
23
+ // Older hosts and bare Bun do not provide the virtual registry. Their source
24
+ // loader still applies the Solid transform, so retain the raw TSX fallback.
25
+ mod = await import('../tui.tsx')
26
+ }
27
+
28
+ if (!mod) {
29
+ try {
30
+ mod = await import('../tui-compiled/tui.tsx')
31
+ } catch (error) {
32
+ console.error('Anthropic Auth compiled TUI failed to load', error)
33
+ throw error
34
+ }
35
+ }
36
+
37
+ export default mod.default
@@ -0,0 +1,70 @@
1
+ import {
2
+ mkdir,
3
+ readdir,
4
+ readFile,
5
+ rename,
6
+ unlink,
7
+ writeFile,
8
+ } from 'node:fs/promises'
9
+ import { join } from 'node:path'
10
+
11
+ export interface PortFileEntry {
12
+ port: number
13
+ token: string
14
+ pid: number
15
+ startedAt: number
16
+ }
17
+
18
+ function pidAlive(pid: number): boolean {
19
+ try {
20
+ process.kill(pid, 0)
21
+ return true
22
+ } catch (error) {
23
+ return (error as NodeJS.ErrnoException).code === 'EPERM'
24
+ }
25
+ }
26
+
27
+ export async function writePortFile(
28
+ dir: string,
29
+ entry: { port: number; token: string; pid: number },
30
+ ): Promise<string> {
31
+ await mkdir(dir, { recursive: true })
32
+ const full: PortFileEntry = { ...entry, startedAt: Date.now() }
33
+ const target = join(dir, `port-${entry.pid}.json`)
34
+ const tmp = `${target}.${process.pid}.tmp`
35
+ await writeFile(tmp, JSON.stringify(full), { encoding: 'utf8', mode: 0o600 })
36
+ await rename(tmp, target)
37
+ return target
38
+ }
39
+
40
+ export async function discoverPortFile(
41
+ dir: string,
42
+ expectedPid?: number,
43
+ ): Promise<PortFileEntry | null> {
44
+ let names: string[]
45
+ try {
46
+ names = await readdir(dir)
47
+ } catch {
48
+ return null
49
+ }
50
+ const live: PortFileEntry[] = []
51
+ for (const name of names) {
52
+ if (!name.startsWith('port-') || !name.endsWith('.json')) continue
53
+ try {
54
+ const parsed = JSON.parse(
55
+ await readFile(join(dir, name), 'utf8'),
56
+ ) as PortFileEntry
57
+ if (Number.isFinite(parsed.port)) {
58
+ if (pidAlive(parsed.pid)) live.push(parsed)
59
+ else unlink(join(dir, name)).catch(() => {})
60
+ }
61
+ } catch {}
62
+ }
63
+ if (live.length === 0) return null
64
+ const candidates =
65
+ expectedPid && expectedPid >= 1
66
+ ? live.filter((entry) => entry.pid === expectedPid)
67
+ : []
68
+ const entries = candidates.length > 0 ? candidates : live
69
+ return entries.sort((a, b) => b.startedAt - a.startedAt)[0] ?? null
70
+ }
@@ -0,0 +1,34 @@
1
+ export type CommandModalName =
2
+ | 'claude-account'
3
+ | 'claude-cache'
4
+ | 'claude-cachekeep'
5
+ | 'claude-quota'
6
+ | 'claude-dump'
7
+ | 'claude-fast'
8
+ | 'claude-routing'
9
+ | 'claude-killswitch'
10
+ | 'claude-logging'
11
+
12
+ export interface OpenDialogPayload {
13
+ command: CommandModalName
14
+ text: string
15
+ knobs: Record<string, unknown>
16
+ }
17
+
18
+ export interface RpcNotification {
19
+ id: number
20
+ type: 'open-dialog'
21
+ payload: OpenDialogPayload
22
+ sessionId?: string
23
+ }
24
+
25
+ export interface ApplyRequest {
26
+ command: CommandModalName
27
+ arguments: string
28
+ sessionId?: string
29
+ }
30
+
31
+ export interface ApplyResult {
32
+ text: string
33
+ knobs: Record<string, unknown>
34
+ }
@@ -0,0 +1,62 @@
1
+ import { discoverPortFile } from './port-file'
2
+ import type { ApplyRequest, ApplyResult, RpcNotification } from './protocol'
3
+
4
+ export interface RpcClient {
5
+ pending: (
6
+ lastReceivedId: number,
7
+ sessionId?: string,
8
+ ) => Promise<RpcNotification[]>
9
+ apply: (request: ApplyRequest) => Promise<ApplyResult>
10
+ }
11
+
12
+ async function call<T>(
13
+ dir: string,
14
+ method: string,
15
+ params: Record<string, unknown>,
16
+ expectedPid?: number,
17
+ ): Promise<T | null> {
18
+ const entry = await discoverPortFile(dir, expectedPid)
19
+ if (!entry) return null
20
+ const controller = new AbortController()
21
+ const timer = setTimeout(() => controller.abort(), 2_000)
22
+ try {
23
+ const res = await fetch(`http://127.0.0.1:${entry.port}/rpc/${method}`, {
24
+ method: 'POST',
25
+ headers: {
26
+ 'content-type': 'application/json',
27
+ authorization: `Bearer ${entry.token}`,
28
+ },
29
+ body: JSON.stringify(params),
30
+ signal: controller.signal,
31
+ })
32
+ if (!res.ok) return null
33
+ return (await res.json()) as T
34
+ } catch {
35
+ return null
36
+ } finally {
37
+ clearTimeout(timer)
38
+ }
39
+ }
40
+
41
+ export function createRpcClient(dir: string, expectedPid?: number): RpcClient {
42
+ return {
43
+ async pending(lastReceivedId, sessionId) {
44
+ const out = await call<{ messages: RpcNotification[] }>(
45
+ dir,
46
+ 'pending-notifications',
47
+ { lastReceivedId, sessionId },
48
+ expectedPid,
49
+ )
50
+ return out?.messages ?? []
51
+ },
52
+ async apply(request) {
53
+ const out = await call<ApplyResult>(
54
+ dir,
55
+ 'apply',
56
+ { ...request },
57
+ expectedPid,
58
+ )
59
+ return out ?? { text: 'apply failed', knobs: {} }
60
+ },
61
+ }
62
+ }
@@ -0,0 +1,19 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { homedir, tmpdir } from 'node:os'
3
+ import { join, resolve } from 'node:path'
4
+
5
+ const RPC_DIR_ENV = 'OPENCODE_ANTHROPIC_AUTH_RPC_DIR'
6
+
7
+ // Both processes must resolve the SAME dir from the SAME project directory.
8
+ export function getRpcDir(projectDirectory: string): string {
9
+ const override = process.env[RPC_DIR_ENV]?.trim()
10
+ if (override) return resolve(projectDirectory, override)
11
+ const hash = createHash('sha256')
12
+ .update(projectDirectory)
13
+ .digest('hex')
14
+ .slice(0, 16)
15
+ const base = process.env.XDG_STATE_HOME || join(homedir(), '.local', 'state')
16
+ return join(base, 'cortexkit', 'anthropic-auth', 'rpc', hash)
17
+ }
18
+
19
+ export { tmpdir }
@@ -0,0 +1,410 @@
1
+ export interface QuotaWindow {
2
+ usedPercent: number
3
+ remainingPercent: number
4
+ resetsAt?: string
5
+ }
6
+
7
+ export interface ScopedQuotaWindow extends QuotaWindow {
8
+ id: string
9
+ title: string
10
+ modelId?: string
11
+ modelName: string
12
+ }
13
+
14
+ export interface AccountQuota {
15
+ five_hour?: QuotaWindow
16
+ seven_day?: QuotaWindow
17
+ scoped?: ScopedQuotaWindow[]
18
+ }
19
+
20
+ export interface SidebarAccountState {
21
+ id: string
22
+ label: string | undefined
23
+ quota: AccountQuota | null
24
+ enabled: boolean
25
+ // True when the account's refresh token is permanently dead (400
26
+ // invalid_grant) and it needs a re-login — distinct from a transient backoff.
27
+ needsReauth: boolean
28
+ }
29
+
30
+ export interface FableRecoverySidebarState {
31
+ sessionId: string
32
+ mode: 'opus' | 'fable'
33
+ remaining: number
34
+ changedAt: number
35
+ }
36
+
37
+ export interface SidebarState {
38
+ main: {
39
+ quota: AccountQuota | null
40
+ quotaBackedOff?: boolean
41
+ quotaBackoffUntil?: number
42
+ refreshBackedOff?: boolean
43
+ refreshBackoffUntil?: number
44
+ }
45
+ fallbacks: SidebarAccountState[]
46
+ activeId: string | undefined
47
+ route: string
48
+ relay: { enabled: boolean; transport: string } | null
49
+ fastMode: boolean
50
+ cacheKeep?: {
51
+ enabled: boolean
52
+ window?: string
53
+ trackedSessions?: number
54
+ }
55
+ fableRecoveries?: FableRecoverySidebarState[]
56
+ lastUpdated: number
57
+ }
58
+
59
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
60
+ import { tmpdir } from 'node:os'
61
+ import { dirname, join } from 'node:path'
62
+
63
+ const STATE_FILE_ENV = 'OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE'
64
+ const DEFAULT_STATE_DIR = join(tmpdir(), 'opencode-anthropic-auth')
65
+ const DEFAULT_STATE_FILE = join(DEFAULT_STATE_DIR, 'sidebar-state.json')
66
+
67
+ export function getSidebarStateFile(): string {
68
+ return process.env[STATE_FILE_ENV] || DEFAULT_STATE_FILE
69
+ }
70
+
71
+ export const DEFAULT_SIDEBAR_STATE: SidebarState = {
72
+ main: { quota: null },
73
+ fallbacks: [],
74
+ activeId: undefined,
75
+ route: 'main',
76
+ relay: null,
77
+ fastMode: false,
78
+ lastUpdated: 0,
79
+ }
80
+ function isRecord(v: unknown): v is Record<string, unknown> {
81
+ return v !== null && typeof v === 'object' && !Array.isArray(v)
82
+ }
83
+
84
+ function normalizeQuotaWindow(value: unknown): QuotaWindow | undefined {
85
+ if (!isRecord(value)) return undefined
86
+ const usedPercent = Number(value.usedPercent)
87
+ const remainingPercent = Number(value.remainingPercent)
88
+ if (!Number.isFinite(usedPercent) || !Number.isFinite(remainingPercent)) {
89
+ return undefined
90
+ }
91
+ return {
92
+ usedPercent,
93
+ remainingPercent,
94
+ resetsAt: typeof value.resetsAt === 'string' ? value.resetsAt : undefined,
95
+ }
96
+ }
97
+
98
+ function normalizeAccountQuota(value: unknown): AccountQuota | null {
99
+ if (!isRecord(value)) return null
100
+ const quota: AccountQuota = {}
101
+ const fiveHour = normalizeQuotaWindow(value.five_hour)
102
+ const sevenDay = normalizeQuotaWindow(value.seven_day)
103
+ if (fiveHour) quota.five_hour = fiveHour
104
+ if (sevenDay) quota.seven_day = sevenDay
105
+
106
+ if (Array.isArray(value.scoped)) {
107
+ const scoped = value.scoped
108
+ .map((entry): ScopedQuotaWindow | undefined => {
109
+ if (!isRecord(entry)) return undefined
110
+ const window = normalizeQuotaWindow(entry)
111
+ if (!window) return undefined
112
+ if (typeof entry.id !== 'string' || !entry.id.trim()) return undefined
113
+ if (typeof entry.title !== 'string' || !entry.title.trim()) {
114
+ return undefined
115
+ }
116
+ if (typeof entry.modelName !== 'string' || !entry.modelName.trim()) {
117
+ return undefined
118
+ }
119
+ const modelId =
120
+ typeof entry.modelId === 'string' && entry.modelId.trim()
121
+ ? entry.modelId.trim()
122
+ : undefined
123
+ return {
124
+ ...window,
125
+ id: entry.id.trim(),
126
+ title: entry.title.trim(),
127
+ ...(modelId && { modelId }),
128
+ modelName: entry.modelName.trim(),
129
+ }
130
+ })
131
+ .filter((entry): entry is ScopedQuotaWindow => entry != null)
132
+ // Preserve empty `[]` so a sidebar reader can distinguish "scoped owned
133
+ // by anthropic-auth, none visible" from "no quota data at all". The OUTER
134
+ // Array.isArray guard means pre-feature inputs without a `scoped` key are
135
+ // not affected — only inputs that already carried an array reach this line.
136
+ quota.scoped = scoped
137
+ }
138
+
139
+ return Object.keys(quota).length ? quota : null
140
+ }
141
+
142
+ export function normalizeSidebarState(raw: unknown): SidebarState {
143
+ if (!isRecord(raw)) return { ...DEFAULT_SIDEBAR_STATE }
144
+
145
+ const main: SidebarState['main'] = { quota: null }
146
+ if (isRecord(raw.main)) {
147
+ const m = raw.main
148
+ main.quota = normalizeAccountQuota(m.quota)
149
+ if (typeof m.quotaBackedOff === 'boolean')
150
+ main.quotaBackedOff = m.quotaBackedOff
151
+ if (typeof m.quotaBackoffUntil === 'number')
152
+ main.quotaBackoffUntil = m.quotaBackoffUntil
153
+ if (typeof m.refreshBackedOff === 'boolean')
154
+ main.refreshBackedOff = m.refreshBackedOff
155
+ if (typeof m.refreshBackoffUntil === 'number')
156
+ main.refreshBackoffUntil = m.refreshBackoffUntil
157
+ }
158
+
159
+ const fallbacks: SidebarAccountState[] = Array.isArray(raw.fallbacks)
160
+ ? raw.fallbacks
161
+ .filter(isRecord)
162
+ .filter((entry) => typeof entry.id === 'string')
163
+ .map((entry) => ({
164
+ id: entry.id as string,
165
+ label: typeof entry.label === 'string' ? entry.label : undefined,
166
+ quota: normalizeAccountQuota(entry.quota),
167
+ enabled: typeof entry.enabled === 'boolean' ? entry.enabled : false,
168
+ needsReauth:
169
+ typeof entry.needsReauth === 'boolean' ? entry.needsReauth : false,
170
+ }))
171
+ : []
172
+
173
+ let relay: SidebarState['relay'] = null
174
+ if (isRecord(raw.relay)) {
175
+ const r = raw.relay
176
+ if (typeof r.enabled === 'boolean' && typeof r.transport === 'string') {
177
+ relay = { enabled: r.enabled, transport: r.transport }
178
+ }
179
+ }
180
+
181
+ let cacheKeep: SidebarState['cacheKeep']
182
+ if (isRecord(raw.cacheKeep)) {
183
+ const ck = raw.cacheKeep
184
+ if (typeof ck.enabled === 'boolean') {
185
+ cacheKeep = {
186
+ enabled: ck.enabled,
187
+ window: typeof ck.window === 'string' ? ck.window : undefined,
188
+ trackedSessions:
189
+ typeof ck.trackedSessions === 'number'
190
+ ? ck.trackedSessions
191
+ : undefined,
192
+ }
193
+ }
194
+ }
195
+
196
+ const fableRecoveries: FableRecoverySidebarState[] = Array.isArray(
197
+ raw.fableRecoveries,
198
+ )
199
+ ? raw.fableRecoveries
200
+ .filter(isRecord)
201
+ .flatMap((recovery): FableRecoverySidebarState[] => {
202
+ if (
203
+ typeof recovery.sessionId !== 'string' ||
204
+ (recovery.mode !== 'opus' && recovery.mode !== 'fable') ||
205
+ typeof recovery.remaining !== 'number' ||
206
+ !Number.isFinite(recovery.remaining) ||
207
+ typeof recovery.changedAt !== 'number' ||
208
+ !Number.isFinite(recovery.changedAt)
209
+ )
210
+ return []
211
+ return [
212
+ {
213
+ sessionId: recovery.sessionId,
214
+ mode: recovery.mode,
215
+ remaining: Math.max(0, Math.floor(recovery.remaining)),
216
+ changedAt: recovery.changedAt,
217
+ },
218
+ ]
219
+ })
220
+ : []
221
+
222
+ return {
223
+ main,
224
+ fallbacks,
225
+ activeId: typeof raw.activeId === 'string' ? raw.activeId : undefined,
226
+ route:
227
+ typeof raw.route === 'string' ? raw.route : DEFAULT_SIDEBAR_STATE.route,
228
+ relay,
229
+ fastMode:
230
+ typeof raw.fastMode === 'boolean'
231
+ ? raw.fastMode
232
+ : DEFAULT_SIDEBAR_STATE.fastMode,
233
+ cacheKeep,
234
+ fableRecoveries: fableRecoveries.length > 0 ? fableRecoveries : undefined,
235
+ lastUpdated: typeof raw.lastUpdated === 'number' ? raw.lastUpdated : 0,
236
+ }
237
+ }
238
+
239
+ let writeChain: Promise<void> = Promise.resolve()
240
+
241
+ export async function getSidebarState(): Promise<SidebarState> {
242
+ try {
243
+ const raw = await readFile(getSidebarStateFile(), 'utf8')
244
+ return normalizeSidebarState(JSON.parse(raw))
245
+ } catch {
246
+ return DEFAULT_SIDEBAR_STATE
247
+ }
248
+ }
249
+
250
+ export async function setSidebarState(
251
+ state: SidebarState,
252
+ stateFile = getSidebarStateFile(),
253
+ ): Promise<void> {
254
+ writeChain = writeChain
255
+ .then(async () => {
256
+ await mkdir(dirname(stateFile), { recursive: true })
257
+ await writeFile(stateFile, JSON.stringify(state), 'utf8')
258
+ })
259
+ .catch(() => {
260
+ // Best-effort — sidebar is non-critical
261
+ })
262
+
263
+ return writeChain
264
+ }
265
+
266
+ export async function drainSidebarWrites(): Promise<void> {
267
+ return writeChain
268
+ }
269
+
270
+ // Resolve the currently-active account from activeId for the collapsed sidebar
271
+ // view. activeId === 'main' (or undefined/unmatched/disabled) → the main
272
+ // account; otherwise the enabled fallback whose id matches.
273
+ export function resolveActiveAccount(state: SidebarState): {
274
+ id: string
275
+ name: string
276
+ quota: AccountQuota | null
277
+ } {
278
+ const activeId = state.activeId
279
+ if (activeId && activeId !== 'main') {
280
+ // `account.enabled` is defensive: writeSidebarState already filters disabled
281
+ // accounts out of state.fallbacks, so in normal operation every entry is
282
+ // enabled. Kept so this pure helper stays correct for any caller and is
283
+ // exercised directly by the unit tests.
284
+ const fallback = state.fallbacks?.find(
285
+ (account) => account.enabled && account.id === activeId,
286
+ )
287
+ if (fallback) {
288
+ return {
289
+ id: fallback.id,
290
+ name: fallback.label ?? fallback.id,
291
+ quota: fallback.quota,
292
+ }
293
+ }
294
+ }
295
+ return { id: 'main', name: 'main', quota: state.main?.quota ?? null }
296
+ }
297
+
298
+ export function formatScopedQuotaLabel(title: string) {
299
+ const label = title.replace(/\s+only$/i, '').trim()
300
+ return /^fable$/i.test(label) ? 'Fa' : label
301
+ }
302
+
303
+ export function getFableRecoverySummary(
304
+ state: SidebarState,
305
+ sessionId: string,
306
+ ): string | undefined {
307
+ const recovery = state.fableRecoveries?.find(
308
+ (candidate) => candidate.sessionId === sessionId,
309
+ )
310
+ if (!recovery) return undefined
311
+ if (recovery.mode === 'fable') return 'Fable 5 · restored'
312
+ return `Opus 4.8 · ${recovery.remaining} left`
313
+ }
314
+
315
+ export function getCollapsedQuotaSummary(quota: AccountQuota | null): {
316
+ fiveHourUsedPercent: number | null
317
+ sevenDayUsedPercent: number | null
318
+ scopedUsedPercents: number[]
319
+ text: string | null
320
+ } {
321
+ const fiveHourUsedPercent = quota?.five_hour?.usedPercent ?? null
322
+ const sevenDayUsedPercent = quota?.seven_day?.usedPercent ?? null
323
+ const scoped = quota?.scoped ?? []
324
+ const scopedUsedPercents = scoped.map((window) => window.usedPercent)
325
+ if (
326
+ fiveHourUsedPercent == null &&
327
+ sevenDayUsedPercent == null &&
328
+ scoped.length === 0
329
+ ) {
330
+ return {
331
+ fiveHourUsedPercent,
332
+ sevenDayUsedPercent,
333
+ scopedUsedPercents,
334
+ text: null,
335
+ }
336
+ }
337
+
338
+ const scopedSegments = scoped.map(
339
+ (window) =>
340
+ `${formatScopedQuotaLabel(window.title)}: ${Math.round(window.usedPercent)}%`,
341
+ )
342
+ const primarySegments =
343
+ fiveHourUsedPercent == null && sevenDayUsedPercent == null
344
+ ? []
345
+ : [
346
+ `5h: ${fiveHourUsedPercent == null ? '—' : `${Math.round(fiveHourUsedPercent)}%`}`,
347
+ `7d: ${sevenDayUsedPercent == null ? '—' : `${Math.round(sevenDayUsedPercent)}%`}`,
348
+ ]
349
+
350
+ return {
351
+ fiveHourUsedPercent,
352
+ sevenDayUsedPercent,
353
+ scopedUsedPercents,
354
+ text: [...primarySegments, ...scopedSegments].join(' '),
355
+ }
356
+ }
357
+
358
+ export const FIVE_HOUR_MS = 5 * 60 * 60 * 1000
359
+ export const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000
360
+
361
+ const PACING_MIN_ELAPSED_MS = 5 * 60 * 1000
362
+ const PACING_MIN_ELAPSED_FRACTION = 0.01
363
+ const ON_PACE_DELTA = 1
364
+
365
+ export interface QuotaPacing {
366
+ pacePercent: number
367
+ deltaPercent: number
368
+ state: 'deficit' | 'reserve' | 'on-pace'
369
+ runsOutAt: string | null
370
+ }
371
+
372
+ // Even-burn pacing for a quota window. The window start is inferred from the
373
+ // reset timestamp minus the window length. Two metrics: deltaPercent compares
374
+ // usage against a uniform burn-down (positive = deficit), and runsOutAt
375
+ // projects the current average burn rate forward — null means the window
376
+ // lasts until reset at that rate. Returns null when there is no reset
377
+ // timestamp or the elapsed time is too small to give a meaningful rate.
378
+ export function computeQuotaPacing(
379
+ window: QuotaWindow,
380
+ windowMs: number,
381
+ now: number,
382
+ ): QuotaPacing | null {
383
+ if (!window.resetsAt) return null
384
+ const resetsAt = new Date(window.resetsAt).getTime()
385
+ if (!Number.isFinite(resetsAt)) return null
386
+ const start = resetsAt - windowMs
387
+ const elapsed = now - start
388
+ if (elapsed < PACING_MIN_ELAPSED_MS) return null
389
+ if (elapsed < windowMs * PACING_MIN_ELAPSED_FRACTION) return null
390
+ if (elapsed >= windowMs) return null
391
+
392
+ const used = window.usedPercent
393
+ const pacePercent = Math.min(Math.max((elapsed / windowMs) * 100, 0), 100)
394
+ const deltaPercent = used - pacePercent
395
+ const state =
396
+ Math.abs(deltaPercent) < ON_PACE_DELTA
397
+ ? 'on-pace'
398
+ : deltaPercent > 0
399
+ ? 'deficit'
400
+ : 'reserve'
401
+
402
+ let runsOutAt: string | null = null
403
+ if (used > 0) {
404
+ const msToFull = (elapsed * 100) / used
405
+ const runOut = start + msToFull
406
+ if (runOut < resetsAt) runsOutAt = new Date(runOut).toISOString()
407
+ }
408
+
409
+ return { pacePercent, deltaPercent, state, runsOutAt }
410
+ }