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.
Files changed (74) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +246 -0
  3. package/bin/kaizen +15 -0
  4. package/dist/client/apple-touch-icon.png +0 -0
  5. package/dist/client/assets/index-D-ORCGrq.js +603 -0
  6. package/dist/client/assets/index-r28mcHqz.css +32 -0
  7. package/dist/client/favicon.png +0 -0
  8. package/dist/client/fonts/body-medium.woff2 +0 -0
  9. package/dist/client/fonts/body-regular-italic.woff2 +0 -0
  10. package/dist/client/fonts/body-regular.woff2 +0 -0
  11. package/dist/client/fonts/body-semibold.woff2 +0 -0
  12. package/dist/client/index.html +22 -0
  13. package/dist/client/manifest-dark.webmanifest +24 -0
  14. package/dist/client/manifest.webmanifest +24 -0
  15. package/dist/client/pwa-192.png +0 -0
  16. package/dist/client/pwa-512.png +0 -0
  17. package/dist/client/pwa-icon.svg +15 -0
  18. package/dist/client/pwa-splash.png +0 -0
  19. package/dist/client/pwa-splash.svg +15 -0
  20. package/package.json +103 -0
  21. package/src/server/acp-shared.ts +315 -0
  22. package/src/server/agent.ts +1120 -0
  23. package/src/server/attachments.ts +133 -0
  24. package/src/server/backgrounds.ts +74 -0
  25. package/src/server/cli-runtime.ts +333 -0
  26. package/src/server/cli-supervisor.ts +81 -0
  27. package/src/server/cli.ts +68 -0
  28. package/src/server/codex-app-server-protocol.ts +453 -0
  29. package/src/server/codex-app-server.ts +1350 -0
  30. package/src/server/cursor-acp.ts +819 -0
  31. package/src/server/discovery.ts +322 -0
  32. package/src/server/event-store.ts +1369 -0
  33. package/src/server/events.ts +244 -0
  34. package/src/server/external-open.ts +272 -0
  35. package/src/server/gemini-acp.ts +844 -0
  36. package/src/server/gemini-cli.ts +525 -0
  37. package/src/server/generate-title.ts +36 -0
  38. package/src/server/git-manager.ts +79 -0
  39. package/src/server/git-repository.ts +101 -0
  40. package/src/server/harness-types.ts +20 -0
  41. package/src/server/keybindings.ts +177 -0
  42. package/src/server/machine-name.ts +22 -0
  43. package/src/server/paths.ts +112 -0
  44. package/src/server/process-utils.ts +22 -0
  45. package/src/server/project-icon.ts +344 -0
  46. package/src/server/project-metadata.ts +10 -0
  47. package/src/server/provider-catalog.ts +85 -0
  48. package/src/server/provider-settings.ts +155 -0
  49. package/src/server/quick-response.ts +153 -0
  50. package/src/server/read-models.ts +275 -0
  51. package/src/server/recovery.ts +507 -0
  52. package/src/server/restart.ts +30 -0
  53. package/src/server/server.ts +244 -0
  54. package/src/server/terminal-manager.ts +350 -0
  55. package/src/server/theme-settings.ts +179 -0
  56. package/src/server/update-manager.ts +230 -0
  57. package/src/server/usage/base-provider-usage.ts +57 -0
  58. package/src/server/usage/claude-usage.ts +558 -0
  59. package/src/server/usage/codex-usage.ts +144 -0
  60. package/src/server/usage/cursor-browser.ts +120 -0
  61. package/src/server/usage/cursor-cookies.ts +390 -0
  62. package/src/server/usage/cursor-usage.ts +490 -0
  63. package/src/server/usage/gemini-usage.ts +24 -0
  64. package/src/server/usage/provider-usage.ts +61 -0
  65. package/src/server/usage/test-helpers.ts +9 -0
  66. package/src/server/usage/types.ts +54 -0
  67. package/src/server/usage/utils.ts +325 -0
  68. package/src/server/ws-router.ts +717 -0
  69. package/src/shared/branding.ts +83 -0
  70. package/src/shared/dev-ports.ts +43 -0
  71. package/src/shared/ports.ts +2 -0
  72. package/src/shared/protocol.ts +152 -0
  73. package/src/shared/tools.ts +251 -0
  74. 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
+ }