package cc import ( "bufio" "context" "encoding/json" "fmt" "os" "path/filepath" "regexp" "strings" "time" "github.com/fsnotify/fsnotify" ) // rolloutUUIDRegex matches the UUID at the tail of rollout--.jsonl. // Matches v7 UUIDs (still 8-4-4-4-12 hex groups). var rolloutUUIDRegex = regexp.MustCompile(`-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$`) // extractIDFromRolloutFilename tries to extract the thread_id UUID from a // rollout filename. Returns "" when the name doesn't match the expected pattern. func extractIDFromRolloutFilename(filename string) string { m := rolloutUUIDRegex.FindStringSubmatch(strings.ToLower(filename)) if m == nil { return "" } return m[1] } // CodexSessionsDir returns the codex sessions directory for a given date: // /.codex/sessions/YYYY/MM/DD. The time.Time parameter makes this // testable without calling time.Now() inside the library. func CodexSessionsDir(homeDir string, t time.Time) string { return filepath.Join(homeDir, ".codex", "sessions", fmt.Sprintf("%04d", t.Year()), fmt.Sprintf("%02d", t.Month()), fmt.Sprintf("%02d", t.Day()), ) } // CodexSessionWatchOpts configures WatchForNewCodexSession. type CodexSessionWatchOpts struct { SessionsDir string Timeout time.Duration } // WatchForNewCodexSession watches SessionsDir for the next new rollout-*.jsonl // file. It prefers the UUID embedded in the filename; falls back to parsing // payload.id from the first JSON line. Returns ("", nil) on timeout or context // cancellation. MkdirAll ensures the leaf dir exists so the first-of-day // session (when codex creates the YYYY/MM/DD dir from scratch) is captured. // // Known limitation: a session file landing in a different day-dir than the one // watched (watcher started just before midnight) will not be caught. func WatchForNewCodexSession(ctx context.Context, opts CodexSessionWatchOpts) (string, error) { // Ensure the target dir exists so fsnotify can watch it even before codex // creates it for the first time today. if err := os.MkdirAll(opts.SessionsDir, 0o755); err != nil { return "", nil } watcher, err := fsnotify.NewWatcher() if err != nil { return "", nil } defer watcher.Close() if err := watcher.Add(opts.SessionsDir); err != nil { return "", nil } // Deduplicate: fsnotify may fire multiple events for the same file. seen := make(map[string]bool) timer := time.NewTimer(opts.Timeout) defer timer.Stop() for { select { case <-ctx.Done(): return "", nil case <-timer.C: return "", nil case event, ok := <-watcher.Events: if !ok { return "", nil } if event.Op&(fsnotify.Create|fsnotify.Rename) == 0 { continue } name := filepath.Base(event.Name) if !strings.HasPrefix(name, "rollout-") || !strings.HasSuffix(name, ".jsonl") { continue } if seen[name] { continue } seen[name] = true // Confirm the file exists (rename fires for deletes too). if _, err := os.Stat(event.Name); err != nil { continue } // Prefer UUID from filename; fall back to first-line JSON payload.id. if id := extractIDFromRolloutFilename(name); id != "" { return id, nil } if id := extractIDFromFirstLine(event.Name); id != "" { return id, nil } // Could not extract an id — keep watching. case _, ok := <-watcher.Errors: if !ok { return "", nil } } } } // sessionMetaLine is the shape of the first JSON line in a codex rollout file. type sessionMetaLine struct { Payload *struct { ID string `json:"id"` } `json:"payload"` ThreadID string `json:"thread_id"` SessionID string `json:"session_id"` ID string `json:"id"` } // extractIDFromFirstLine reads the first JSON line of filePath and returns the // thread id found in payload.id, thread_id, session_id, or id (in that order). func extractIDFromFirstLine(filePath string) string { f, err := os.Open(filePath) if err != nil { return "" } defer f.Close() scanner := bufio.NewScanner(f) if !scanner.Scan() { return "" } line := strings.TrimSpace(scanner.Text()) if line == "" { return "" } var meta sessionMetaLine if err := json.Unmarshal([]byte(line), &meta); err != nil { return "" } if meta.Payload != nil && meta.Payload.ID != "" { return meta.Payload.ID } if meta.ThreadID != "" { return meta.ThreadID } if meta.SessionID != "" { return meta.SessionID } return meta.ID }