DEV Community

Cover image for Cómo osFoundry encuentra tus sesiones de Claude Code y Codex en disco, recreado en Python
BBT for BBT Español

Posted on

Cómo osFoundry encuentra tus sesiones de Claude Code y Codex en disco, recreado 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

Ese es todo el contrato entre las CLI y un selector de sesiones. Claude Code y Codex guardan cada uno sus sesiones en archivos normales dentro de tu carpeta personal, y cada uno acepta un id para reanudar una. La pantalla External agents de la app de escritorio de osFoundry es un lector montado sobre esos archivos (Rust, runtime/session_inventory.rs y runtime/external_sessions.rs). Lista sesiones de doce apps, busca por título, carpeta, rama, app e id, y lanza en una terminal el comando de reanudación correcto.

A continuación tienes la parte de Claude Code y Codex, recreada como un script de Python de 150 líneas y ejecutada contra las carpetas de sesiones reales del Mac donde se escribió este artículo. En ningún punto del artículo se imprime contenido de las conversaciones. Cada número es un recuento, un tamaño o un tiempo.

¿Cómo se ve una sesión de Claude Code en disco?

Un archivo JSONL por sesión, con el id de la sesión como nombre, dentro de una carpeta que lleva el nombre del directorio de trabajo. El nombre de la carpeta es la ruta absoluta con cada carácter que no sea letra o dígito sustituido por -:

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

Comprobado con las 40 transcripciones de esta máquina: 40 de 40 carpetas siguen esa regla. La regla pierde información (my.app y my-app chocan, igual que / y .), así que nunca decodifiques el nombre de la carpeta. Lee cwd de los registros.

Los subagentes (Explore y compañía, de la herramienta Task) escriben sus propias transcripciones en <session id>/subagents/agent-<id>.jsonl. Aquí había 129. Nadie las reanuda como sesión, y osFoundry se salta cualquier archivo cuyo nombre base empiece por agent-.

Cada línea es un registro con un type. Contando los tipos en las primeras 400 líneas de cada uno de los 40 archivos:

type registros claves que importan para un índice
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+ no hacen falta

Los registros de conversación (user, assistant, attachment, system) llevan todos sessionId, cwd, gitBranch, timestamp y version. message.content es o un string o una lista de bloques; en esas líneas los tipos de bloque fueron tool_result 2,261, tool_use 2,223, thinking 1,224 y text 754. El título viene de un registro custom-title o, en las versiones que lo escriben, de un registro ai-title con aiTitle. Aquí todos los archivos tenían custom-title y ninguno ai-title, así que lee los dos.

¿Por qué no parsear cada archivo entero?

Porque son grandes. Las 40 transcripciones de primer nivel suman aquí 19.4 GB. La mediana es de 168 MB, el más grande pesa 2.07 GB y el percentil 90 está en 1.94 GB.

Pasar el archivo mediano por json.loads línea a línea tardó 0.61 s (11,558 líneas, unos 279 MB/s en un Mac con Apple Silicon, con la caché de páginas caliente). A ese ritmo, parsear los 40 completos llevaría unos 70 s, antes de haber mostrado nada.

Los datos que necesita una lista están en los dos extremos del archivo. Medido en los 40:

  • el primer registro con cwd estaba como muy tarde en la línea 2 (mediana 2)
  • el primer gitBranch y el primer registro user: como muy tarde en la línea 2
  • había un registro de título dentro de los últimos 512 KiB en 40 de 40 archivos
  • ninguna de las primeras 80 líneas pasaba de 256 KiB

Así que el índice lee las primeras 80 líneas y los últimos 512 KiB. Es lo mismo que hace osFoundry: HEAD_LINES = 80 con un límite de 256 KiB por línea en session_inventory.rs, y SETTINGS_TAIL = 512 * 1024 en external_sessions.rs, que se lee para sacar el último modelo, effort, modo de aprobación y rama. Leer cabeza y cola de los 40 archivos tardó 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

El readline con límite es importante. Un turno de usuario con una imagen pegada es una sola línea de varios megas, y un simple for line in f la cargaría entera en memoria solo para tirarla.

¿Qué campos necesita de verdad un selector?

Cinco, más una fecha: app, id, carpeta, rama y título. La cabeza da cwd, el primer gitBranch y un título de reserva sacado del primer mensaje real del usuario (que no sea sidechain ni empiece por <, que es como empiezan los envoltorios de comandos y hooks). La cola lo sobrescribe con el título y la rama más recientes, porque una sesión larga suele sobrevivir a la rama en la que empezó.

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() es json.loads por línea, saltándose las líneas rotas (la primera línea de una lectura de cola sale cortada por la mitad). first_text() devuelve el contenido si es string, o el primer bloque text. La fecha de último uso es el mtime del archivo, que también es lo que usa osFoundry para ordenar.

¿Dónde guarda Codex esos mismos datos?

