DEV Community

Cover image for Wie osFoundry deine Claude Code- und Codex-Sessions auf der Festplatte findet, nachgebaut in Python
BBT for BBT Deutsch

Posted on

Wie osFoundry deine Claude Code- und Codex-Sessions auf der Festplatte findet, nachgebaut 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

Mehr Vertrag gibt es zwischen den CLIs und einem Session-Picker nicht. Claude Code und Codex schreiben ihre Sessions jeweils in ganz normale Dateien in deinem Home-Ordner, und beide nehmen eine ID entgegen, um eine Session fortzusetzen. Der Bildschirm External agents in der Desktop-App von osFoundry ist ein Reader, der auf diesen Dateien aufsetzt (Rust, runtime/session_inventory.rs und runtime/external_sessions.rs). Er listet Sessions aus zwölf Apps, durchsucht Titel, Ordner, Branch, App und ID und startet den passenden Resume-Befehl in einem Terminal.

Unten findest du den Teil für Claude Code und Codex, nachgebaut als Python-Skript mit 150 Zeilen und ausgeführt gegen die echten Session-Ordner auf dem Mac, auf dem dieser Beitrag entstanden ist. Nirgends in diesem Beitrag wird Inhalt aus den Transkripten ausgegeben. Jede Zahl ist eine Anzahl, eine Größe oder eine Zeitmessung.

Wie sieht eine Claude Code-Session auf der Festplatte aus?

Eine JSONL-Datei pro Session, benannt nach der Session-ID, in einem Ordner, der nach dem Arbeitsverzeichnis benannt ist. Der Ordnername ist der absolute Pfad, in dem jedes Zeichen, das kein Buchstabe und keine Ziffer ist, durch - ersetzt wird:

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

Geprüft an den 40 Transkripten auf diesem Rechner: 40 von 40 Ordnern folgen dieser Regel. Die Regel ist verlustbehaftet (my.app und my-app kollidieren, ebenso / und .), also dekodiere den Ordnernamen nie. Lies cwd stattdessen aus den Records.

Sub-Agents (Explore und Co. aus dem Task-Tool) schreiben eigene Transkripte unter <session id>/subagents/agent-<id>.jsonl. Hier waren es 129. Das sind keine Sessions, die jemand fortsetzt, und osFoundry überspringt jede Datei, deren Stem mit agent- beginnt.

Jede Zeile ist ein Record mit einem type. Gezählt über die ersten 400 Zeilen jeder der 40 Dateien:

type Records Keys, die für einen Index zählen
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+ nicht nötig

Die Konversations-Records (user, assistant, attachment, system) tragen alle sessionId, cwd, gitBranch, timestamp und version. message.content ist entweder ein String oder eine Liste von Blöcken; über diese Zeilen hinweg waren die Blocktypen tool_result 2,261, tool_use 2,223, thinking 1,224 und text 754. Ein Titel ist entweder ein custom-title-Record oder, bei Builds, die einen schreiben, ein ai-title-Record mit aiTitle. Hier hatte jede Datei custom-title und keine ai-title, also lies beide.

Warum nicht einfach jede Datei komplett parsen?

Weil sie groß sind. Die 40 Top-Level-Transkripte kommen hier zusammen auf 19.4 GB. Der Median liegt bei 168 MB, die größte Datei hat 2.07 GB, das 90. Perzentil 1.94 GB.

Die Median-Datei Zeile für Zeile durch json.loads zu streamen dauerte 0.61 s (11,558 Zeilen, etwa 279 MB/s auf einem Apple-Silicon-Mac mit warmem Page Cache). Bei diesem Tempo bräuchte ein vollständiges Parsen aller 40 Dateien rund 70 s, bevor du überhaupt etwas angezeigt hast.

Was eine Liste braucht, steht an den beiden Enden der Datei. Gemessen an allen 40:

  • der erste Record mit cwd stand spätestens in Zeile 2 (Median 2)
  • der erste gitBranch und der erste user-Record: spätestens Zeile 2
  • ein Titel-Record lag in 40 von 40 Dateien innerhalb der letzten 512 KiB
  • keine der ersten 80 Zeilen war länger als 256 KiB

Also liest der Index die ersten 80 Zeilen und die letzten 512 KiB. Genau das macht osFoundry auch: HEAD_LINES = 80 mit einem Limit von 256 KiB pro Zeile in session_inventory.rs und SETTINGS_TAIL = 512 * 1024 in external_sessions.rs, gelesen für das letzte Modell, Effort, Freigabemodus und Branch. Head und Tail aller 40 Dateien zu lesen dauerte 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

Das begrenzte readline ist wichtig. Ein User-Turn mit eingefügtem Bild ist eine einzige, mehrere Megabyte große Zeile, und ein schlichtes for line in f würde sie komplett in den Speicher holen, nur um sie dann wegzuwerfen.

Welche Felder braucht ein Picker wirklich?

Fünf, plus eine Zeit: App, ID, Ordner, Branch, Titel. Der Head liefert cwd, den ersten gitBranch und einen Fallback-Titel aus der ersten echten User-Nachricht (keine Sidechain und nicht mit < beginnend, so fangen Command- und Hook-Wrapper an). Der Tail überschreibt das mit dem neuesten Titel und dem neuesten Branch, denn eine lange Session überlebt oft den Branch, auf dem sie begonnen hat.

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() ist json.loads pro Zeile, kaputte Zeilen werden übersprungen (die erste Zeile eines Tail-Reads ist in der Mitte abgeschnitten). first_text() gibt den String-Inhalt zurück oder den ersten text-Block. Die Zeit der letzten Nutzung ist die mtime der Datei, und danach sortiert auch osFoundry.

