Claude Code is not the only agent writing JSONL to your disk. The OpenAI Codex CLI records every session as a rollout — rollout-<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
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
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")
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')
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:
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.