~/.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 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 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
cwdstand spätestens in Zeile 2 (Median 2) - der erste
gitBranchund der ersteuser-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 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} 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} 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 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) 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 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 HEADvergleichen. 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
defaultoderplan; bei Codex Approvaluntrustedoderon-request, Sandboxread-onlyoderworkspace-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
cwdaus den Records, nicht aus dem escapten Ordnernamen. - Nimm lieber die
state_<n>.sqlitevon Codex (höchstesn), 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.