DEV Community

Cover image for What Codex CLI writes to disk for every session, and how to follow it live
Bargan Constantin
Bargan Constantin

Posted on Originally published at ccdeck.dev AI-assisted

What Codex CLI writes to disk for every session, and how to follow it live

I build ccdeck, a local dashboard that reads these files, and it is the example in this post; every switch is in its Codex guide. The file samples are from codex-cli 0.162.1 on my machine, Codex's docs are quoted as I read them on 14 October 2026, and ccdeck is described as of 3.48.0.

I run Codex CLI in two or three repositories at once, each in its own terminal, each a scroll of commands, patches and output. Codex has part of the overview itself, and it is worth starting there:

  • codex agents, added in Codex CLI 0.149.0, is "an interactive codex agents dashboard for searching, starting, opening, renaming, and stopping tasks". codex queue, from the same release, sends messages to existing sessions.
  • Inside a session, /status shows its model, approval policy, writable roots and current token usage, and /statusline can keep rate limits and token counters in Codex's footer.

For anything else, such as every session's tool calls as they happen, or a view that does not depend on which terminal you are in, the source is a file Codex already writes.


Where the files are

Codex's configuration docs say it "stores its local state under CODEX_HOME (defaults to ~/.codex)", and name config.toml, auth.json, logs and history.jsonl there. They do not describe the per-session files, which sit beside them:

~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
Enter fullscreen mode Exit fullscreen mode

One file per session, in the folder for the day it started. Because the docs leave them out, treat the format as Codex's own: read it defensively, and expect new kinds of line.


What a line looks like

Each line is one JSON object with a timestamp, a type and a payload. Counting the kinds in one of my sessions:

f=$(ls -t ~/.codex/sessions/*/*/*/rollout-*.jsonl | head -1)
jq -r '[.type, (.payload.type // "-")] | join(" ")' "$f" | sort | uniq -c | sort -rn
Enter fullscreen mode Exit fullscreen mode
    468 event_msg item_completed
    178 event_msg token_count
    176 token_usage_record -
    134 response_item custom_tool_call_output
    134 response_item custom_tool_call
    115 response_item reasoning
     97 response_item message
     28 turn_context -
     27 event_msg task_started
     27 event_msg task_complete
     13 response_item function_call_output
     13 response_item function_call
      1 session_meta -
Enter fullscreen mode Exit fullscreen mode

(trimmed). The ones a live view needs:

  • session_meta, once at the top: the session id, cwd, cli_version, model_provider.
  • turn_context, once per turn: model, approval_policy, sandbox_policy, effort. Join mid-session and you know neither the model nor whether the session can stop to ask until the next one arrives.
  • response_item: the conversation. A tool call is a function_call or custom_tool_call line, and its result the matching …_output line with the same call_id.
  • event_msg: what happened. task_started and task_complete bracket a turn; token_count carries token usage and a rate-limit snapshot.

Following a session live

The file is appended to as the session runs, so following it is tail plus a parser:

tail -n 0 -F "$f" | jq --unbuffered -r '[.timestamp, .type, (.payload.type // "")] | @tsv'
Enter fullscreen mode Exit fullscreen mode

A program that does this for every session has three more things to handle:

  1. New files. A new session is a new file in today's folder, and a long session, or one picked up with codex resume, keeps writing to its file in an older day folder.
  2. Joining late. Start reading mid-session and you have no turn_context until the next turn: no model, no approval policy, no price for the tokens.
  3. Partial lines. The last line can be half-written when you read it; keep it until its newline arrives.

ccdeck is one such program. It checks for new lines every 1.5 seconds and turns each session into a card named after its folder:

A Codex session card: LIVE and a context ring at 15, then a GPT-5.5 chip and its running time on its second row, "approvals not visible", 3 tools, 1 in-flight, 119.5k tok and 53¢, with three tool bubbles beside it
Demo data. The last row counts the calls, then gives the tokens and an estimated cost.

The pill reads LIVE while a turn runs and DONE when Codex finishes it, or when you press Esc in Codex. Tool calls get short names: Shell for exec/shell and friends, Edit for apply_patch, Plan for update_plan, Web, stdin and wait. A session that was already running when the deck started appears the next time it writes, from that point on, with ? beside its name, ≥ before its time and a plain Codex chip until its next turn starts: point 2 above, made visible. One caught mid-turn, its file written in the last 24 hours, is read back from the end of its file instead and appears at once, marked restored.

