@cortexkit/opencode-anthropic-auth 1.15.0 → 1.15.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.
- package/dist/index.js +11 -20
- package/dist/prompt-context.d.ts +5 -2
- package/package.json +13 -10
- package/src/tui/entry.mjs +37 -0
- package/src/tui-compiled/rpc/port-file.ts +70 -0
- package/src/tui-compiled/rpc/protocol.ts +34 -0
- package/src/tui-compiled/rpc/rpc-client.ts +62 -0
- package/src/tui-compiled/rpc/rpc-dir.ts +19 -0
- package/src/tui-compiled/sidebar-state.ts +410 -0
- package/src/tui-compiled/tui/command-dialogs.tsx +615 -0
- package/src/tui-compiled/tui-preferences.ts +370 -0
- package/src/tui-compiled/tui.tsx +1102 -0
- package/src/tui.tsx +16 -13
|
@@ -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
|
+
}
|