@cortexkit/opencode-anthropic-auth 1.15.1 → 1.17.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 +14 -6
- package/dist/cli.js +6 -6
- package/dist/fable-fallback.d.ts +2 -0
- package/dist/{index-qejbx8c9.js → index-63keqwnf.js} +1 -1
- package/dist/{index-jkbar3k3.js → index-am5pq04s.js} +1 -1
- package/dist/{index-qsaph2g4.js → index-f5r96vyv.js} +1 -1
- package/dist/index-gj7emmj5.js +2 -0
- package/dist/index-xvfesm3w.js +422 -0
- package/dist/{index-6gdd6e4s.js → index-y2n9h7p6.js} +1 -1
- package/dist/index.js +12 -12
- package/dist/{logger-vxnb5hjr.js → logger-ywfmddsw.js} +1 -1
- 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 +52 -2
- package/dist/sidebar-state.js +1 -1
- package/dist/tui/command-dialogs.d.ts +12 -0
- package/dist/tui-preferences.js +1 -1
- package/dist/tui.d.ts +2 -0
- package/package.json +2 -2
- package/src/sidebar-state.ts +381 -4
- package/src/tui/command-dialogs.tsx +35 -13
- package/src/tui-compiled/sidebar-state.ts +381 -4
- package/src/tui-compiled/tui/command-dialogs.tsx +22 -10
- package/src/tui-compiled/tui.tsx +147 -104
- package/src/tui.tsx +34 -2
- package/dist/index-bxs26mw4.js +0 -2
- package/dist/index-z03wayzs.js +0 -417
|
@@ -32,6 +32,26 @@ export function buildKillswitchThresholdSeed(
|
|
|
32
32
|
return seedParts.join(' ')
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
export function buildAccountDialogOption(account: {
|
|
36
|
+
id: string
|
|
37
|
+
label: string
|
|
38
|
+
role: string
|
|
39
|
+
enabled: boolean
|
|
40
|
+
quotaPercent: number | null
|
|
41
|
+
tierLabel?: string
|
|
42
|
+
}) {
|
|
43
|
+
const pct =
|
|
44
|
+
account.quotaPercent != null
|
|
45
|
+
? ` ${Math.round(account.quotaPercent)}%`
|
|
46
|
+
: ' \u2013%'
|
|
47
|
+
const status = !account.enabled ? ' (disabled)' : ''
|
|
48
|
+
return {
|
|
49
|
+
title: `${account.label} [${account.role}]${status}${pct}`,
|
|
50
|
+
value: account.id,
|
|
51
|
+
...(account.tierLabel && { description: account.tierLabel }),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
35
55
|
function showText(api: TuiPluginApi, text: string) {
|
|
36
56
|
api.ui.dialog.setSize('xlarge')
|
|
37
57
|
api.ui.dialog.replace(() => (
|
|
@@ -65,6 +85,17 @@ export function openCommandDialog(
|
|
|
65
85
|
value: 'fallback-first',
|
|
66
86
|
description: 'Prefer fallback accounts, preserve main',
|
|
67
87
|
},
|
|
88
|
+
{
|
|
89
|
+
title: 'Sticky balanced',
|
|
90
|
+
value: 'sticky-balanced',
|
|
91
|
+
description:
|
|
92
|
+
'Balance new sessions by quota and keep each account sticky',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
title: 'Reset this session',
|
|
96
|
+
value: 'reset',
|
|
97
|
+
description: 'Reassign this session on its next request',
|
|
98
|
+
},
|
|
68
99
|
]}
|
|
69
100
|
onSelect={(option) => {
|
|
70
101
|
void apply('claude-routing', String(option.value)).then((r) => {
|
|
@@ -158,9 +189,9 @@ export function openCommandDialog(
|
|
|
158
189
|
api.ui.dialog.setSize('xlarge')
|
|
159
190
|
api.ui.dialog.replace(() => (
|
|
160
191
|
<DialogPrompt
|
|
161
|
-
title='Claude cachekeep
|
|
192
|
+
title='Claude cachekeep schedule'
|
|
162
193
|
description={() => <text>{payload.text}</text>}
|
|
163
|
-
placeholder="HH-HH (e.g. 08-20) or 'off'"
|
|
194
|
+
placeholder="'always', HH-HH (e.g. 08-20), or 'off'"
|
|
164
195
|
value={seed}
|
|
165
196
|
onConfirm={(value: string) => {
|
|
166
197
|
void apply('claude-cachekeep', value.trim()).then((r) => {
|
|
@@ -242,6 +273,7 @@ export function openCommandDialog(
|
|
|
242
273
|
role: string
|
|
243
274
|
enabled: boolean
|
|
244
275
|
quotaPercent: number | null
|
|
276
|
+
tierLabel?: string
|
|
245
277
|
}>) ?? []
|
|
246
278
|
|
|
247
279
|
const updateAccounts = (r: {
|
|
@@ -267,17 +299,7 @@ export function openCommandDialog(
|
|
|
267
299
|
value: '__add__',
|
|
268
300
|
description: 'Add an API key or OAuth fallback account',
|
|
269
301
|
},
|
|
270
|
-
...accounts.map(
|
|
271
|
-
const pct =
|
|
272
|
-
a.quotaPercent != null
|
|
273
|
-
? ` ${Math.round(a.quotaPercent)}%`
|
|
274
|
-
: ' \u2013%'
|
|
275
|
-
const status = !a.enabled ? ' (disabled)' : ''
|
|
276
|
-
return {
|
|
277
|
-
title: `${a.label} [${a.role}]${status}${pct}`,
|
|
278
|
-
value: a.id,
|
|
279
|
-
}
|
|
280
|
-
}),
|
|
302
|
+
...accounts.map(buildAccountDialogOption),
|
|
281
303
|
]
|
|
282
304
|
api.ui.dialog.setSize('xlarge')
|
|
283
305
|
api.ui.dialog.replace(() => (
|
|
@@ -15,6 +15,15 @@ export interface AccountQuota {
|
|
|
15
15
|
five_hour?: QuotaWindow
|
|
16
16
|
seven_day?: QuotaWindow
|
|
17
17
|
scoped?: ScopedQuotaWindow[]
|
|
18
|
+
extraUsage?: {
|
|
19
|
+
used: { amountMinor: number; currency: string; exponent: number }
|
|
20
|
+
limit: { amountMinor: number; currency: string; exponent: number }
|
|
21
|
+
utilizationPercent?: number
|
|
22
|
+
severity?: string
|
|
23
|
+
exhausted: boolean
|
|
24
|
+
}
|
|
25
|
+
bindingWindow?: string
|
|
26
|
+
fallbackAdvised?: boolean
|
|
18
27
|
}
|
|
19
28
|
|
|
20
29
|
export interface SidebarAccountState {
|
|
@@ -25,6 +34,7 @@ export interface SidebarAccountState {
|
|
|
25
34
|
// True when the account's refresh token is permanently dead (400
|
|
26
35
|
// invalid_grant) and it needs a re-login — distinct from a transient backoff.
|
|
27
36
|
needsReauth: boolean
|
|
37
|
+
tierLabel?: string
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
export interface FableRecoverySidebarState {
|
|
@@ -37,6 +47,7 @@ export interface FableRecoverySidebarState {
|
|
|
37
47
|
export interface SidebarState {
|
|
38
48
|
main: {
|
|
39
49
|
quota: AccountQuota | null
|
|
50
|
+
tierLabel?: string
|
|
40
51
|
quotaBackedOff?: boolean
|
|
41
52
|
quotaBackoffUntil?: number
|
|
42
53
|
refreshBackedOff?: boolean
|
|
@@ -56,9 +67,20 @@ export interface SidebarState {
|
|
|
56
67
|
lastUpdated: number
|
|
57
68
|
}
|
|
58
69
|
|
|
59
|
-
import {
|
|
70
|
+
import { randomUUID } from 'node:crypto'
|
|
71
|
+
import {
|
|
72
|
+
mkdir,
|
|
73
|
+
readFile,
|
|
74
|
+
rename,
|
|
75
|
+
rm,
|
|
76
|
+
rmdir,
|
|
77
|
+
stat,
|
|
78
|
+
unlink,
|
|
79
|
+
writeFile,
|
|
80
|
+
} from 'node:fs/promises'
|
|
60
81
|
import { tmpdir } from 'node:os'
|
|
61
82
|
import { dirname, join } from 'node:path'
|
|
83
|
+
import { logger } from '@cortexkit/anthropic-auth-core'
|
|
62
84
|
|
|
63
85
|
const STATE_FILE_ENV = 'OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE'
|
|
64
86
|
const DEFAULT_STATE_DIR = join(tmpdir(), 'opencode-anthropic-auth')
|
|
@@ -136,9 +158,53 @@ function normalizeAccountQuota(value: unknown): AccountQuota | null {
|
|
|
136
158
|
quota.scoped = scoped
|
|
137
159
|
}
|
|
138
160
|
|
|
161
|
+
if (isRecord(value.extraUsage)) {
|
|
162
|
+
const used = normalizeQuotaMoney(value.extraUsage.used)
|
|
163
|
+
const limit = normalizeQuotaMoney(value.extraUsage.limit)
|
|
164
|
+
if (used && limit && typeof value.extraUsage.exhausted === 'boolean') {
|
|
165
|
+
quota.extraUsage = {
|
|
166
|
+
used,
|
|
167
|
+
limit,
|
|
168
|
+
...(typeof value.extraUsage.utilizationPercent === 'number' &&
|
|
169
|
+
Number.isFinite(value.extraUsage.utilizationPercent) && {
|
|
170
|
+
utilizationPercent: value.extraUsage.utilizationPercent,
|
|
171
|
+
}),
|
|
172
|
+
...(typeof value.extraUsage.severity === 'string' && {
|
|
173
|
+
severity: value.extraUsage.severity,
|
|
174
|
+
}),
|
|
175
|
+
exhausted: value.extraUsage.exhausted,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (typeof value.bindingWindow === 'string' && value.bindingWindow.trim()) {
|
|
180
|
+
quota.bindingWindow = value.bindingWindow.trim()
|
|
181
|
+
}
|
|
182
|
+
if (typeof value.fallbackAdvised === 'boolean') {
|
|
183
|
+
quota.fallbackAdvised = value.fallbackAdvised
|
|
184
|
+
}
|
|
185
|
+
|
|
139
186
|
return Object.keys(quota).length ? quota : null
|
|
140
187
|
}
|
|
141
188
|
|
|
189
|
+
function normalizeQuotaMoney(value: unknown) {
|
|
190
|
+
if (!isRecord(value)) return undefined
|
|
191
|
+
if (
|
|
192
|
+
!Number.isInteger(value.amountMinor) ||
|
|
193
|
+
typeof value.currency !== 'string' ||
|
|
194
|
+
!/^[A-Za-z]{3}$/.test(value.currency.trim()) ||
|
|
195
|
+
!Number.isInteger(value.exponent) ||
|
|
196
|
+
(value.exponent as number) < 0 ||
|
|
197
|
+
(value.exponent as number) > 20
|
|
198
|
+
) {
|
|
199
|
+
return undefined
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
amountMinor: value.amountMinor as number,
|
|
203
|
+
currency: value.currency.trim(),
|
|
204
|
+
exponent: value.exponent as number,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
142
208
|
export function normalizeSidebarState(raw: unknown): SidebarState {
|
|
143
209
|
if (!isRecord(raw)) return { ...DEFAULT_SIDEBAR_STATE }
|
|
144
210
|
|
|
@@ -146,6 +212,9 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
|
|
|
146
212
|
if (isRecord(raw.main)) {
|
|
147
213
|
const m = raw.main
|
|
148
214
|
main.quota = normalizeAccountQuota(m.quota)
|
|
215
|
+
if (typeof m.tierLabel === 'string' && m.tierLabel.trim()) {
|
|
216
|
+
main.tierLabel = m.tierLabel.trim()
|
|
217
|
+
}
|
|
149
218
|
if (typeof m.quotaBackedOff === 'boolean')
|
|
150
219
|
main.quotaBackedOff = m.quotaBackedOff
|
|
151
220
|
if (typeof m.quotaBackoffUntil === 'number')
|
|
@@ -167,6 +236,10 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
|
|
|
167
236
|
enabled: typeof entry.enabled === 'boolean' ? entry.enabled : false,
|
|
168
237
|
needsReauth:
|
|
169
238
|
typeof entry.needsReauth === 'boolean' ? entry.needsReauth : false,
|
|
239
|
+
tierLabel:
|
|
240
|
+
typeof entry.tierLabel === 'string' && entry.tierLabel.trim()
|
|
241
|
+
? entry.tierLabel.trim()
|
|
242
|
+
: undefined,
|
|
170
243
|
}))
|
|
171
244
|
: []
|
|
172
245
|
|
|
@@ -238,23 +311,327 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
|
|
|
238
311
|
|
|
239
312
|
let writeChain: Promise<void> = Promise.resolve()
|
|
240
313
|
|
|
241
|
-
|
|
314
|
+
// Wait through ordinary contention while staying below the 2s stale-eviction window.
|
|
315
|
+
const SIDEBAR_LOCK_BUDGET_MS = 1_000
|
|
316
|
+
const SIDEBAR_LOCK_STALE_MS = 2_000
|
|
317
|
+
const SIDEBAR_LOCK_RETRY_MIN_MS = 5
|
|
318
|
+
const SIDEBAR_LOCK_RETRY_MAX_MS = 15
|
|
319
|
+
|
|
320
|
+
interface SidebarStateWriteTestHooks {
|
|
321
|
+
afterMergeRead?: (stateFile: string) => void | Promise<void>
|
|
322
|
+
beforeRename?: (stateFile: string, tempFile: string) => void | Promise<void>
|
|
323
|
+
afterRename?: (stateFile: string) => void | Promise<void>
|
|
324
|
+
afterStaleLockStat?: (lockDir: string) => void | Promise<void>
|
|
325
|
+
onStaleLockClaimed?: (lockDir: string) => void | Promise<void>
|
|
326
|
+
onLockAcquired?: (lockDir: string) => void | Promise<void>
|
|
327
|
+
beforeReleaseDirectoryRemoval?: (lockDir: string) => void | Promise<void>
|
|
328
|
+
lockBudgetMs?: number
|
|
329
|
+
lockRetryMinMs?: number
|
|
330
|
+
lockRetryMaxMs?: number
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
let sidebarStateWriteTestHooks: SidebarStateWriteTestHooks | null = null
|
|
334
|
+
|
|
335
|
+
export function __setSidebarStateWriteTestHooks(
|
|
336
|
+
hooks: SidebarStateWriteTestHooks | null,
|
|
337
|
+
): void {
|
|
338
|
+
sidebarStateWriteTestHooks = hooks
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Boot-routing test hooks live here rather than in index.ts: the plugin entry
|
|
342
|
+
// module must export nothing beyond the plugin factory — opencode's plugin
|
|
343
|
+
// loader treats extra entry-point exports as plugin candidates and fails to
|
|
344
|
+
// load the plugin (same incident class as the removed
|
|
345
|
+
// __setBootProfileHydrationForTest export).
|
|
346
|
+
export interface InitialSidebarRoutingTestHooks {
|
|
347
|
+
beforeSidebarRead?: () => void | Promise<void>
|
|
348
|
+
beforeStorageLoad?: () => void | Promise<void>
|
|
349
|
+
afterStorageLoad?: () => void | Promise<void>
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
let initialSidebarRoutingTestHooks: InitialSidebarRoutingTestHooks | null = null
|
|
353
|
+
|
|
354
|
+
export function __setInitialSidebarRoutingTestHooks(
|
|
355
|
+
hooks: InitialSidebarRoutingTestHooks | null,
|
|
356
|
+
): void {
|
|
357
|
+
initialSidebarRoutingTestHooks = hooks
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function getInitialSidebarRoutingTestHooks(): InitialSidebarRoutingTestHooks | null {
|
|
361
|
+
return initialSidebarRoutingTestHooks
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function sleep(ms: number): Promise<void> {
|
|
365
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function acquireSidebarStateLock(stateFile: string): Promise<{
|
|
369
|
+
release: () => Promise<void>
|
|
370
|
+
ownsLock: () => Promise<boolean>
|
|
371
|
+
} | null> {
|
|
372
|
+
const lockDir = `${stateFile}.lock`
|
|
373
|
+
const budgetMs =
|
|
374
|
+
sidebarStateWriteTestHooks?.lockBudgetMs ?? SIDEBAR_LOCK_BUDGET_MS
|
|
375
|
+
const retryMinMs =
|
|
376
|
+
sidebarStateWriteTestHooks?.lockRetryMinMs ?? SIDEBAR_LOCK_RETRY_MIN_MS
|
|
377
|
+
const retryMaxMs =
|
|
378
|
+
sidebarStateWriteTestHooks?.lockRetryMaxMs ?? SIDEBAR_LOCK_RETRY_MAX_MS
|
|
379
|
+
const deadline = Date.now() + budgetMs
|
|
380
|
+
|
|
381
|
+
while (true) {
|
|
382
|
+
try {
|
|
383
|
+
await mkdir(lockDir)
|
|
384
|
+
const ownerId = randomUUID()
|
|
385
|
+
const ownerFile = join(lockDir, ownerId)
|
|
386
|
+
await writeFile(ownerFile, '', {
|
|
387
|
+
encoding: 'utf8',
|
|
388
|
+
mode: 0o600,
|
|
389
|
+
flag: 'wx',
|
|
390
|
+
})
|
|
391
|
+
await sidebarStateWriteTestHooks?.onLockAcquired?.(lockDir)
|
|
392
|
+
return {
|
|
393
|
+
ownsLock: async () => {
|
|
394
|
+
try {
|
|
395
|
+
await stat(ownerFile)
|
|
396
|
+
return true
|
|
397
|
+
} catch {
|
|
398
|
+
return false
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
release: async () => {
|
|
402
|
+
// Unlinking this acquisition's unique file makes the filesystem reject
|
|
403
|
+
// a stale release after eviction handed the path to a successor.
|
|
404
|
+
try {
|
|
405
|
+
await unlink(ownerFile)
|
|
406
|
+
} catch {
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
await sidebarStateWriteTestHooks?.beforeReleaseDirectoryRemoval?.(
|
|
410
|
+
lockDir,
|
|
411
|
+
)
|
|
412
|
+
await rmdir(lockDir).catch(() => {})
|
|
413
|
+
},
|
|
414
|
+
}
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
|
417
|
+
logger.trace('sidebar', 'state lock unavailable; write skipped', {
|
|
418
|
+
stateFile,
|
|
419
|
+
error: error instanceof Error ? error.message : String(error),
|
|
420
|
+
})
|
|
421
|
+
return null
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
const lockStat = await stat(lockDir)
|
|
427
|
+
if (Date.now() - lockStat.mtimeMs > SIDEBAR_LOCK_STALE_MS) {
|
|
428
|
+
await sidebarStateWriteTestHooks?.afterStaleLockStat?.(lockDir)
|
|
429
|
+
const evictedLockDir = `${lockDir}.evict-${process.pid}-${randomUUID()}`
|
|
430
|
+
try {
|
|
431
|
+
await rename(lockDir, evictedLockDir)
|
|
432
|
+
} catch (error) {
|
|
433
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
|
|
434
|
+
throw error
|
|
435
|
+
}
|
|
436
|
+
await sidebarStateWriteTestHooks?.onStaleLockClaimed?.(lockDir)
|
|
437
|
+
await rm(evictedLockDir, { recursive: true, force: true }).catch(
|
|
438
|
+
() => {},
|
|
439
|
+
)
|
|
440
|
+
continue
|
|
441
|
+
}
|
|
442
|
+
} catch (error) {
|
|
443
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
|
|
444
|
+
logger.trace('sidebar', 'state lock inspection failed; write skipped', {
|
|
445
|
+
stateFile,
|
|
446
|
+
error: error instanceof Error ? error.message : String(error),
|
|
447
|
+
})
|
|
448
|
+
return null
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const remainingMs = deadline - Date.now()
|
|
452
|
+
if (remainingMs <= 0) {
|
|
453
|
+
logger.warn('sidebar', 'lock budget exhausted, write skipped', {
|
|
454
|
+
stateFile,
|
|
455
|
+
budgetMs,
|
|
456
|
+
})
|
|
457
|
+
return null
|
|
458
|
+
}
|
|
459
|
+
const jitterMs =
|
|
460
|
+
retryMinMs + Math.floor(Math.random() * (retryMaxMs - retryMinMs + 1))
|
|
461
|
+
await sleep(Math.min(jitterMs, remainingMs))
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export async function __acquireSidebarStateLockForTest(
|
|
466
|
+
stateFile: string,
|
|
467
|
+
): Promise<(() => Promise<void>) | null> {
|
|
468
|
+
return (await acquireSidebarStateLock(stateFile))?.release ?? null
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function writeSidebarStateAtomic(
|
|
472
|
+
stateFile: string,
|
|
473
|
+
state: SidebarState,
|
|
474
|
+
ownsLock: () => Promise<boolean>,
|
|
475
|
+
): Promise<'written' | 'lock-lost-before-rename' | 'lock-lost-after-rename'> {
|
|
476
|
+
const tempFile = `${stateFile}.${process.pid}.${randomUUID()}.tmp`
|
|
477
|
+
await writeFile(tempFile, JSON.stringify(state), {
|
|
478
|
+
encoding: 'utf8',
|
|
479
|
+
mode: 0o600,
|
|
480
|
+
})
|
|
242
481
|
try {
|
|
243
|
-
|
|
482
|
+
if (!(await ownsLock())) {
|
|
483
|
+
logger.trace('sidebar', 'state lock lost before rename; write aborted', {
|
|
484
|
+
stateFile,
|
|
485
|
+
})
|
|
486
|
+
await rm(tempFile, { force: true }).catch(() => {})
|
|
487
|
+
return 'lock-lost-before-rename'
|
|
488
|
+
}
|
|
489
|
+
await sidebarStateWriteTestHooks?.beforeRename?.(stateFile, tempFile)
|
|
490
|
+
// No production await separates this ownership fence from rename. A process
|
|
491
|
+
// freeze between the adjacent syscalls remains possible, so rename is also
|
|
492
|
+
// fenced from the other side below.
|
|
493
|
+
if (!(await ownsLock())) {
|
|
494
|
+
logger.trace('sidebar', 'state lock lost before rename; write aborted', {
|
|
495
|
+
stateFile,
|
|
496
|
+
})
|
|
497
|
+
await rm(tempFile, { force: true }).catch(() => {})
|
|
498
|
+
return 'lock-lost-before-rename'
|
|
499
|
+
}
|
|
500
|
+
await rename(tempFile, stateFile)
|
|
501
|
+
await sidebarStateWriteTestHooks?.afterRename?.(stateFile)
|
|
502
|
+
if (!(await ownsLock())) return 'lock-lost-after-rename'
|
|
503
|
+
return 'written'
|
|
504
|
+
} catch (error) {
|
|
505
|
+
await rm(tempFile, { force: true }).catch(() => {})
|
|
506
|
+
throw error
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export async function getSidebarState(
|
|
511
|
+
stateFile = getSidebarStateFile(),
|
|
512
|
+
): Promise<SidebarState> {
|
|
513
|
+
try {
|
|
514
|
+
const raw = await readFile(stateFile, 'utf8')
|
|
244
515
|
return normalizeSidebarState(JSON.parse(raw))
|
|
245
516
|
} catch {
|
|
246
517
|
return DEFAULT_SIDEBAR_STATE
|
|
247
518
|
}
|
|
248
519
|
}
|
|
249
520
|
|
|
521
|
+
export interface SidebarStateWriteOptions {
|
|
522
|
+
routingAuthoritative?: boolean
|
|
523
|
+
resolvePreservedRouting?: (current: SidebarState) =>
|
|
524
|
+
| (Pick<SidebarState, 'activeId' | 'route'> & {
|
|
525
|
+
state?: SidebarState
|
|
526
|
+
})
|
|
527
|
+
| undefined
|
|
528
|
+
| Promise<
|
|
529
|
+
| (Pick<SidebarState, 'activeId' | 'route'> & {
|
|
530
|
+
state?: SidebarState
|
|
531
|
+
})
|
|
532
|
+
| undefined
|
|
533
|
+
>
|
|
534
|
+
onRoutingResolved?: (
|
|
535
|
+
routing: Pick<SidebarState, 'activeId' | 'route'>,
|
|
536
|
+
) => void
|
|
537
|
+
}
|
|
538
|
+
|
|
250
539
|
export async function setSidebarState(
|
|
251
540
|
state: SidebarState,
|
|
252
541
|
stateFile = getSidebarStateFile(),
|
|
542
|
+
options: SidebarStateWriteOptions = {},
|
|
253
543
|
): Promise<void> {
|
|
254
544
|
writeChain = writeChain
|
|
255
545
|
.then(async () => {
|
|
256
546
|
await mkdir(dirname(stateFile), { recursive: true })
|
|
257
|
-
|
|
547
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
548
|
+
const repairingPostRenameLoss = attempt === 1
|
|
549
|
+
const lock = await acquireSidebarStateLock(stateFile)
|
|
550
|
+
// Sidebar frames are display-only and refresh within seconds; dropping one
|
|
551
|
+
// is safer than clobbering routing state that may stay authoritative until
|
|
552
|
+
// another process handles its next request.
|
|
553
|
+
if (!lock) {
|
|
554
|
+
if (repairingPostRenameLoss) {
|
|
555
|
+
logger.warn(
|
|
556
|
+
'sidebar',
|
|
557
|
+
'post-rename repair lock unavailable; write skipped',
|
|
558
|
+
{ stateFile },
|
|
559
|
+
)
|
|
560
|
+
}
|
|
561
|
+
return
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
let stateToWrite = state
|
|
565
|
+
let result:
|
|
566
|
+
| 'written'
|
|
567
|
+
| 'lock-lost-before-rename'
|
|
568
|
+
| 'lock-lost-after-rename'
|
|
569
|
+
try {
|
|
570
|
+
if (options.routingAuthoritative === false) {
|
|
571
|
+
const current = await getSidebarState(stateFile)
|
|
572
|
+
const preservedRouting =
|
|
573
|
+
await options.resolvePreservedRouting?.(current)
|
|
574
|
+
if (preservedRouting) {
|
|
575
|
+
const preservedState = preservedRouting.state ?? state
|
|
576
|
+
stateToWrite = {
|
|
577
|
+
...preservedState,
|
|
578
|
+
activeId: preservedRouting.activeId,
|
|
579
|
+
route: preservedRouting.route,
|
|
580
|
+
lastUpdated: Math.max(
|
|
581
|
+
preservedState.lastUpdated,
|
|
582
|
+
current.lastUpdated,
|
|
583
|
+
),
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
await sidebarStateWriteTestHooks?.afterMergeRead?.(stateFile)
|
|
587
|
+
} else if (repairingPostRenameLoss) {
|
|
588
|
+
const current = await getSidebarState(stateFile)
|
|
589
|
+
// A successor owns its fresh account/quota and recovery snapshots;
|
|
590
|
+
// this routing frame may only republish its decision and shared UI state.
|
|
591
|
+
stateToWrite = {
|
|
592
|
+
...current,
|
|
593
|
+
activeId: state.activeId,
|
|
594
|
+
route: state.route,
|
|
595
|
+
relay: state.relay,
|
|
596
|
+
fastMode: state.fastMode,
|
|
597
|
+
cacheKeep: state.cacheKeep,
|
|
598
|
+
lastUpdated: Math.max(current.lastUpdated, state.lastUpdated),
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
result = await writeSidebarStateAtomic(
|
|
602
|
+
stateFile,
|
|
603
|
+
stateToWrite,
|
|
604
|
+
lock.ownsLock,
|
|
605
|
+
)
|
|
606
|
+
} finally {
|
|
607
|
+
await lock.release()
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (result === 'lock-lost-after-rename') {
|
|
611
|
+
if (repairingPostRenameLoss) {
|
|
612
|
+
logger.warn(
|
|
613
|
+
'sidebar',
|
|
614
|
+
'post-rename repair lost lock; write skipped',
|
|
615
|
+
{ stateFile },
|
|
616
|
+
)
|
|
617
|
+
return
|
|
618
|
+
}
|
|
619
|
+
logger.warn(
|
|
620
|
+
'sidebar',
|
|
621
|
+
'state lock lost after rename; repairing write',
|
|
622
|
+
{
|
|
623
|
+
stateFile,
|
|
624
|
+
},
|
|
625
|
+
)
|
|
626
|
+
continue
|
|
627
|
+
}
|
|
628
|
+
if (result === 'lock-lost-before-rename') return
|
|
629
|
+
options.onRoutingResolved?.({
|
|
630
|
+
activeId: stateToWrite.activeId,
|
|
631
|
+
route: stateToWrite.route,
|
|
632
|
+
})
|
|
633
|
+
return
|
|
634
|
+
}
|
|
258
635
|
})
|
|
259
636
|
.catch(() => {
|
|
260
637
|
// Best-effort — sidebar is non-critical
|
|
@@ -25,6 +25,17 @@ export function buildKillswitchThresholdSeed(config, accountIds) {
|
|
|
25
25
|
}
|
|
26
26
|
return seedParts.join(' ');
|
|
27
27
|
}
|
|
28
|
+
export function buildAccountDialogOption(account) {
|
|
29
|
+
const pct = account.quotaPercent != null ? ` ${Math.round(account.quotaPercent)}%` : ' \u2013%';
|
|
30
|
+
const status = !account.enabled ? ' (disabled)' : '';
|
|
31
|
+
return {
|
|
32
|
+
title: `${account.label} [${account.role}]${status}${pct}`,
|
|
33
|
+
value: account.id,
|
|
34
|
+
...(account.tierLabel && {
|
|
35
|
+
description: account.tierLabel
|
|
36
|
+
})
|
|
37
|
+
};
|
|
38
|
+
}
|
|
28
39
|
function showText(api, text) {
|
|
29
40
|
api.ui.dialog.setSize('xlarge');
|
|
30
41
|
api.ui.dialog.replace(() => (() => {
|
|
@@ -54,6 +65,14 @@ export function openCommandDialog(api, payload, apply) {
|
|
|
54
65
|
title: 'Fallback first',
|
|
55
66
|
value: 'fallback-first',
|
|
56
67
|
description: 'Prefer fallback accounts, preserve main'
|
|
68
|
+
}, {
|
|
69
|
+
title: 'Sticky balanced',
|
|
70
|
+
value: 'sticky-balanced',
|
|
71
|
+
description: 'Balance new sessions by quota and keep each account sticky'
|
|
72
|
+
}, {
|
|
73
|
+
title: 'Reset this session',
|
|
74
|
+
value: 'reset',
|
|
75
|
+
description: 'Reassign this session on its next request'
|
|
57
76
|
}],
|
|
58
77
|
onSelect: option => {
|
|
59
78
|
void apply('claude-routing', String(option.value)).then(r => {
|
|
@@ -140,13 +159,13 @@ export function openCommandDialog(api, payload, apply) {
|
|
|
140
159
|
const DialogPrompt = api.ui.DialogPrompt;
|
|
141
160
|
api.ui.dialog.setSize('xlarge');
|
|
142
161
|
api.ui.dialog.replace(() => _$createComponent(DialogPrompt, {
|
|
143
|
-
title: "Claude cachekeep
|
|
162
|
+
title: "Claude cachekeep schedule",
|
|
144
163
|
description: () => (() => {
|
|
145
164
|
var _el$3 = _$createElement("text");
|
|
146
165
|
_$insert(_el$3, () => payload.text);
|
|
147
166
|
return _el$3;
|
|
148
167
|
})(),
|
|
149
|
-
placeholder: "HH-HH (e.g. 08-20) or 'off'",
|
|
168
|
+
placeholder: "'always', HH-HH (e.g. 08-20), or 'off'",
|
|
150
169
|
value: seed,
|
|
151
170
|
onConfirm: value => {
|
|
152
171
|
void apply('claude-cachekeep', value.trim()).then(r => {
|
|
@@ -231,14 +250,7 @@ export function openCommandDialog(api, payload, apply) {
|
|
|
231
250
|
title: 'Add account\u2026',
|
|
232
251
|
value: '__add__',
|
|
233
252
|
description: 'Add an API key or OAuth fallback account'
|
|
234
|
-
}, ...accounts.map(
|
|
235
|
-
const pct = a.quotaPercent != null ? ` ${Math.round(a.quotaPercent)}%` : ' \u2013%';
|
|
236
|
-
const status = !a.enabled ? ' (disabled)' : '';
|
|
237
|
-
return {
|
|
238
|
-
title: `${a.label} [${a.role}]${status}${pct}`,
|
|
239
|
-
value: a.id
|
|
240
|
-
};
|
|
241
|
-
})];
|
|
253
|
+
}, ...accounts.map(buildAccountDialogOption)];
|
|
242
254
|
api.ui.dialog.setSize('xlarge');
|
|
243
255
|
api.ui.dialog.replace(() => _$createComponent(DialogSelect, {
|
|
244
256
|
title: "Claude accounts",
|