Codex rollouts

Parse OpenAI Codex CLI sessions with the same API — provider-transparent parsing, rollout discovery, subagent joins, gate specs over apply_patch, rollout attachments, per-rollout session info, and the activity probe.

Claude Code is not the only agent writing JSONL to your disk. The OpenAI Codex CLI records every session as a rolloutrollout-<timestamp>-<session-id>.jsonl under the date-sharded ~/.codex/sessions/YYYY/MM/DD tree — and cc-transcript parses those too, as a second provider beside Claude Code transcripts. The design is a faithful typed Rust model of the rollout format plus a documented lowering into the same Entry event model every other layer consumes. The consequence for you: parse, stream, Session.from_id, SessionActivity, and session_activity_probe take a rollout path exactly as they take a Claude transcript. A content sniff on the first line picks the provider, and the same typed events come out. What the lowered event stream drops — codex identity, turn lifecycle, token totals — the cc_transcript.codex module adds back.

A rollout tree to work on

Codex shards rollouts by date, and each rollout opens with a session_meta line carrying the session’s identity. The fixture mirrors that shape: a completed main session, a subagent child pointing back at it via parent_thread_id, a session interrupted mid-tool-call, and a compressed .jsonl.zst rollout:

import json
import subprocess
import sys
import tempfile
from pathlib import Path

ROOT = Path(tempfile.mkdtemp()) / "sessions"
DAY = ROOT / "2026" / "07" / "16"
DAY.mkdir(parents=True)

MAIN_ID = "019f67f0-2a3b-7c4d-8e5f-000000000303"
CHILD_ID = "019f6800-3b4c-7d5e-9f60-000000000404"
OPEN_ID = "019f6820-4c5d-7e6f-9071-00000000050a"
ZST_ID = "019f6824-8091-72b3-84b5-000000000505"
TURN = "019f67f0-0aa1-7000-8000-0000000000c1"

def line(ts, kind, payload):
    return json.dumps({"timestamp": f"2026-07-16T{ts}Z", "type": kind, "payload": payload})

def meta(session_id, parent=None):
    payload = {"session_id": session_id, "id": session_id, "cwd": "/Users/dev/webapp",
               "originator": "codex_exec", "cli_version": "0.144.5", "model_provider": "openai"}
    return line("16:20:00.100", "session_meta",
                payload | {"parent_thread_id": parent} if parent else payload)

def event(ts, payload):
    return line(ts, "event_msg", payload)

def response(ts, payload, turn_id):
    passthrough = {"internal_chat_message_metadata_passthrough": {"turn_id": turn_id}}
    return line(ts, "response_item", payload | passthrough)

fix_login = [
    meta(MAIN_ID),
    event("16:20:00.200", {"type": "task_started", "turn_id": TURN}),
    line("16:20:00.500", "turn_context",
         {"turn_id": TURN, "cwd": "/Users/dev/webapp", "model": "gpt-5.2-codex"}),
    response("16:20:00.600", {"type": "message", "role": "user", "content": [
        {"type": "input_text", "text": "why does the login test fail?"}]}, TURN),
    response("16:20:01.400", {"type": "function_call", "name": "exec", "call_id": "call_0001",
             "arguments": json.dumps({"command": ["pytest", "tests/test_login.py"]})}, TURN),
    response("16:20:01.900", {"type": "function_call_output", "call_id": "call_0001",
                              "output": "1 failed: TypeError"}, TURN),
    response("16:20:02.100", {"type": "message", "role": "assistant", "content": [
        {"type": "output_text", "text": "The call site is missing the new password argument."}]}, TURN),
    event("16:20:02.200", {"type": "token_count", "info": {
        "total_token_usage": {"input_tokens": 1200, "cached_input_tokens": 800,
                              "output_tokens": 240, "reasoning_output_tokens": 128,
                              "total_tokens": 1440},
        "model_context_window": 272000}}),
    event("16:22:15.000", {"type": "task_complete", "turn_id": TURN}),
]
subagent = [
    meta(CHILD_ID, parent=MAIN_ID),
    event("16:21:00.000", {"type": "task_started", "turn_id": "019f6800-0bb2-7000-8000-0000000000d2"}),
    event("16:21:30.000", {"type": "task_complete", "turn_id": "019f6800-0bb2-7000-8000-0000000000d2"}),
]
interrupted = [
    meta(OPEN_ID),
    event("16:41:30.000", {"type": "task_started", "turn_id": "019f6820-0aa1-7000-8000-0000000000e1"}),
    response("16:41:31.000", {"type": "function_call", "name": "exec", "call_id": "call_dangling",
             "arguments": json.dumps({"command": ["cargo", "build"]})},
             "019f6820-0aa1-7000-8000-0000000000e1"),
]

