Provider adapters
These are the ingest side of the conversion sesh runs: adapters turn a vendor's on-disk session format into the canonical model: parseClaudeSession for Claude Code, parseCodexRollout for Codex. Both live under src/messages/adapters/ and return the same shape:
type IngestResult = {
thread: Thread;
messages: Message[];
children: IngestedThread[]; // sidechain / subagent threads (Claude only)
report: { skipped: Record<string, number>; notes: string[] };
};Nothing is ever silently dropped — anything an adapter can't map to a canonical field is either skipped-and-counted in report.skipped, or kept as a { type: "unknown" } block.
Claude Code session files
A session lives at ~/.claude/projects/<munged-cwd>/<sessionId>.jsonl — one JSON object per line. Structurally it's a tree, not a list:
- Message lines (
type: "user" | "assistant") carry an envelope:uuid,parentUuid(the tree edge),isSidechain,sessionId,timestamp,cwd,gitBranch,version,userType, plus assistant-onlyrequestIdand user-onlypromptId/promptSource/permissionMode. Tool-result lines also carry a rich parsedtoolUseResult. - The nested
messagefield is in Anthropic API shape: content blocks aretext,thinking(with asignature),tool_use(id,name,input), ortool_result(tool_use_id,content,is_error). Assistantmessage.usagehasinput_tokens,output_tokens,cache_read_input_tokens,cache_creation_input_tokens, and more. - Everything else —
attachment,ai-title,system(e.g.subtype: "turn_duration"),file-history-snapshot,mode,permission-mode,last-prompt,queue-operation,pr-link, … — is session/UI state, not conversation.
parseClaudeSession(text, options?) walks the tree, turns message lines into canonical Messages, and treats isSidechain runs as child threads (parentThreadId + originMessageId) rather than folding them into the parent. Pass { keepRaw: true } to retain the original line on each message as raw — useful since toolUseResult can be large and isn't kept by default.
Codex rollouts
A rollout lives at ~/.codex/sessions/**/rollout-*.jsonl — also one JSON object per line, but linear, { timestamp, type, payload }:
session_meta—payload.id(session id),cwd,originator(codex-tui),cli_version,base_instructions.turn_context— per-turnturn_id, cwd, model, effort, sandbox/approval policy.response_item— the model-facing conversation:message— roles includedeveloper; content partsinput_text/output_text.reasoning—encrypted_content(opaque) +summary(usually empty).function_call/function_call_output—name(e.g.exec_command),call_id,argumentsas a JSON string, paired output.custom_tool_call(_output)— e.g.apply_patch, raw patch text asinput.web_search_call—action.query(+action.queries),status; nocall_id.
event_msg— UI telemetry:user_message/agent_message(duplicate content already inresponse_item),task_started/task_complete(turn boundaries),token_count(cumulative + per-step usage),patch_apply_end,web_search_end.
parseCodexRollout(text, options?) folds this into the same canonical shape: turn_context / task_started become canonical turnId boundaries, token_count becomes usage on the turn's latest assistant message, and duplicate/telemetry event_msg entries are skipped and counted.
Mapping table
| Source | → Canonical |
|---|---|
Claude assistant line | Message{role:assistant}; blocks: text→text, thinking→reasoning, tool_use→tool_call |
Claude user line with tool_result | Message{role:tool}; tool_result block (toolUseResult into raw if keepRaw) |
Claude user line (prompt) | Message{role:user}, starts a new turn |
Claude ai-title | thread title (last non-empty wins) |
Claude system/attachment/mode/etc. lines | skipped, counted in ingest report |
Claude sidechain lines (isSidechain) | child threads (parentThreadId + chain grouping) |
Codex session_meta | thread metadata + provenance sessionId |
Codex response_item:message | Message (developer role → system) |
Codex response_item:reasoning | reasoning block (redacted when only encrypted_content; summary text kept when present) |
Codex function_call(_output) | tool_call / tool_result, callId = call_id, arguments JSON-parsed |
Codex custom_tool_call apply_patch | file_change block (+ tool_result for the output) |
Codex web_search_call | web_search block |
Codex event_msg:token_count | usage on the turn's latest assistant message |
Codex turn_context/task_started | canonical turnId boundaries |
Codex event_msg others | skipped (duplicates/telemetry), counted in ingest report |
Ingest reports
Every parse call returns a report: { skipped, notes } alongside the thread/messages/children — a count of skipped source lines/items keyed by their type, plus free-text notes. Nothing is dropped without it showing up here, which is what makes ingestion safe to run against real session files without silently losing history.
Emitters
Emitters run the mapping in reverse: canonical Thread/Message[] back out to a vendor's on-disk shape. Both live under src/messages/emitters/ and return an EmitReport ({ dropped: Record<string, number>; notes: string[] }) alongside the emitted lines — the same "reported, never silent" contract as ingestion.
| Direction | Function |
|---|---|
| Codex rollout → canonical → Claude Code session | emitClaudeSession(thread, messages, options?) |
| Claude Code session → canonical → Codex rollout | emitCodexRollout(thread, messages, options?) |
emitCodexRollout is the reverse handoff: parse a live Claude Code session with parseClaudeSession, then emit it as Codex rollout lines ready to drop into ~/.codex/sessions/<dateDir>/<fileName> for codex resume <sessionId>. It accepts a link option that gets stamped onto session_meta.payload.sesh (with an emittedLines count) so a later merge-back knows exactly where the handoff happened — see Session handoff for the full round-trip walkthrough. emitClaudeSession gained a matching initialParentUuid option, so emitted lines can be chained onto an existing session instead of always starting a new tree — what the merge-back direction needs to graft Codex's new turns back onto a copy of the original Claude session.
Block-mapping summary (canonical → Codex)
| Canonical block | Codex response_item |
|---|---|
text (assistant) | message with role: "assistant", content part output_text |
text (user) | message with role: "user", content part input_text |
tool_call | function_call — name, call_id = callId, arguments JSON-stringified from the block's input |
file_change | function_call named apply_patch, arguments JSON-stringified { input: patch } |
tool_result (role tool) | function_call_output — call_id = callId, output as a plain string (non-string outputs flattened to their text parts, or JSON.stringifyd) |
system message | message with role: "developer" |
reasoning | dropped, counted in report.dropped.reasoning / reasoning-redacted |
See Session handoff for the round-trip: ingest a Codex rollout, then emit it back out as a Claude Code session — and the reverse, ingest a Claude Code session and emit it as a Codex rollout.