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>
282 lines
6.3 KiB
Go
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
|
|
}
|