def write(stamp, session_id, lines):
    path = DAY / f"rollout-2026-07-16T{stamp}-{session_id}.jsonl"
    path.write_text("\n".join(lines) + "\n")
    return path

MAIN = write("16-20-00", MAIN_ID, fix_login)
CHILD = write("16-22-15", CHILD_ID, subagent)
OPEN = write("16-41-30", OPEN_ID, interrupted)
(DAY / f"rollout-2026-07-16T17-00-00-{ZST_ID}.jsonl.zst").write_bytes(b"")
sorted(path.name for path in DAY.iterdir())
['rollout-2026-07-16T16-20-00-019f67f0-2a3b-7c4d-8e5f-000000000303.jsonl',
 'rollout-2026-07-16T16-22-15-019f6800-3b4c-7d5e-9f60-000000000404.jsonl',
 'rollout-2026-07-16T16-41-30-019f6820-4c5d-7e6f-9071-00000000050a.jsonl',
 'rollout-2026-07-16T17-00-00-019f6824-8091-72b3-84b5-000000000505.jsonl.zst']

Parse it like any transcript

parse needs no flag, no mode, no codex-specific entry point. Hand it the rollout path; the gateway sniffs the content and reports which provider it found on Transcript.provider:

from cc_transcript import parse

transcript = parse(MAIN)
transcript.provider
'codex'

The events are the same typed views a Claude transcript produces. User and assistant messages lower to UserEvent and AssistantEvent, a function_call becomes a tool-use block, its function_call_output the matching tool result, and every rollout line that has no Claude analogue — session_meta, turn_context, the task_started/task_complete brackets, token_count — surfaces as an OtherEvent instead of vanishing. One line, one event, order preserved:

[type(event).__name__ for event in transcript.events]
['OtherEvent',
 'OtherEvent',
 'OtherEvent',
 'UserEvent',
 'AssistantEvent',
 'UserEvent',
 'AssistantEvent',
 'OtherEvent',
 'OtherEvent']

Assistant events carry the model that turn_context declared for their turn. Their usage is None, a codex-specific gap the limitations section explains:

from cc_transcript import AssistantEvent

[(event.model, event.usage) for event in transcript.events if isinstance(event, AssistantEvent)]
[('gpt-5.2-codex', None), ('gpt-5.2-codex', None)]

Because the lowering lands in the standard event model, the lifted layers work unchanged. SessionActivity materializes codex turns with their prompts, tool uses, and results, exactly as it does for Claude sessions:

from cc_transcript import SessionActivity

turn = SessionActivity.from_events(MAIN_ID, transcript.events).turns[-1]
turn.prompt, [(use.call.name, use.result is not None) for use in turn.tool_uses]
('why does the login test fail?', [('exec', True)])

Find rollouts on disk

cc_transcript.codex mirrors the discovery module for the codex tree. discover walks the date shards under ~/.codex/sessions (or an explicit root) and returns CodexRollout records newest first: path, the session id from the filename, and whether the file is .jsonl.zst-compressed:

from cc_transcript import codex

for rollout in codex.discover(ROOT):
    print(rollout.session_id, f"compressed={rollout.compressed}")
019f6824-8091-72b3-84b5-000000000505 compressed=True
019f6820-4c5d-7e6f-9071-00000000050a compressed=False
019f6800-3b4c-7d5e-9f60-000000000404 compressed=False
019f67f0-2a3b-7c4d-8e5f-000000000303 compressed=False

find_transcript resolves one session id to its rollout path. Only uncompressed rollouts resolve, so a session that exists only as .jsonl.zst returns None:

codex.find_transcript(MAIN_ID, ROOT).name, codex.find_transcript(ZST_ID, ROOT)
('rollout-2026-07-16T16-20-00-019f67f0-2a3b-7c4d-8e5f-000000000303.jsonl',
 None)

