~/.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 -> 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
세션 목록 도구와 CLI 사이의 약속은 이게 전부다. Claude Code도 Codex도 세션을 홈 디렉터리 아래 평범한 파일로 남기고, ID만 넘기면 이어서 실행된다. osFoundry 데스크톱 앱의 External agents(외부 에이전트) 화면은 이 파일들 위에 얹은 읽기 전용 뷰어다(Rust runtime/session_inventory.rs, runtime/external_sessions.rs). 12개 앱의 세션을 한 목록에 띄우고, 제목·폴더·브랜치·앱 이름·ID로 검색하고, 맞는 CLI의 resume 명령을 터미널에서 실행한다.
이 글에서는 그중 Claude Code와 Codex 부분을 Python 150줄 정도로 다시 짜서, 이 글을 쓴 Mac의 실제 세션 폴더에 돌려 봤다. 대화 내용은 한 줄도 출력하지 않았다. 나오는 숫자는 전부 개수, 크기, 시간이다.
Claude Code 세션은 디스크에 어떻게 저장될까
세션 하나가 JSONL 파일 하나고, 파일 이름이 세션 ID다. 위치는 작업 디렉터리 경로로 만든 폴더인데, 절대 경로에서 영문자·숫자가 아닌 문자를 전부 -로 바꾼 이름이다.
re.sub(r"[^A-Za-z0-9]", "-", cwd) # /Users/alex/code/shop-api -> -Users-alex-code-shop-api
로컬의 40개 파일로 확인해 보니 40개 모두 이 규칙과 맞았다. 다만 되돌릴 수 없는 변환이라 my.app과 my-app이 같은 이름이 된다. 폴더 이름에서 경로를 복원하려다 삽질하기 딱 좋으니, cwd는 레코드에서 읽는 게 맞다.
서브에이전트(Task 도구의 Explore 같은 것)는 <세션 ID>/subagents/agent-<id>.jsonl에 따로 기록된다. 로컬에는 129개가 있었다. 사람이 이어서 쓰는 세션이 아니라서 목록에서는 뺀다. osFoundry도 파일 이름이 agent-로 시작하면 건너뛴다.
한 줄이 type을 가진 레코드 하나다. 40개 파일의 앞 400줄씩에서 종류를 세어 봤다.
| type | 개수 | 인덱스에 쓰는 키 |
|---|---|---|
| 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+ | 필요 없음 |
대화 레코드(user, assistant, attachment, system)에는 모두 sessionId, cwd, gitBranch, timestamp, version이 붙어 있다. message.content는 문자열이거나 블록 배열인데, 블록 종류는 tool_result 2,261, tool_use 2,223, thinking 1,224, text 754였다. 제목은 custom-title 레코드에 있거나, 그걸 쓰는 빌드라면 ai-title 레코드의 aiTitle에 있다. 로컬에서는 모든 파일에 custom-title이 있었고 ai-title은 0개였으니 둘 다 읽게 해 둔다.
그냥 전부 파싱하면 안 될까
파일이 너무 크다. 최상위 대화 로그 40개를 합치면 19.4 GB였다. 중앙값 168 MB, 최대 2.07 GB, 90번째 백분위수가 1.94 GB.
중앙값 크기 파일을 한 줄씩 json.loads에 넣어 보니 0.61초 걸렸다(11,558줄, Apple Silicon Mac에서 페이지 캐시가 데워진 상태로 약 279 MB/s). 이 속도라면 40개 전체 파싱에 70초쯤 걸린다. 목록 하나 띄우기 전에 그만큼 기다려야 한다는 얘기다.
목록에 필요한 정보는 파일 양 끝에 몰려 있다. 40개 전부에서 측정한 결과다.
-
cwd가 있는 첫 레코드는 늦어도 2번째 줄(중앙값 2) - 첫
gitBranch, 첫user레코드도 늦어도 2번째 줄 - 제목 레코드는 40개 중 40개가 마지막 512 KiB 안에 있었다
- 앞 80줄 중 256 KiB를 넘는 줄은 없었다
그래서 앞 80줄과 마지막 512 KiB만 읽는다. osFoundry도 똑같다. session_inventory.rs에 HEAD_LINES = 80과 줄당 256 KiB 상한이 있고, external_sessions.rs의 SETTINGS_TAIL = 512 * 1024로 끝부분에서 최신 모델, effort, 승인 모드, 브랜치를 읽는다. 40개 파일의 앞뒤를 읽는 데 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
readline에 상한을 붙인 게 은근히 중요하다. 이미지를 붙여 넣은 턴 하나가 몇 MB짜리 한 줄이 되는데, 그냥 for line in f로 돌리면 버릴 줄을 통째로 메모리에 올리게 된다.
목록에 진짜 필요한 필드는 뭘까
앱, ID, 폴더, 브랜치, 제목 다섯 개와 시간이다. 앞부분에서는 cwd, 첫 gitBranch, 그리고 예비 제목으로 첫 진짜 사용자 메시지(sidechain이 아니고 <로 시작하지 않는 것. 명령어나 훅 래퍼가 <로 시작한다)를 가져온다. 끝부분에서는 최신 제목과 브랜치로 덮어쓴다. 긴 세션은 시작할 때의 브랜치보다 오래 살아남는 경우가 많기 때문이다.
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()는 줄마다 json.loads하고 깨진 줄은 건너뛰는 함수다(끝부분 읽기의 첫 줄은 중간에서 잘려 있다). first_text()는 문자열이면 그대로, 배열이면 첫 text 블록을 돌려준다. 마지막 사용 시각은 파일 mtime이고, osFoundry도 정렬에 이걸 쓴다.
Codex는 같은 정보를 어디에 둘까
Codex는 세션마다 rollout 파일 하나를 날짜별 트리에 쓴다. ~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>-<thread id>.jsonl이고, 보관한 건 ~/.codex/archived_sessions/에 있다. 로컬에는 rollout 10개(28.5 MB)와 보관된 것 2개가 있었다. 모든 rollout의 첫 줄은 session_meta 레코드고, payload에 id, cwd, originator, source, cli_version, model_provider, timestamp, 빌드에 따라 git.branch가 들어 있다. 나머지는 event_msg, response_item(payload 종류는 message, function_call, function_call_output, reasoning, custom_tool_call 등), turn_context, compacted 레코드다.
게다가 Codex는 인덱스를 따로 들고 있어서 rollout을 열 필요조차 없다. ~/.codex/state_<n>.sqlite에 threads 테이블이 있고, 로컬에서는 state_5.sqlite에 컬럼 43개, 행 12개였다. id, rollout_path, cwd, git_branch, title, model, reasoning_effort, approval_mode, sandbox_policy, source, archived, updated_at_ms 등이 다 있다. state_<n> 파일은 여러 개일 수 있는데, osFoundry의 codex_state_db는 n이 가장 큰 걸 고르고 스크립트도 그렇게 했다. source가 JSON 객체인 행은 서브에이전트 스레드라서 뺀다.
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}
한 번 막힌 곳이 SQLite 여는 방식이었다. ?mode=ro만 주면 이 환경에서는 unable to open database file로 실패했다. immutable=1을 붙이니 열렸다. immutable은 열려 있는 동안 파일이 안 바뀐다고 SQLite에 알려 주는 플래그라서 락이나 -shm 파일을 건드리지 않는다. 스냅샷으로 읽는 인덱스라면 이걸로 충분하고, 앱이 열어 둔 DB도 읽을 수 있다. osFoundry는 Codex 인덱스를 SQLITE_OPEN_READ_ONLY로 열고, 다른 앱이 쓰기용으로 열고 있을 수 있는 DB는 immutable=1로 여는 함수를 따로 둔다.
실제 폴더에서는 얼마나 빠를까
$ 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개(Claude Code 40, Codex 12), 전부 폴더와 제목이 있었고 Claude Code 40개에는 브랜치도 있었다. 첫 실행이 가장 차가운 상태였고 나머지는 캐시가 데워진 상태다. 인덱스 JSON은 11,480바이트. 전체 파싱 70초와 비교하면 80~150 ms는 기다림 없이 바로 열리는 목록이다.
검색은 다섯 필드에 대한 부분 문자열 비교고, osFoundry의 lib/external-sessions.ts에 있는 matchesQuery와 같은 조합이다.
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)
같은 형태로 만든 가짜 홈 디렉터리(예시 데이터, 실제 세션 아님)에 돌리면 이렇다.
$ 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
메시지 본문까지 검색하려면 19.4 GB를 읽거나, 에이전트에 붙여 넣은 모든 걸 전문 인덱스로 만들어야 한다. 폴더와 브랜치로 찾으면 어차피 기억하고 있는 프로젝트 이름으로 찾힌다.
안전하게 이어 실행하려면 뭘 더 붙여야 할까
claude --resume <id>를 출력하는 것까지는 쉽다. osFoundry가 그 앞뒤로 하는 일(external_sessions.rs의 plan_command와 실행 계획, 그리고 화면에서 터미널을 열기 전 확인)은 그대로 따라 할 만하다.
- 기록된 폴더에서 실행한다. 폴더가 없어졌으면 다른 곳에서 이어 가지 말고 멈춘다.
- 기록된 브랜치를
git -C <cwd> rev-parse --abbrev-ref HEAD와 비교한다. 다르면 알려 주고 확인받는다. checkout은 하지 않는다. - 모델(
--model, Codex는-m)과 effort(--effort, Codex는-c model_reasoning_effort="…")는 넘긴다. - 기록된 승인 모드는 CLI 기본값만큼 엄격하거나 더 엄격할 때만 넘긴다. Claude Code는
default나plan, Codex는 승인untrusted나on-request, 샌드박스read-only나workspace-write. 그보다 느슨하면 그 한 번의 실행에서 사용자가 직접 고를 때만 넘긴다.
sessidx.py 전체는 위 세 조각에 홈 경로 환경 변수, JSON 출력, find와 resume 서브커맨드 30줄쯤을 붙인 게 끝이다. 이 Mac에는 claude도 codex 바이너리도 설치돼 있지 않아서 resume 명령은 실행하지 않고 출력만 한다.
직접 붙여 본다면 미리 정할 것
- 파일을 API처럼 다룬다. 읽기만 하고, 옆에 아무것도 쓰지 않고, 계정 이름을 알려고 CLI 인증 파일을 열지 않는다.
- 대화 로그는 앞뒤만 읽는다. 줄 길이에 상한을 둬서 붙여 넣은 이미지 하나만큼 메모리를 먹지 않게 한다.
-
cwd는 이스케이프된 폴더 이름이 아니라 레코드에서 가져온다. - Codex는 날짜 트리를 도는 것보다
state_<n>.sqlite(n이 가장 큰 것)를 우선하고, 앱이 열고 있을 수 있으면 immutable로 연다. - 서브에이전트 로그(
agent-*.jsonl,source가 JSON인 스레드)는 목록에 띄우지 않는다. 직접 이어 쓰는 대상이 아니다. - 이어 실행할 때 뭘 바꿔도 되는지 먼저 정한다. 무난한 기본값은 폴더 유지, 모델 유지, 느슨한 승인은 요청할 때만, 브랜치는 건드리지 않기.
osFoundry 자체 에이전트의 세션 목록은 다른 데이터에서 만들어지고, osFoundry 에이전트 세션 페이지에 설명이 있다.
Top comments (0)