DEV Community

Cover image for Come osFoundry trova le tue sessioni di Claude Code e Codex su disco, ricostruito in Python
BBT for BBT Italiano

Posted on

Come osFoundry trova le tue sessioni di Claude Code e Codex su disco, ricostruito in Python

~/.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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 cwd era al massimo alla riga 2 (mediana 2)
  • il primo gitBranch e il primo record user: 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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 default o plan; per Codex, approvazione untrusted o on-request, sandbox read-only o workspace-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 cwd dai record, non dal nome della cartella con l'escape.
  • Preferisci lo state_<n>.sqlite di Codex (n più 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 con source JSON); 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)