The top-level resolve — and Session.from_id on top of it — falls back to the codex tree when the Claude projects tree yields nothing, so a codex session id resolves through the provider-agnostic entry point too.

Join subagent rollouts

A codex subagent writes its own rollout file; the join key is the parent_thread_id its session_meta line records. children_of runs that join across the tree, returning the rollouts spawned by a session:

[child.session_id for child in codex.children_of(MAIN_ID, root=ROOT)]
['019f6800-3b4c-7d5e-9f60-000000000404']

The key itself sits on session_info, so one child rollout links back without a tree scan:

codex.session_info(CHILD).parent_thread_id == MAIN_ID
True

Gate specs match apply_patch as an Edit

A codex edit arrives as apply_patch, not Edit, so Edit forward-aliases it: any spec that names Edit through expand_tool_names or tool_name_matches — a captain-hook gate on "Edit|Write", a tool_calls.named("Edit") query — matches an apply_patch call too:

from cc_transcript import expand_tool_names, tool_name_matches

sorted(expand_tool_names("Edit")), tool_name_matches("apply_patch", "Edit|Write")
(['Edit', 'apply_patch'], True)

The alias is forward-only, and that’s deliberate. A spec written literally as apply_patch does not match a canonical Edit call — apply_patch keeps its own ApplyPatchCall parse, and a reverse alias would rekey it:

tool_name_matches("Edit", "apply_patch")
False

Attach a rollout to a session

Session.attachments registers external transcripts — a rollout, say — that Session.walk() and Session.deep fold in at depth 1 beside the session’s own sidechains. Write a rollout whose one call is a multi-file apply_patch, attach it, and every patched file surfaces through the aliased Edit|Write query:

from cc_transcript import Session, SessionActivity
from cc_transcript.ids import SessionId

PATCH_ID = "019f6830-5d6e-7e6f-9071-000000000707"
envelope = ("*** Begin Patch\n"
            "*** Update File: src/login.py\n@@\n-    login(user)\n+    login(user, password)\n"
            "*** Add File: tests/test_password.py\n+def test_password(): ...\n"
            "*** End Patch\n")
rollout = [
    meta(PATCH_ID),
    event("18:00:00.100", {"type": "task_started", "turn_id": TURN}),
    response("18:00:01.000", {"type": "custom_tool_call", "name": "apply_patch",
                              "call_id": "call_0002", "input": envelope}, TURN),
    response("18:00:02.000", {"type": "custom_tool_call_output", "call_id": "call_0002",
                              "output": "Done"}, TURN),
]
ROLLOUT = Path(tempfile.mkdtemp()) / f"rollout-2026-07-16T18-00-00-{PATCH_ID}.jsonl"
ROLLOUT.write_text("\n".join(rollout) + "\n")

host = Session.from_activity(SessionActivity.from_events(SessionId("host"), []), attachments=(ROLLOUT,))
sorted(str(file) for file in host.deep.tool_calls.named("Edit|Write").files())
['src/login.py', 'tests/test_password.py']

The attachment walks as a depth-1 codex DeepSession with no dispatching tool-use id, and the bare window query stays empty — the deep view is opt-in:

[(deep.provider, deep.depth, deep.spawned_by) for deep in host.walk()], host.tool_calls.named("Edit|Write").any()
([('codex', 1, None)], False)

A real host is a parsed Claude session, which gains registrations with dataclasses.replace(session, attachments=(ROLLOUT,)). The has_* predicates recurse over the same walk, so host.has_tool("Edit") sees the rollout’s apply_patch as well. cc-transcript stays ignorant of registration — it takes paths; captain-hook’s transcript registry (or your own bookkeeping) decides what to attach. Ordering, windowing, and dedup semantics live in The deep view.

Session info and the lifecycle

session_info folds one rollout into a CodexSessionInfo: the identity from session_meta, the turn lifecycle, any dangling tool calls, the last envelope timestamp, and the session’s token totals:

info = codex.session_info(MAIN)
info.lifecycle, info.originator, info.cli_version
('completed', 'codex_exec', '0.144.5')
info.usage
CodexUsage(input_tokens=1200, cached_input_tokens=800, output_tokens=240, reasoning_output_tokens=128, total_tokens=1440, model_context_window=272000, token_count_events=1)

The lifecycle field lands in one of five states, folded from the task_started/task_complete/turn_aborted brackets:

