@@ -48,6 +48,9 @@ async function parseCodexSessionFile(
4848 let cwd : string | undefined
4949 let project : string | undefined
5050 let model : string | undefined
51+ // Headless `codex exec` files have no session_meta; emit a synthetic
52+ // session.started on the first usage line so rollups still get a boundary.
53+ let sessionStartEmitted = false
5154 // Per-turn service tier. Updated whenever a turn_context (or future per-turn
5255 // event) carries an explicit service_tier; never inferred from config.toml.
5356 let serviceTier : string | undefined
@@ -104,6 +107,7 @@ async function parseCodexSessionFile(
104107 model,
105108 confidence : 'exact' ,
106109 } , { filePath, sourcePathHash, lineNumber, topType, payloadType : 'session_meta' , options } ) )
110+ sessionStartEmitted = true
107111 continue
108112 }
109113
@@ -123,6 +127,40 @@ async function parseCodexSessionFile(
123127 continue
124128 }
125129
130+ // Headless `codex exec` output: turn.completed / result / bare {data:{usage}}
131+ // lines carry usage directly, with no session_meta/event_msg wrapper.
132+ // Mirrors ccusage's headless parser (adapter/codex/parser.rs).
133+ if ( topType === 'turn.completed' || topType === 'result' || topType === undefined ) {
134+ const usage = headlessCodexUsage ( raw )
135+ if ( usage ) {
136+ const parsedModel = headlessCodexModel ( raw )
137+ if ( parsedModel ) {
138+ model = parsedModel
139+ }
140+ const eventModel = parsedModel || model || 'gpt-5'
141+ const headlessTs = headlessCodexTimestamp ( raw ) || ts
142+ if ( ! sessionStartEmitted ) {
143+ events . push ( withBackfillRefs ( baseCodexEvent ( {
144+ ts : headlessTs ,
145+ type : 'session.started' ,
146+ sessionId,
147+ model : eventModel ,
148+ confidence : 'derived' ,
149+ } ) , { filePath, sourcePathHash, lineNumber, topType : topType || 'result' , payloadType : 'session' , options } ) )
150+ sessionStartEmitted = true
151+ }
152+ events . push ( withBackfillRefs ( baseCodexEvent ( {
153+ ts : headlessTs ,
154+ type : 'model.usage' ,
155+ sessionId,
156+ model : eventModel ,
157+ confidence : 'partial' ,
158+ metrics : usage ,
159+ } ) , { filePath, sourcePathHash, lineNumber, topType : topType || 'result' , payloadType : 'headless' , options } ) )
160+ }
161+ continue
162+ }
163+
126164 if ( topType !== 'event_msg' && topType !== 'response_item' ) {
127165 continue
128166 }
@@ -442,6 +480,74 @@ function baseCodexEvent(
442480 }
443481}
444482
483+ // ── Headless codex exec (turn.completed / result / bare data.usage) ──
484+
485+ // Pull usage from the top-level object or a nested data/result/response wrapper,
486+ // honoring the field aliases ccusage accepts (prompt/completion/cached_tokens).
487+ function headlessCodexUsage ( raw : Record < string , unknown > ) {
488+ const usage = [
489+ objectField ( raw , 'usage' ) ,
490+ objectField ( objectField ( raw , 'data' ) , 'usage' ) ,
491+ objectField ( objectField ( raw , 'result' ) , 'usage' ) ,
492+ objectField ( objectField ( raw , 'response' ) , 'usage' ) ,
493+ ] . find ( candidate => Object . keys ( candidate ) . length > 0 )
494+ if ( ! usage ) {
495+ return
496+ }
497+
498+ const input = numberField ( usage , 'input_tokens' ) ?? numberField ( usage , 'prompt_tokens' ) ?? 0
499+ const output = numberField ( usage , 'output_tokens' ) ?? numberField ( usage , 'completion_tokens' ) ?? 0
500+ const cached = numberField ( usage , 'cached_input_tokens' )
501+ ?? numberField ( usage , 'cache_read_input_tokens' )
502+ ?? numberField ( usage , 'cached_tokens' )
503+ ?? 0
504+ const reasoning = numberField ( usage , 'reasoning_output_tokens' ) ?? numberField ( usage , 'reasoning_tokens' ) ?? 0
505+ const totalField = numberField ( usage , 'total_tokens' )
506+ // ccusage keeps total_tokens only when positive (or everything is zero),
507+ // otherwise recomputes from input+output+reasoning — cache is NOT added.
508+ const total = totalField !== undefined && ( totalField > 0 || input + output + reasoning === 0 )
509+ ? totalField
510+ : input + output + reasoning
511+
512+ if ( input === 0 && cached === 0 && output === 0 && reasoning === 0 && total === 0 ) {
513+ return
514+ }
515+
516+ return {
517+ tokensInput : input || undefined ,
518+ tokensCachedInput : cached || undefined ,
519+ tokensOutput : output || undefined ,
520+ tokensReasoningOutput : reasoning || undefined ,
521+ tokensTotal : total ,
522+ modelCalls : 1 ,
523+ }
524+ }
525+
526+ function headlessCodexModel ( raw : Record < string , unknown > ) : string | undefined {
527+ for ( const source of [ raw , objectField ( raw , 'data' ) , objectField ( raw , 'result' ) , objectField ( raw , 'response' ) ] ) {
528+ const model = stringField ( source , 'model' ) ?? stringField ( source , 'model_name' )
529+ if ( model ) {
530+ return model
531+ }
532+ }
533+ return undefined
534+ }
535+
536+ function headlessCodexTimestamp ( raw : Record < string , unknown > ) : string | undefined {
537+ const data = objectField ( raw , 'data' )
538+ const result = objectField ( raw , 'result' )
539+ const response = objectField ( raw , 'response' )
540+ for ( const source of [ raw , data , result , response ] ) {
541+ const ts = timestampFrom ( source . timestamp )
542+ ?? timestampFrom ( source . created_at )
543+ ?? timestampFrom ( source . createdAt )
544+ if ( ts ) {
545+ return ts
546+ }
547+ }
548+ return undefined
549+ }
550+
445551export function tokenUsageFromPayload ( payload : Record < string , unknown > ) {
446552 const info = objectField ( payload , 'info' )
447553 const usage = objectField ( info , 'last_token_usage' )
0 commit comments