~/.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
Questo è l'intero contratto tra le CLI e un selettore di sessioni. Claude Code e Codex scrivono ciascuno le proprie sessioni in normali file nella tua cartella home, ed entrambi accettano un id per riprenderne una. La schermata External agents dell'app desktop di osFoundry è un lettore costruito sopra quei file (Rust, runtime/session_inventory.rs e runtime/external_sessions.rs). Elenca le sessioni di dodici app, cerca su titolo, cartella, branch, app e id, e avvia in un terminale il comando di ripresa giusto.
Qui sotto trovi la parte relativa a Claude Code e Codex, ricostruita come script Python di 150 righe ed eseguita sulle vere cartelle delle sessioni del Mac su cui è stato scritto questo articolo. In nessun punto dell'articolo viene stampato il contenuto delle trascrizioni. Ogni numero è un conteggio, una dimensione o un tempo.
Com'è fatta una sessione di Claude Code su disco?
Un file JSONL per sessione, con l'id della sessione come nome, dentro una cartella che prende il nome dalla directory di lavoro. Il nome della cartella è il percorso assoluto in cui ogni carattere che non è una lettera o una cifra viene sostituito da -:
re.sub(r"[^A-Za-z0-9]", "-", cwd) # /Users/alex/code/shop-api -> -Users-alex-code-shop-api
Verificato sulle 40 trascrizioni presenti qui: 40 cartelle su 40 rispettano la regola. La regola perde informazioni (my.app e my-app collidono, e lo stesso vale per / e .), quindi non decodificare mai il nome della cartella. Leggi cwd dai record.
I sub-agent (Explore e simili, dello strumento Task) scrivono le proprie trascrizioni in <session id>/subagents/agent-<id>.jsonl. Qui ce n'erano 129. Non sono sessioni che qualcuno riprende, e osFoundry salta qualsiasi file il cui nome base inizi con agent-.
Ogni riga è un record con un type. Contando i tipi nelle prime 400 righe di ciascuno dei 40 file:
| type | record | chiavi utili per un indice |
|---|---|---|
| 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+ | non servono |
I record di conversazione (user, assistant, attachment, system) contengono tutti sessionId, cwd, gitBranch, timestamp e version. message.content è una stringa oppure una lista di blocchi; in quelle righe i tipi di blocco erano tool_result 2,261, tool_use 2,223, thinking 1,224 e text 754. Il titolo arriva da un record custom-title oppure, nelle build che lo scrivono, da un record ai-title con aiTitle. Qui tutti i file avevano custom-title e nessuno ai-title, quindi leggili entrambi.
Perché non fare semplicemente il parsing di ogni file?
Perché sono grandi. Le 40 trascrizioni di primo livello qui arrivano in totale a 19.4 GB. Il file mediano pesa 168 MB, il più grande 2.07 GB e il 90° percentile 1.94 GB.
Far passare il file mediano riga per riga attraverso json.loads ha richiesto 0.61 s (11,558 righe, circa 279 MB/s su un Mac Apple Silicon, con la page cache calda). A quella velocità, un parsing completo di tutti e 40 richiederebbe circa 70 s, prima ancora di aver mostrato qualcosa.
Le informazioni che servono a una lista stanno alle due estremità del file. Misurato su tutti e 40:
- il primo record con
cwdera al massimo alla riga 2 (mediana 2) - il primo
gitBranche il primo recorduser: al massimo alla riga 2 - un record di titolo era negli ultimi 512 KiB in 40 file su 40
- nessuna delle prime 80 righe superava i 256 KiB
Quindi l'indice legge le prime 80 righe e gli ultimi 512 KiB. È quello che fa anche osFoundry: HEAD_LINES = 80 con un limite di 256 KiB per riga in session_inventory.rs, e SETTINGS_TAIL = 512 * 1024 in external_sessions.rs, letto per ricavare l'ultimo modello, l'effort, la modalità di approvazione e il branch. Leggere inizio e fine di tutti e 40 i file ha richiesto 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
Il readline con limite è importante. Un turno utente con un'immagine incollata è un'unica riga da diversi megabyte, e un semplice for line in f la caricherebbe tutta in memoria solo per buttarla via.
Quali campi servono davvero a un selettore?
Cinque, più un orario: app, id, cartella, branch, titolo. L'inizio del file fornisce cwd, il primo gitBranch e un titolo di riserva preso dal primo vero messaggio dell'utente (non una sidechain, e non uno che inizia con <, che è il modo in cui iniziano i wrapper di comandi e hook). La fine del file lo sovrascrive con il titolo e il branch più recenti, perché una sessione lunga spesso sopravvive al branch su cui è iniziata.
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() è json.loads riga per riga, saltando le righe non valide (la prima riga di una lettura dalla fine è tagliata a metà). first_text() restituisce il contenuto se è una stringa, oppure il primo blocco text. L'ora dell'ultimo utilizzo è l'mtime del file, che è anche ciò che osFoundry usa per ordinare.
Dove tiene Codex le stesse informazioni?
Codex scrive un rollout per sessione in un albero organizzato per data, ~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>-<thread id>.jsonl, e tiene quelli archiviati in ~/.codex/archived_sessions/. Qui: 10 rollout (28.5 MB) e 2 archiviati. La prima riga di ogni rollout è un record session_meta il cui payload contiene id, cwd, originator, source, cli_version, model_provider, timestamp e, in alcune build, git.branch. Il resto sono record event_msg, response_item (con i tipi di payload message, function_call, function_call_output, reasoning, custom_tool_call, …), turn_context e compacted.
Codex mantiene anche un indice, quindi non devi nemmeno aprire i rollout. ~/.codex/state_<n>.sqlite ha una tabella threads; qui è state_5.sqlite, con 43 colonne e 12 righe, tra cui id, rollout_path, cwd, git_branch, title, model, reasoning_effort, approval_mode, sandbox_policy, source, archived e updated_at_ms. Possono esserci più file state_<n>; codex_state_db in osFoundry prende l'n più alto, e lo script fa lo stesso. Le righe il cui source è un oggetto JSON sono thread di sub-agent; saltale.
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}
Una cosa che ha richiesto un secondo tentativo: aprire il database solo con ?mode=ro qui è fallito con unable to open database file. Aggiungendo immutable=1 ha funzionato. Immutable dice a SQLite che il file non cambierà finché resta aperto, quindi salta il lock e i file -shm; per un indice letto come snapshot va bene, e ti permette di leggere un database che l'app tiene aperto. osFoundry apre l'indice di Codex con SQLITE_OPEN_READ_ONLY, e usa un'apertura con immutable=1 per i database che un'altra app potrebbe tenere aperti in scrittura.
Quanto è veloce su cartelle reali?
$ 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 sessioni (40 di Claude Code, 12 di Codex), tutte con cartella e titolo, e le 40 di Claude Code anche con il branch. La prima esecuzione è quella più a freddo; le altre erano a caldo. Il file di indice pesa 11,480 byte. Contro i 70 s di un parsing completo, 80–150 ms fanno la differenza tra una lista che si apre all'istante e una con lo spinner.
La ricerca diventa quindi un test di sottostringa su cinque campi, gli stessi che usa matchesQuery di osFoundry 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)
Su una home finta costruita con la stessa struttura (dati di esempio, non le sessioni reali):
$ 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
Cercare nel testo dei messaggi vorrebbe dire leggere i 19.4 GB, oppure costruire un indice full-text di tutto quello che hai mai incollato in un agent. Cercare su cartella e branch trova la sessione tramite il nome del progetto, che è comunque quello che ti ricordi.
Cosa aggiunge una ripresa sicura?
Stampare claude --resume <id> è la parte facile. Quello che osFoundry aggiunge attorno (plan_command e il piano di avvio in external_sessions.rs, più i controlli che la schermata fa prima di aprire il terminale) vale la pena di copiarlo:
- Avviare nella cartella registrata, e rifiutarsi se non esiste più invece di riprendere altrove.
- Confrontare il branch registrato con
git -C <cwd> rev-parse --abbrev-ref HEAD. Se sono diversi, segnalarlo e chiedere; mai fare checkout. - Riportare il modello (
--model, per Codex-m) e l'effort (--effort, per Codex-c model_reasoning_effort="…"). - Riportare una modalità di approvazione registrata solo se è almeno altrettanto restrittiva del default della CLI. Per Claude Code significa
defaultoplan; per Codex, approvazioneuntrustedoon-request, sandboxread-onlyoworkspace-write. Tutto ciò che è più permissivo viene trattenuto, a meno che la persona non lo attivi per quel singolo avvio.
Il sessidx.py completo è composto dai tre pezzi qui sopra più circa 30 righe di collante (override tramite variabili d'ambiente per le home, output JSON, i sottocomandi find e resume). Sul Mac usato qui non c'è nessun binario claude o codex, quindi la riga di ripresa viene stampata, non eseguita.
Se lo costruisci da solo
- Tratta i file come l'API: solo lettura, non scrivere mai accanto a loro e non aprire mai i file di autenticazione delle CLI per ricavare un nome account.
- Leggi inizio e fine, non le trascrizioni intere. Limita la lunghezza delle righe, così un'immagine incollata non ti costa la sua dimensione in memoria.
- Prendi
cwddai record, non dal nome della cartella con l'escape. - Preferisci lo
state_<n>.sqlitedi Codex (npiù alto) alla visita dell'albero per date, e aprilo come immutable se l'app potrebbe tenerlo aperto. - Escludi dalla lista le trascrizioni dei sub-agent (
agent-*.jsonl, thread consourceJSON); nessuno le riprende direttamente. - Decidi in anticipo cosa può cambiare una ripresa. Un default sicuro: cartella sì, modello sì, approvazioni più permissive solo su richiesta, branch mai.
La vista Sessions degli agent di osFoundry lavora su una fonte diversa; è descritta nella pagina sulle sessioni degli agent di osFoundry.
Top comments (0)