kaizenai 0.1.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/LICENSE +22 -0
- package/README.md +246 -0
- package/bin/kaizen +15 -0
- package/dist/client/apple-touch-icon.png +0 -0
- package/dist/client/assets/index-D-ORCGrq.js +603 -0
- package/dist/client/assets/index-r28mcHqz.css +32 -0
- package/dist/client/favicon.png +0 -0
- package/dist/client/fonts/body-medium.woff2 +0 -0
- package/dist/client/fonts/body-regular-italic.woff2 +0 -0
- package/dist/client/fonts/body-regular.woff2 +0 -0
- package/dist/client/fonts/body-semibold.woff2 +0 -0
- package/dist/client/index.html +22 -0
- package/dist/client/manifest-dark.webmanifest +24 -0
- package/dist/client/manifest.webmanifest +24 -0
- package/dist/client/pwa-192.png +0 -0
- package/dist/client/pwa-512.png +0 -0
- package/dist/client/pwa-icon.svg +15 -0
- package/dist/client/pwa-splash.png +0 -0
- package/dist/client/pwa-splash.svg +15 -0
- package/package.json +103 -0
- package/src/server/acp-shared.ts +315 -0
- package/src/server/agent.ts +1120 -0
- package/src/server/attachments.ts +133 -0
- package/src/server/backgrounds.ts +74 -0
- package/src/server/cli-runtime.ts +333 -0
- package/src/server/cli-supervisor.ts +81 -0
- package/src/server/cli.ts +68 -0
- package/src/server/codex-app-server-protocol.ts +453 -0
- package/src/server/codex-app-server.ts +1350 -0
- package/src/server/cursor-acp.ts +819 -0
- package/src/server/discovery.ts +322 -0
- package/src/server/event-store.ts +1369 -0
- package/src/server/events.ts +244 -0
- package/src/server/external-open.ts +272 -0
- package/src/server/gemini-acp.ts +844 -0
- package/src/server/gemini-cli.ts +525 -0
- package/src/server/generate-title.ts +36 -0
- package/src/server/git-manager.ts +79 -0
- package/src/server/git-repository.ts +101 -0
- package/src/server/harness-types.ts +20 -0
- package/src/server/keybindings.ts +177 -0
- package/src/server/machine-name.ts +22 -0
- package/src/server/paths.ts +112 -0
- package/src/server/process-utils.ts +22 -0
- package/src/server/project-icon.ts +344 -0
- package/src/server/project-metadata.ts +10 -0
- package/src/server/provider-catalog.ts +85 -0
- package/src/server/provider-settings.ts +155 -0
- package/src/server/quick-response.ts +153 -0
- package/src/server/read-models.ts +275 -0
- package/src/server/recovery.ts +507 -0
- package/src/server/restart.ts +30 -0
- package/src/server/server.ts +244 -0
- package/src/server/terminal-manager.ts +350 -0
- package/src/server/theme-settings.ts +179 -0
- package/src/server/update-manager.ts +230 -0
- package/src/server/usage/base-provider-usage.ts +57 -0
- package/src/server/usage/claude-usage.ts +558 -0
- package/src/server/usage/codex-usage.ts +144 -0
- package/src/server/usage/cursor-browser.ts +120 -0
- package/src/server/usage/cursor-cookies.ts +390 -0
- package/src/server/usage/cursor-usage.ts +490 -0
- package/src/server/usage/gemini-usage.ts +24 -0
- package/src/server/usage/provider-usage.ts +61 -0
- package/src/server/usage/test-helpers.ts +9 -0
- package/src/server/usage/types.ts +54 -0
- package/src/server/usage/utils.ts +325 -0
- package/src/server/ws-router.ts +717 -0
- package/src/shared/branding.ts +83 -0
- package/src/shared/dev-ports.ts +43 -0
- package/src/shared/ports.ts +2 -0
- package/src/shared/protocol.ts +152 -0
- package/src/shared/tools.ts +251 -0
- package/src/shared/types.ts +1028 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import process from "node:process"
|
|
5
|
+
import type { ChatUsageSnapshot, ProviderUsageEntry, TranscriptEntry } from "../../shared/types"
|
|
6
|
+
import { CLAUDE_CONTEXT_WINDOW_FALLBACKS } from "../../shared/types"
|
|
7
|
+
import { BaseProviderUsage } from "./base-provider-usage"
|
|
8
|
+
import type { ClaudeRateLimitCacheSnapshot, ClaudeRateLimitInfo } from "./types"
|
|
9
|
+
import {
|
|
10
|
+
asRecord,
|
|
11
|
+
buildSnapshot,
|
|
12
|
+
estimateCurrentThreadTokens,
|
|
13
|
+
parseJsonLine,
|
|
14
|
+
relevantMessagesForCurrentContext,
|
|
15
|
+
snapshotToEntry,
|
|
16
|
+
toNumber,
|
|
17
|
+
toPercent,
|
|
18
|
+
usageTotals,
|
|
19
|
+
usageWarnings,
|
|
20
|
+
} from "./utils"
|
|
21
|
+
|
|
22
|
+
const PROVIDER_CACHE_TTL_MS = 30_000
|
|
23
|
+
const PROVIDER_USAGE_REQUEST_MIN_INTERVAL_MS = 30 * 60 * 1000
|
|
24
|
+
|
|
25
|
+
function extractClaudeUsageRecord(entry: TranscriptEntry) {
|
|
26
|
+
if (!entry.debugRaw) return null
|
|
27
|
+
const raw = parseJsonLine(entry.debugRaw)
|
|
28
|
+
if (!raw) return null
|
|
29
|
+
|
|
30
|
+
const type = raw.type
|
|
31
|
+
if (type !== "assistant" && type !== "result") return null
|
|
32
|
+
|
|
33
|
+
if (type === "assistant") {
|
|
34
|
+
const message = asRecord(raw.message)
|
|
35
|
+
const usage = asRecord(message?.usage)
|
|
36
|
+
if (!usage) return null
|
|
37
|
+
|
|
38
|
+
const usageTotalsRecord = usageTotals(usage)
|
|
39
|
+
return {
|
|
40
|
+
key: typeof raw.uuid === "string" ? raw.uuid : entry.messageId ?? entry._id,
|
|
41
|
+
updatedAt: entry.createdAt,
|
|
42
|
+
totals: usageTotalsRecord,
|
|
43
|
+
contextWindowTokens: null,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const usage = asRecord(raw.usage)
|
|
48
|
+
const modelUsage = asRecord(raw.modelUsage)
|
|
49
|
+
const firstModel = modelUsage ? Object.values(modelUsage).map((value) => asRecord(value)).find(Boolean) ?? null : null
|
|
50
|
+
if (!usage && !firstModel) return null
|
|
51
|
+
|
|
52
|
+
const usageTotalsRecord = usage ? usageTotals(usage) : {
|
|
53
|
+
inputTokens: toNumber(firstModel?.inputTokens) ?? 0,
|
|
54
|
+
outputTokens: toNumber(firstModel?.outputTokens) ?? 0,
|
|
55
|
+
cachedInputTokens:
|
|
56
|
+
(toNumber(firstModel?.cacheReadInputTokens) ?? 0)
|
|
57
|
+
+ (toNumber(firstModel?.cacheCreationInputTokens) ?? 0),
|
|
58
|
+
reasoningOutputTokens: 0,
|
|
59
|
+
totalTokens:
|
|
60
|
+
(toNumber(firstModel?.inputTokens) ?? 0)
|
|
61
|
+
+ (toNumber(firstModel?.outputTokens) ?? 0)
|
|
62
|
+
+ (toNumber(firstModel?.cacheReadInputTokens) ?? 0)
|
|
63
|
+
+ (toNumber(firstModel?.cacheCreationInputTokens) ?? 0),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
key: typeof raw.uuid === "string" ? raw.uuid : entry.messageId ?? entry._id,
|
|
68
|
+
updatedAt: entry.createdAt,
|
|
69
|
+
totals: usageTotalsRecord,
|
|
70
|
+
contextWindowTokens: toNumber(firstModel?.contextWindow),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function reconstructClaudeUsage(
|
|
75
|
+
messages: TranscriptEntry[],
|
|
76
|
+
liveRateLimit?: ClaudeRateLimitInfo | null
|
|
77
|
+
): ChatUsageSnapshot | null {
|
|
78
|
+
const relevantMessages = relevantMessagesForCurrentContext(messages)
|
|
79
|
+
const deduped = new Map<string, ReturnType<typeof extractClaudeUsageRecord>>()
|
|
80
|
+
|
|
81
|
+
for (const entry of relevantMessages) {
|
|
82
|
+
const usageRecord = extractClaudeUsageRecord(entry)
|
|
83
|
+
if (!usageRecord) continue
|
|
84
|
+
deduped.set(usageRecord.key, usageRecord)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const latest = [...deduped.values()]
|
|
88
|
+
.filter((value): value is NonNullable<typeof value> => Boolean(value))
|
|
89
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)[0]
|
|
90
|
+
|
|
91
|
+
if (!latest && liveRateLimit?.percent == null) {
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const latestModel = [...relevantMessages]
|
|
96
|
+
.reverse()
|
|
97
|
+
.find((entry): entry is Extract<TranscriptEntry, { kind: "system_init" }> =>
|
|
98
|
+
entry.kind === "system_init" && entry.provider === "claude"
|
|
99
|
+
)
|
|
100
|
+
const fallbackContextWindow = latestModel
|
|
101
|
+
? CLAUDE_CONTEXT_WINDOW_FALLBACKS[latestModel.model.toLowerCase()] ?? null
|
|
102
|
+
: null
|
|
103
|
+
|
|
104
|
+
return buildSnapshot({
|
|
105
|
+
provider: "claude",
|
|
106
|
+
threadTokens: estimateCurrentThreadTokens(messages),
|
|
107
|
+
contextWindowTokens: latest?.contextWindowTokens ?? fallbackContextWindow,
|
|
108
|
+
lastTurnTokens: latest?.totals.totalTokens ?? null,
|
|
109
|
+
inputTokens: latest?.totals.inputTokens ?? null,
|
|
110
|
+
outputTokens: latest?.totals.outputTokens ?? null,
|
|
111
|
+
cachedInputTokens: latest?.totals.cachedInputTokens ?? null,
|
|
112
|
+
reasoningOutputTokens: latest?.totals.reasoningOutputTokens ?? null,
|
|
113
|
+
sessionLimitUsedPercent: liveRateLimit?.percent ?? null,
|
|
114
|
+
rateLimitResetAt: liveRateLimit?.resetsAt ?? null,
|
|
115
|
+
source: latest ? "reconstructed" : "live",
|
|
116
|
+
updatedAt: latest?.updatedAt ?? null,
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function createClaudeRateLimitSnapshot(percent: number | null, resetsAt: number | null): ChatUsageSnapshot | null {
|
|
121
|
+
return buildSnapshot({
|
|
122
|
+
provider: "claude",
|
|
123
|
+
threadTokens: null,
|
|
124
|
+
contextWindowTokens: null,
|
|
125
|
+
lastTurnTokens: null,
|
|
126
|
+
inputTokens: null,
|
|
127
|
+
outputTokens: null,
|
|
128
|
+
cachedInputTokens: null,
|
|
129
|
+
reasoningOutputTokens: null,
|
|
130
|
+
sessionLimitUsedPercent: percent,
|
|
131
|
+
rateLimitResetAt: resetsAt,
|
|
132
|
+
source: "live",
|
|
133
|
+
updatedAt: Date.now(),
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function stripAnsi(text: string) {
|
|
138
|
+
return text
|
|
139
|
+
.replace(/\u001b\][^\u0007]*\u0007/g, "")
|
|
140
|
+
.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
141
|
+
.replace(/\u001b[@-_]/g, "")
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function normalizeClaudeScreenText(text: string) {
|
|
145
|
+
return stripAnsi(text)
|
|
146
|
+
.replace(/\r/g, "\n")
|
|
147
|
+
.replace(/[^\S\n]+/g, " ")
|
|
148
|
+
.replace(/\n+/g, "\n")
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function normalizeResetLabel(label: string | null) {
|
|
152
|
+
if (!label) return null
|
|
153
|
+
return label
|
|
154
|
+
.replace(/\b([A-Za-z]{3})(\d)/g, "$1 $2")
|
|
155
|
+
.replace(/,(\S)/g, ", $1")
|
|
156
|
+
.trim()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function looksLikeClaudeWeeklyResetLabel(label: string | null) {
|
|
160
|
+
if (!label) return false
|
|
161
|
+
return /\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b/i.test(label)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function parseClaudeUsageScreen(text: string): {
|
|
165
|
+
sessionLimitUsedPercent: number | null
|
|
166
|
+
rateLimitResetLabel: string | null
|
|
167
|
+
weeklyLimitUsedPercent: number | null
|
|
168
|
+
weeklyRateLimitResetLabel: string | null
|
|
169
|
+
} | null {
|
|
170
|
+
const normalized = normalizeClaudeScreenText(text)
|
|
171
|
+
const lines = normalized
|
|
172
|
+
.split("\n")
|
|
173
|
+
.map((line) => line.trim())
|
|
174
|
+
.filter(Boolean)
|
|
175
|
+
|
|
176
|
+
const compactLines = lines.map((line) => line.replace(/\s+/g, "").toLowerCase())
|
|
177
|
+
const parseSection = (pattern: RegExp) => {
|
|
178
|
+
const sectionIndex = compactLines.findIndex((line) => pattern.test(line))
|
|
179
|
+
if (sectionIndex === -1) return null
|
|
180
|
+
|
|
181
|
+
let percent: number | null = null
|
|
182
|
+
let resetLabel: string | null = null
|
|
183
|
+
|
|
184
|
+
for (let index = sectionIndex; index < Math.min(lines.length, sectionIndex + 6); index += 1) {
|
|
185
|
+
const compact = compactLines[index] ?? ""
|
|
186
|
+
if (percent === null) {
|
|
187
|
+
const match = compact.match(/(\d{1,3})%used/)
|
|
188
|
+
if (match) {
|
|
189
|
+
percent = Number.parseInt(match[1] ?? "", 10)
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (resetLabel === null && /^res\w*/.test(compact)) {
|
|
195
|
+
resetLabel = lines[index]?.replace(/^Res(?:ets?|es)?\s*/i, "").trim() ?? null
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!Number.isFinite(percent)) return null
|
|
200
|
+
return { percent: toPercent(percent), resetLabel }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const currentSession = parseSection(/cur\w*session/)
|
|
204
|
+
const currentWeek = parseSection(/current\w*week/)
|
|
205
|
+
if (!currentSession && !currentWeek) return null
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
sessionLimitUsedPercent: currentSession?.percent ?? null,
|
|
209
|
+
rateLimitResetLabel: normalizeResetLabel(currentSession?.resetLabel ?? null),
|
|
210
|
+
weeklyLimitUsedPercent: currentWeek?.percent ?? null,
|
|
211
|
+
weeklyRateLimitResetLabel: normalizeResetLabel(currentWeek?.resetLabel ?? null),
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function claudeUsageCollectorScript() {
|
|
216
|
+
return [
|
|
217
|
+
"import os, pty, select, subprocess, time, sys",
|
|
218
|
+
"cwd = os.environ.get('CLAUDE_USAGE_CWD') or None",
|
|
219
|
+
"command = ['claude']",
|
|
220
|
+
"if os.environ.get('CLAUDE_USAGE_CONTINUE') == '1':",
|
|
221
|
+
" command.append('-c')",
|
|
222
|
+
"master, slave = pty.openpty()",
|
|
223
|
+
"proc = subprocess.Popen(command, cwd=cwd, stdin=slave, stdout=slave, stderr=slave, close_fds=True)",
|
|
224
|
+
"os.close(slave)",
|
|
225
|
+
"buf = bytearray()",
|
|
226
|
+
"try:",
|
|
227
|
+
" ready_deadline = time.time() + 15",
|
|
228
|
+
" ready_seen_at = None",
|
|
229
|
+
" while time.time() < ready_deadline:",
|
|
230
|
+
" r, _, _ = select.select([master], [], [], 0.5)",
|
|
231
|
+
" if master not in r:",
|
|
232
|
+
" if ready_seen_at is not None and time.time() - ready_seen_at > 0.7:",
|
|
233
|
+
" break",
|
|
234
|
+
" continue",
|
|
235
|
+
" try:",
|
|
236
|
+
" data = os.read(master, 65536)",
|
|
237
|
+
" except OSError:",
|
|
238
|
+
" break",
|
|
239
|
+
" if not data:",
|
|
240
|
+
" break",
|
|
241
|
+
" buf.extend(data)",
|
|
242
|
+
" if b'/effort' in buf:",
|
|
243
|
+
" ready_seen_at = time.time()",
|
|
244
|
+
" os.write(master, b'/usage\\r')",
|
|
245
|
+
" deadline = time.time() + 8",
|
|
246
|
+
" while time.time() < deadline:",
|
|
247
|
+
" r, _, _ = select.select([master], [], [], 0.5)",
|
|
248
|
+
" if master not in r:",
|
|
249
|
+
" continue",
|
|
250
|
+
" try:",
|
|
251
|
+
" data = os.read(master, 65536)",
|
|
252
|
+
" except OSError:",
|
|
253
|
+
" break",
|
|
254
|
+
" if not data:",
|
|
255
|
+
" break",
|
|
256
|
+
" buf.extend(data)",
|
|
257
|
+
" week_pos = buf.find(b'Current week')",
|
|
258
|
+
" if week_pos >= 0 and b'used' in buf[week_pos:]:",
|
|
259
|
+
" break",
|
|
260
|
+
" try: os.write(master, b'\\x1b')",
|
|
261
|
+
" except OSError: pass",
|
|
262
|
+
" time.sleep(0.2)",
|
|
263
|
+
" try: os.write(master, b'\\x03')",
|
|
264
|
+
" except OSError: pass",
|
|
265
|
+
" time.sleep(0.2)",
|
|
266
|
+
"finally:",
|
|
267
|
+
" try: proc.terminate()",
|
|
268
|
+
" except ProcessLookupError: pass",
|
|
269
|
+
" try: proc.wait(timeout=2)",
|
|
270
|
+
" except Exception:",
|
|
271
|
+
" try: proc.kill()",
|
|
272
|
+
" except ProcessLookupError: pass",
|
|
273
|
+
"sys.stdout.write(buf.decode('utf-8', 'ignore'))",
|
|
274
|
+
].join("\n")
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function collectClaudeUsageScreen(args?: { cwd?: string; continueSession?: boolean }) {
|
|
278
|
+
const result = Bun.spawnSync(["python3", "-c", claudeUsageCollectorScript()], {
|
|
279
|
+
stdin: "ignore",
|
|
280
|
+
stdout: "pipe",
|
|
281
|
+
stderr: "pipe",
|
|
282
|
+
cwd: args?.cwd,
|
|
283
|
+
env: {
|
|
284
|
+
...process.env,
|
|
285
|
+
...(args?.cwd ? { CLAUDE_USAGE_CWD: args.cwd } : {}),
|
|
286
|
+
CLAUDE_USAGE_CONTINUE: args?.continueSession ? "1" : "0",
|
|
287
|
+
},
|
|
288
|
+
})
|
|
289
|
+
return new TextDecoder().decode(result.stdout)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isRunningPid(pid: number) {
|
|
293
|
+
try {
|
|
294
|
+
process.kill(pid, 0)
|
|
295
|
+
return true
|
|
296
|
+
} catch {
|
|
297
|
+
return false
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function findRunningClaudeSessions(): Array<{ cwd: string; sessionId: string; startedAt: number }> {
|
|
302
|
+
const sessionsDir = path.join(homedir(), ".claude", "sessions")
|
|
303
|
+
if (!existsSync(sessionsDir)) return []
|
|
304
|
+
|
|
305
|
+
const sessions: Array<{ cwd: string; sessionId: string; startedAt: number }> = []
|
|
306
|
+
for (const entry of readdirSync(sessionsDir, { withFileTypes: true })) {
|
|
307
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue
|
|
308
|
+
try {
|
|
309
|
+
const data = JSON.parse(readFileSync(path.join(sessionsDir, entry.name), "utf8"))
|
|
310
|
+
const pid = typeof data.pid === "number" ? data.pid : null
|
|
311
|
+
const cwd = typeof data.cwd === "string" ? data.cwd : null
|
|
312
|
+
const sessionId = typeof data.sessionId === "string" ? data.sessionId : null
|
|
313
|
+
const startedAt = typeof data.startedAt === "number" ? data.startedAt : 0
|
|
314
|
+
if (!pid || !cwd || !sessionId) continue
|
|
315
|
+
if (!isRunningPid(pid)) continue
|
|
316
|
+
sessions.push({ cwd, sessionId, startedAt })
|
|
317
|
+
} catch {
|
|
318
|
+
continue
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return sessions.sort((a, b) => b.startedAt - a.startedAt)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function claudeRateLimitPath(dataDir: string) {
|
|
326
|
+
return path.join(dataDir, "claude-rate-limit.json")
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function hasClaudeSidebarRateLimitData(snapshot: ChatUsageSnapshot | null): snapshot is ClaudeRateLimitCacheSnapshot {
|
|
330
|
+
if (!snapshot || snapshot.provider !== "claude") return false
|
|
331
|
+
const claudeSnapshot = snapshot as ClaudeRateLimitCacheSnapshot
|
|
332
|
+
return Boolean(
|
|
333
|
+
claudeSnapshot.rateLimitResetLabel
|
|
334
|
+
|| claudeSnapshot.weeklyLimitUsedPercent !== undefined
|
|
335
|
+
|| claudeSnapshot.weeklyRateLimitResetAt !== undefined
|
|
336
|
+
|| claudeSnapshot.weeklyRateLimitResetLabel !== undefined
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function mergeClaudeProviderSnapshot(
|
|
341
|
+
liveSnapshot: ChatUsageSnapshot | null,
|
|
342
|
+
persistedSnapshot: ChatUsageSnapshot | null
|
|
343
|
+
): ChatUsageSnapshot | null {
|
|
344
|
+
if (hasClaudeSidebarRateLimitData(persistedSnapshot)) {
|
|
345
|
+
if (!liveSnapshot) return persistedSnapshot
|
|
346
|
+
|
|
347
|
+
const merged = {
|
|
348
|
+
...liveSnapshot,
|
|
349
|
+
...persistedSnapshot,
|
|
350
|
+
source: persistedSnapshot.source,
|
|
351
|
+
updatedAt: Math.max(liveSnapshot.updatedAt ?? 0, persistedSnapshot.updatedAt ?? 0) || null,
|
|
352
|
+
} as ClaudeRateLimitCacheSnapshot
|
|
353
|
+
merged.sessionLimitUsedPercent = persistedSnapshot.sessionLimitUsedPercent
|
|
354
|
+
merged.rateLimitResetAt = persistedSnapshot.rateLimitResetAt
|
|
355
|
+
merged.rateLimitResetLabel = persistedSnapshot.rateLimitResetLabel ?? null
|
|
356
|
+
merged.weeklyLimitUsedPercent = persistedSnapshot.weeklyLimitUsedPercent ?? null
|
|
357
|
+
merged.weeklyRateLimitResetAt = persistedSnapshot.weeklyRateLimitResetAt ?? null
|
|
358
|
+
merged.weeklyRateLimitResetLabel = persistedSnapshot.weeklyRateLimitResetLabel ?? null
|
|
359
|
+
merged.warnings = usageWarnings({
|
|
360
|
+
contextUsedPercent: null,
|
|
361
|
+
sessionLimitUsedPercent: merged.sessionLimitUsedPercent,
|
|
362
|
+
updatedAt: merged.updatedAt,
|
|
363
|
+
})
|
|
364
|
+
return merged
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return liveSnapshot ?? persistedSnapshot
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function createClaudeRateLimitCacheSnapshot(args: {
|
|
371
|
+
sessionLimitUsedPercent: number | null
|
|
372
|
+
rateLimitResetAt: number | null
|
|
373
|
+
rateLimitResetLabel?: string | null
|
|
374
|
+
weeklyLimitUsedPercent?: number | null
|
|
375
|
+
weeklyRateLimitResetAt?: number | null
|
|
376
|
+
weeklyRateLimitResetLabel?: string | null
|
|
377
|
+
updatedAt?: number | null
|
|
378
|
+
}): ClaudeRateLimitCacheSnapshot | null {
|
|
379
|
+
const basePercent = args.sessionLimitUsedPercent ?? args.weeklyLimitUsedPercent ?? null
|
|
380
|
+
const snapshot = createClaudeRateLimitSnapshot(basePercent, args.rateLimitResetAt) as ClaudeRateLimitCacheSnapshot | null
|
|
381
|
+
if (!snapshot) return null
|
|
382
|
+
|
|
383
|
+
snapshot.sessionLimitUsedPercent = args.sessionLimitUsedPercent
|
|
384
|
+
snapshot.rateLimitResetAt = args.rateLimitResetAt
|
|
385
|
+
snapshot.rateLimitResetLabel = args.rateLimitResetLabel ?? null
|
|
386
|
+
snapshot.weeklyLimitUsedPercent = args.weeklyLimitUsedPercent ?? null
|
|
387
|
+
snapshot.weeklyRateLimitResetAt = args.weeklyRateLimitResetAt ?? null
|
|
388
|
+
snapshot.weeklyRateLimitResetLabel = args.weeklyRateLimitResetLabel ?? null
|
|
389
|
+
snapshot.updatedAt = args.updatedAt ?? snapshot.updatedAt
|
|
390
|
+
snapshot.warnings = usageWarnings({
|
|
391
|
+
contextUsedPercent: null,
|
|
392
|
+
sessionLimitUsedPercent: snapshot.sessionLimitUsedPercent,
|
|
393
|
+
updatedAt: snapshot.updatedAt,
|
|
394
|
+
})
|
|
395
|
+
return snapshot
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export class ClaudeUsage extends BaseProviderUsage {
|
|
399
|
+
readonly provider = "claude" as const
|
|
400
|
+
private fileCache: { snapshot: ChatUsageSnapshot | null; cachedAt: number } | null = null
|
|
401
|
+
private refreshInFlight: Promise<ChatUsageSnapshot | null> | null = null
|
|
402
|
+
|
|
403
|
+
private persistRateLimit(snapshot: ChatUsageSnapshot) {
|
|
404
|
+
try {
|
|
405
|
+
writeFileSync(claudeRateLimitPath(this.dataDir), JSON.stringify({
|
|
406
|
+
sessionLimitUsedPercent: snapshot.sessionLimitUsedPercent,
|
|
407
|
+
rateLimitResetAt: snapshot.rateLimitResetAt,
|
|
408
|
+
rateLimitResetLabel: (snapshot as ClaudeRateLimitCacheSnapshot).rateLimitResetLabel ?? null,
|
|
409
|
+
weeklyLimitUsedPercent: (snapshot as ClaudeRateLimitCacheSnapshot).weeklyLimitUsedPercent ?? null,
|
|
410
|
+
weeklyRateLimitResetAt: (snapshot as ClaudeRateLimitCacheSnapshot).weeklyRateLimitResetAt ?? null,
|
|
411
|
+
weeklyRateLimitResetLabel: (snapshot as ClaudeRateLimitCacheSnapshot).weeklyRateLimitResetLabel ?? null,
|
|
412
|
+
updatedAt: snapshot.updatedAt,
|
|
413
|
+
}))
|
|
414
|
+
} catch {
|
|
415
|
+
// best-effort
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
loadPersistedSnapshot(): ChatUsageSnapshot | null {
|
|
420
|
+
const now = Date.now()
|
|
421
|
+
if (this.fileCache && now - this.fileCache.cachedAt < PROVIDER_CACHE_TTL_MS) {
|
|
422
|
+
return this.fileCache.snapshot
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
const filePath = claudeRateLimitPath(this.dataDir)
|
|
427
|
+
if (!existsSync(filePath)) return null
|
|
428
|
+
const data = JSON.parse(readFileSync(filePath, "utf8"))
|
|
429
|
+
let percent = typeof data.sessionLimitUsedPercent === "number" ? data.sessionLimitUsedPercent : null
|
|
430
|
+
const resetsAt = typeof data.rateLimitResetAt === "number" ? data.rateLimitResetAt : null
|
|
431
|
+
let resetLabel = typeof data.rateLimitResetLabel === "string" ? data.rateLimitResetLabel : null
|
|
432
|
+
let weeklyPercent = typeof data.weeklyLimitUsedPercent === "number" ? data.weeklyLimitUsedPercent : null
|
|
433
|
+
const weeklyResetsAt = typeof data.weeklyRateLimitResetAt === "number" ? data.weeklyRateLimitResetAt : null
|
|
434
|
+
let weeklyResetLabel = typeof data.weeklyRateLimitResetLabel === "string" ? data.weeklyRateLimitResetLabel : null
|
|
435
|
+
const persistedAt = typeof data.updatedAt === "number" ? data.updatedAt : null
|
|
436
|
+
|
|
437
|
+
if (weeklyPercent === null && weeklyResetsAt === null && weeklyResetLabel === null && looksLikeClaudeWeeklyResetLabel(resetLabel)) {
|
|
438
|
+
weeklyPercent = percent
|
|
439
|
+
weeklyResetLabel = resetLabel
|
|
440
|
+
percent = null
|
|
441
|
+
resetLabel = null
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (percent === null && resetsAt === null && weeklyPercent === null && weeklyResetsAt === null) return null
|
|
445
|
+
|
|
446
|
+
const snapshot = createClaudeRateLimitCacheSnapshot({
|
|
447
|
+
sessionLimitUsedPercent: percent,
|
|
448
|
+
rateLimitResetAt: resetsAt,
|
|
449
|
+
rateLimitResetLabel: resetLabel,
|
|
450
|
+
weeklyLimitUsedPercent: weeklyPercent,
|
|
451
|
+
weeklyRateLimitResetAt: weeklyResetsAt,
|
|
452
|
+
weeklyRateLimitResetLabel: weeklyResetLabel,
|
|
453
|
+
updatedAt: persistedAt,
|
|
454
|
+
})
|
|
455
|
+
this.fileCache = { snapshot, cachedAt: now }
|
|
456
|
+
return snapshot
|
|
457
|
+
} catch {
|
|
458
|
+
this.fileCache = { snapshot: null, cachedAt: now }
|
|
459
|
+
return null
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
loadPersistedEntry(): ProviderUsageEntry | null {
|
|
464
|
+
return snapshotToEntry(this.provider, this.loadPersistedSnapshot())
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
deriveEntry(liveSnapshot: ChatUsageSnapshot | null): ProviderUsageEntry {
|
|
468
|
+
const mergedSnapshot = mergeClaudeProviderSnapshot(liveSnapshot, this.loadPersistedSnapshot())
|
|
469
|
+
if (hasClaudeSidebarRateLimitData(mergedSnapshot)) {
|
|
470
|
+
this.persistRateLimit(mergedSnapshot)
|
|
471
|
+
}
|
|
472
|
+
return snapshotToEntry(this.provider, mergedSnapshot)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async refreshFromCli(runCommand?: () => Promise<string>, force = false): Promise<ChatUsageSnapshot | null> {
|
|
476
|
+
if (!runCommand) {
|
|
477
|
+
if (this.refreshInFlight) {
|
|
478
|
+
return this.refreshInFlight
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (this.shouldSkipRefresh(this.provider, PROVIDER_USAGE_REQUEST_MIN_INTERVAL_MS, force)) {
|
|
482
|
+
return this.loadPersistedSnapshot()
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
this.recordRequestTime(this.provider)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const performRefresh = async () => {
|
|
489
|
+
let parsed: ReturnType<typeof parseClaudeUsageScreen> = null
|
|
490
|
+
|
|
491
|
+
if (runCommand) {
|
|
492
|
+
parsed = parseClaudeUsageScreen(await runCommand())
|
|
493
|
+
} else {
|
|
494
|
+
const liveSessions = findRunningClaudeSessions()
|
|
495
|
+
let best: ReturnType<typeof parseClaudeUsageScreen> = null
|
|
496
|
+
|
|
497
|
+
for (const session of liveSessions) {
|
|
498
|
+
const candidate = parseClaudeUsageScreen(collectClaudeUsageScreen({
|
|
499
|
+
cwd: session.cwd,
|
|
500
|
+
continueSession: true,
|
|
501
|
+
}))
|
|
502
|
+
if (!candidate?.sessionLimitUsedPercent && candidate?.sessionLimitUsedPercent !== 0) continue
|
|
503
|
+
if (!best || (candidate.sessionLimitUsedPercent ?? -1) > (best.sessionLimitUsedPercent ?? -1)) {
|
|
504
|
+
best = candidate
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
parsed = best ?? parseClaudeUsageScreen(collectClaudeUsageScreen())
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (!parsed) return null
|
|
512
|
+
|
|
513
|
+
const snapshot = createClaudeRateLimitCacheSnapshot({
|
|
514
|
+
sessionLimitUsedPercent: parsed.sessionLimitUsedPercent,
|
|
515
|
+
rateLimitResetAt: null,
|
|
516
|
+
rateLimitResetLabel: parsed.rateLimitResetLabel,
|
|
517
|
+
weeklyLimitUsedPercent: parsed.weeklyLimitUsedPercent,
|
|
518
|
+
weeklyRateLimitResetAt: null,
|
|
519
|
+
weeklyRateLimitResetLabel: parsed.weeklyRateLimitResetLabel,
|
|
520
|
+
updatedAt: Date.now(),
|
|
521
|
+
})
|
|
522
|
+
if (!snapshot) return null
|
|
523
|
+
this.persistRateLimit(snapshot)
|
|
524
|
+
this.fileCache = { snapshot, cachedAt: Date.now() }
|
|
525
|
+
return snapshot
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (runCommand) {
|
|
529
|
+
return performRefresh()
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
this.refreshInFlight = performRefresh().finally(() => {
|
|
533
|
+
this.refreshInFlight = null
|
|
534
|
+
})
|
|
535
|
+
return this.refreshInFlight
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const _instances = new Map<string, ClaudeUsage>()
|
|
540
|
+
|
|
541
|
+
export function getClaudeUsage(dataDir: string): ClaudeUsage {
|
|
542
|
+
if (!_instances.has(dataDir)) {
|
|
543
|
+
_instances.set(dataDir, new ClaudeUsage(dataDir))
|
|
544
|
+
}
|
|
545
|
+
return _instances.get(dataDir)!
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export async function refreshClaudeRateLimitFromCli(
|
|
549
|
+
dataDir: string,
|
|
550
|
+
runCommand?: () => Promise<string>,
|
|
551
|
+
force?: boolean
|
|
552
|
+
): Promise<ChatUsageSnapshot | null> {
|
|
553
|
+
return getClaudeUsage(dataDir).refreshFromCli(runCommand, force)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function resetClaudeUsageCaches() {
|
|
557
|
+
_instances.clear()
|
|
558
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import type { ChatUsageSnapshot, ProviderUsageEntry } from "../../shared/types"
|
|
5
|
+
import type { EventStore } from "../event-store"
|
|
6
|
+
import { BaseProviderUsage } from "./base-provider-usage"
|
|
7
|
+
import type { ProviderRateLimitSnapshot } from "./types"
|
|
8
|
+
import { asRecord, buildSnapshot, parseJsonLine, snapshotToEntry, toNumber } from "./utils"
|
|
9
|
+
|
|
10
|
+
const PROVIDER_CACHE_TTL_MS = 30_000
|
|
11
|
+
|
|
12
|
+
function findCodexSessionFile(sessionToken: string, sessionsDir = path.join(homedir(), ".codex", "sessions")): string | null {
|
|
13
|
+
if (!existsSync(sessionsDir)) {
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const stack = [sessionsDir]
|
|
18
|
+
while (stack.length > 0) {
|
|
19
|
+
const current = stack.pop()!
|
|
20
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
21
|
+
const fullPath = path.join(current, entry.name)
|
|
22
|
+
if (entry.isDirectory()) {
|
|
23
|
+
stack.push(fullPath)
|
|
24
|
+
continue
|
|
25
|
+
}
|
|
26
|
+
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue
|
|
27
|
+
if (entry.name.includes(sessionToken)) {
|
|
28
|
+
return fullPath
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const firstLine = readFileSync(fullPath, "utf8").split("\n", 1)[0]
|
|
32
|
+
const record = parseJsonLine(firstLine)
|
|
33
|
+
const payload = asRecord(record?.payload)
|
|
34
|
+
if (record?.type === "session_meta" && payload?.id === sessionToken) {
|
|
35
|
+
return fullPath
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function reconstructCodexUsageFromFile(
|
|
44
|
+
sessionToken: string,
|
|
45
|
+
sessionsDir = path.join(homedir(), ".codex", "sessions")
|
|
46
|
+
): ChatUsageSnapshot | null {
|
|
47
|
+
const sessionFile = findCodexSessionFile(sessionToken, sessionsDir)
|
|
48
|
+
if (!sessionFile || !existsSync(sessionFile)) {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let latestUsage: ProviderRateLimitSnapshot | null = null
|
|
53
|
+
for (const line of readFileSync(sessionFile, "utf8").split("\n")) {
|
|
54
|
+
if (!line.trim()) continue
|
|
55
|
+
const record = parseJsonLine(line)
|
|
56
|
+
if (!record || record.type !== "event_msg") continue
|
|
57
|
+
const payload = asRecord(record.payload)
|
|
58
|
+
if (!payload || payload.type !== "token_count") continue
|
|
59
|
+
const info = asRecord(payload.info)
|
|
60
|
+
const totalTokenUsage = asRecord(info?.total_token_usage)
|
|
61
|
+
const lastTokenUsage = asRecord(info?.last_token_usage)
|
|
62
|
+
const rateLimits = asRecord(payload.rate_limits)
|
|
63
|
+
const primary = asRecord(rateLimits?.primary)
|
|
64
|
+
const secondary = asRecord(rateLimits?.secondary)
|
|
65
|
+
const updatedAt = typeof record.timestamp === "string" ? Date.parse(record.timestamp) : Date.now()
|
|
66
|
+
const primaryWindowMinutes = toNumber(primary?.window_minutes)
|
|
67
|
+
const primaryIsWeekly = primaryWindowMinutes !== null && primaryWindowMinutes >= 10_080
|
|
68
|
+
const weeklyLimit = secondary ?? (primaryIsWeekly ? primary : null)
|
|
69
|
+
|
|
70
|
+
latestUsage = buildSnapshot({
|
|
71
|
+
provider: "codex",
|
|
72
|
+
threadTokens: null,
|
|
73
|
+
contextWindowTokens: toNumber(info?.model_context_window),
|
|
74
|
+
lastTurnTokens: toNumber(lastTokenUsage?.total_tokens),
|
|
75
|
+
inputTokens: toNumber(totalTokenUsage?.input_tokens),
|
|
76
|
+
outputTokens: toNumber(totalTokenUsage?.output_tokens),
|
|
77
|
+
cachedInputTokens: toNumber(totalTokenUsage?.cached_input_tokens),
|
|
78
|
+
reasoningOutputTokens: toNumber(totalTokenUsage?.reasoning_output_tokens),
|
|
79
|
+
sessionLimitUsedPercent: primaryIsWeekly ? null : toNumber(primary?.used_percent),
|
|
80
|
+
rateLimitResetAt: primaryIsWeekly ? null : (typeof primary?.resets_at === "number" ? primary.resets_at * 1000 : null),
|
|
81
|
+
source: "reconstructed",
|
|
82
|
+
updatedAt,
|
|
83
|
+
}) as ProviderRateLimitSnapshot | null
|
|
84
|
+
|
|
85
|
+
if (latestUsage) {
|
|
86
|
+
latestUsage.weeklyLimitUsedPercent = toNumber(weeklyLimit?.used_percent)
|
|
87
|
+
latestUsage.weeklyRateLimitResetAt = typeof weeklyLimit?.resets_at === "number" ? weeklyLimit.resets_at * 1000 : null
|
|
88
|
+
latestUsage.weeklyRateLimitResetLabel = null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return latestUsage
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export class CodexUsage extends BaseProviderUsage {
|
|
96
|
+
readonly provider = "codex" as const
|
|
97
|
+
private cache: { snapshot: ChatUsageSnapshot | null; cachedAt: number } | null = null
|
|
98
|
+
|
|
99
|
+
loadPersistedEntry(): ProviderUsageEntry | null {
|
|
100
|
+
return snapshotToEntry(this.provider, this.cache?.snapshot ?? null)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
deriveFromStore(store: EventStore): ChatUsageSnapshot | null {
|
|
104
|
+
const now = Date.now()
|
|
105
|
+
if (this.cache && now - this.cache.cachedAt < PROVIDER_CACHE_TTL_MS) {
|
|
106
|
+
return this.cache.snapshot
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let bestSnapshot: ChatUsageSnapshot | null = null
|
|
110
|
+
let bestMessageAt = 0
|
|
111
|
+
|
|
112
|
+
for (const chat of store.state.chatsById.values()) {
|
|
113
|
+
if (chat.deletedAt || chat.provider !== "codex" || !chat.sessionToken) continue
|
|
114
|
+
const messageAt = chat.lastMessageAt ?? chat.updatedAt ?? 0
|
|
115
|
+
if (messageAt > bestMessageAt) {
|
|
116
|
+
bestMessageAt = messageAt
|
|
117
|
+
const reconstructed = reconstructCodexUsageFromFile(chat.sessionToken)
|
|
118
|
+
if (reconstructed) {
|
|
119
|
+
bestSnapshot = reconstructed
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
this.cache = { snapshot: bestSnapshot, cachedAt: now }
|
|
125
|
+
return bestSnapshot
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
deriveEntry(liveSnapshot: ChatUsageSnapshot | null, store: EventStore): ProviderUsageEntry {
|
|
129
|
+
return snapshotToEntry(this.provider, liveSnapshot ?? this.deriveFromStore(store))
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const _instances = new Map<string, CodexUsage>()
|
|
134
|
+
|
|
135
|
+
export function getCodexUsage(dataDir: string): CodexUsage {
|
|
136
|
+
if (!_instances.has(dataDir)) {
|
|
137
|
+
_instances.set(dataDir, new CodexUsage(dataDir))
|
|
138
|
+
}
|
|
139
|
+
return _instances.get(dataDir)!
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function resetCodexUsageCaches() {
|
|
143
|
+
_instances.clear()
|
|
144
|
+
}
|