DEV Community

Cover image for Comment osFoundry retrouve vos sessions Claude Code et Codex sur le disque, reconstruit en Python
BBT for BBT Français

Posted on

Comment osFoundry retrouve vos sessions Claude Code et Codex sur le disque, reconstruit en 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

C'est tout le contrat entre les CLI et un sélecteur de sessions. Claude Code et Codex écrivent chacun leurs sessions dans de simples fichiers de votre dossier personnel, et chacun accepte un id pour en reprendre une. L'écran External agents de l'application de bureau d'osFoundry est un lecteur posé sur ces fichiers (Rust, runtime/session_inventory.rs et runtime/external_sessions.rs). Il liste les sessions de douze applications, fait correspondre une recherche avec le titre, le dossier, la branche, l'application et l'id, puis lance la bonne commande de reprise dans un terminal.

Voici la partie Claude Code et Codex, reconstruite sous forme d'un script Python de 150 lignes et exécutée sur les vrais dossiers de sessions du Mac sur lequel cet article a été écrit. Aucun contenu de transcription n'est affiché dans cet article. Chaque chiffre est un nombre, une taille ou une durée.

À quoi ressemble une session Claude Code sur le disque ?

Un fichier JSONL par session, nommé d'après l'id de la session, dans un dossier nommé d'après le répertoire de travail. Le nom du dossier est le chemin absolu dans lequel chaque caractère qui n'est ni une lettre ni un chiffre est remplacé par - :

re.sub(r"[^A-Za-z0-9]", "-", cwd)   # /Users/alex/code/shop-api -> -Users-alex-code-shop-api
Enter fullscreen mode Exit fullscreen mode

Vérifié sur les 40 transcriptions présentes ici : 40 dossiers sur 40 respectent cette règle. La règle perd de l'information (my.app et my-app entrent en collision, tout comme / et .), donc ne décodez jamais le nom du dossier. Lisez plutôt cwd dans les enregistrements.

