From 59ae10ebbd875c387b2dcc5b08715a1a38fe7399 Mon Sep 17 00:00:00 2001 From: jjju Date: Mon, 10 Aug 2026 17:38:05 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20Codex=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=ED=9B=84=20=EC=82=AC=EC=9A=A9=EB=9F=89=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 빈 rate_limits 로그를 건너뛰고 최근 유효 데이터 탐색 - 이벤트 timestamp 기준 최신 사용량 및 리셋 시각 선택 refs: fix/codex-account-reconnect --- src/codex.ts | 79 ++++++++++++++++++++++++++------------------- test/codex.test.cjs | 74 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 33 deletions(-) diff --git a/src/codex.ts b/src/codex.ts index 4ac5941..dd3de34 100644 --- a/src/codex.ts +++ b/src/codex.ts @@ -5,7 +5,6 @@ import { l10n } from 'vscode'; import { UsageResult, UsageWindow, WindowKind } from './types'; // 세션 로그 탐색 상한 -const MAX_FILES_TO_SCAN = 8; const MAX_FILES_TO_COLLECT = 24; const TAIL_READ_BYTES = 256 * 1024; // 세션 구간 판별 상한 (분) @@ -25,6 +24,7 @@ interface CodexRateLimits { } interface CodexLogLine { + timestamp?: string | null; payload?: { type?: string; rate_limits?: CodexRateLimits | null; @@ -32,6 +32,11 @@ interface CodexLogLine { rate_limits?: CodexRateLimits | null; } +interface CodexRateLimitRecord { + rateLimits: CodexRateLimits; + recordedAt: Date; +} + // Codex 세션 디렉터리 기본 경로 결정 export function resolveSessionsPath(customPath: string): string { return customPath || path.join(os.homedir(), '.codex', 'sessions'); @@ -61,7 +66,7 @@ async function collectRecentJsonlFiles(dir: string, out: string[], limit: number } // 파일 끝부분만 부분 읽기 -async function readTail(file: string, maxBytes: number): Promise { +async function readTail(file: string, maxBytes: number): Promise<{ text: string; mtime: Date }> { const stat = await fs.stat(file); const start = Math.max(0, stat.size - maxBytes); const handle = await fs.open(file, 'r'); @@ -69,17 +74,25 @@ async function readTail(file: string, maxBytes: number): Promise { const length = stat.size - start; const buffer = Buffer.alloc(length); await handle.read(buffer, 0, length, start); - return buffer.toString('utf8'); + return { text: buffer.toString('utf8'), mtime: stat.mtime }; } finally { await handle.close(); } } -// 로그 라인에서 rate_limits 추출 -function extractRateLimits(line: string): CodexRateLimits | null { +// 로그 라인의 사용량 제한 기록 추출 +function extractRateLimitRecord(line: string, fallbackRecordedAt: Date): CodexRateLimitRecord | null { try { const parsed: CodexLogLine = JSON.parse(line); - return parsed.payload?.rate_limits ?? parsed.rate_limits ?? null; + const rateLimits = parsed.payload?.rate_limits ?? parsed.rate_limits ?? null; + if (!rateLimits) { + return null; + } + const recordedAt = typeof parsed.timestamp === 'string' ? new Date(parsed.timestamp) : fallbackRecordedAt; + return { + rateLimits, + recordedAt: Number.isNaN(recordedAt.getTime()) ? fallbackRecordedAt : recordedAt, + }; } catch { return null; } @@ -144,55 +157,55 @@ export async function fetchCodexUsage(customSessionsPath: string): Promise { - try { - const stat = await fs.stat(file); - return { file, mtime: stat.mtime }; - } catch { - return { file, mtime: new Date(0) }; - } - }), - ); - withMtime.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); - - for (const { file, mtime } of withMtime.slice(0, MAX_FILES_TO_SCAN)) { - let tail: string; + let latestUsage: { windows: UsageWindow[]; plan: string | null; recordedAt: Date } | null = null; + for (const file of files) { + let tail: { text: string; mtime: Date }; try { tail = await readTail(file, TAIL_READ_BYTES); } catch { continue; } - const lines = tail.split('\n'); + const lines = tail.text.split('\n'); for (let i = lines.length - 1; i >= 0; i -= 1) { const line = lines[i]; if (!line.includes('"rate_limits"')) { continue; } - const rateLimits = extractRateLimits(line); - if (!rateLimits) { + const record = extractRateLimitRecord(line, tail.mtime); + if (!record) { continue; } const windows = [ - toUsageWindow(rateLimits.primary, 'session', mtime), - toUsageWindow(rateLimits.secondary, 'weekly', mtime), + toUsageWindow(record.rateLimits.primary, 'session', record.recordedAt), + toUsageWindow(record.rateLimits.secondary, 'weekly', record.recordedAt), ] .filter((w): w is UsageWindow => w !== null) .sort((a, b) => (a.kind === 'session' ? 0 : 1) - (b.kind === 'session' ? 0 : 1)); if (windows.length === 0) { continue; } - return { - status: 'ok', - data: { + if (!latestUsage || record.recordedAt.getTime() > latestUsage.recordedAt.getTime()) { + latestUsage = { windows, - plan: rateLimits.plan_type ?? null, - fetchedAt: mtime, - sourceNote: l10n.t('Updates from session logs when Codex runs'), - }, - }; + plan: record.rateLimits.plan_type ?? null, + recordedAt: record.recordedAt, + }; + } + break; } } + if (latestUsage) { + return { + status: 'ok', + data: { + windows: latestUsage.windows, + plan: latestUsage.plan, + fetchedAt: latestUsage.recordedAt, + sourceNote: l10n.t('Updates from session logs when Codex runs'), + }, + }; + } + return { status: 'missing', message: l10n.t('No usage records in session logs (run codex to refresh)') }; } diff --git a/test/codex.test.cjs b/test/codex.test.cjs index 241b16f..0e8f9eb 100644 --- a/test/codex.test.cjs +++ b/test/codex.test.cjs @@ -49,6 +49,19 @@ async function writeLogFile(dir, name, rateLimits, mtime) { return file; } +// 이벤트 시각이 포함된 rate_limits 로그 파일 기록 +async function writeTimestampedLogFile(dir, name, rateLimits, recordedAt, mtime) { + await fsp.mkdir(dir, { recursive: true }); + const file = path.join(dir, name); + const line = JSON.stringify({ + timestamp: recordedAt.toISOString(), + payload: { type: 'token_count', rate_limits: rateLimits }, + }); + await fsp.writeFile(file, `${line}\n`, 'utf8'); + await fsp.utimes(file, mtime, mtime); + return file; +} + // 최신 로그 우선 조회 검증 test('가장 최근 세션 로그의 rate_limits를 사용한다', async () => { const root = await createTempDir(); @@ -147,3 +160,64 @@ test('기본 세션 경로가 없으면 absent, 커스텀 경로가 없으면 mi await fsp.rm(root, { recursive: true, force: true }); } }); + +// 빈 사용량 로그가 몰린 계정 전환 직후 복구 검증 +test('최신 로그가 비어 있어도 최근 유효 사용량을 탐색한다', async () => { + const root = await createTempDir(); + try { + const valid = await writeLogFile( + path.join(root, '2026', '08', '10'), + 'rollout-2026-08-10T16-00-00-valid.jsonl', + { primary: { used_percent: 37, window_minutes: 300, resets_in_seconds: 3600 } }, + new Date(Date.now() - 60 * 1000), + ); + for (let index = 0; index < 12; index += 1) { + await writeLogFile( + path.join(root, '2026', '08', '10'), + `rollout-2026-08-10T17-00-${String(index).padStart(2, '0')}-empty.jsonl`, + null, + new Date(Date.now() + index), + ); + } + + const result = await fetchCodexUsage(root); + + assert.equal(result.status, 'ok'); + assert.equal(result.data.windows[0].percent, 37); + assert.equal(result.data.fetchedAt.getTime(), (await fsp.stat(valid)).mtime.getTime()); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } +}); + +// 파일 수정 시각과 무관한 최신 이벤트 선택 검증 +test('파일 수정 시각 대신 최신 rate_limits 이벤트 시각을 사용한다', async () => { + const root = await createTempDir(); + try { + const now = Date.now(); + const latestEventAt = new Date(now - 30 * 1000); + await writeTimestampedLogFile( + path.join(root, '2026', '08', '10'), + 'rollout-2026-08-10T16-00-00-active.jsonl', + { primary: { used_percent: 42, window_minutes: 300, resets_in_seconds: 3600 } }, + latestEventAt, + new Date(now - 2 * 60 * 60 * 1000), + ); + await writeTimestampedLogFile( + path.join(root, '2026', '08', '10'), + 'rollout-2026-08-10T17-00-00-touched.jsonl', + { primary: { used_percent: 81, window_minutes: 300, resets_in_seconds: 3600 } }, + new Date(now - 60 * 60 * 1000), + new Date(now), + ); + + const result = await fetchCodexUsage(root); + + assert.equal(result.status, 'ok'); + assert.equal(result.data.windows[0].percent, 42); + assert.equal(result.data.fetchedAt.getTime(), latestEventAt.getTime()); + assert.equal(result.data.windows[0].resetsAt.getTime(), latestEventAt.getTime() + 3600 * 1000); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } +}); From 65ff1742e9fcb0487180e5fae1ff2295f0b203a8 Mon Sep 17 00:00:00 2001 From: jjju Date: Mon, 10 Aug 2026 17:45:18 +0900 Subject: [PATCH 2/2] =?UTF-8?q?chore:=200.3.3=20=EB=A6=B4=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=20=EC=A4=80=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 패키지와 잠금 파일 버전 0.3.3 동기화 - Codex 계정 전환 복구 변경 내역 추가 refs: fix/codex-account-reconnect --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9706a59..d23e5b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## 0.3.3 — 2026-08-10 + +- Codex usage now recovers after account switches that create consecutive empty rate-limit logs +- Latest usage and reset times now follow each session event timestamp instead of file modification time +- Regression coverage added for account-switch log bursts and long-running sessions + ## 0.3.2 — 2026-07-18 - Japanese (日本語) localization for the marketplace listing and UI diff --git a/package-lock.json b/package-lock.json index bbf3e76..e437a61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-codex-usage-monitor", - "version": "0.3.0", + "version": "0.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-codex-usage-monitor", - "version": "0.3.0", + "version": "0.3.3", "license": "MIT", "devDependencies": { "@types/node": "^18.19.0", diff --git a/package.json b/package.json index 684d263..b2b2cab 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "claude-codex-usage-monitor", "displayName": "Claude Code Usage & Codex Usage Monitor", "description": "%ext.description%", - "version": "0.3.2", + "version": "0.3.3", "publisher": "jjju", "license": "MIT", "pricing": "Free",