fix(codex): restore discovery fast path, duration precedence, and dashboard width

Review fixes on top of #805.

Discovery: getCachedCodexProject short-circuits again without opening the
file. The PR read the first line of every rollout to collect session_id,
which cost +129ms on an 827-file session directory with a warm cache.
Archived duplicates are now filtered by basename, which needs no file I/O.

Timing: on oversized mcp_tool_call_end lines a `duration_ms` key inside
invocation.arguments outranked the payload-level duration, inflating tool
wait. The depth-aware payload value wins; the naive scan stays as the
fallback for task_complete.

Layout: MIN_WIDE goes back to 90 so 90-129 column terminals keep the
two-column dashboard. The By Model panel drops the Tok/s column (and its
footnote) when the panel has fewer than 61 inner columns or when no model
recorded active timing, so narrow terminals do not truncate and non-Codex
users get no dead column.
This commit is contained in:
iamtoruk 2026-07-27 11:36:07 -07:00
parent a6bf81f756
commit fad846622f
5 changed files with 102 additions and 40 deletions

View file

@ -1,5 +1,13 @@
# Changelog
## Unreleased
### Added (CLI)
- **Codex throughput tracking**: per-model Tok/s in the dashboard and report, active time excludes tool wait. (#805, thanks @ihearttokyo)
### Fixed (CLI)
- **Codex parser corrections**: fork-replay no longer double-counts `patch_apply_end` and `mcp_tool_call_end`; `exec` is normalized to Bash; `custom_tool_call` events are handled; token_count lines larger than 32 KiB now parse exact token counts instead of estimating. Codex session cache bumps from v7 to v8 for a one-time re-parse. Only tool attribution changes for ordinary sessions, leaving their cost identical; sessions that logged an oversized token_count line are repriced from exact counts instead of an estimate. (#805)
## 0.9.19 - 2026-07-20
One version across every surface: CLI, macOS menubar, and the desktop app all ship as 0.9.19.

View file

@ -46,10 +46,9 @@ export function showEmptyState(projectCount: number, scrollableHistory: boolean,
return historyProjectCount === 0 && !historyLoading
}
// The By Model panel now carries six numeric columns. Keep panels stacked until
// each half has enough room for those columns instead of truncating Tok/s at
// ordinary 100120 column terminals.
const MIN_WIDE = 130
// The By Model panel drops the Tok/s column when the panel is too narrow, so
// the wider two-column layout can still activate at ordinary terminal widths.
const MIN_WIDE = 90
const ORANGE = '#FF8C42'
const DIM = '#555555'
const GOLD = '#FFD700'
@ -198,9 +197,9 @@ function nextTick(): Promise<void> {
return new Promise(resolve => setImmediate(resolve))
}
type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number }
export type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number }
function getLayout(columns?: number): Layout {
export function getLayout(columns?: number): Layout {
const termWidth = columns || parseInt(process.env['COLUMNS'] ?? '') || 80
const dashWidth = Math.min(160, termWidth)
const wide = dashWidth >= MIN_WIDE
@ -453,6 +452,9 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
const modelEfficiency = aggregateModelEfficiency(projects)
const anyEstimated = Object.values(modelTotals).some(d => d.estimatedCostUSD > 0)
const anyActiveTiming = Object.values(modelTotals).some(d => d.activeDurationMs > 0 && d.activeGeneratedTokens > 0)
// The Tok/s column needs 61 inner columns for the full row; hide it on narrower
// panels and when no model has timing data (non-Codex users get no dead column).
const showTps = pw - PANEL_CHROME >= 61 && anyActiveTiming
const sorted = Object.entries(modelTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD)
const maxCost = sorted[0]?.[1]?.costUSD ?? 0
const unpriced = findUnpricedModels(Object.entries(modelTotals).map(([model, d]) => ({
@ -464,7 +466,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
return (
<Panel title="By Model" color={PANEL_COLORS.model} width={pw}>
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)}{'Tok/s'.padStart(MODEL_COL_TPS)}</Text>
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)}{showTps ? 'Tok/s'.padStart(MODEL_COL_TPS) : ''}</Text>
{sorted.map(([model, data], i) => {
const totalInput = data.freshInput + data.cacheRead + data.cacheWrite
const cacheHit = totalInput > 0 ? (data.cacheRead / totalInput) * 100 : 0
@ -484,7 +486,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
<Text>{cacheLabel.padStart(MODEL_COL_CACHE)}</Text>
<Text>{String(data.calls).padStart(MODEL_COL_CALLS)}</Text>
<Text>{oneShotLabel.padStart(MODEL_COL_ONESHOT)}</Text>
<Text>{tpsLabel.padStart(MODEL_COL_TPS)}</Text>
{showTps && <Text>{tpsLabel.padStart(MODEL_COL_TPS)}</Text>}
</Text>
)
})}
@ -496,7 +498,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
{anyEstimated && (
<Text dimColor wrap="truncate-end">~ estimated cost (priced from estimated tokens)</Text>
)}
{anyActiveTiming && (
{showTps && (
<Text dimColor wrap="truncate-end">~ Tok/s: generated tokens / active time; tool wait excluded</Text>
)}
</Panel>

View file

@ -438,7 +438,11 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null {
invocation,
call_id: getRawJsonStringField(pHead, 'call_id'),
turn_id: getRawJsonStringField(pHead, 'turn_id'),
duration_ms: timingNumber('duration_ms') ?? timingDuration,
// On mcp_tool_call_end a coincidental `duration_ms` inside the large
// invocation.arguments object can shadow the payload-level duration, so the
// depth-aware value wins. The naive scan stays as the fallback for
// task_complete, which records duration_ms at the payload level directly.
duration_ms: timingDuration ?? timingNumber('duration_ms'),
started_at: timingNumber('started_at'),
info: compactInfo,
},
@ -453,48 +457,34 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null {
return entry
}
type DiscoveredCodexSession = {
source: SessionSource
sessionId?: string
}
async function discoverSessionFile(filePath: string): Promise<DiscoveredCodexSession | null> {
async function discoverSessionFile(filePath: string): Promise<SessionSource | null> {
const s = await stat(filePath).catch(() => null)
if (!s?.isFile()) return null
// Fast path: cached results already know the project, so avoid opening the
// file. This keeps discovery cheap on large session directories.
const cachedProject = await getCachedCodexProject(filePath)
const { valid, meta } = await isValidCodexSession(filePath)
if (cachedProject) {
return {
source: { path: filePath, project: cachedProject, provider: 'codex' },
sessionId: valid ? meta?.payload?.session_id : undefined,
}
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 {
source: { path: filePath, project: sanitizeProject(cwd), provider: 'codex' },
sessionId: meta.payload?.session_id,
}
return { path: filePath, project: sanitizeProject(cwd), provider: 'codex' }
}
async function discoverSessionsInDir(codexDir: string): Promise<SessionSource[]> {
const sources: SessionSource[] = []
// A rollout can exist in both roots during/after archiving. The active root
// is scanned first, and session_id keeps the archived copy from resurfacing.
const seenSessionIds = new Set<string>()
// Codex archives a session by moving it from sessions/YYYY/MM/DD/ to
// archived_sessions/, keeping the same basename. Deduplicate by basename so
// a session does not appear twice while it exists in both roots. This avoids
// reading every file to extract session_id and preserves the cheap cached
// fast path.
const seenBasenames = new Set<string>()
const sessionsDir = join(codexDir, 'sessions')
const addSession = (discovered: DiscoveredCodexSession | null): void => {
if (!discovered) return
const sessionId = discovered.sessionId?.trim()
if (sessionId && seenSessionIds.has(sessionId)) return
if (sessionId) seenSessionIds.add(sessionId)
sources.push(discovered.source)
}
const years = await readdir(sessionsDir).catch(() => [] as string[])
for (const year of years) {
@ -514,8 +504,10 @@ 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)
addSession(await discoverSessionFile(filePath))
if (seenBasenames.has(file)) continue
seenBasenames.add(file)
const source = await discoverSessionFile(join(dayDir, file))
if (source) sources.push(source)
}
}
}
@ -523,11 +515,16 @@ async function discoverSessionsInDir(codexDir: string): Promise<SessionSource[]>
// Codex moves archived sessions into a flat directory. Keep them in usage
// reports so archiving a conversation does not erase its historical usage.
// Call-level deduplication (seenKeys) already collapses any remaining
// archived copies, while basename dedup above prevents double discovery.
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
addSession(await discoverSessionFile(join(archivedDir, file)))
if (seenBasenames.has(file)) continue
seenBasenames.add(file)
const source = await discoverSessionFile(join(archivedDir, file))
if (source) sources.push(source)
}
return sources

View file

@ -2,7 +2,7 @@ import { homedir } from 'os'
import { describe, it, expect } from 'vitest'
import { getDailyActivityRows, getDashboardScanRange, pageHistoryCursor, scrollHistoryCursor, selectDashboardPeriodProjects, shortProject, showEmptyState } from '../src/dashboard.js'
import { getDailyActivityRows, getDashboardScanRange, getLayout, pageHistoryCursor, scrollHistoryCursor, selectDashboardPeriodProjects, shortProject, showEmptyState } from '../src/dashboard.js'
import { getDateRange } from '../src/cli-date.js'
import { formatCost } from '../src/format.js'
import type { ProjectSummary, SessionSummary } from '../src/types.js'
@ -248,3 +248,25 @@ describe('showEmptyState', () => {
expect(showEmptyState(2, false, 0, false)).toBe(false)
})
})
describe('getLayout - dashboard width breakpoints', () => {
it('uses a single column at 89 columns or below', () => {
expect(getLayout(89)).toMatchObject({ dashWidth: 89, wide: false, halfWidth: 89 })
})
it('switches to two columns at 90 columns', () => {
expect(getLayout(90)).toMatchObject({ dashWidth: 90, wide: true, halfWidth: 45 })
})
it('keeps two columns at 120 columns but the By-Model panel is too narrow for Tok/s', () => {
// Inner panel width is halfWidth - PANEL_CHROME (4). At 120 cols halfWidth=60,
// inner=56, below the 61-col threshold where Tok/s renders.
expect(getLayout(120)).toMatchObject({ dashWidth: 120, wide: true, halfWidth: 60 })
expect(getLayout(120).halfWidth - 4).toBeLessThan(61)
})
it('keeps two columns and has enough room for Tok/s at 130 columns', () => {
expect(getLayout(130)).toMatchObject({ dashWidth: 130, wide: true, halfWidth: 65 })
expect(getLayout(130).halfWidth - 4).toBeGreaterThanOrEqual(61)
})
})

View file

@ -551,6 +551,39 @@ describe('codex provider - JSONL parsing', () => {
expect(calls[0]).toMatchObject({ tools: ['mcp__github__get_issue'], activeDurationMs: 7000, toolWaitMs: 3000 })
})
it('prefers payload-level duration over a nested duration_ms in large mcp_tool_call_end lines', async () => {
// Regression guard: a naive first-match regex would pick up the
// `duration_ms: 9999` inside invocation.arguments instead of the payload-level
// `duration: { secs: 3 }`. The depth-aware payload scan must win.
const largeMcpLine = JSON.stringify({
type: 'event_msg',
timestamp: '2026-04-14T10:00:05Z',
payload: {
type: 'mcp_tool_call_end',
call_id: 'mcp-duration-collision',
invocation: { server: 'github', tool: 'get_issue', arguments: { duration_ms: 9999, body: 'x'.repeat(40_000) } },
duration: { secs: 3, nanos: 0 },
result: { Ok: { content: [{ type: 'text', text: 'x'.repeat(40_000) }] } },
},
})
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp-duration-collision.jsonl', [
sessionMeta({ session_id: 'sess-mcp-duration-collision', model: 'gpt-5.5' }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }),
userMessage('look up the issue'),
largeMcpLine,
tokenCount({ timestamp: '2026-04-14T10:00:08Z', last: { input: 300, output: 100 }, total: { total: 400 } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }),
])
const provider = createCodexProvider(tmpDir)
const source = { path: filePath, project: 'test', provider: 'codex' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({ tools: ['mcp__github__get_issue'], activeDurationMs: 7000, toolWaitMs: 3000 })
})
it('omits active timing when recorded tool wait consumes the task duration', async () => {
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-degenerate-timing.jsonl', [
sessionMeta({ session_id: 'sess-degenerate-timing', model: 'gpt-5.5' }),