What the two providers expose
Requirements
Dependencies.claude-code-capture brings claude-code-sdk 1.7.0 and needs a Java 21 runtime. codex-cli-capture depends on journal-core only and runs on Java 17.
v1.11.0:
claude-code-capture/src/test/resources/fixtures/claude-subagents-synthetic.jsonl, a hand-written Claude Code session with four sub-agents and one refused spawn; itsREADME.mdnext to it has the token arithmetic.codex-cli-capture/src/test/resources/fixtures/codex-subagents-synthetic/{root,child-a,child-b,child-c,orphan}.jsonl, a hand-written Codex execution with three children;fixtures/README.mdhas the arithmetic.
CLIOptions) with --forward-subagent-text; claude-code-sdk 1.6.0 and later expose it as CLIOptions.forwardSubagentText. For Codex, run codex exec and locate the rollout files Codex writes under its sessions directory: the root file is named with the root thread id, and each child file with the child’s thread id, which the root’s SubAgentActivity{kind:"started"} records as agent_thread_id.
agent-client. The released agent-client (0.31.0 on Maven Central) does not set --forward-subagent-text, harvests the root rollout only, calls the single-file CodexSessionParser.parse, and does not apply withSessionBaseline. Those four pieces exist on pushed proof branches of markpollack/agent-client (proof/journal-1.11.0-subagent-capture, proof/codex-rollout-children, proof/codex-subagent-capture-journal-1.11.0), none of them merged, and 0.31.0 pins Journal 1.10.0. Until an agent-client release adopts them, use the Journal APIs directly as shown here.
Claude Code: capture and record
The parser takes the SDK’sIterator<ParsedMessage>. Live, that iterator comes from the SDK’s query; from a saved stream file, build it with the SDK’s MessageParser, one line at a time, which is also how the repository’s tests read the fixture.
toolu_spawn_refused, never started; it is not in subagents() but in capture.subagentsWithoutTrack().
recordOnce is the simplest path. The alternative is to open the run yourself, which is what callers did before 1.11.0; it records the same runs but is not guarded against being called twice:
SubagentCapture.textOutput() and thinkingBlocks(), and in the portable trace file when parse is given one. No journal event stores text, for the main loop or for a sub-agent.
Codex: parse the root with its children
Codex writes one rollout file per thread. Journal does not search the filesystem: you read each file and tag it with its thread id and, for a child, its parent’s thread id. The parent id is theparent_thread_id of the child’s first session_meta record.
CodexSessionParser.parse(Path, …) and parse(BufferedReader, …) are unchanged; a capture parsed from the root file alone has subagentTracksAvailable() == false and no sub-agent runs.
Read the runs back and check the linkage
Open a fresh storage instance on the same directory. Each sub-agent run carries the tagtrack=subagent and the join keys in its run.json:
Every row but the first has
tags.track = subagent; “parent” is parentRunId and “spawning tool call” is config["subagent.spawnToolUseId"].
The join: a child’s subagent.spawnToolUseId equals the id of a tool_call event in its parent’s events.jsonl. Sub-agent c was started by sub-agent b, so its parentRunId is b’s run and its subagent.depth is 2. The parent’s llm_call metadata lists the children under subagents (spawn id, run id, depth, status) and the spawns that produced no record under subagentsWithoutTrack.
The keys in a sub-agent’s run.json:
Accounting
Claude Code
Claude Code reports one cost and onemodelUsage for the whole call, sub-agents included, and per-message usage for each agent’s own turns. Journal records:
- the call’s cost on the main loop’s
llm_call, markedcostIncludesSubagents=true, and split over the main loop’s steps inanalysis.jsonl; - each sub-agent’s
llm_callwith cost 0,costAvailable=falseandcostSource=included_in_parent; - each agent’s own tokens on its own run. Input tokens exclude cache reads; cache reads and cache creation are separate fields.
The last row equals the result line’s
modelUsage, and the main-loop row equals its usage. Live, with --forward-subagent-text, the same reconciliation held exactly (12 / 41,544 / 151,728 against modelUsage on Claude Code 2.1.292). Two figures are not exact:
- A sub-agent’s output tokens are a lower bound, because the stream reports no final output figure per sub-agent (536 recorded against 672 reported in the live run).
- Without
--forward-subagent-text, a sub-agent’s text-only turns are not on the stream, so its recorded usage is short by those turns (about 58,000 cache-read tokens across two sub-agents in the live check). The main loop’s own figures and the call’s cost are complete either way.
costAvailable=false calls as zero is correct for Claude sub-agents because their cost is inside the parent’s figure.
Codex
Codex reports no dollar cost for any thread, so everyllm_call has cost 0 with costAvailable=false and costSource=unreported. Treat that as unknown, not free. Each run’s tokens are its own file’s last token_count; Codex’s root total excludes its children, so each thread is counted once and the root’s llm_call is unchanged by its children. inputTokens excludes cached input (cachedInputTokens is separate), and thinkingTokens holds the reasoning count, which is a subset of outputTokens.
Worked example, the fixture:
An execution total is partial when a spawn produced no record (
subagentsWithoutTrack non-empty) or a child’s end was not observed; the fixture’s fourth spawn failed, so its row is missing from the sum.
Missing evidence and status
What is missing is recorded as missing. A spawn that produced no sub-agent record is listed in the parent’ssubagentsWithoutTrack: for Claude Code a refused or never-started spawn, next to Claude Code’s own counts in subagentStats; for Codex a spawn_failed spawn or a started child whose file you did not supply (not_collected). If Claude Code’s messages arrive without their wire lines, the agents cannot be told apart: the metadata has subagentTracksAvailable=false, no sub-agent run is written, and the activity is merged into the parent’s run as before 1.11.0.
Claude Code status is what the CLI reported, never inferred; subagent.statusSource names where it was read (task_notification, tool_use_result, or none).
Codex status is the last observed turn outcome, because Codex writes no thread-terminal record. Journal takes the parent’s last
completed or interrupted activity for the child; failing that, the child’s last own task_complete or turn_aborted; failing that, unknown. completed maps to FINISHED, interrupted to FAILED, anything else to CRASHED, and every Codex child carries subagent.statusMeaning=last_observed_turn so a reader does not mistake FINISHED for a terminal event. A forked child (subagent.forked=true) replays its parent’s history at the start of its file; those records are skipped and counted in subagent.replayedRecordsSkipped, so the child’s text, duration and tokens are its own.
Run end for the recorder itself. A Claude Code RunRecorder closed without finish() records CRASHED; recordOnce calls finish() for you, or ends the run FAILED when the capture reports an error.
Record an execution once
Recording the same capture twice writes a second parent run with a second set of sub-agent runs.RunRecorder.recordOnce and CodexRunRecorder.recordOnce guard against that within one experiment and one storage root:
- The parent run carries the execution’s key in its config,
capture.sourceKindandcapture.sourceKey: for Claude Code the session id plus the final assistant message id, for Codex the root thread id. - Before writing, the recorder looks for that key in the stored run records, so the check holds across a restart.
- The same source again writes nothing and returns the earlier run with
recordedNow() == false. A different execution, including a later prompt of the same Claude Code session, records normally. - An earlier recording that has not ended, or that ended
FAILEDwith an error recorded, is neither resumed nor duplicated: the call throwsIllegalStateExceptionnaming the run. Remove that run, then record again.
recordPhase on a run you created yourself is unchanged and unguarded. A resumed Codex thread keeps its id, so codex exec resume turns are found as already recorded and are not captured; resume is not supported for sub-agent capture.
Consecutive prompts in one Claude Code session
Claude Code reportstotal_cost_usd, modelUsage and duration_api_ms as the session’s running totals, while usage, duration_ms and num_turns cover the query alone. A caller that records one capture per prompt in one session passes the previous capture to SessionLogParser.withSessionBaseline(current, previous):
costBasis=session_delta and sessionCumulativeCostUsd in the llm_call metadata of a rebased capture. The method never subtracts across session ids and returns current unchanged when the counters restarted. parse itself is unchanged: a record without costBasis from a later prompt carries the running total. You apply the rebase; agent-client 0.31.0 does not.