Skip to content

Commit 9beed32

Browse files
fix: import archived codex sessions (#479)
## Summary - import Codex sessions from both `~/.codex/sessions` and `~/.codex/archived_sessions` - support both dated live-session paths and flat archived-session paths during discovery and direct path sync - deduplicate Codex sessions by canonical session ID and prefer live paths when both live and archived copies exist Closes #477.
1 parent 7039773 commit 9beed32

6 files changed

Lines changed: 342 additions & 40 deletions

File tree

internal/config/config_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,24 @@ func loadConfigFromPFlags(t *testing.T, args ...string) (Config, error) {
7272
return LoadPFlags(fs)
7373
}
7474

75+
func TestDefault_IncludesCodexArchivedSessionsDir(t *testing.T) {
76+
cfg, err := Default()
77+
if err != nil {
78+
t.Fatal(err)
79+
}
80+
81+
dirs := cfg.ResolveDirs(parser.AgentCodex)
82+
if len(dirs) != 2 {
83+
t.Fatalf("len(codex dirs) = %d, want 2", len(dirs))
84+
}
85+
if !strings.HasSuffix(dirs[0], filepath.Join(".codex", "sessions")) {
86+
t.Fatalf("dirs[0] = %q", dirs[0])
87+
}
88+
if !strings.HasSuffix(dirs[1], filepath.Join(".codex", "archived_sessions")) {
89+
t.Fatalf("dirs[1] = %q", dirs[1])
90+
}
91+
}
92+
7593
func TestLoadEnv_OverridesDataDir(t *testing.T) {
7694
custom := setupTestEnv(t)
7795

internal/parser/discovery.go

Lines changed: 111 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -382,11 +382,28 @@ func DiscoverClaudeProjects(projectsDir string) []DiscoveredFile {
382382
return files
383383
}
384384

385-
// DiscoverCodexSessions finds all JSONL files under the Codex
386-
// sessions dir (year/month/day structure).
385+
// DiscoverCodexSessions finds all Codex JSONL session files under
386+
// either the standard year/month/day layout or a flat archived dir.
387387
func DiscoverCodexSessions(sessionsDir string) []DiscoveredFile {
388388
var files []DiscoveredFile
389389

390+
entries, err := os.ReadDir(sessionsDir)
391+
if err != nil {
392+
return nil
393+
}
394+
for _, entry := range entries {
395+
if entry.IsDir() {
396+
continue
397+
}
398+
if !isCodexSessionFilename(entry.Name()) {
399+
continue
400+
}
401+
files = append(files, DiscoveredFile{
402+
Path: filepath.Join(sessionsDir, entry.Name()),
403+
Agent: AgentCodex,
404+
})
405+
}
406+
390407
walkCodexDayDirs(sessionsDir, func(dayPath string) bool {
391408
entries, err := os.ReadDir(dayPath)
392409
if err != nil {
@@ -396,7 +413,7 @@ func DiscoverCodexSessions(sessionsDir string) []DiscoveredFile {
396413
if sf.IsDir() {
397414
continue
398415
}
399-
if !strings.HasSuffix(sf.Name(), ".jsonl") {
416+
if !isCodexSessionFilename(sf.Name()) {
400417
continue
401418
}
402419
files = append(files, DiscoveredFile{
@@ -473,16 +490,34 @@ func FindClaudeSourceFile(
473490
}
474491

475492
// FindCodexSourceFile finds a Codex session file by UUID.
476-
// Searches the year/month/day directory structure for files matching
477-
// rollout-{timestamp}-{uuid}.jsonl.
493+
// Prefers the standard year/month/day live path when present,
494+
// then falls back to a flat archived dir entry.
478495
func FindCodexSourceFile(sessionsDir, sessionID string) string {
479496
if !IsValidSessionID(sessionID) {
480497
return ""
481498
}
482499

483-
var result string
500+
var archived string
501+
entries, err := os.ReadDir(sessionsDir)
502+
if err == nil {
503+
for _, f := range entries {
504+
if f.IsDir() {
505+
continue
506+
}
507+
name := f.Name()
508+
if !isCodexSessionFilename(name) {
509+
continue
510+
}
511+
if extractUUIDFromRollout(name) == sessionID {
512+
archived = filepath.Join(sessionsDir, name)
513+
break
514+
}
515+
}
516+
}
517+
518+
var live string
484519
walkCodexDayDirs(sessionsDir, func(dayPath string) bool {
485-
if result != "" {
520+
if live != "" {
486521
return false
487522
}
488523
entries, err := os.ReadDir(dayPath)
@@ -494,18 +529,83 @@ func FindCodexSourceFile(sessionsDir, sessionID string) string {
494529
continue
495530
}
496531
name := f.Name()
497-
if !strings.HasPrefix(name, "rollout-") ||
498-
!strings.HasSuffix(name, ".jsonl") {
532+
if !isCodexSessionFilename(name) {
499533
continue
500534
}
501535
if extractUUIDFromRollout(name) == sessionID {
502-
result = filepath.Join(dayPath, name)
536+
live = filepath.Join(dayPath, name)
503537
return false
504538
}
505539
}
506540
return true
507541
})
508-
return result
542+
if live != "" {
543+
return live
544+
}
545+
return archived
546+
}
547+
548+
func isCodexSessionFilename(name string) bool {
549+
return strings.HasPrefix(name, "rollout-") &&
550+
strings.HasSuffix(name, ".jsonl")
551+
}
552+
553+
// CodexSessionUUIDFromFilename extracts the canonical session UUID
554+
// from a Codex rollout filename. Returns "" when the filename does
555+
// not match Codex session naming.
556+
func CodexSessionUUIDFromFilename(name string) string {
557+
if !isCodexSessionFilename(name) {
558+
return ""
559+
}
560+
return extractUUIDFromRollout(name)
561+
}
562+
563+
// CodexLayout reports which on-disk layout a Codex session path uses.
564+
type CodexLayout int
565+
566+
const (
567+
CodexLayoutUnknown CodexLayout = iota
568+
CodexLayoutArchivedFlat
569+
CodexLayoutDated
570+
)
571+
572+
// CodexSessionPathInfo parses a Codex path relative to a configured
573+
// root and reports whether it is a valid session path plus its layout
574+
// and canonical session UUID.
575+
func CodexSessionPathInfo(root, path string) (CodexLayout, string, bool) {
576+
root = filepath.Clean(root)
577+
path = filepath.Clean(path)
578+
rel, err := filepath.Rel(root, path)
579+
if err != nil {
580+
return CodexLayoutUnknown, "", false
581+
}
582+
sep := string(filepath.Separator)
583+
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+sep) {
584+
return CodexLayoutUnknown, "", false
585+
}
586+
if !strings.HasSuffix(path, ".jsonl") {
587+
return CodexLayoutUnknown, "", false
588+
}
589+
parts := strings.Split(rel, sep)
590+
switch len(parts) {
591+
case 1:
592+
if !isCodexSessionFilename(parts[0]) {
593+
return CodexLayoutUnknown, "", false
594+
}
595+
return CodexLayoutArchivedFlat,
596+
CodexSessionUUIDFromFilename(parts[0]), true
597+
case 4:
598+
if !IsDigits(parts[0]) || !IsDigits(parts[1]) || !IsDigits(parts[2]) {
599+
return CodexLayoutUnknown, "", false
600+
}
601+
if !isCodexSessionFilename(parts[3]) {
602+
return CodexLayoutUnknown, "", false
603+
}
604+
return CodexLayoutDated,
605+
CodexSessionUUIDFromFilename(parts[3]), true
606+
default:
607+
return CodexLayoutUnknown, "", false
608+
}
509609
}
510610

511611
// walkCodexDayDirs traverses a Codex sessions directory with

internal/parser/discovery_test.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ func TestDiscoverClaudeProjects(t *testing.T) {
136136
func TestDiscoverCodexSessions(t *testing.T) {
137137
file1 := "rollout-123-abc-def-ghi-jkl-mno.jsonl"
138138
file2 := "rollout-456-abc-def-ghi-jkl-mno.jsonl"
139+
flat := "rollout-2026-05-04T02-10-04-019df19b-ad1a-7f82-bfc3-38701744cc66.jsonl"
139140

140141
tests := []struct {
141142
name string
@@ -150,6 +151,13 @@ func TestDiscoverCodexSessions(t *testing.T) {
150151
},
151152
wantFiles: []string{file1, file2},
152153
},
154+
{
155+
name: "FlatArchivedDir",
156+
files: map[string]string{
157+
flat: "{}",
158+
},
159+
wantFiles: []string{flat},
160+
},
153161
{
154162
name: "SkipsNonDigit",
155163
files: map[string]string{
@@ -330,6 +338,7 @@ func TestFindCodexSourceFile(t *testing.T) {
330338
uuid := "abc12345-1234-5678-9abc-def012345678"
331339
filename := "rollout-20240115-" + uuid + ".jsonl"
332340
relPath := filepath.Join("2024", "01", "15", filename)
341+
flatName := "rollout-2026-05-04T02-10-04-" + uuid + ".jsonl"
333342

334343
tests := []struct {
335344
name string
@@ -338,11 +347,26 @@ func TestFindCodexSourceFile(t *testing.T) {
338347
wantFile string
339348
}{
340349
{
341-
name: "Found",
350+
name: "FoundDated",
342351
files: map[string]string{relPath: "{}"},
343352
targetID: uuid,
344353
wantFile: relPath,
345354
},
355+
{
356+
name: "FoundFlatArchived",
357+
files: map[string]string{flatName: "{}"},
358+
targetID: uuid,
359+
wantFile: flatName,
360+
},
361+
{
362+
name: "PrefersDatedOverFlat",
363+
files: map[string]string{
364+
relPath: "{}",
365+
flatName: "{}",
366+
},
367+
targetID: uuid,
368+
wantFile: relPath,
369+
},
346370
{
347371
name: "Nonexistent",
348372
files: map[string]string{relPath: "{}"},

internal/parser/types.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,14 @@ var Registry = []AgentDef{
7474
FindSourceFunc: FindClaudeSourceFile,
7575
},
7676
{
77-
Type: AgentCodex,
78-
DisplayName: "Codex",
79-
EnvVar: "CODEX_SESSIONS_DIR",
80-
ConfigKey: "codex_sessions_dirs",
81-
DefaultDirs: []string{".codex/sessions"},
77+
Type: AgentCodex,
78+
DisplayName: "Codex",
79+
EnvVar: "CODEX_SESSIONS_DIR",
80+
ConfigKey: "codex_sessions_dirs",
81+
DefaultDirs: []string{
82+
".codex/sessions",
83+
".codex/archived_sessions",
84+
},
8285
IDPrefix: "codex:",
8386
FileBased: true,
8487
DiscoverFunc: DiscoverCodexSessions,

internal/sync/engine.go

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,76 @@ func (e *Engine) classifyPaths(
310310
files = append(files, df)
311311
}
312312
}
313-
return files
313+
return dedupeDiscoveredFiles(files)
314+
}
315+
316+
func dedupeDiscoveredFiles(
317+
files []parser.DiscoveredFile,
318+
) []parser.DiscoveredFile {
319+
if len(files) < 2 {
320+
return files
321+
}
322+
323+
bestByKey := make(map[string]parser.DiscoveredFile, len(files))
324+
for _, file := range files {
325+
key := discoveredFileKey(file)
326+
if current, ok := bestByKey[key]; ok {
327+
if preferDiscoveredFile(file, current) {
328+
bestByKey[key] = file
329+
}
330+
continue
331+
}
332+
bestByKey[key] = file
333+
}
334+
335+
out := make([]parser.DiscoveredFile, 0, len(bestByKey))
336+
for _, file := range files {
337+
key := discoveredFileKey(file)
338+
chosen, ok := bestByKey[key]
339+
if !ok || chosen.Path != file.Path || chosen.Agent != file.Agent {
340+
continue
341+
}
342+
out = append(out, file)
343+
delete(bestByKey, key)
344+
}
345+
return out
346+
}
347+
348+
func discoveredFileKey(file parser.DiscoveredFile) string {
349+
if file.Agent == parser.AgentCodex {
350+
if id := parser.CodexSessionUUIDFromFilename(filepath.Base(file.Path)); id != "" {
351+
return string(file.Agent) + "\x00" + id
352+
}
353+
}
354+
return string(file.Agent) + "\x00" + file.Path
355+
}
356+
357+
func preferDiscoveredFile(
358+
candidate, current parser.DiscoveredFile,
359+
) bool {
360+
if candidate.Agent == parser.AgentCodex && current.Agent == parser.AgentCodex {
361+
candLayout := codexLayoutForPath(candidate.Path)
362+
currLayout := codexLayoutForPath(current.Path)
363+
if candLayout != currLayout {
364+
return candLayout == parser.CodexLayoutDated
365+
}
366+
}
367+
return false
368+
}
369+
370+
func codexLayoutForPath(path string) parser.CodexLayout {
371+
path = filepath.Clean(path)
372+
name := filepath.Base(path)
373+
if parser.CodexSessionUUIDFromFilename(name) == "" {
374+
return parser.CodexLayoutUnknown
375+
}
376+
day := filepath.Base(filepath.Dir(path))
377+
month := filepath.Base(filepath.Dir(filepath.Dir(path)))
378+
year := filepath.Base(filepath.Dir(filepath.Dir(filepath.Dir(path))))
379+
if parser.IsDigits(day) && parser.IsDigits(month) && parser.IsDigits(year) {
380+
return parser.CodexLayoutDated
381+
}
382+
return parser.CodexLayoutArchivedFlat
314383
}
315384

316385
// isUnder checks whether path is strictly inside dir after
@@ -409,24 +478,13 @@ func (e *Engine) classifyOnePath(
409478
}
410479
}
411480

412-
// Codex: <codexDir>/<year>/<month>/<day>/<file>.jsonl
481+
// Codex: either <codexDir>/<year>/<month>/<day>/<file>.jsonl
482+
// or <codexDir>/<file>.jsonl for archived sessions.
413483
for _, codexDir := range e.agentDirs[parser.AgentCodex] {
414484
if codexDir == "" {
415485
continue
416486
}
417-
if rel, ok := isUnder(codexDir, path); ok {
418-
parts := strings.Split(rel, sep)
419-
if len(parts) != 4 {
420-
continue
421-
}
422-
if !parser.IsDigits(parts[0]) ||
423-
!parser.IsDigits(parts[1]) ||
424-
!parser.IsDigits(parts[2]) {
425-
continue
426-
}
427-
if !strings.HasSuffix(parts[3], ".jsonl") {
428-
continue
429-
}
487+
if _, _, ok := parser.CodexSessionPathInfo(codexDir, path); ok {
430488
return parser.DiscoveredFile{
431489
Path: path,
432490
Agent: parser.AgentCodex,
@@ -1430,6 +1488,8 @@ func (e *Engine) syncAllLocked(
14301488
all = filterFilesByMtime(all, since)
14311489
}
14321490

1491+
all = dedupeDiscoveredFiles(all)
1492+
14331493
verbose := onProgress == nil
14341494

14351495
if verbose {

0 commit comments

Comments
 (0)