Les sous-agents (Explore et compagnie, de l'outil Task) écrivent leurs propres transcriptions dans <session id>/subagents/agent-<id>.jsonl. Il y en avait 129 ici. Ce ne sont pas des sessions que l'on reprend, et osFoundry ignore tout fichier dont le nom de base commence par agent-.

Chaque ligne est un enregistrement avec un type. En comptant les types sur les 400 premières lignes de chacun des 40 fichiers :

type enregistrements clés utiles pour un 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+ inutiles

Les enregistrements de conversation (user, assistant, attachment, system) portent tous sessionId, cwd, gitBranch, timestamp et version. message.content est soit une chaîne, soit une liste de blocs ; sur ces lignes, les types de blocs étaient tool_result 2,261, tool_use 2,223, thinking 1,224 et text 754. Un titre est soit un enregistrement custom-title, soit, sur les builds qui en écrivent un, un enregistrement ai-title avec aiTitle. Ici, tous les fichiers avaient custom-title et aucun ai-title, donc lisez les deux.

Pourquoi ne pas simplement parser chaque fichier ?

Parce qu'ils sont gros. Les 40 transcriptions de premier niveau totalisent ici 19.4 GB. Le fichier médian fait 168 MB, le plus gros 2.07 GB, et le 90e percentile 1.94 GB.

Passer le fichier médian dans json.loads ligne par ligne a pris 0.61 s (11,558 lignes, environ 279 MB/s sur un Mac Apple Silicon, cache de pages chaud). À ce rythme, parser entièrement les 40 prendrait environ 70 s, avant même d'avoir affiché quoi que ce soit.

Les informations dont une liste a besoin se trouvent aux deux extrémités du fichier. Mesuré sur les 40 :

  • le premier enregistrement avec cwd était au plus tard à la ligne 2 (médiane 2)
  • le premier gitBranch et le premier enregistrement user : au plus tard ligne 2
  • un enregistrement de titre se trouvait dans les derniers 512 KiB dans 40 fichiers sur 40
  • aucune des 80 premières lignes ne dépassait 256 KiB

L'index lit donc les 80 premières lignes et les derniers 512 KiB. C'est aussi ce que fait osFoundry : HEAD_LINES = 80 avec une limite de 256 KiB par ligne dans session_inventory.rs, et SETTINGS_TAIL = 512 * 1024 dans external_sessions.rs, lu pour récupérer le dernier modèle, l'effort, le mode d'approbation et la branche. Lire le début et la fin des 40 fichiers a pris 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

Le readline plafonné compte. Un tour utilisateur avec une image collée tient sur une seule ligne de plusieurs mégaoctets, et un simple for line in f la chargerait entièrement en mémoire juste pour la jeter.

De quels champs un sélecteur a-t-il vraiment besoin ?

Cinq, plus une date : application, id, dossier, branche, titre. Le début du fichier donne cwd, le premier gitBranch et un titre de secours tiré du premier vrai message utilisateur (pas une sidechain, et pas un message commençant par <, qui est la façon dont commencent les wrappers de commandes et de hooks). La fin du fichier écrase ces valeurs avec le titre et la branche les plus récents, car une longue session survit souvent à la branche sur laquelle elle a commencé.

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() applique json.loads à chaque ligne en ignorant les lignes invalides (la première ligne d'une lecture de fin est coupée en deux). first_text() renvoie le contenu s'il s'agit d'une chaîne, ou le premier bloc text. La date de dernière utilisation est le mtime du fichier, que osFoundry utilise aussi pour le tri.

Où Codex range-t-il les mêmes informations ?

Codex écrit un rollout par session dans une arborescence datée, ~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>-<thread id>.jsonl, et range les sessions archivées dans ~/.codex/archived_sessions/. Ici : 10 rollouts (28.5 MB) et 2 archivées. La première ligne de chaque rollout est un enregistrement session_meta dont le payload contient id, cwd, originator, source, cli_version, model_provider, timestamp et, sur certains builds, git.branch. Le reste, ce sont des enregistrements event_msg, response_item (avec les types de payload message, function_call, function_call_output, reasoning, custom_tool_call, …), turn_context et compacted.

Codex tient aussi un index, si bien que vous n'avez même pas à ouvrir les rollouts. ~/.codex/state_<n>.sqlite contient une table threads ; ici, c'est state_5.sqlite, avec 43 colonnes et 12 lignes, dont id, rollout_path, cwd, git_branch, title, model, reasoning_effort, approval_mode, sandbox_policy, source, archived et updated_at_ms. Il peut y avoir plusieurs fichiers state_<n> ; codex_state_db dans osFoundry prend le n le plus élevé, et le script fait de même. Les lignes dont la source est un objet JSON sont des threads de sous-agents ; ignorez-les.

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

Un point qui a coûté un deuxième essai : ouvrir la base avec ?mode=ro seul a échoué ici avec unable to open database file. Ajouter immutable=1 a réglé le problème. Immutable indique à SQLite que le fichier ne changera pas tant qu'il est ouvert, il se passe donc du verrou et des fichiers -shm ; c'est sans problème pour un index pris comme instantané, et cela permet de lire une base que l'application garde ouverte. osFoundry ouvre l'index de Codex avec SQLITE_OPEN_READ_ONLY, et utilise une ouverture immutable=1 pour les bases qu'une autre application peut garder ouvertes en écriture.

Quelle rapidité sur de vrais dossiers ?

$ 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 sessions (40 Claude Code, 12 Codex), toutes avec un dossier et un titre, et les 40 de Claude Code avec une branche. La première exécution est la plus à froid ; les autres étaient à chaud. Le fichier d'index fait 11,480 octets. Face à un parsing complet de 70 s, 80 à 150 ms font la différence entre une liste qui s'ouvre instantanément et une liste avec un spinner.

La recherche se résume alors à un test de sous-chaîne sur cinq champs, les mêmes que ceux qu'utilise matchesQuery d'osFoundry dans 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

Sur un faux dossier personnel construit avec la même structure (données d'exemple, pas les vraies 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
Enter fullscreen mode Exit fullscreen mode

Chercher dans le texte des messages voudrait dire lire les 19.4 GB, ou construire un index plein texte de tout ce que vous avez jamais collé dans un agent. Chercher sur le dossier et la branche retrouve la session par le nom du projet, et c'est de toute façon ce dont vous vous souvenez.

Qu'apporte une reprise sûre ?

Afficher claude --resume <id>, c'est la partie facile. Ce qu'osFoundry ajoute autour (plan_command et le plan de lancement dans external_sessions.rs, plus les vérifications de l'écran avant d'ouvrir le terminal) mérite d'être copié :

  • Lancer dans le dossier enregistré, et refuser s'il n'existe plus plutôt que de reprendre ailleurs.
  • Comparer la branche enregistrée avec git -C <cwd> rev-parse --abbrev-ref HEAD. Si elles diffèrent, le signaler et demander ; ne jamais faire de checkout.
  • Reprendre le modèle (--model, -m pour Codex) et l'effort (--effort, -c model_reasoning_effort="…" pour Codex).
  • Reprendre un mode d'approbation enregistré seulement s'il est au moins aussi strict que le défaut de la CLI. Pour Claude Code, c'est default ou plan ; pour Codex, approbation untrusted ou on-request, sandbox read-only ou workspace-write. Tout ce qui est plus permissif est mis de côté, sauf si la personne l'active pour ce lancement précis.

Le sessidx.py complet, ce sont les trois morceaux ci-dessus plus environ 30 lignes de colle (surcharges par variables d'environnement pour les dossiers personnels, sortie JSON, sous-commandes find et resume). Il n'y a pas de binaire claude ni codex sur le Mac utilisé ici, donc la ligne de reprise est affichée, pas exécutée.

Si vous le mettez en place vous-même

  • Traitez les fichiers comme l'API : lecture seule, n'écrivez jamais à côté, et n'ouvrez jamais les fichiers d'authentification des CLI pour récupérer un nom de compte.
  • Lisez le début et la fin, pas les transcriptions entières. Plafonnez la longueur des lignes pour qu'une image collée ne vous coûte pas sa taille en mémoire.
  • Récupérez cwd dans les enregistrements, pas dans le nom de dossier échappé.
  • Préférez le state_<n>.sqlite de Codex (le n le plus élevé) au parcours de l'arborescence datée, et ouvrez-le en immutable si l'application peut le garder ouvert.
  • Excluez de la liste les transcriptions de sous-agents (agent-*.jsonl, threads avec une source JSON) ; personne ne les reprend directement.
  • Décidez à l'avance ce qu'une reprise a le droit de changer. Un défaut sûr : dossier oui, modèle oui, approbations plus permissives seulement sur demande, branche jamais.

La vue Sessions des agents propres à osFoundry s'appuie sur une autre source ; elle est décrite sur la page des sessions d'agents d'osFoundry.

Top comments (0)