~/.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 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 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
cwdestaba como muy tarde en la línea 2 (mediana 2) - el primer
gitBranchy el primer registrouser: 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 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} 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} 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 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) 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 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
defaultoplan; en Codex, aprobaciónuntrusteduon-request, y sandboxread-onlyoworkspace-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
cwdde los registros, no del nombre de carpeta escapado. - Usa preferentemente el
state_<n>.sqlitede Codex (elnmá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 consourceJSON); 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.
