~/.claude/projects/-Users-alex-code-shop-api/c5e1a7b2-9d40-4f3e-8b16-2a7c0e9d4f58.jsonl
~/.claude/projects/-Users-alex-code-shop-api/c5e1a7b2-9d40-4f3e-8b16-2a7c0e9d4f58/subagents/agent-<id>.jsonl
~/.codex/state_5.sqlite -> table threads (id, rollout_path, cwd, git_branch, title, …)
~/.codex/sessions/2026/11/04/rollout-2026-11-04T10-00-00-<thread id>.jsonl
$ claude --resume c5e1a7b2-9d40-4f3e-8b16-2a7c0e9d4f58
$ codex resume 019a2f1c-7d0e-7b31-9c4e-5a1f0e2d8b63
That is the whole contract between the CLIs and a session picker. Claude Code and Codex each write their sessions to plain files in your home folder, and each takes an id to resume one. The External agents screen in osFoundry's desktop app is a reader on top of those files (Rust, runtime/session_inventory.rs and runtime/external_sessions.rs). It lists sessions from twelve apps, matches a search against title, folder, branch, app and id, and starts the right resume command in a terminal.
Below is the Claude Code and Codex part of that, rebuilt as a 150-line Python script and run against the real session folders on the Mac this was written on. No transcript content is printed anywhere in this post. Every number is a count, a size, or a timing.
What does one Claude Code session look like on disk?
One JSONL file per session, named by the session id, inside a folder named after the working directory. The folder name is the absolute path with every character that is not a letter or digit replaced by -:
re.sub(r"[^A-Za-z0-9]", "-", cwd) # /Users/alex/code/shop-api -> -Users-alex-code-shop-api
Checked against the 40 transcripts here: 40 of 40 folders match that rule. The rule is lossy (my.app and my-app collide, so do / and .), so never decode the folder name. Read cwd from the records instead.
Sub-agents (the Task tool's Explore and friends) write their own transcripts under <session id>/subagents/agent-<id>.jsonl. There were 129 of those here. They are not sessions anyone resumes, and osFoundry skips any file whose stem starts with agent-.
Each line is one record with a type. Counting types in the first 400 lines of each of the 40 files:
| type | records | keys that matter for an index |
|---|---|---|
| assistant | 4,060 |
requestedModel, effort, gitBranch, message.model
|
| attachment | 3,197 |
cwd, gitBranch
|
| user | 2,469 |
cwd, gitBranch, permissionMode, isSidechain, message.content, entrypoint
|
| custom-title | 922 | customTitle |
| agent-name | 827 | agentName |
| system, mode, queue-operation, file-history-, api-request, … | 2,000+ | not needed |
The conversation records (user, assistant, attachment, system) all carry sessionId, cwd, gitBranch, timestamp and version. message.content is either a string or a list of blocks; across those lines the block types were tool_result 2,261, tool_use 2,223, thinking 1,224 and text 754. A title is either a custom-title record or, on builds that write one, an ai-title record with aiTitle. Here every file had custom-title and none had ai-title, so read both.
Why not just parse every file?
Because they are big. The 40 top-level transcripts here add up to 19.4 GB. The median file is 168 MB, the largest 2.07 GB, and the 90th percentile 1.94 GB.
Streaming the median file through json.loads line by line took 0.61 s (11,558 lines, about 279 MB/s on an Apple Silicon Mac, warm page cache). At that rate a full parse of all 40 would be around 70 s, before you have shown anything.
The facts a list needs sit at the two ends of the file. Measured on all 40:
- the first record with
cwdwas at line 2 at the latest (median 2) - the first
gitBranchand the firstuserrecord: line 2 at the latest - a title record was inside the last 512 KiB in 40 of 40 files
- no line in the first 80 was over 256 KiB
So the index reads the first 80 lines and the last 512 KiB. That is what osFoundry does too: HEAD_LINES = 80 with a 256 KiB per-line cap in session_inventory.rs, and SETTINGS_TAIL = 512 * 1024 in external_sessions.rs, read for the latest model, effort, approval mode and branch. Reading head and tail of all 40 files took 86 ms.
HEAD_LINES, LINE_CAP, TAIL_BYTES = 80, 256 * 1024, 512 * 1024
def head(p, n=HEAD_LINES):
out = []
with open(p, "rb") as f:
for _ in range(n):
line = f.readline(LINE_CAP + 1)
if not line:
break
if len(line) > LINE_CAP: # a pasted file or image: skip the rest of it
while line and not line.endswith(b"\n"):
line = f.readline(1 << 20)
continue
out.append(line)
return out
def tail(p, nbytes=TAIL_BYTES):
with open(p, "rb") as f:
f.seek(0, 2)
size = f.tell()
f.seek(max(0, size - nbytes))
lines = f.read().split(b"\n")
return lines[1:] if size > nbytes else lines # first piece is a cut line
The capped readline matters. One user turn with a pasted image is a single multi-megabyte line, and a plain for line in f would pull all of it into memory just to throw it away.
Which fields does a picker actually need?
Five, plus a time: app, id, folder, branch, title. The head gives cwd, the first gitBranch, and a fallback title from the first real user message (not a sidechain, and not one starting with <, which is how command and hook wrappers begin). The tail overrides with the newest title and the newest branch, because a long session often outlives the branch it started on.
def claude_sessions():
for proj in sorted((CLAUDE / "projects").glob("*/")):
for p in proj.glob("*.jsonl"):
if p.stem.startswith("agent-"): # sub-agent transcript, not resumable
continue
st = p.stat()
cwd = branch = title = None
for v in records(head(p)):
cwd = cwd or v.get("cwd")
branch = branch or (v.get("gitBranch") or None)
if title is None and v.get("type") == "user" and not v.get("isSidechain"):
t = (first_text((v.get("message") or {}).get("content")) or "").strip()
if t and not t.startswith("<"):
title = t[:80]
if cwd and branch and title:
break
for v in records(tail(p)): # newest wins: titles, branch moves
for k in ("customTitle", "aiTitle"):
if v.get(k):
title = v[k][:100]
branch = v.get("gitBranch") or branch
yield {"app": "claude-code", "id": p.stem, "cwd": cwd, "branch": branch,
"title": title, "mtime": int(st.st_mtime * 1000), "bytes": st.st_size}
records() is json.loads per line with bad lines skipped (the first line of a tail read is cut in half). first_text() returns the string content, or the first text block. Last-used time is the file's mtime, which is what osFoundry uses for sorting as well.
Where does Codex keep the same facts?
Codex writes one rollout per session under a dated tree, ~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>-<thread id>.jsonl, and keeps archived ones in ~/.codex/archived_sessions/. Here: 10 rollouts (28.5 MB) and 2 archived. The first line of every rollout is a session_meta record whose payload has id, cwd, originator, source, cli_version, model_provider, timestamp and, on some builds, git.branch. The rest are event_msg, response_item (with payload types message, function_call, function_call_output, reasoning, custom_tool_call, …), turn_context and compacted records.
Codex also keeps an index, so you do not have to open rollouts at all. ~/.codex/state_<n>.sqlite has a threads table; here it is state_5.sqlite with 43 columns and 12 rows, including id, rollout_path, cwd, git_branch, title, model, reasoning_effort, approval_mode, sandbox_policy, source, archived and updated_at_ms. There can be more than one state_<n> file; codex_state_db in osFoundry takes the highest n, and so does the script. Rows whose source is a JSON object are sub-agent threads; skip them.
def codex_sessions():
db = codex_db()
if db:
con = sqlite3.connect(f"file:{db}?mode=ro&immutable=1", uri=True)
q = ("SELECT id, cwd, git_branch, title, COALESCE(updated_at_ms, updated_at*1000), source "
"FROM threads ORDER BY 5 DESC LIMIT 300")
for id_, cwd, br, title, ms, source in con.execute(q):
if (source or "").startswith("{"): # sub-agent thread
continue
yield {"app": "codex", "id": id_, "cwd": cwd, "branch": br,
"title": (title or "")[:100] or None, "mtime": ms or 0, "bytes": None}
return
for p in (CODEX / "sessions").glob("*/*/*/rollout-*.jsonl"):
v = next(records(head(p, 1)), {})
if v.get("type") != "session_meta":
continue
pl = v.get("payload", {})
yield {"app": "codex", "id": pl.get("id"), "cwd": pl.get("cwd"),
"branch": (pl.get("git") or {}).get("branch"), "title": None,
"mtime": int(p.stat().st_mtime * 1000), "bytes": p.stat().st_size}
One thing that cost a retry: opening the database with ?mode=ro alone failed here with unable to open database file. Adding immutable=1 worked. Immutable tells SQLite the file will not change while open, so it skips the lock and -shm files; that is fine for a snapshot index, and it means you can read a database that the app holds open. osFoundry opens Codex's index with SQLITE_OPEN_READ_ONLY, and uses an immutable=1 open for databases another app may hold open for writing.
How fast is it on real folders?
$ python3 sessidx.py build # four runs, same Mac, real ~/.claude and ~/.codex
52 sessions claude 154 ms codex 3 ms
52 sessions claude 80 ms codex 2 ms
52 sessions claude 83 ms codex 2 ms
52 sessions claude 107 ms codex 4 ms
52 sessions (40 Claude Code, 12 Codex), every one with a folder and a title, the 40 Claude Code ones with a branch. The first run is the coldest; the others were warm. The index file is 11,480 bytes. Against a 70 s full parse, 80 to 150 ms is the difference between a list that opens instantly and one with a spinner.
Search is then a substring test over five fields, the same set osFoundry's matchesQuery uses in lib/external-sessions.ts:
def matches(r, q):
q = q.strip().lower()
return not q or any(q in str(v).lower() for v in (r["title"], r["cwd"], r["branch"], r["app"], r["id"]) if v)
On a fake home made in the same shapes (Example data, not the real sessions):
$ SESSIDX_HOME=./fx python3 sessidx.py find checkout
codex 019a2f1c test/checkout-e2e -
claude-code c5e1a7b2 fix/checkout-retry Fix double charge on checkout retry
$ SESSIDX_HOME=./fx python3 sessidx.py resume c5e1
cd /Users/alex/code/shop-api && claude --resume c5e1a7b2-9d40-4f3e-8b16-2a7c0e9d4f58
Matching inside message text would mean reading the 19.4 GB, or building a full-text index of everything you ever pasted into an agent. Matching on folder and branch finds the session by the project name, which is what you remember anyway.
What does a safe resume add?
Printing claude --resume <id> is the easy part. What osFoundry adds around it (plan_command and the launch plan in external_sessions.rs, plus the screen's checks before it opens the terminal) is worth copying:
- Run in the recorded folder, and refuse if it no longer exists rather than resuming somewhere else.
- Compare the recorded branch with
git -C <cwd> rev-parse --abbrev-ref HEAD. If they differ, say so and ask; never check out. - Carry the model (
--model, Codex-m) and effort (--effort, Codex-c model_reasoning_effort="…"). - Carry a recorded approval mode only when it is at least as strict as the CLI's default. For Claude Code that is
defaultorplan; for Codex approvaluntrustedoron-request, sandboxread-onlyorworkspace-write. Anything looser is held back unless the person opts in for that one launch.
The full sessidx.py is the three pieces above plus about 30 lines of glue (env overrides for the homes, JSON output, a find and a resume subcommand). There is no claude or codex binary on the Mac used here, so the resume line is printed, not executed.
If you wire this up yourself
- Treat the files as the API: read only, never write next to them, and never open the CLIs' auth files to get an account name.
- Read head and tail, not whole transcripts. Cap line length so one pasted image cannot cost you its size in memory.
- Get
cwdfrom the records, not from the escaped folder name. - Prefer Codex's
state_<n>.sqlite(highestn) over walking the dated tree, and open it immutable if the app may hold it. - Skip sub-agent transcripts (
agent-*.jsonl, JSONsourcethreads) in the list; nobody resumes them directly. - Decide up front what a resume may change. A safe default is: folder yes, model yes, looser approvals only on request, branch never.
The Sessions view of osFoundry's own agents works from a different source; it is described on the osFoundry agent sessions page.
Top comments (0)