Files
cc-connect/agent/codex/list.go
T
chenhg5andClaude e22a6028f4 feat(codex): add OpenAI Codex agent support with docs and config updates
Add new Codex agent implementation with permission modes, session management, and history retrieval. Update installation docs, README files, and config templates to reflect Codex support. Enhance session listing and history commands to work with both Claude and Codex agents.

generated by llmgit

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-01 09:20:38 +08:00

282 lines
6.3 KiB
Go

package codex
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/chenhg5/cc-connect/core"
)
// listCodexSessions scans ~/.codex/sessions/ for JSONL transcript files
// whose cwd matches workDir.
func listCodexSessions(workDir string) ([]core.AgentSessionInfo, error) {
absWorkDir, err := filepath.Abs(workDir)
if err != nil {
absWorkDir = workDir
}
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
codexHome := os.Getenv("CODEX_HOME")
if codexHome == "" {
codexHome = filepath.Join(homeDir, ".codex")
}
sessionsDir := filepath.Join(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 {
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 string) string {
homeDir, err := os.UserHomeDir()
if err != nil {
return ""
}
codexHome := os.Getenv("CODEX_HOME")
if codexHome == "" {
codexHome = filepath.Join(homeDir, ".codex")
}
sessionsDir := filepath.Join(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 string, limit int) ([]core.HistoryEntry, error) {
path := findSessionFile(sessionID)
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
}
// 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
}