Files
Claude 20268b2b17 fix(codex): use configured codex_home for session listing/history/delete
ListSessions, GetSessionHistory, DeleteSession, and patchSessionSource
all ignored the Agent.codexHome config field and only checked the
CODEX_HOME environment variable. When codex_home was set in config.toml,
StartSession correctly wrote sessions to the custom directory, but the
listing functions searched the default ~/.codex/sessions/ — making all
sessions invisible.

- Add resolveCodexHomeDir helper (explicit config > env > default)
- Thread codexHome through listCodexSessions, findSessionFile,
  getSessionHistory, and patchSessionSource
- Extract CODEX_HOME from session extraEnv for post-exit patching

Made-with: Cursor
2026-04-20 21:49:37 +08:00

322 lines
7.5 KiB
Go

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
}