~/.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
Hanya itu kontrak antara CLI dan sebuah session picker. Claude Code dan Codex masing-masing menyimpan sesinya sebagai file biasa di folder home kamu, dan masing-masing menerima sebuah id untuk melanjutkan satu sesi. Layar External agents di aplikasi desktop osFoundry adalah pembaca yang berjalan di atas file-file itu (Rust, runtime/session_inventory.rs dan runtime/external_sessions.rs). Layar ini menampilkan sesi dari dua belas aplikasi, mencocokkan pencarian dengan judul, folder, branch, aplikasi, dan id, lalu menjalankan perintah resume yang tepat di terminal.
Di bawah ini bagian Claude Code dan Codex dari fitur itu, dibuat ulang sebagai skrip Python 150 baris dan dijalankan pada folder sesi asli di Mac tempat tulisan ini dibuat. Tidak ada isi transkrip yang ditampilkan di mana pun dalam tulisan ini. Setiap angka adalah jumlah, ukuran, atau waktu.
Seperti apa satu sesi Claude Code di disk?
Satu file JSONL per sesi, dinamai dengan id sesi, di dalam folder yang dinamai sesuai direktori kerja. Nama foldernya adalah path absolut dengan setiap karakter yang bukan huruf atau angka diganti -:
re.sub(r"[^A-Za-z0-9]", "-", cwd) # /Users/alex/code/shop-api -> -Users-alex-code-shop-api
Dicek terhadap 40 transkrip di mesin ini: 40 dari 40 folder sesuai aturan itu. Aturannya lossy (my.app dan my-app bentrok, begitu juga / dan .), jadi jangan pernah men-decode nama folder. Baca cwd dari record-nya.
Sub-agent (Explore dan kawan-kawan dari tool Task) menulis transkripnya sendiri di <session id>/subagents/agent-<id>.jsonl. Di sini ada 129. Itu bukan sesi yang akan di-resume orang, dan osFoundry melewati setiap file yang stem-nya diawali agent-.
Setiap baris adalah satu record dengan sebuah type. Menghitung type di 400 baris pertama dari masing-masing 40 file:
| type | record | key yang penting untuk 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+ | tidak diperlukan |
Record percakapan (user, assistant, attachment, system) semuanya membawa sessionId, cwd, gitBranch, timestamp, dan version. message.content bisa berupa string atau list of blocks; di baris-baris itu tipe block-nya adalah tool_result 2,261, tool_use 2,223, thinking 1,224, dan text 754. Judul berasal dari record custom-title atau, pada build yang menulisnya, record ai-title dengan aiTitle. Di sini semua file punya custom-title dan tidak ada yang punya ai-title, jadi baca keduanya.
Kenapa tidak parse semua file saja?
Karena ukurannya besar. Ke-40 transkrip level atas di sini totalnya 19.4 GB. Median file-nya 168 MB, yang terbesar 2.07 GB, dan persentil ke-90 1.94 GB.
Men-stream file median lewat json.loads baris per baris butuh 0.61 s (11,558 baris, sekitar 279 MB/s di Mac Apple Silicon, dengan page cache yang sudah hangat). Dengan kecepatan itu, parse penuh untuk semua 40 file akan makan sekitar 70 s, bahkan sebelum ada yang ditampilkan.
Data yang dibutuhkan sebuah daftar ada di kedua ujung file. Diukur pada semua 40 file:
- record pertama yang punya
cwdpaling lambat ada di baris 2 (median 2) -
gitBranchpertama dan recorduserpertama: paling lambat baris 2 - record judul ada di dalam 512 KiB terakhir pada 40 dari 40 file
- tidak ada baris di 80 baris pertama yang lebih dari 256 KiB
Jadi index membaca 80 baris pertama dan 512 KiB terakhir. Itu juga yang dilakukan osFoundry: HEAD_LINES = 80 dengan batas 256 KiB per baris di session_inventory.rs, dan SETTINGS_TAIL = 512 * 1024 di external_sessions.rs, yang dibaca untuk model, effort, mode approval, dan branch terbaru. Membaca head dan tail dari semua 40 file butuh 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
readline yang dibatasi itu penting. Satu giliran user yang berisi gambar yang di-paste adalah satu baris berukuran beberapa megabyte, dan for line in f biasa akan menarik semuanya ke memori hanya untuk dibuang.
Field apa saja yang benar-benar dibutuhkan picker?
Lima, ditambah waktu: aplikasi, id, folder, branch, judul. Head memberikan cwd, gitBranch pertama, dan judul cadangan dari pesan user asli yang pertama (bukan sidechain, dan bukan yang diawali <, karena begitulah wrapper command dan hook dimulai). Tail menimpanya dengan judul dan branch terbaru, karena sesi yang panjang sering bertahan lebih lama dari branch tempat ia dimulai.
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() adalah json.loads per baris dengan baris rusak dilewati (baris pertama dari pembacaan tail terpotong di tengah). first_text() mengembalikan isi yang berupa string, atau block text pertama. Waktu terakhir dipakai diambil dari mtime file, yang juga dipakai osFoundry untuk pengurutan.
Di mana Codex menyimpan data yang sama?
Codex menulis satu rollout per sesi di bawah pohon folder berdasarkan tanggal, ~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>-<thread id>.jsonl, dan menyimpan yang diarsipkan di ~/.codex/archived_sessions/. Di sini: 10 rollout (28.5 MB) dan 2 yang diarsipkan. Baris pertama setiap rollout adalah record session_meta yang payload-nya berisi id, cwd, originator, source, cli_version, model_provider, timestamp dan, di beberapa build, git.branch. Sisanya adalah record event_msg, response_item (dengan tipe payload message, function_call, function_call_output, reasoning, custom_tool_call, …), turn_context, dan compacted.
Codex juga menyimpan index, jadi kamu sama sekali tidak perlu membuka rollout. ~/.codex/state_<n>.sqlite punya tabel threads; di sini file-nya state_5.sqlite dengan 43 kolom dan 12 baris, termasuk id, rollout_path, cwd, git_branch, title, model, reasoning_effort, approval_mode, sandbox_policy, source, archived, dan updated_at_ms. Bisa ada lebih dari satu file state_<n>; codex_state_db di osFoundry mengambil n tertinggi, dan skripnya juga begitu. Baris yang source-nya berupa objek JSON adalah thread sub-agent; lewati saja.
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}
Satu hal yang butuh percobaan kedua: membuka database hanya dengan ?mode=ro gagal di sini dengan unable to open database file. Menambahkan immutable=1 berhasil. Immutable memberi tahu SQLite bahwa file tidak akan berubah selama dibuka, jadi SQLite melewati lock dan file -shm; itu aman untuk index berupa snapshot, dan artinya kamu bisa membaca database yang sedang dibuka oleh aplikasinya. osFoundry membuka index Codex dengan SQLITE_OPEN_READ_ONLY, dan memakai open dengan immutable=1 untuk database yang mungkin sedang dibuka aplikasi lain untuk menulis.
Seberapa cepat di folder sungguhan?
$ 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 sesi (40 Claude Code, 12 Codex), semuanya punya folder dan judul, dan ke-40 sesi Claude Code punya branch. Run pertama adalah yang paling dingin; sisanya sudah hangat. File index-nya 11,480 byte. Dibandingkan parse penuh 70 s, 80 sampai 150 ms adalah beda antara daftar yang langsung terbuka dan daftar yang menampilkan spinner.
Pencarian lalu cukup berupa tes substring pada lima field, sama dengan yang dipakai matchesQuery milik osFoundry di 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)
Pada home palsu yang dibuat dengan struktur yang sama (data contoh, bukan sesi asli):
$ 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
Mencari di dalam teks pesan berarti membaca 19.4 GB itu, atau membangun full-text index dari semua yang pernah kamu paste ke agent. Mencari berdasarkan folder dan branch menemukan sesi lewat nama proyek, dan memang itu yang biasanya kamu ingat.
Apa yang ditambahkan oleh resume yang aman?
Mencetak claude --resume <id> itu bagian yang gampang. Yang ditambahkan osFoundry di sekitarnya (plan_command dan launch plan di external_sessions.rs, plus pengecekan yang dilakukan layar sebelum membuka terminal) layak ditiru:
- Jalankan di folder yang tercatat, dan tolak kalau folder itu sudah tidak ada, alih-alih melanjutkan di tempat lain.
- Bandingkan branch yang tercatat dengan
git -C <cwd> rev-parse --abbrev-ref HEAD. Kalau berbeda, beri tahu dan tanyakan; jangan pernah checkout. - Bawa model (
--model, di Codex-m) dan effort (--effort, di Codex-c model_reasoning_effort="…"). - Bawa mode approval yang tercatat hanya kalau setidaknya seketat default CLI. Untuk Claude Code itu
defaultatauplan; untuk Codex, approvaluntrustedatauon-request, sandboxread-onlyatauworkspace-write. Yang lebih longgar ditahan, kecuali orangnya memilih untuk mengaktifkannya pada peluncuran itu saja.
sessidx.py lengkapnya terdiri dari tiga bagian di atas plus sekitar 30 baris kode perekat (override env untuk folder home, output JSON, subcommand find dan resume). Tidak ada binary claude atau codex di Mac yang dipakai di sini, jadi baris resume-nya dicetak, bukan dijalankan.
Kalau kamu mau membuatnya sendiri
- Perlakukan file-file itu sebagai API: hanya baca, jangan pernah menulis di sebelahnya, dan jangan pernah membuka file auth milik CLI untuk mengambil nama akun.
- Baca head dan tail, bukan seluruh transkrip. Batasi panjang baris supaya satu gambar yang di-paste tidak memakan memori sebesar ukurannya.
- Ambil
cwddari record, bukan dari nama folder yang di-escape. - Utamakan
state_<n>.sqlitemilik Codex (ntertinggi) daripada menelusuri pohon tanggal, dan buka dalam mode immutable kalau aplikasinya mungkin sedang memegangnya. - Jangan masukkan transkrip sub-agent (
agent-*.jsonl, thread dengansourceJSON) ke daftar; tidak ada yang me-resume-nya secara langsung. - Tentukan di awal apa saja yang boleh diubah oleh resume. Default yang aman: folder ya, model ya, approval yang lebih longgar hanya kalau diminta, branch tidak pernah.
Tampilan Sessions untuk agent milik osFoundry sendiri bekerja dari sumber yang berbeda; penjelasannya ada di halaman sesi agent osFoundry.
Top comments (0)