Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
79 changes: 46 additions & 33 deletions src/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
// 세션 구간 판별 상한 (분)
Expand All @@ -25,13 +24,19 @@ interface CodexRateLimits {
}

interface CodexLogLine {
timestamp?: string | null;
payload?: {
type?: string;
rate_limits?: CodexRateLimits | null;
} | null;
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');
Expand Down Expand Up @@ -61,25 +66,33 @@ async function collectRecentJsonlFiles(dir: string, out: string[], limit: number
}

// 파일 끝부분만 부분 읽기
async function readTail(file: string, maxBytes: number): Promise<string> {
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');
try {
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;
}
Expand Down Expand Up @@ -144,55 +157,55 @@ export async function fetchCodexUsage(customSessionsPath: string): Promise<Usage
return { status: 'missing', message: l10n.t('No Codex session logs (run codex to populate)') };
}

const withMtime = await Promise.all(
files.map(async (file) => {
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)') };
}
74 changes: 74 additions & 0 deletions test/codex.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 });
}
});
Loading