State Meaning
uninitialized No content at all — a rollout that never got past its header.
no_instrumentation Content, but no turn brackets — rollouts written before codex shipped lifecycle events.
open The latest turn is still running.
completed The latest turn finished.
aborted The latest turn was interrupted.

The interrupted fixture stopped mid-exec, so its latest turn never closed:

codex.session_info(OPEN).lifecycle, codex.session_info(OPEN).pending
('open',
 (CodexPendingItem(tool_use_id='call_dangling', name='exec', kind='mid_tool'),))

Is the session still working?

session_activity_probe — the disk-only is_waiting oracle captain-hook consumes — sniffs providers the same way parse does. On a codex rollout the verdict comes from the turn lifecycle: an unanswered tool call in an open turn is mid_tool, an aborted or completed turn clears the pending set, and is_waiting flips only when the dangling call is one of codex’s own wait tools (wait, wait_agent):

from cc_transcript.activity_probe import session_activity_probe

probe = session_activity_probe(OPEN)
probe.is_waiting, probe.mid_tool, probe.pending
(False,
 True,
 (PendingItem(tool_use_id='call_dangling', name='exec', kind='mid_tool'),))

From the shell

The CLI’s list grows a --provider flag: claude (the default), codex, or all, which merges both trees into one newest-first listing. --codex-root overrides ~/.codex/sessions the way --root overrides the projects tree:

uvx cc-transcript list --provider codex
cli = Path(sys.executable).parent / "cc-transcript"

def cc(*args):
    return subprocess.run([str(cli), *args], capture_output=True, text=True).stdout

print(cc("list", "--provider", "codex", "--codex-root", str(ROOT)))
2026-07-29 11:40     742B 019f6820-4c5d-7e6f-9071-00000000050a /tmp/tmppj1a8y8s/sessions/2026/07/16/rollout-2026-07-16T16-41-30-019f6820-4c5d-7e6f-9071-00000000050a.jsonl
2026-07-29 11:40       0B 019f6824-8091-72b3-84b5-000000000505 /tmp/tmppj1a8y8s/sessions/2026/07/16/rollout-2026-07-16T17-00-00-019f6824-8091-72b3-84b5-000000000505.jsonl.zst [compressed]
2026-07-29 11:40     652B 019f6800-3b4c-7d5e-9f60-000000000404 /tmp/tmppj1a8y8s/sessions/2026/07/16/rollout-2026-07-16T16-22-15-019f6800-3b4c-7d5e-9f60-000000000404.jsonl
2026-07-29 11:40    2.2KB 019f67f0-2a3b-7c4d-8e5f-000000000303 /tmp/tmppj1a8y8s/sessions/2026/07/16/rollout-2026-07-16T16-20-00-019f67f0-2a3b-7c4d-8e5f-000000000303.jsonl
4 transcripts under /tmp/tmppj1a8y8s/sessions

The rest of the investigation funnel works on explicit rollout paths, because the sniffing gateway sits under the parser every command shares. show, stats, and grep render lowered codex events like any others:

print(cc("show", str(MAIN), "--tail", "5"))
    4 asst  16:20:01 [gpt-5.2-codex] exec("{\"command\": [\"pytest\", \"tests/test_login.py\"]}")
    5 user  16:20:01 <-exec (19ch) 1 failed: TypeError
    6 asst  16:20:02 [gpt-5.2-codex] "The call site is missing the new password argument."
    7 other          codex-token_count
    8 other          codex-task_complete

The limits, plainly

  • No per-event token usage. Codex token_count events record running totals, not per-message deltas, so every lowered assistant event carries usage=None and there is nothing to sum. session_info(path).usage holds the session-level aggregate instead: the last observed totals, as a CodexUsage.
  • Compressed rollouts don’t parse yet. .jsonl.zst files show up in discover with compressed=True, but find_transcript returns None for a session that exists only compressed, and parse can’t read one until zstd support lands.
  • Live tailing stays Claude-only. Watcher drains the ~/.claude/projects tree; there is no codex equivalent yet.
  • Model names depend on turn_context. An assistant event’s model comes from its turn’s turn_context line; a rollout without one reports unknown. Old rollouts predate the turn brackets entirely; those report the honest lifecycle no_instrumentation instead of guessing.

For every field on CodexRollout, CodexSessionInfo, CodexUsage, and CodexPendingItem, see the API reference.