package codex import ( "bufio" "bytes" "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "time" "github.com/chenhg5/cc-connect/core" ) // resolveCodexHomeDir returns the effective CODEX_HOME directory. // Priority: explicit config value > CODEX_HOME env > ~/.codex func resolveCodexHomeDir(explicit string) string { if h := strings.TrimSpace(explicit); h != "" { return h } if h := os.Getenv("CODEX_HOME"); h != "" { return h } homeDir, err := os.UserHomeDir() if err != nil { return "" } return filepath.Join(homeDir, ".codex") } // listCodexSessions scans the codex sessions directory for JSONL transcript // files whose cwd matches workDir. func listCodexSessions(workDir, codexHome string) ([]core.AgentSessionInfo, error) { absWorkDir, err := filepath.Abs(workDir) if err != nil { absWorkDir = workDir } sessionsDir := filepath.Join(resolveCodexHomeDir(codexHome), "sessions") var files []string _ = filepath.Walk(sessionsDir, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return nil } if strings.HasSuffix(path, ".jsonl") { files = append(files, path) } return nil }) if len(files) == 0 { return nil, nil } var sessions []core.AgentSessionInfo for _, f := range files { info := parseCodexSessionFile(f, absWorkDir) if info != nil { patchSessionSource(info.ID, codexHome) sessions = append(sessions, *info) } } sort.Slice(sessions, func(i, j int) bool { return sessions[i].ModifiedAt.After(sessions[j].ModifiedAt) }) return sessions, nil } // parseCodexSessionFile reads a Codex JSONL transcript. // Returns nil if the session's cwd doesn't match filterCwd. func parseCodexSessionFile(path, filterCwd string) *core.AgentSessionInfo { f, err := os.Open(path) if err != nil { return nil } defer f.Close() stat, err := f.Stat() if err != nil { return nil } var sessionID string var sessionCwd string var summary string var msgCount int userMsgSeen := 0 scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 256*1024), 256*1024) for scanner.Scan() { line := scanner.Text() if line == "" { continue } var entry struct { Type string `json:"type"` Payload json.RawMessage `json:"payload"` } if err := json.Unmarshal([]byte(line), &entry); err != nil { continue } switch entry.Type { case "session_meta": var meta struct { ID string `json:"id"` Cwd string `json:"cwd"` } if json.Unmarshal(entry.Payload, &meta) == nil { sessionID = meta.ID sessionCwd = meta.Cwd } case "response_item": var item struct { Role string `json:"role"` Content []struct { Type string `json:"type"` Text string `json:"text"` } `json:"content"` } if json.Unmarshal(entry.Payload, &item) == nil { if item.Role == "user" { userMsgSeen++ msgCount++ // The actual user prompt is the last user response_item // (earlier ones are system/AGENTS.md instructions). // Pick the last content block that looks like a real prompt. for _, c := range item.Content { if c.Type == "input_text" && c.Text != "" && isUserPrompt(c.Text) { summary = c.Text } } } else if item.Role == "assistant" { msgCount++ } } } } // Filter by cwd if filterCwd != "" && sessionCwd != "" && sessionCwd != filterCwd { return nil } if sessionID == "" { return nil } if len([]rune(summary)) > 60 { summary = string([]rune(summary)[:60]) + "..." } return &core.AgentSessionInfo{ ID: sessionID, Summary: summary, MessageCount: msgCount, ModifiedAt: stat.ModTime(), } } // findSessionFile locates the JSONL transcript for a given session ID. func findSessionFile(sessionID, codexHome string) string { sessionsDir := filepath.Join(resolveCodexHomeDir(codexHome), "sessions") var found string _ = filepath.Walk(sessionsDir, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() || found != "" { return nil } if strings.Contains(filepath.Base(path), sessionID) { found = path } return nil }) return found } // getSessionHistory reads the JSONL transcript and returns user/assistant messages. func getSessionHistory(sessionID, codexHome string, limit int) ([]core.HistoryEntry, error) { path := findSessionFile(sessionID, codexHome) if path == "" { return nil, fmt.Errorf("session file not found for %s", sessionID) } f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() var entries []core.HistoryEntry scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 256*1024), 256*1024) for scanner.Scan() { line := scanner.Text() if line == "" { continue } var raw struct { Timestamp string `json:"timestamp"` Type string `json:"type"` Payload json.RawMessage `json:"payload"` } if json.Unmarshal([]byte(line), &raw) != nil { continue } if raw.Type != "response_item" { continue } var item struct { Role string `json:"role"` Type string `json:"type"` Text string `json:"text"` Content []struct { Type string `json:"type"` Text string `json:"text"` } `json:"content"` } if json.Unmarshal(raw.Payload, &item) != nil { continue } ts, _ := time.Parse(time.RFC3339Nano, raw.Timestamp) switch { case item.Role == "user" && len(item.Content) > 0: for _, c := range item.Content { if c.Type == "input_text" && c.Text != "" && isUserPrompt(c.Text) { entries = append(entries, core.HistoryEntry{ Role: "user", Content: c.Text, Timestamp: ts, }) } } case item.Role == "assistant" && len(item.Content) > 0: for _, c := range item.Content { if c.Type == "output_text" && c.Text != "" { entries = append(entries, core.HistoryEntry{ Role: "assistant", Content: c.Text, Timestamp: ts, }) } } case item.Type == "reasoning" && item.Text != "": // skip reasoning items } } if limit > 0 && len(entries) > limit { entries = entries[len(entries)-limit:] } return entries, nil } // patchSessionSource rewrites the session_meta line in a Codex JSONL transcript // so that source="cli" and originator="codex_cli_rs", making the session visible // in the interactive `codex` terminal. func patchSessionSource(sessionID, codexHome string) { path := findSessionFile(sessionID, codexHome) if path == "" { return } data, err := os.ReadFile(path) if err != nil { return } idx := bytes.IndexByte(data, '\n') if idx < 0 { return } firstLine := data[:idx] // Only patch if it's actually an exec-sourced session if !bytes.Contains(firstLine, []byte(`"source":"exec"`)) { return } patched := bytes.Replace(firstLine, []byte(`"source":"exec"`), []byte(`"source":"cli"`), 1) patched = bytes.Replace(patched, []byte(`"originator":"codex_exec"`), []byte(`"originator":"codex_cli_rs"`), 1) if bytes.Equal(patched, firstLine) { return } out := make([]byte, 0, len(patched)+len(data)-idx) out = append(out, patched...) out = append(out, data[idx:]...) _ = os.WriteFile(path, out, 0o644) } // isUserPrompt returns true if the text looks like an actual user prompt // rather than system context (AGENTS.md, environment_context, permissions, etc.) func isUserPrompt(text string) bool { t := strings.TrimSpace(text) if t == "" { return false } // Skip XML-style system context if strings.HasPrefix(t, "<") { return false } // Skip AGENTS.md instructions injected by Codex if strings.HasPrefix(t, "# AGENTS.md") || strings.HasPrefix(t, "#AGENTS.md") { return false } return true }