@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.
- package/README.md +14 -6
- package/dist/cli.js +6 -6
- package/dist/fable-fallback.d.ts +2 -0
- package/dist/{index-bxs26mw4.js → index-0z4mmpxy.js} +1 -1
- package/dist/{index-qejbx8c9.js → index-5xsc5gwa.js} +1 -1
- package/dist/{index-qsaph2g4.js → index-dbczmr1v.js} +1 -1
- package/dist/{index-6gdd6e4s.js → index-epg96jpt.js} +1 -1
- package/dist/index-sgwz35qx.js +422 -0
- package/dist/{index-jkbar3k3.js → index-z9dywmf2.js} +1 -1
- package/dist/index.js +11 -20
- package/dist/{logger-vxnb5hjr.js → logger-ta3qm0a2.js} +1 -1
- package/dist/prompt-context.d.ts +5 -2
- 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.js +1 -1
- package/dist/tui-preferences.js +1 -1
- package/package.json +13 -10
- package/src/tui/command-dialogs.tsx +13 -2
- 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 +623 -0
- package/src/tui-compiled/tui-preferences.ts +370 -0
- package/src/tui-compiled/tui.tsx +1102 -0
- package/src/tui.tsx +16 -13
- package/dist/index-z03wayzs.js +0 -417
|
@@ -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
|
|
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
|
+
}
|