Codex escribe un rollout por sesión en un árbol por fechas, ~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>-<thread id>.jsonl, y guarda los archivados en ~/.codex/archived_sessions/. Aquí: 10 rollouts (28.5 MB) y 2 archivados. La primera línea de cada rollout es un registro session_meta cuyo payload tiene id, cwd, originator, source, cli_version, model_provider, timestamp y, en algunas versiones, git.branch. El resto son registros event_msg, response_item (con tipos de payload message, function_call, function_call_output, reasoning, custom_tool_call, …), turn_context y compacted.

Codex además mantiene un índice, así que ni siquiera tienes que abrir los rollouts. ~/.codex/state_<n>.sqlite tiene una tabla threads; aquí es state_5.sqlite, con 43 columnas y 12 filas, entre ellas id, rollout_path, cwd, git_branch, title, model, reasoning_effort, approval_mode, sandbox_policy, source, archived y updated_at_ms. Puede haber más de un archivo state_<n>; codex_state_db en osFoundry coge el n más alto, y el script hace lo mismo. Las filas cuyo source es un objeto JSON son hilos de subagentes; sáltatelas.

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

Algo que costó un segundo intento: abrir la base de datos solo con ?mode=ro falló aquí con unable to open database file. Añadir immutable=1 lo resolvió. Immutable le dice a SQLite que el archivo no va a cambiar mientras esté abierto, así que se salta el bloqueo y los archivos -shm; para un índice que es una instantánea no pasa nada, y te permite leer una base de datos que la app tiene abierta. osFoundry abre el índice de Codex con SQLITE_OPEN_READ_ONLY, y usa una apertura con immutable=1 para bases de datos que otra app puede tener abiertas en escritura.

¿Qué tan rápido va con carpetas reales?

$ 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 sesiones (40 de Claude Code, 12 de Codex), todas con carpeta y título, y las 40 de Claude Code con rama. La primera ejecución es la más fría; las demás fueron en caliente. El archivo de índice ocupa 11,480 bytes. Frente a un parseo completo de 70 s, 80 a 150 ms es la diferencia entre una lista que se abre al instante y otra con spinner.

La búsqueda es entonces una comprobación de substring sobre cinco campos, los mismos que usa matchesQuery de osFoundry en 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

Sobre una carpeta personal falsa con la misma estructura (datos de ejemplo, no las sesiones reales):

$ 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

Buscar dentro del texto de los mensajes significaría leer los 19.4 GB, o montar un índice de texto completo de todo lo que alguna vez pegaste en un agente. Buscar por carpeta y rama encuentra la sesión por el nombre del proyecto, que es lo que recuerdas de todas formas.

¿Qué añade una reanudación segura?

Imprimir claude --resume <id> es lo fácil. Lo que osFoundry añade alrededor (plan_command y el plan de lanzamiento en external_sessions.rs, más las comprobaciones que hace la pantalla antes de abrir la terminal) vale la pena copiarlo:

  • Ejecutar en la carpeta registrada, y negarse si ya no existe en lugar de reanudar en otro sitio.
  • Comparar la rama registrada con git -C <cwd> rev-parse --abbrev-ref HEAD. Si no coinciden, avisar y preguntar; nunca hacer checkout.
  • Mantener el modelo (--model, en Codex -m) y el effort (--effort, en Codex -c model_reasoning_effort="…").
  • Mantener un modo de aprobación registrado solo si es al menos tan estricto como el predeterminado de la CLI. En Claude Code eso es default o plan; en Codex, aprobación untrusted u on-request, y sandbox read-only o workspace-write. Cualquier cosa más permisiva se retiene salvo que la persona lo active para ese lanzamiento concreto.

El sessidx.py completo son las tres piezas de arriba más unas 30 líneas de pegamento (variables de entorno para cambiar las carpetas personales, salida JSON, y los subcomandos find y resume). En el Mac usado aquí no hay binario de claude ni de codex, así que la línea de reanudación se imprime, no se ejecuta.

Si lo montas tú

  • Trata los archivos como la API: solo lectura, nunca escribas junto a ellos y nunca abras los archivos de autenticación de las CLI para sacar un nombre de cuenta.
  • Lee cabeza y cola, no transcripciones enteras. Limita la longitud de línea para que una imagen pegada no te cueste su tamaño en memoria.
  • Saca cwd de los registros, no del nombre de carpeta escapado.
  • Usa preferentemente el state_<n>.sqlite de Codex (el n más alto) en vez de recorrer el árbol por fechas, y ábrelo como immutable si la app puede tenerlo abierto.
  • Excluye de la lista las transcripciones de subagentes (agent-*.jsonl, hilos con source JSON); nadie las reanuda directamente.
  • Decide de antemano qué puede cambiar una reanudación. Un valor seguro por defecto: carpeta sí, modelo sí, aprobaciones más permisivas solo si se piden, rama nunca.

La vista Sessions de los propios agentes de osFoundry trabaja con otra fuente; está descrita en la página de sesiones de agentes de osFoundry.

Top comments (0)