This is a safety-first field guide for moving and recovering Codex CLI/Desktop conversation history, making it visible to Codex Remote, and diagnosing the particularly confusing case where a thread exists on disk but does not appear in a client.
It covers two different layers:
- Supported Codex operations through the CLI and App Server protocol.
- Forensic recovery of local persisted state when supported operations can no longer discover an otherwise intact thread.
The second layer is deliberately conservative. Codex's on-disk schema is an
implementation detail and can change between releases. Never begin there when
thread/read, thread/resume, thread/fork, or thread/unarchive can solve
the problem.
Privacy warning: a Codex rollout can contain prompts, responses, file paths, diffs, command output, tool input/output, images, account identifiers, and secrets accidentally exposed during a session. Treat it as sensitive data. Encrypt transfers, narrow file permissions, and never publish a real rollout as a troubleshooting sample.
The Codex App Server describes its hierarchy as:
- A thread is a conversation.
- A turn is one user request and the agent work that follows.
- An item is a user/agent message, command execution, file change, tool call, or another unit inside a turn.
For local sessions, the durable record is normally a JSON Lines rollout under the user's Codex data directory. A local SQLite state database provides an index and mutable metadata. The CLI, Desktop app, and Remote clients do not simply enumerate SQLite rows: normal listing can scan rollout files and repair the index.
That distinction explains several otherwise contradictory symptoms:
- The database contains the thread, but the Desktop app does not list it.
thread/readsucceeds by ID, butthread/listomits the thread.thread/listwithuseStateDbOnly: truefinds it, while the default call does not.- A fresh App Server sees a repaired thread, but an already-running daemon or mobile client still shows the old list.
Prefer the supported App Server operations:
thread/listdiscovers stored threads and supports pagination, source, archive, working-directory, and search filters.thread/readreads a stored thread without loading or subscribing to it.thread/resumeloads an existing thread so a later turn can append to it.thread/forkcreates a new thread from stored history.thread/archiveandthread/unarchivemove threads between active and archived collections.thread/metadata/updateupdates documented mutable metadata such as pin and Git information.
The official protocol requires an initialize request followed by an
initialized notification before other requests on a connection.
Codex Remote is a client of a Codex process running on the selected computer. Choosing the wrong host can therefore show a completely valid—but different— project and thread list. The host selector is part of the debugging surface, not decoration.
At the time of writing, the public Remote quickstart documents connected Mac and Windows computers. Linux host procedures may rely on preview or internal capabilities and should be treated as version-sensitive unless current official documentation says otherwise.
Typical local state looks conceptually like this:
~/.codex/
├── sessions/
│ └── YYYY/MM/DD/
│ └── rollout-<timestamp>-<thread-id>.jsonl
├── archived_sessions/
├── state_5.sqlite
├── thread_history_1.sqlite
└── ...
Names and schema versions can change. Discover them; do not hard-code them into an unattended migration tool.
A rollout is append-oriented JSONL. Common record classes include
session_meta, turn_context, response_item, event_msg, and compaction or
usage records. Important properties include:
- The first usable
session_metaidentifies the thread and original runtime. - Ordering matters.
- Some releases persist an
ordinal; if present, it should remain unique and monotonic. - A user message may exist both as model-visible history and as a lifecycle event used by discovery/presentation.
- The rollout's working directory influences project grouping.
- SQLite is an index, not a safe substitute for the rollout.
Do not copy authentication files, tokens, or an entire Codex home merely to move one conversation. Copy only the required rollout and intentionally chosen non-secret metadata.
Use this order:
- Confirm the correct account, workspace, computer, and project are selected.
- Confirm the source and destination run compatible/current Codex versions.
- Try normal
thread/listand search without a restrictivecwdor source filter. - Try
thread/readby the known ID. - If archived, use
thread/unarchive. - If readable, use
thread/resumeorthread/forkrather than rewriting the rollout. - Only if the supported layer cannot discover an intact file, perform read-only forensic inspection.
- Back up, stop writers, make one narrow repair, validate with a fresh App Server, and retain rollback material.
On both computers, record versions and resolve the actual data directory:
codex --version
printf 'CODEX_HOME=%s\n' "${CODEX_HOME:-$HOME/.codex}"Check for active writers before copying or editing:
pgrep -af 'codex|ChatGPT'Pause active turns. Close or stop only the process that owns the target thread; do not kill every Codex process on a multi-session workstation. A copied file can be inconsistent if the source process appends during transfer.
Inventory candidate rollouts without printing their content:
CODEX_DATA="${CODEX_HOME:-$HOME/.codex}"
find "$CODEX_DATA/sessions" -type f \
\( -name 'rollout-*.jsonl' -o -name 'rollout-*.jsonl.gz' \) \
-printf '%TY-%Tm-%TdT%TH:%TM:%TS %s %p\n' | sortAvoid piping real rollout contents into public paste services, issue trackers, AI tools, or shell history.
Back up the exact rollout before every mutation:
rollout='/path/to/rollout-...jsonl'
backup="${rollout}.backup.$(date -u +%Y%m%dT%H%M%SZ)"
cp --preserve=mode,timestamps -- "$rollout" "$backup"
sha256sum -- "$rollout" "$backup"For a broader recovery snapshot, stop the owning processes first and archive the sessions directory plus the state database:
CODEX_DATA="${CODEX_HOME:-$HOME/.codex}"
snapshot="$HOME/codex-recovery-$(date -u +%Y%m%dT%H%M%SZ).tar"
tar -C "$CODEX_DATA" -cf "$snapshot" sessions archived_sessions state_5.sqlite
chmod 600 "$snapshot"Store this archive securely and delete it when no longer needed. It may contain the complete text of private work.
Clone or copy the project first. Decide its canonical destination path, for example:
/home/example/work/project-a
The old path does not need to exist forever, but path differences matter for project grouping and for tools or messages that reference absolute paths. Moving a transcript does not move its repository, uncommitted changes, ignored files, credentials, tool configuration, or external services.
If the original thread used multiple source folders, note that a remote-project UI may expose only one primary folder even when the agent can access other authorized local paths. Do not infer filesystem access from sidebar structure.
Prefer a thread ID obtained through thread/list or the source client. Confirm
that the filename and the first session_meta agree. A safe summary script:
#!/usr/bin/env python3
import json
import pathlib
import sys
path = pathlib.Path(sys.argv[1])
with path.open("r", encoding="utf-8") as handle:
first = json.loads(handle.readline())
payload = first.get("payload", {})
print({
"record_type": first.get("type"),
"thread_id": payload.get("id"),
"cwd": payload.get("cwd"),
"source": payload.get("source"),
"history_mode": payload.get("history_mode"),
})Do not print the rest of the file unless necessary.
Place the rollout in the destination's active sessions tree. Preserve it as a separate file; do not concatenate rollouts.
src='/path/to/rollout-...jsonl'
dest_host='example-host'
dest_dir='~/.codex/sessions/YYYY/MM/DD/'
rsync -a --protect-args -- "$src" "$dest_host:$dest_dir"Then compare hashes over SSH:
sha256sum -- "$src"
ssh "$dest_host" 'sha256sum -- ~/.codex/sessions/YYYY/MM/DD/rollout-...jsonl'Use an SSH configuration alias instead of embedding usernames, addresses, or keys in reusable documentation.
Start a fresh App Server or restart only the dedicated headless remote-control daemon. Do not restart an interactive Desktop app that owns unrelated active turns.
Run normal thread/list first. The default behavior scans rollout logs and can
repair metadata; useStateDbOnly: true intentionally skips that scan.
A successful migration requires all of these:
- Normal
thread/listreturns the ID. thread/readwith turns succeeds.- The destination workspace path is correct.
- The intended Desktop/Remote project lists the thread.
- Opening it renders real history, not merely a title or empty shell.
- Starting a new turn, if desired, writes to the destination thread rather than creating an accidental duplicate.
Do not call a migration complete after checking only SQLite or only the file.
The following script follows the documented initialization handshake and sends
read-only requests. Save it locally as inspect_codex_threads.py.
#!/usr/bin/env python3
import json
import subprocess
import sys
proc = subprocess.Popen(
["codex", "app-server"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=sys.stderr,
text=True,
bufsize=1,
)
def send(message):
proc.stdin.write(json.dumps(message) + "\n")
proc.stdin.flush()
send({
"method": "initialize",
"id": 1,
"params": {
"clientInfo": {
"name": "local_recovery_inspector",
"title": "Local recovery inspector",
"version": "0.1.0",
}
},
})
send({"method": "initialized", "params": {}})
send({
"method": "thread/list",
"id": 2,
"params": {"limit": 100, "sortKey": "recency_at"},
})
for line in proc.stdout:
message = json.loads(line)
if message.get("id") != 2:
continue
for thread in message.get("result", {}).get("data", []):
# Deliberately omit preview text: it may contain private prompt content.
print(json.dumps({
"id": thread.get("id"),
"name": thread.get("name"),
"cwd": thread.get("cwd"),
"status": thread.get("status"),
"createdAt": thread.get("createdAt"),
"updatedAt": thread.get("updatedAt"),
}))
break
proc.terminate()To read a known thread without resuming it, replace the thread/list request
with:
send({
"method": "thread/read",
"id": 2,
"params": {"threadId": "THREAD_ID", "includeTurns": True},
})Be aware that printing the response would expose the transcript. Validate structure or counts instead of dumping it to a shared terminal log.
| Symptom | Likely layer | First checks |
|---|---|---|
| Host is absent or marked offline | Remote transport/daemon | Host power, network, account/workspace, Remote enabled, daemon status |
| Host is online but shows the wrong projects | Host selection or working-directory grouping | Explicitly select the intended host; compare canonical cwd values |
| File exists but all listing methods omit it | Corrupt/unsupported rollout head | Parse every JSONL line; inspect first session_meta; compare version/history mode |
useStateDbOnly lists it but normal listing does not |
Rollout discovery disagrees with SQLite | Inspect early preview-bearing user lifecycle events; do not “fix” SQLite first |
| Fresh App Server sees it, long-running daemon does not | In-memory daemon state | Restart only the dedicated daemon after active turns are paused |
| Daemon sees it, mobile still does not | Mobile/UI cache or selected host | Switch hosts; if unchanged, restart only the mobile app process without clearing data |
| Thread title appears but opening is blank | Read/materialization failure | thread/read(includeTurns=true), JSONL parse, history-mode compatibility |
Side chat fails and logs say expected ordinal N, got M |
Stale paginated-history projection | Stop writers; compare the projection checkpoint with the rollout record at that byte offset |
| Sidebar shows a spinner after work stopped | Stale loaded/active state or disconnected client | Check thread/loaded/list, runtime status, daemon logs, and last turn-completion event |
| Model list differs between local and remote | Different runtime/version/account policy | Query versions on both computers and restart the process still running the old binary |
| Thread appears under the wrong project | Stored/runtime cwd mismatch |
Verify canonical destination path and client project definition |
The hidden-thread failure: SQLite finds it, normal listing does not
This case deserves special treatment because it looks like data loss when it is usually an indexing problem.
In the inspected Codex source revision, filesystem discovery reads the head of each rollout and requires:
- a session metadata record; and
- a discoverable preview.
The preview is derived from certain lifecycle events, including a user-message
event or a completed user-message item. A raw response_item whose role is
user can preserve the model-visible transcript without satisfying this
discovery predicate.
The inspected revision scans a small normal head plus a bounded extension while
looking for the preview. Consequently, a migrated rollout can be fully valid
and readable but invisible to normal thread/list if its first recognized
user-message lifecycle event occurs too late. SQLite may still retain a title
and preview from earlier indexing, explaining why useStateDbOnly: true finds
the same thread.
This is version-specific behavior, not a promised storage contract. Inspect the source matching the installed Codex version before applying any repair.
This detector reports whether an early completed user-message event exists. It does not print message text.
#!/usr/bin/env python3
import json
import pathlib
import sys
path = pathlib.Path(sys.argv[1])
limit = int(sys.argv[2]) if len(sys.argv) > 2 else 250
saw_meta = False
saw_user_response = False
saw_user_event = False
errors = []
with path.open("r", encoding="utf-8") as handle:
for number, line in enumerate(handle, 1):
try:
record = json.loads(line)
except Exception as exc:
errors.append((number, str(exc)))
continue
payload = record.get("payload", {})
if record.get("type") == "session_meta":
saw_meta = True
if (
record.get("type") == "response_item"
and payload.get("type") == "message"
and payload.get("role") == "user"
):
saw_user_response = True
if (
number <= limit
and record.get("type") == "event_msg"
and payload.get("type") == "item_completed"
and payload.get("item", {}).get("type") == "UserMessage"
):
saw_user_event = True
print({
"session_meta": saw_meta,
"user_response_anywhere": saw_user_response,
"completed_user_event_in_head": saw_user_event,
"parse_error_count": len(errors),
})If—and only if—the installed source confirms this exact mismatch:
- Stop the thread owner and dedicated remote daemon.
- Make a byte-for-byte backup and hash it.
- Locate the first real user
response_itemand its turn identifier. - Derive the missing completed-user lifecycle event from that existing message. Do not invent or rewrite conversation text.
- Insert it adjacent to the source message.
- If the rollout uses ordinals, increment subsequent ordinals atomically so they remain unique and monotonic.
- Write a new file in the same directory,
fsync, preserve permissions, and atomically replace the target. - Parse every line and verify ordering.
- Start a fresh App Server and confirm normal
thread/list, thenthread/read(includeTurns=true). - Restart only the required UI/daemon layers and verify the real client.
Do not publish a universal mutation script for this operation. Exact event schemas, capitalization, IDs, timestamp fields, and history modes can differ by version. A script that is correct for one build can silently damage another.
Some Codex builds keep a second SQLite database for paginated turn and item
history. In one observed schema it was named thread_history_1.sqlite and
contained tables similar to:
thread_history_projection_statethread_turnsthread_itemsthread_realtime_items
These are derived data. The rollout remains the durable conversation record, but a stale projection can prevent history pagination, opening a side chat, or showing recent turns. A characteristic App Server error is:
thread history projection for THREAD_ID expected ordinal N, got M
This often occurs after a forensic rollout repair changes record ordinals or byte positions without rebuilding the projection. It is not evidence that the conversation text is gone.
Inspect the schema from the installed build first. If it has the fields shown below, this script compares each projection checkpoint with the rollout record at the checkpoint byte offset. It prints no prompts or responses.
#!/usr/bin/env python3
import json
import os
import pathlib
import sqlite3
codex_home = pathlib.Path(os.environ.get("CODEX_HOME", pathlib.Path.home() / ".codex"))
state_path = codex_home / "state_5.sqlite"
history_path = codex_home / "thread_history_1.sqlite"
state = sqlite3.connect(f"file:{state_path}?mode=ro", uri=True)
state.row_factory = sqlite3.Row
history = sqlite3.connect(f"file:{history_path}?mode=ro", uri=True)
query = """
select id, rollout_path, archived
from threads
where rollout_path is not null
order by updated_at desc
"""
for thread in state.execute(query):
rollout = pathlib.Path(thread["rollout_path"])
if not rollout.is_file():
continue
checkpoint = history.execute(
"""
select next_rollout_byte_offset, next_rollout_ordinal
from thread_history_projection_state
where thread_id = ?
""",
(thread["id"],),
).fetchone()
if checkpoint is None:
continue
offset, expected = checkpoint
size = rollout.stat().st_size
if offset == size:
continue
boundary = True
actual = None
try:
with rollout.open("rb") as handle:
if offset:
handle.seek(offset - 1)
boundary = handle.read(1) == b"\n"
handle.seek(offset)
actual = json.loads(handle.readline()).get("ordinal")
except (OSError, json.JSONDecodeError):
boundary = False
if not boundary or actual != expected:
print({
"thread_id": thread["id"],
"archived": bool(thread["archived"]),
"rollout_size": size,
"checkpoint_offset": offset,
"expected_ordinal": expected,
"actual_ordinal": actual,
"record_boundary": boundary,
})An offset behind end-of-file is not automatically corrupt. If the record at that offset has the expected ordinal, the projection may simply be valid but not yet materialized. A mismatch, a non-record boundary, or the corresponding App Server error is stronger evidence.
Do not rebuild while any process can append to an affected rollout.
- Identify exact non-archived target thread IDs. Exclude archived duplicates unless they are independently needed.
- Verify the final rollout lifecycle event. A terminal
task_completeorturn_abortedis useful evidence, not proof by itself; also confirm no process owns the thread and that the file size remains stable. - Parse every JSONL line and verify ordinals from beginning to end.
- Back up the history database together with its
-waland-shmfiles. Also create a consistent SQLite backup using SQLite's backup API. - Hash every affected rollout. Copy any rollout that will require mutation.
- If the raw rollout is already contiguous, do not change it.
- If one inserted record duplicates the preceding ordinal, inspect the local context and installed parser. Only after proving that exact defect, shift the inserted record and every later ordinal by one using an atomic rewrite.
- Re-parse the entire rewritten rollout and prove ordinals are contiguous.
- In one transaction, delete only the affected thread IDs from the derived projection tables. Never drop the database or delete unrelated rows.
- Let the matching Codex App Server rebuild each projection. Process large rollouts independently so memory is released between threads.
- Verify each projection's next byte offset equals the rollout's exact size,
the next ordinal equals the record count, and
PRAGMA integrity_checkreturnsok.
One safe way to ask App Server to exercise the paginated materializer without creating a persistent side chat is an ephemeral fork:
{
"method": "thread/fork",
"id": 2,
"params": {
"threadId": "THREAD_ID",
"ephemeral": true,
"excludeTurns": true
}
}Enable capabilities.experimentalApi during initialization. Confirm the
returned thread is ephemeral and has no persisted path. This is a validation
technique, not a promise that future Codex builds will keep the same internal
projection schema.
Reading the state database can answer:
- Does an index row exist?
- What rollout path, title, archive flag, or working directory is indexed?
- Does database-only listing disagree with filesystem discovery?
Avoid direct database writes. Normal listing may overwrite them during reconciliation, and schema constraints can change. Prefer documented App Server metadata methods. If recovery absolutely requires SQLite work:
- stop every process using that Codex home;
- back up the database,
-wal, and-shmfiles together; - inspect the schema from that exact build;
- use a transaction;
- never delete unrelated rows;
- validate through App Server afterward;
- retain rollback files until the UI is verified.
Deleting the state database to “force a rebuild” is a broad destructive action, not a first-line repair.
A recovered thread may be healthy yet appear under the wrong project because
its historical cwd names a path from the source computer or a no-longer-
existing worktree.
Prefer these options:
- Recreate the same path when that is natural.
- Create a narrowly scoped compatibility symlink when the old path is safe and unambiguous.
- Resume or fork through supported APIs with the intended destination context.
- As a forensic last resort, adjust only the first session metadata record, after checking the installed parser and retaining a backup.
Never rewrite every historical cwd. Turn contexts and command records are a
historical account of where work actually ran. Changing all of them makes the
record misleading and can corrupt tool presentation.
Remember that thread migration and worktree migration are separate:
git -C /path/to/project status --short --branch
git -C /path/to/project worktree list --porcelainPreserve uncommitted and untracked work independently. A perfect transcript is not a repository backup.
Treat Remote as a chain:
mobile client
-> account/workspace authorization
-> selected computer
-> remote-control transport
-> Codex App Server process
-> thread discovery/index
-> rollout materialization
-> destination workspace
Test from the bottom up:
- Is the destination project directory present and readable?
- Does a fresh local App Server list and read the thread?
- Does the dedicated remote daemon run the expected Codex binary/version?
- Does the daemon reconnect after restart?
- Is the mobile app explicitly showing the intended host?
- Does a cold mobile-app reopen refresh the list?
- Can the thread open and render its latest items/diff?
Do not equate a green host dot with correct history. It proves transport health, not that indexing or project grouping is correct.
There are two different operational shapes that are easy to conflate:
- Desktop-owned host: ChatGPT Desktop owns its Codex App Server and Remote Control connection. Closing Desktop takes that host offline. This is the behavior documented for normal connected Mac and Windows computers.
- Headless host: a service manager owns a dedicated Codex App Server on an always-on or SSH development machine. This is an operational deployment choice and must be managed explicitly; exact commands remain version-sensitive.
Choose one owner for one persisted Remote Control enrollment. Do not run a Desktop-owned server and a headless server against the same identity at the same time. A common symptom is repeated HTTP 409 responses containing:
Remote app server already online
The safe recovery is not a broad pkill:
- List exact App Server PIDs, parents, start times, and command lines.
- Determine which process is the intended durable owner.
- Confirm affected turns are terminal or explicitly pause them.
- Stop only the losing owner with a normal termination signal.
- Leave the intended owner running and allow the stale backend lease to clear; repeated restarts can prolong the collision.
- Verify an explicit
Connectedstatus or connection log from the intended process, then verify the real mobile/Desktop client.
Persisting a Remote Control preference is not the same as ensuring a process exists to honor it. Conversely, a running App Server does not prove it owns the current remote connection.
- Pause active turns before daemon maintenance.
- Identify process ownership and exact command lines.
- Restart the narrow dedicated service, not every Codex/Desktop process.
- Never use a broad
pkill codexon a workstation with concurrent sessions. - Confirm the new daemon uses the expected executable; updating a binary does not replace an already-running process.
- After server repair, a client may still cache the old list. Restarting only the client process is safer than clearing application data.
The minimum acceptance test is:
[ ] Source rollout backup exists and hashes match the pre-edit file
[ ] Every JSONL line parses
[ ] First session metadata ID matches the target thread
[ ] Ordinals are unique and monotonic when present
[ ] Normal thread/list returns the thread
[ ] Database-only and normal listing no longer contradict each other
[ ] thread/read with turns returns real history
[ ] Correct canonical workspace is present
[ ] Correct host and project show the thread
[ ] Opening the thread renders messages and recent work
[ ] A new turn is appended only when intentionally tested
[ ] Repository/untracked work is independently intact
[ ] Temporary recovery copies are secured or removed
For a thread ending in an interrupted turn, confirm that the interruption has a terminal event. An interrupted last turn is not, by itself, evidence that the whole thread is corrupt.
- Copying only SQLite: the index points at a rollout that is absent.
- Copying all of
~/.codex: unnecessarily transfers credentials and other private conversations. - Editing while Codex is running: the writer can append to or replace your repaired file.
- Trusting a title-only UI check: the transcript may still fail to load.
- Fixing the database first: filesystem reconciliation can undo the change.
- Changing thread IDs casually: references, fork lineage, and filename identity can diverge.
- Flattening fork metadata: it may make a thread look standalone while losing real lineage.
- Rewriting all absolute paths: historical tool records become false.
- Assuming an update is active: the daemon may still be the old process.
- Confusing host status dots: a horizontally scrolling host selector can visually associate a dot with the neighboring computer.
- Clearing mobile app data: this is rarely necessary and may sign the user out or remove unrelated local state.
- Publishing diagnostic output: previews and tool logs can expose private data even when tokens are redacted.
For important long-running work:
- Keep repositories committed or separately backed up; transcripts are not source control.
- Record the thread ID and canonical project path in a private recovery note.
- Keep Codex versions reasonably aligned across machines.
- Update the executable and restart its owning daemon as one managed operation.
- Use a service manager for a headless remote daemon, with a narrow unit and explicit executable path.
- Monitor connection/restart state without logging prompt or transcript bodies.
- Periodically validate that a normal thread list—not just SQLite—can discover critical sessions.
- Prefer supported fork/resume operations for planned moves.
- Keep recovery tooling read-only by default and require an explicit target path for mutation.
- Never build automation that recursively deletes or rewrites the Codex data directory.
- Official Codex Remote guide
- Official ChatGPT Remote Connections guide
- Official Codex CLI guide
- Official Codex App Server protocol guide
- Open-source Codex repository
- Filesystem thread discovery implementation at the inspected revision
The low-level observations in this guide were validated against open-source
revision ac192cd7937b0d73edc6dffe009940ae53782dd4. Re-check the corresponding
source before using any forensic procedure with another version.
Restore evidence before restoring convenience. Preserve the original rollout, prove which layer is failing, change one invariant at a time, and verify the result through the same client the user actually depends on.