Failure is a guess. The file records a tool's output, not a status. ccdeck counts a call as failed only when the output begins "Script failed", or "Exit code:" with a number other than 0, so a command that failed in some other way can still show ✓. Read the output before you trust the tick.


Tokens, cost and quota from the same file

token_count events hold two things.

Usage. An info object with total_token_usage, last_token_usage and model_context_window: input (cached tokens included), output, and the context-window figures from Codex's most recent request. Pricing them needs a rate, and here the file goes only part of the way: it can say which service tier a session asked for (ccdeck reads it from a thread_settings_applied event), not the tier a request was billed at. ccdeck prices cached tokens at the cache rate, the rest at the tier recorded for them (the standard rate where none is), Codex's auto-review checks on a ChatGPT plan at $0, and labels the result an estimate.

A rate-limit snapshot. A rate_limits object with primary and secondary windows, each holding used_percent, window_minutes and resets_at:

jq -c 'select(.type == "event_msg" and .payload.type == "token_count")
       | .payload.rate_limits.primary' "$f" | tail -1
Enter fullscreen mode Exit fullscreen mode
{"used_percent":93.0,"window_minutes":10080,"resets_at":1792383934}
Enter fullscreen mode Exit fullscreen mode

On my account "primary" was the 7-day window, so name windows by window_minutes, not by position. And it is a snapshot from the last request: a session that has been idle for an hour holds an hour-old reading. ccdeck reads the quota about once a minute while its page is open, from Codex's usage endpoint rather than the file, and names each bar by its length (5-hour window, 7-day window, 30-day window, or Rate limit when OpenAI gives no length).


What the file never says

When a session is waiting for your approval. Codex writes no approval request to the rollout file. A call waiting for you looks exactly like one still running. ccdeck's answer is to say so rather than guess: a running card shows approvals not visible whenever its approval_policy can ask (untrusted, on-request, on-failure, or not read yet), and nothing at never.

Two Codex cards: infra under on-request shows "approvals not visible"; billing under never does not
Demo data.

Codex itself has two ways to tell you, both in its config: tui.notifications "can optionally filter by event type (for example, agent-turn-complete and approval-requested)", and notify runs a program of yours, though only for agent-turn-complete, with a JSON argument. Codex's app-server also reports a waiting status; reading that from ccdeck is open work.

When a session closed. Nothing is written when you close the terminal. ccdeck keeps such a card LIVE until 90 minutes pass with no new line.

ccdeck also draws no title for a Codex session, which a Claude Code card has, and joins a Codex sub-agent to its parent only when the file names that parent (parent_thread_id in its session_meta, or a sub-agent started event in the parent's file).


The one write: refreshing the token safely

Reading Codex's quota needs a live token from $CODEX_HOME/auth.json, and tokens expire. Refreshing one from outside the CLI is where a reader can do damage: OpenAI's refresh tokens are single-use, so a rotation that never reaches disk leaves auth.json holding a token that no longer works, and the next step is codex login.

ccdeck's refresh is a pattern worth copying for any file another program also writes:

  • Only when needed: the token is within 90 seconds of expiring, or the usage endpoint turned it down.
  • One refresh at a time, and the file is re-read inside a lock, in case codex refreshed it a moment earlier.
  • The write is atomic, and only the token fields are written; nothing else in auth.json is touched.
  • It happens only while a deck page is open or a quota notification is on.

The quota needs Codex signed in with codex login and a ChatGPT account. With an API key there is no quota to read.


Why read files instead of installing a hook

The rollout file is on every system Codex runs on, and reading it installs nothing and asks for no trust prompt; starting ccdeck writes nothing in the Codex folder (the token refresh above is the one write, and only when needed). The cost of following rollout files is described above: a file written for Codex's own history tells you what happened, not what Codex is waiting on.

If you try this yourself, the short version: ls -t for the newest file, tail -F it, key off type and payload.type, take the model and policy from turn_context, and name quota windows by their minutes.

AI assistance was used to prepare this article and check it against the cited source code and documentation.

Top comments (0)