- 홈
- Documentation
- 세션
- 세션 저장소 및 엔트리 모델
세션 저장소 및 엔트리 모델
이 문서는 코딩 에이전트 세션이 어떻게 표현되고, 영속화되며, 마이그레이션되고, 런타임에 재구성되는지에 대한 정보 원천(source of truth)입니다.
다루는 내용:
- 세션 JSONL 포맷 및 버전 관리
- 엔트리 분류 체계 및 트리 의미론 (
id/parentId+ 리프 포인터) - 오래되거나 잘못된 형식의 파일을 로드할 때의 마이그레이션/호환성 동작
- 컨텍스트 재구성 (
buildSessionContext) - 영속성 보장, 실패 동작, 잘라내기/블롭 외부화
- 저장소 추상화 (
FileSessionStorage,MemorySessionStorage) 및 관련 유틸리티
세션 데이터에 영향을 미치는 의미론을 넘어서는 /tree UI 렌더링 동작은 다루지 않습니다.
구현 파일
섹션 제목: “구현 파일”src/session/session-manager.tssrc/session/messages.tssrc/session/session-storage.tssrc/session/history-storage.tssrc/session/blob-store.ts
디스크 레이아웃
섹션 제목: “디스크 레이아웃”기본 세션 파일 위치:
~/.xcsh/agent/sessions/--<cwd-encoded>--/<timestamp>_<sessionId>.jsonl<cwd-encoded>는 작업 디렉터리에서 선행 슬래시를 제거하고 /, \\, :를 -로 대체하여 생성됩니다.
블롭 저장소 위치:
~/.xcsh/agent/blobs/<sha256>터미널 브레드크럼 파일은 다음 경로에 기록됩니다:
~/.xcsh/agent/terminal-sessions/<terminal-id>브레드크럼 내용은 두 줄로 구성됩니다: 원래 cwd, 그다음 세션 파일 경로. continueRecent()는 가장 최근 mtime을 스캔하기 전에 이 터미널 범위 포인터를 우선적으로 사용합니다.
파일 포맷
섹션 제목: “파일 포맷”세션 파일은 JSONL 형식입니다: 한 줄에 하나의 JSON 객체.
- 1번째 줄은 항상 세션 헤더입니다 (
type: "session"). - 나머지 줄은
SessionEntry값입니다. - 엔트리는 런타임에 추가 전용(append-only)입니다; 브랜치 탐색은 기존 엔트리를 변경하는 대신 포인터(
leafId)를 이동합니다.
헤더 (SessionHeader)
섹션 제목: “헤더 (SessionHeader)”{ "type": "session", "version": 3, "id": "1f9d2a6b9c0d1234", "timestamp": "2026-02-16T10:20:30.000Z", "cwd": "/work/pi", "title": "optional session title", "parentSession": "optional lineage marker"}참고 사항:
version은 v1 파일에서는 선택 사항입니다; 없으면 v1을 의미합니다.parentSession은 불투명한(opaque) 계보 문자열입니다. 현재 코드는 흐름에 따라 세션 id 또는 세션 경로를 기록합니다 (fork,forkFrom,createBranchedSession, 또는 명시적newSession({ parentSession })). 타입이 지정된 외래 키가 아닌 메타데이터로 취급하십시오.
엔트리 기본 (SessionEntryBase)
섹션 제목: “엔트리 기본 (SessionEntryBase)”모든 비헤더 엔트리는 다음을 포함합니다:
{ "type": "...", "id": "8-char-id", "parentId": "previous-or-branch-parent", "timestamp": "2026-02-16T10:20:30.000Z"}parentId는 루트 엔트리(첫 번째 추가, 또는 resetLeaf() 이후)의 경우 null일 수 있습니다.
엔트리 분류 체계
섹션 제목: “엔트리 분류 체계”SessionEntry는 다음의 합집합(union)입니다:
messagethinking_level_changemodel_changecompactionbranch_summarycustomcustom_messagelabelttsr_injectionsession_initmode_change
message
섹션 제목: “message”AgentMessage를 직접 저장합니다.
{ "type": "message", "id": "a1b2c3d4", "parentId": null, "timestamp": "2026-02-16T10:21:00.000Z", "message": { "role": "assistant", "provider": "anthropic", "model": "claude-sonnet-4-5", "content": [{ "type": "text", "text": "Done." }], "usage": { "input": 100, "output": 20, "cacheRead": 0, "cacheWrite": 0, "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "total": 0 } }, "timestamp": 1760000000000 }}model_change
섹션 제목: “model_change”{ "type": "model_change", "id": "b1c2d3e4", "parentId": "a1b2c3d4", "timestamp": "2026-02-16T10:21:30.000Z", "model": "openai/gpt-4o", "role": "default"}role은 선택 사항입니다; 누락된 경우 컨텍스트 재구성 시 default로 처리됩니다.
thinking_level_change
섹션 제목: “thinking_level_change”{ "type": "thinking_level_change", "id": "c1d2e3f4", "parentId": "b1c2d3e4", "timestamp": "2026-02-16T10:22:00.000Z", "thinkingLevel": "high"}compaction
섹션 제목: “compaction”{ "type": "compaction", "id": "d1e2f3a4", "parentId": "c1d2e3f4", "timestamp": "2026-02-16T10:23:00.000Z", "summary": "Conversation summary", "shortSummary": "Short recap", "firstKeptEntryId": "a1b2c3d4", "tokensBefore": 42000, "details": { "readFiles": ["src/a.ts"] }, "preserveData": { "hookState": true }, "fromExtension": false}branch_summary
섹션 제목: “branch_summary”{ "type": "branch_summary", "id": "e1f2a3b4", "parentId": "a1b2c3d4", "timestamp": "2026-02-16T10:24:00.000Z", "fromId": "a1b2c3d4", "summary": "Summary of abandoned path", "details": { "note": "optional" }, "fromExtension": true}루트에서 분기하는 경우 (branchFromId === null), fromId는 리터럴 문자열 "root"입니다.
custom
섹션 제목: “custom”확장 기능 상태 영속성; buildSessionContext에서 무시됩니다.
{ "type": "custom", "id": "f1a2b3c4", "parentId": "e1f2a3b4", "timestamp": "2026-02-16T10:25:00.000Z", "customType": "my-extension", "data": { "state": 1 }}custom_message
섹션 제목: “custom_message”LLM 컨텍스트에 참여하는 확장 기능 제공 메시지입니다.
{ "type": "custom_message", "id": "a2b3c4d5", "parentId": "f1a2b3c4", "timestamp": "2026-02-16T10:26:00.000Z", "customType": "my-extension", "content": "Injected context", "display": true, "details": { "debug": false }}label
섹션 제목: “label”{ "type": "label", "id": "b2c3d4e5", "parentId": "a2b3c4d5", "timestamp": "2026-02-16T10:27:00.000Z", "targetId": "a1b2c3d4", "label": "checkpoint"}label: undefined는 targetId에 대한 레이블을 지웁니다.
ttsr_injection
섹션 제목: “ttsr_injection”{ "type": "ttsr_injection", "id": "c2d3e4f5", "parentId": "b2c3d4e5", "timestamp": "2026-02-16T10:28:00.000Z", "injectedRules": ["ruleA", "ruleB"]}session_init
섹션 제목: “session_init”{ "type": "session_init", "id": "d2e3f4a5", "parentId": "c2d3e4f5", "timestamp": "2026-02-16T10:29:00.000Z", "systemPrompt": "...", "task": "...", "tools": ["read", "edit"], "outputSchema": { "type": "object" }}mode_change
섹션 제목: “mode_change”{ "type": "mode_change", "id": "e2f3a4b5", "parentId": "d2e3f4a5", "timestamp": "2026-02-16T10:30:00.000Z", "mode": "plan", "data": { "planFile": "/tmp/plan.md" }}버전 관리 및 마이그레이션
섹션 제목: “버전 관리 및 마이그레이션”현재 세션 버전: 3.
v1 -> v2
섹션 제목: “v1 -> v2”헤더 version이 누락되었거나 < 2일 때 적용됩니다:
- 각 비헤더 엔트리에
id와parentId를 추가합니다. - 파일 순서를 사용하여 선형 부모 체인을 재구성합니다.
firstKeptEntryIndex->firstKeptEntryId압축 필드가 있는 경우 마이그레이션합니다.- 헤더
version = 2로 설정합니다.
v2 -> v3
섹션 제목: “v2 -> v3”헤더 version < 3일 때 적용됩니다:
message엔트리의 경우: 레거시message.role === "hookMessage"를"custom"으로 재작성합니다.- 헤더
version = 3으로 설정합니다.
마이그레이션 트리거 및 영속화
섹션 제목: “마이그레이션 트리거 및 영속화”- 마이그레이션은 세션 로드(
setSessionFile) 중에 실행됩니다. - 마이그레이션이 실행된 경우, 전체 파일이 즉시 디스크에 다시 기록됩니다.
- 마이그레이션은 먼저 메모리 내 엔트리를 변경한 다음, 재작성된 JSONL을 영속화합니다.
로드 및 호환성 동작
섹션 제목: “로드 및 호환성 동작”loadEntriesFromFile(path) 동작:
- 파일이 없는 경우 (
ENOENT) ->[]를 반환합니다. - 파싱할 수 없는 줄은 관대한 JSONL 파서(
parseJsonlLenient)에 의해 처리됩니다. - 첫 번째 파싱된 엔트리가 유효한 세션 헤더가 아닌 경우 (
type !== "session"또는 문자열id가 누락) ->[]를 반환합니다.
SessionManager.setSessionFile() 동작:
- 로더에서 반환된
[]는 비어 있거나 존재하지 않는 세션으로 처리되며, 해당 경로에 새로 초기화된 세션 파일로 대체됩니다. - 유효한 파일은 로드되고, 필요한 경우 마이그레이션되며, 블롭 참조가 해결된 후 인덱싱됩니다.
트리 및 리프 의미론
섹션 제목: “트리 및 리프 의미론”기본 모델은 추가 전용 트리 + 변경 가능한 리프 포인터입니다:
- 모든 추가 메서드는 현재
leafId를parentId로 하는 새 엔트리를 정확히 하나 생성합니다. - 새 엔트리가 새로운
leafId가 됩니다. branch(entryId)는leafId만 이동합니다; 기존 엔트리는 변경되지 않습니다.resetLeaf()는leafId = null로 설정합니다; 다음 추가는 새로운 루트 엔트리(parentId: null)를 생성합니다.branchWithSummary()는 리프를 브랜치 대상으로 설정하고branch_summary엔트리를 추가합니다.
getEntries()는 모든 비헤더 엔트리를 삽입 순서대로 반환합니다. 기존 엔트리는 정상 운영 중에 삭제되지 않습니다; 재작성은 표현을 업데이트하면서 논리적 이력을 보존합니다(마이그레이션, 이동, 대상 지정 재작성 헬퍼).
컨텍스트 재구성 (buildSessionContext)
섹션 제목: “컨텍스트 재구성 (buildSessionContext)”buildSessionContext(entries, leafId, byId?)는 모델에 전송되는 내용을 결정합니다.
알고리즘:
- 리프 결정:
leafId === null-> 빈 컨텍스트를 반환합니다.- 명시적
leafId-> 해당 엔트리가 존재하면 사용합니다. - 그 외의 경우 마지막 엔트리로 폴백합니다.
- 리프에서 루트까지
parentId체인을 따라가고 루트->리프 경로로 역순 정렬합니다. - 경로를 따라 런타임 상태를 도출합니다:
thinkingLevel은 가장 최근thinking_level_change에서 가져옵니다 (기본값"off")- 모델 맵은
model_change엔트리에서 가져옵니다 (role ?? "default") - 명시적 모델 변경이 없는 경우 어시스턴트 메시지 provider/model에서 폴백
models.default를 가져옵니다 - 모든
ttsr_injection엔트리에서 중복 제거된injectedTtsrRules - 가장 최근
mode_change에서 mode/modeData (기본 모드"none")
- 메시지 목록 구성:
message엔트리는 그대로 전달됩니다custom_message엔트리는createCustomMessage를 통해customAgentMessages가 됩니다branch_summary엔트리는createBranchSummaryMessage를 통해branchSummaryAgentMessages가 됩니다- 경로에
compaction이 존재하는 경우:- 먼저 압축 요약을 발행합니다 (
createCompactionSummaryMessage) firstKeptEntryId부터 압축 경계까지의 경로 엔트리를 발행합니다- 압축 경계 이후의 엔트리를 발행합니다
- 먼저 압축 요약을 발행합니다 (
custom 및 session_init 엔트리는 모델 컨텍스트에 직접 주입되지 않습니다.
영속성 보장 및 실패 모델
섹션 제목: “영속성 보장 및 실패 모델”영속 vs 인메모리
섹션 제목: “영속 vs 인메모리”SessionManager.create/open/continueRecent/forkFrom-> 영속 모드 (persist = true).SessionManager.inMemory-> 비영속 모드 (persist = false),MemorySessionStorage사용.
쓰기 파이프라인
섹션 제목: “쓰기 파이프라인”쓰기는 내부 프로미스 체인(#persistChain)과 NdjsonFileWriter를 통해 직렬화됩니다.
append*는 인메모리 상태를 즉시 업데이트합니다.- 영속화는 최소 하나의 어시스턴트 메시지가 존재할 때까지 지연됩니다.
- 첫 번째 어시스턴트 이전: 엔트리는 메모리에 유지됩니다; 파일 추가가 발생하지 않습니다.
- 첫 번째 어시스턴트가 존재할 때: 전체 인메모리 세션이 파일로 플러시됩니다.
- 이후: 새 엔트리가 점진적으로 추가됩니다.
코드 내 근거: 어시스턴트 응답을 생성하지 않은 세션의 영속화를 방지합니다.
내구성 연산
섹션 제목: “내구성 연산”flush()는 라이터를 플러시하고fsync()를 호출합니다.- 원자적 전체 재작성(
#rewriteFile)은 임시 파일에 쓰고, flush+fsync, close, 그런 다음 대상 위에 rename합니다. - 마이그레이션,
setSessionName,rewriteEntries, 이동 연산, 도구 호출 인자 재작성에 사용됩니다.
오류 동작
섹션 제목: “오류 동작”- 영속화 오류는 래치됩니다(
#persistError), 후속 연산에서 다시 throw됩니다. - 첫 번째 오류는 세션 파일 컨텍스트와 함께 한 번만 로깅됩니다.
- 라이터 close는 최선 노력(best-effort)이지만 첫 번째 의미 있는 오류를 전파합니다.
데이터 크기 제어 및 블롭 외부화
섹션 제목: “데이터 크기 제어 및 블롭 외부화”엔트리를 영속화하기 전에:
- 큰 문자열은
MAX_PERSIST_CHARS(500,000자)로 잘리며 알림이 포함됩니다:"[Session persistence truncated large content]"
- 일시적 필드
partialJson및jsonlEvents가 제거됩니다. - 객체에
content와lineCount가 모두 있는 경우, 잘라내기 후 줄 수가 다시 계산됩니다. content배열의 이미지 블록 중 base64 길이가 >= 1024인 것은 블롭 참조로 외부화됩니다:blob:sha256:<hash>로 저장됩니다- 원시 바이트는 블롭 저장소에 기록됩니다 (
BlobStore.put)
로드 시, 블롭 참조는 message/custom_message 이미지 블록에 대해 다시 base64로 해결됩니다.
저장소 추상화
섹션 제목: “저장소 추상화”SessionStorage 인터페이스는 SessionManager가 사용하는 모든 파일시스템 연산을 제공합니다:
- 동기:
ensureDirSync,existsSync,writeTextSync,statSync,listFilesSync - 비동기:
exists,readText,readTextPrefix,writeText,rename,unlink,openWriter
구현체:
FileSessionStorage: 실제 파일시스템 (Bun + node fs)MemorySessionStorage: 테스트/비영속 세션을 위한 맵 기반 인메모리 구현
SessionStorageWriter는 writeLine, flush, fsync, close, getError를 노출합니다.
세션 탐색 유틸리티
섹션 제목: “세션 탐색 유틸리티”session-manager.ts에 정의되어 있습니다:
getRecentSessions(sessionDir, limit)-> UI/세션 선택기를 위한 경량 메타데이터findMostRecentSession(sessionDir)-> mtime 기준 가장 최근 세션list(cwd, sessionDir?)-> 하나의 프로젝트 범위 내 세션들listAll()->~/.xcsh/agent/sessions하위 모든 프로젝트 범위의 세션들
메타데이터 추출은 가능한 경우 접두사만 읽습니다 (readTextPrefix(..., 4096)).
관련되지만 별개인 것: 프롬프트 히스토리 저장소
섹션 제목: “관련되지만 별개인 것: 프롬프트 히스토리 저장소”HistoryStorage (history-storage.ts)는 세션 재생이 아닌 프롬프트 재호출/검색을 위한 별도의 SQLite 하위 시스템입니다.
- DB:
~/.xcsh/agent/history.db - 테이블:
history(id, prompt, created_at, cwd) - FTS5 인덱스: 트리거로 유지되는 동기화와 함께
history_fts - 인메모리 마지막 프롬프트 캐시를 사용하여 연속된 동일 프롬프트를 중복 제거합니다
- 비동기 삽입(
setImmediate)으로 프롬프트 캡처가 턴 실행을 차단하지 않습니다
대화 그래프/상태 재생에는 세션 파일을 사용하고, 프롬프트 히스토리 UX에는 HistoryStorage를 사용하십시오.