Wo legt Codex dieselben Infos ab?

Codex schreibt pro Session ein Rollout in einen nach Datum sortierten Baum, ~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>-<thread id>.jsonl, und archivierte landen in ~/.codex/archived_sessions/. Hier: 10 Rollouts (28.5 MB) und 2 archivierte. Die erste Zeile jedes Rollouts ist ein session_meta-Record, dessen payload id, cwd, originator, source, cli_version, model_provider, timestamp und bei manchen Builds git.branch enthält. Der Rest sind event_msg-, response_item- (mit den Payload-Typen message, function_call, function_call_output, reasoning, custom_tool_call, …), turn_context- und compacted-Records.

Codex pflegt außerdem einen Index, du musst Rollouts also gar nicht öffnen. ~/.codex/state_<n>.sqlite hat eine Tabelle threads; hier ist das state_5.sqlite mit 43 Spalten und 12 Zeilen, darunter id, rollout_path, cwd, git_branch, title, model, reasoning_effort, approval_mode, sandbox_policy, source, archived und updated_at_ms. Es kann mehr als eine state_<n>-Datei geben; codex_state_db in osFoundry nimmt das höchste n, und das Skript ebenso. Zeilen, deren source ein JSON-Objekt ist, sind Sub-Agent-Threads; überspring sie.

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

Eine Sache hat einen zweiten Anlauf gekostet: Die Datenbank nur mit ?mode=ro zu öffnen, schlug hier mit unable to open database file fehl. Mit zusätzlichem immutable=1 ging es. Immutable sagt SQLite, dass sich die Datei nicht ändert, solange sie offen ist, also lässt es Lock und -shm-Dateien weg; für einen Snapshot-Index ist das in Ordnung, und so kannst du eine Datenbank lesen, die die App gerade offen hält. osFoundry öffnet den Index von Codex mit SQLITE_OPEN_READ_ONLY und nutzt ein Öffnen mit immutable=1 für Datenbanken, die eine andere App eventuell schreibend offen hält.

Wie schnell ist das auf echten Ordnern?

$ 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), alle mit Ordner und Titel, die 40 von Claude Code auch mit Branch. Der erste Lauf ist der kälteste, die anderen liefen warm. Die Indexdatei ist 11,480 Bytes groß. Verglichen mit 70 s für ein vollständiges Parsen machen 80 bis 150 ms den Unterschied zwischen einer Liste, die sofort aufgeht, und einer mit Spinner.

Die Suche ist dann ein Substring-Test über fünf Felder, dieselben, die osFoundrys matchesQuery in lib/external-sessions.ts verwendet:

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

Auf einem Fake-Home mit derselben Struktur (Beispieldaten, nicht die echten 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

Im Nachrichtentext zu suchen hieße, die 19.4 GB zu lesen oder einen Volltextindex über alles aufzubauen, was du jemals in einen Agent eingefügt hast. Die Suche über Ordner und Branch findet die Session über den Projektnamen, und an den erinnerst du dich ohnehin.

Was macht ein sicheres Resume zusätzlich?

claude --resume <id> auszugeben ist der einfache Teil. Was osFoundry drumherum ergänzt (plan_command und der Launch-Plan in external_sessions.rs, dazu die Checks des Bildschirms, bevor er das Terminal öffnet), lohnt sich zum Nachmachen:

  • Im gespeicherten Ordner starten und abbrechen, wenn er nicht mehr existiert, statt woanders fortzusetzen.
  • Den gespeicherten Branch mit git -C <cwd> rev-parse --abbrev-ref HEAD vergleichen. Wenn sie abweichen, darauf hinweisen und nachfragen; nie auschecken.
  • Modell (--model, bei Codex -m) und Effort (--effort, bei Codex -c model_reasoning_effort="…") übernehmen.
  • Einen gespeicherten Freigabemodus nur übernehmen, wenn er mindestens so streng ist wie der Standard der CLI. Bei Claude Code ist das default oder plan; bei Codex Approval untrusted oder on-request, Sandbox read-only oder workspace-write. Alles Lockerere wird zurückgehalten, außer die Person aktiviert es für genau diesen einen Start.

Das vollständige sessidx.py besteht aus den drei Teilen oben plus etwa 30 Zeilen Glue-Code (Env-Overrides für die Home-Ordner, JSON-Ausgabe, ein find- und ein resume-Subcommand). Auf dem hier verwendeten Mac gibt es kein claude- oder codex-Binary, deshalb wird die Resume-Zeile ausgegeben und nicht ausgeführt.

Wenn du das selbst baust

  • Behandle die Dateien als API: nur lesen, nie daneben schreiben und nie die Auth-Dateien der CLIs öffnen, um an einen Account-Namen zu kommen.
  • Lies Head und Tail, nicht ganze Transkripte. Begrenze die Zeilenlänge, damit dich ein eingefügtes Bild nicht seine volle Größe an Speicher kostet.
  • Hol dir cwd aus den Records, nicht aus dem escapten Ordnernamen.
  • Nimm lieber die state_<n>.sqlite von Codex (höchstes n), statt den Datumsbaum abzulaufen, und öffne sie immutable, falls die App sie offen halten könnte.
  • Lass Sub-Agent-Transkripte (agent-*.jsonl, Threads mit JSON-source) aus der Liste raus; niemand setzt sie direkt fort.
  • Leg vorab fest, was ein Resume ändern darf. Ein sicherer Standard: Ordner ja, Modell ja, lockerere Freigaben nur auf Anfrage, Branch nie.

Die Sessions-Ansicht für osFoundrys eigene Agents arbeitet mit einer anderen Quelle; sie ist auf der Seite zu Agent-Sessions bei osFoundry beschrieben.

Top comments (0)