Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -612,7 +612,7 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta
|----------|---------------|-------|
| **Claude Code** | `~/.claude/projects/<sanitized-path>/<session-id>.jsonl` | Each assistant entry carries model name, token usage (input, output, cache read, cache write), `tool_use` blocks, and timestamps. |
| **Claude (multiple config dirs)** | Set via `CLAUDE_CONFIG_DIRS` (e.g. `~/.claude-work:~/.claude-personal`) | Scans every listed directory and merges sessions into one row per project so totals reflect all your Claude usage. Use `:` on POSIX, `;` on Windows; overrides `CLAUDE_CONFIG_DIR`. Missing or unreadable directories are skipped. |
| **Codex (OpenAI)** | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | Reads `token_count` events (per-call and cumulative usage) and `function_call` entries for tool tracking; attributes cost by project working directory. `codeburn report --provider codex` views Codex alone. |
| **Codex (OpenAI)** | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`, `~/.codex/archived_sessions/rollout-*.jsonl` | Reads `token_count` events (per-call and cumulative usage) and `function_call` entries for tool tracking; attributes cost by project working directory. `codeburn report --provider codex` views Codex alone. |
| **Cursor** | SQLite `state.vscdb` under `globalStorage`: macOS `~/Library/Application Support/Cursor/User/globalStorage/`, Linux `~/.config/Cursor/User/globalStorage/`, Windows `%APPDATA%/Cursor/User/globalStorage/`; results cached at `~/.cache/codeburn/cursor-results.json` | Input tokens come from Cursor's own per-conversation context meter (`composerData.promptTokenBreakdown`), credited once per conversation on a stable anchor; tool calls and shell commands are read from the agent stream (`agentKv`), and Composer house models are priced from Cursor's published rates. Output is a reply-text estimate and cache tokens are server-side only, so figures are marked estimated and undercount the Cursor admin console for long conversations. The cache auto-invalidates when the database changes; first run on a large database can take a minute. |
| **OpenCode** | SQLite `~/.local/share/opencode/opencode*.db` (respects `XDG_DATA_HOME`) | Queries `session`, `message`, and `part` read-only and recalculates cost via LiteLLM (falling back to OpenCode's own cost field for unpriced models). Subtask sessions (`parent_id IS NOT NULL`) are excluded to avoid double counting; multiple channel databases are supported. |
| **Gemini CLI** | `~/.gemini/tmp/<project>/chats/session-*.json` | One JSON file per session with real token counts (input, output, cached, thoughts) per message, so no estimation is needed. Input is reported inclusive of cached, so CodeBurn subtracts cached before pricing to avoid double charging. |
Expand Down
10 changes: 8 additions & 2 deletions docs/providers/codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,19 @@ OpenAI Codex CLI.

## Where it reads from

`$CODEX_HOME` if set, otherwise `~/.codex`. Sessions are nested by date:
`$CODEX_HOME` if set, otherwise `~/.codex`. Active sessions are nested by date:

```
~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl
```

The discovery walk uses strict regex (`^\d{4}$`, `^\d{2}$`) on each path component.
Archived sessions are stored in a flat directory and are included in usage reports:

```
~/.codex/archived_sessions/rollout-*.jsonl
```

The active-session discovery walk uses strict regex (`^\d{4}$`, `^\d{2}$`) on each path component.

## Storage format

Expand Down
51 changes: 30 additions & 21 deletions src/providers/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,16 +281,27 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null {
return entry
}

async function discoverSessionFile(filePath: string): Promise<SessionSource | null> {
const s = await stat(filePath).catch(() => null)
if (!s?.isFile()) return null

const cachedProject = await getCachedCodexProject(filePath)
if (cachedProject) {
return { path: filePath, project: cachedProject, provider: 'codex' }
}

const { valid, meta } = await isValidCodexSession(filePath)
if (!valid || !meta) return null

const cwd = meta.payload?.cwd ?? 'unknown'
return { path: filePath, project: sanitizeProject(cwd), provider: 'codex' }
}

async function discoverSessionsInDir(codexDir: string): Promise<SessionSource[]> {
const sessionsDir = join(codexDir, 'sessions')
const sources: SessionSource[] = []
const sessionsDir = join(codexDir, 'sessions')

let years: string[]
try {
years = await readdir(sessionsDir)
} catch {
return sources
}
const years = await readdir(sessionsDir).catch(() => [] as string[])

for (const year of years) {
if (!/^\d{4}$/.test(year)) continue
Expand All @@ -310,25 +321,23 @@ async function discoverSessionsInDir(codexDir: string): Promise<SessionSource[]>
for (const file of files) {
if (!file.startsWith('rollout-') || !file.endsWith('.jsonl')) continue
const filePath = join(dayDir, file)
const s = await stat(filePath).catch(() => null)
if (!s?.isFile()) continue

const cachedProject = await getCachedCodexProject(filePath)
if (cachedProject) {
sources.push({ path: filePath, project: cachedProject, provider: 'codex' })
continue
}

const { valid, meta } = await isValidCodexSession(filePath)
if (!valid || !meta) continue

const cwd = meta.payload?.cwd ?? 'unknown'
sources.push({ path: filePath, project: sanitizeProject(cwd), provider: 'codex' })
const source = await discoverSessionFile(filePath)
if (source) sources.push(source)
}
}
}
}

// Codex moves archived sessions into a flat directory. Keep them in usage
// reports so archiving a conversation does not erase its historical usage.
const archivedDir = join(codexDir, 'archived_sessions')
const archivedFiles = await readdir(archivedDir).catch(() => [] as string[])
for (const file of archivedFiles) {
if (!file.startsWith('rollout-') || !file.endsWith('.jsonl')) continue
const source = await discoverSessionFile(join(archivedDir, file))
if (source) sources.push(source)
}

return sources
}

Expand Down
24 changes: 24 additions & 0 deletions tests/providers/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ async function writeSession(dir: string, date: string, filename: string, lines:
return filePath
}

async function writeArchivedSession(dir: string, filename: string, lines: string[]) {
const archivedDir = join(dir, 'archived_sessions')
await mkdir(archivedDir, { recursive: true })
const filePath = join(archivedDir, filename)
await writeFile(filePath, lines.join('\n') + '\n')
return filePath
}

describe('codex provider - model display names', () => {
it('maps gpt-5.3-codex-spark to its own label', () => {
const provider = createCodexProvider(tmpDir)
Expand Down Expand Up @@ -136,6 +144,22 @@ describe('codex provider - session discovery', () => {
expect(sessions[0]!.path).toContain('rollout-abc123.jsonl')
})

it('discovers sessions moved to the flat archived_sessions directory', async () => {
const filePath = await writeArchivedSession(tmpDir, 'rollout-archived.jsonl', [
sessionMeta({ cwd: '/Users/test/archived' }),
tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }),
])

const provider = createCodexProvider(tmpDir)
const sessions = await provider.discoverSessions()

expect(sessions).toEqual([{
path: filePath,
project: 'Users-test-archived',
provider: 'codex',
}])
})

it('returns empty for non-existent directory', async () => {
const provider = createCodexProvider('/nonexistent/path/that/does/not/exist')
const sessions = await provider.discoverSessions()
Expand Down