Skip to content

Commit 44572f8

Browse files
feat: add OpenAI Codex CLI agent support
Implement full agent plugin for OpenAI Codex CLI, including: - Agent interface implementation (codex.go) with session management - Hook support via Codex notify config in ~/.codex/config.toml - JSONL transcript parsing with user/assistant/tool extraction - Token usage calculation from cumulative token_count events - Modified file detection via exec_command heuristics - Turn-complete hook handler for checkpoint creation - Comprehensive unit tests (46 tests across 3 test files) - Exhaustive switch coverage for AgentTypeCodex in summarize - Codex hook removal in removeAgentHooks (setup.go) Codex CLI uses a single notify hook (agent-turn-complete) rather than separate before/after hooks, so the handler combines state capture and commit in one pass.
1 parent 2f0ad9a commit 44572f8

13 files changed

Lines changed: 2295 additions & 1 deletion

File tree

Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
package codexcli
2+
3+
import (
4+
"bufio"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"os"
10+
"path/filepath"
11+
"strconv"
12+
"strings"
13+
"time"
14+
15+
"github.com/entireio/cli/cmd/entire/cli/agent"
16+
"github.com/entireio/cli/cmd/entire/cli/paths"
17+
)
18+
19+
//nolint:gochecknoinits // Agent self-registration is the intended pattern
20+
func init() {
21+
agent.Register(agent.AgentNameCodex, NewCodexCLIAgent)
22+
}
23+
24+
// CodexCLIAgent implements the Agent interface for OpenAI Codex CLI.
25+
//
26+
//nolint:revive // CodexCLIAgent is clearer than Agent in this context
27+
type CodexCLIAgent struct{}
28+
29+
// NewCodexCLIAgent creates a new Codex CLI agent instance.
30+
func NewCodexCLIAgent() agent.Agent {
31+
return &CodexCLIAgent{}
32+
}
33+
34+
// Name returns the agent registry key.
35+
func (c *CodexCLIAgent) Name() agent.AgentName {
36+
return agent.AgentNameCodex
37+
}
38+
39+
// Type returns the agent type identifier.
40+
func (c *CodexCLIAgent) Type() agent.AgentType {
41+
return agent.AgentTypeCodex
42+
}
43+
44+
// Description returns a human-readable description.
45+
func (c *CodexCLIAgent) Description() string {
46+
return "Codex CLI - OpenAI's CLI coding agent"
47+
}
48+
49+
// DetectPresence checks if Codex CLI is configured in the repository.
50+
func (c *CodexCLIAgent) DetectPresence() (bool, error) {
51+
repoRoot, err := paths.RepoRoot()
52+
if err != nil {
53+
repoRoot = "."
54+
}
55+
56+
// Check for AGENTS.md (Codex project config file)
57+
agentsFile := filepath.Join(repoRoot, "AGENTS.md")
58+
if _, err := os.Stat(agentsFile); err == nil {
59+
return true, nil
60+
}
61+
62+
// Check for ~/.codex directory (Codex is installed)
63+
homeDir, err := os.UserHomeDir()
64+
if err != nil {
65+
return false, fmt.Errorf("failed to get home directory: %w", err)
66+
}
67+
codexDir := filepath.Join(homeDir, ".codex")
68+
if _, err := os.Stat(codexDir); err == nil {
69+
return true, nil
70+
}
71+
72+
return false, nil
73+
}
74+
75+
// GetHookConfigPath returns the path to Codex's config file.
76+
func (c *CodexCLIAgent) GetHookConfigPath() string {
77+
homeDir, err := os.UserHomeDir()
78+
if err != nil {
79+
return ""
80+
}
81+
return filepath.Join(homeDir, ".codex", "config.toml")
82+
}
83+
84+
// SupportsHooks returns true as Codex CLI supports the notify hook.
85+
func (c *CodexCLIAgent) SupportsHooks() bool {
86+
return true
87+
}
88+
89+
// ParseHookInput parses Codex hook input from stdin.
90+
// Codex's notify sends a JSON payload with turn completion data.
91+
func (c *CodexCLIAgent) ParseHookInput(_ agent.HookType, reader io.Reader) (*agent.HookInput, error) {
92+
data, err := io.ReadAll(reader)
93+
if err != nil {
94+
return nil, fmt.Errorf("failed to read input: %w", err)
95+
}
96+
97+
if len(data) == 0 {
98+
return nil, errors.New("empty input")
99+
}
100+
101+
var payload notifyPayload
102+
if err := json.Unmarshal(data, &payload); err != nil {
103+
return nil, fmt.Errorf("failed to parse notify payload: %w", err)
104+
}
105+
106+
input := &agent.HookInput{
107+
HookType: agent.HookStop, // turn-complete maps to stop semantically
108+
SessionID: payload.ThreadID,
109+
Timestamp: time.Now(),
110+
RawData: make(map[string]interface{}),
111+
}
112+
113+
if len(payload.InputMessages) > 0 {
114+
input.UserPrompt = payload.InputMessages[len(payload.InputMessages)-1]
115+
}
116+
117+
input.RawData["turn_id"] = payload.TurnID
118+
input.RawData["last_message"] = payload.LastAssistantMessage
119+
120+
// Resolve the transcript file for this session
121+
sessionDir, err := c.GetSessionDir("")
122+
if err == nil {
123+
transcriptPath := c.findTranscriptBySessionID(sessionDir, payload.ThreadID)
124+
if transcriptPath != "" {
125+
input.SessionRef = transcriptPath
126+
}
127+
}
128+
129+
return input, nil
130+
}
131+
132+
// GetSessionID extracts the session ID from hook input.
133+
func (c *CodexCLIAgent) GetSessionID(input *agent.HookInput) string {
134+
return input.SessionID
135+
}
136+
137+
// ProtectedDirs returns directories that Codex uses for config/state.
138+
// Codex does not create a project-level config directory (unlike .claude or .gemini).
139+
func (c *CodexCLIAgent) ProtectedDirs() []string { return nil }
140+
141+
// GetSessionDir returns the directory where Codex stores session transcripts.
142+
func (c *CodexCLIAgent) GetSessionDir(_ string) (string, error) {
143+
if override := os.Getenv("ENTIRE_TEST_CODEX_SESSION_DIR"); override != "" {
144+
return override, nil
145+
}
146+
147+
homeDir, err := os.UserHomeDir()
148+
if err != nil {
149+
return "", fmt.Errorf("failed to get home directory: %w", err)
150+
}
151+
152+
return filepath.Join(homeDir, ".codex", "sessions"), nil
153+
}
154+
155+
// ResolveSessionFile returns the path to a Codex session file.
156+
// Codex uses date-based directory hierarchy: sessions/<year>/<month>/<day>/rollout-<date>-<uuid>.jsonl
157+
func (c *CodexCLIAgent) ResolveSessionFile(sessionDir, agentSessionID string) string {
158+
path := c.findTranscriptBySessionID(sessionDir, agentSessionID)
159+
if path != "" {
160+
return path
161+
}
162+
// Return a best-guess path using today's date
163+
now := time.Now()
164+
return filepath.Join(
165+
sessionDir,
166+
strconv.Itoa(now.Year()),
167+
fmt.Sprintf("%02d", now.Month()),
168+
fmt.Sprintf("%02d", now.Day()),
169+
fmt.Sprintf("rollout-%s-%s.jsonl", now.Format("2006-01-02T15-04-05"), agentSessionID),
170+
)
171+
}
172+
173+
// findTranscriptBySessionID walks the sessions directory to find a transcript containing the session ID.
174+
func (c *CodexCLIAgent) findTranscriptBySessionID(sessionDir, sessionID string) string {
175+
if sessionDir == "" || sessionID == "" {
176+
return ""
177+
}
178+
179+
var found string
180+
walkErr := filepath.Walk(sessionDir, func(path string, info os.FileInfo, err error) error {
181+
if err != nil || info.IsDir() {
182+
return nil //nolint:nilerr // skip directories with errors during best-effort search
183+
}
184+
if !strings.HasSuffix(path, ".jsonl") {
185+
return nil
186+
}
187+
// Check if filename contains the session ID
188+
if strings.Contains(filepath.Base(path), sessionID) {
189+
found = path
190+
return filepath.SkipAll
191+
}
192+
return nil
193+
})
194+
if walkErr != nil {
195+
return ""
196+
}
197+
198+
return found
199+
}
200+
201+
// ReadSession reads a session from Codex's storage (JSONL transcript file).
202+
func (c *CodexCLIAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) {
203+
if input.SessionRef == "" {
204+
return nil, errors.New("session reference (transcript path) is required")
205+
}
206+
207+
data, err := os.ReadFile(input.SessionRef)
208+
if err != nil {
209+
return nil, fmt.Errorf("failed to read transcript: %w", err)
210+
}
211+
212+
lines, err := ParseTranscript(data)
213+
if err != nil {
214+
return nil, fmt.Errorf("failed to parse transcript: %w", err)
215+
}
216+
217+
return &agent.AgentSession{
218+
SessionID: input.SessionID,
219+
AgentName: c.Name(),
220+
SessionRef: input.SessionRef,
221+
StartTime: time.Now(),
222+
NativeData: data,
223+
ModifiedFiles: ExtractModifiedFiles(lines),
224+
}, nil
225+
}
226+
227+
// WriteSession writes a session to Codex's storage (JSONL transcript file).
228+
func (c *CodexCLIAgent) WriteSession(session *agent.AgentSession) error {
229+
if session == nil {
230+
return errors.New("session is nil")
231+
}
232+
233+
if session.AgentName != "" && session.AgentName != c.Name() {
234+
return fmt.Errorf("session belongs to agent %q, not %q", session.AgentName, c.Name())
235+
}
236+
237+
if session.SessionRef == "" {
238+
return errors.New("session reference (transcript path) is required")
239+
}
240+
241+
if len(session.NativeData) == 0 {
242+
return errors.New("session has no native data to write")
243+
}
244+
245+
if err := os.WriteFile(session.SessionRef, session.NativeData, 0o600); err != nil {
246+
return fmt.Errorf("failed to write transcript: %w", err)
247+
}
248+
249+
return nil
250+
}
251+
252+
// FormatResumeCommand returns the command to resume a Codex session.
253+
func (c *CodexCLIAgent) FormatResumeCommand(sessionID string) string {
254+
return "codex --resume " + sessionID
255+
}
256+
257+
// TranscriptAnalyzer interface implementation
258+
259+
// GetTranscriptPosition returns the current line count of a Codex transcript.
260+
func (c *CodexCLIAgent) GetTranscriptPosition(path string) (int, error) {
261+
if path == "" {
262+
return 0, nil
263+
}
264+
265+
file, err := os.Open(path) //nolint:gosec // Path comes from Codex transcript location
266+
if err != nil {
267+
if os.IsNotExist(err) {
268+
return 0, nil
269+
}
270+
return 0, fmt.Errorf("failed to open transcript file: %w", err)
271+
}
272+
defer file.Close()
273+
274+
reader := bufio.NewReader(file)
275+
lineCount := 0
276+
277+
for {
278+
_, err := reader.ReadBytes('\n')
279+
if err != nil {
280+
if err == io.EOF {
281+
break
282+
}
283+
return 0, fmt.Errorf("failed to read transcript: %w", err)
284+
}
285+
lineCount++
286+
}
287+
288+
return lineCount, nil
289+
}
290+
291+
// ExtractModifiedFilesFromOffset extracts files modified since a given line number.
292+
func (c *CodexCLIAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) (files []string, currentPosition int, err error) {
293+
if path == "" {
294+
return nil, 0, nil
295+
}
296+
297+
file, openErr := os.Open(path) //nolint:gosec // Path comes from Codex transcript location
298+
if openErr != nil {
299+
return nil, 0, fmt.Errorf("failed to open transcript file: %w", openErr)
300+
}
301+
defer file.Close()
302+
303+
reader := bufio.NewReader(file)
304+
var lines []TranscriptLine
305+
lineNum := 0
306+
307+
for {
308+
lineData, readErr := reader.ReadBytes('\n')
309+
if readErr != nil && readErr != io.EOF {
310+
return nil, 0, fmt.Errorf("failed to read transcript: %w", readErr)
311+
}
312+
313+
if len(lineData) > 0 {
314+
lineNum++
315+
if lineNum > startOffset {
316+
var line TranscriptLine
317+
if parseErr := json.Unmarshal(lineData, &line); parseErr == nil {
318+
lines = append(lines, line)
319+
}
320+
}
321+
}
322+
323+
if readErr == io.EOF {
324+
break
325+
}
326+
}
327+
328+
return ExtractModifiedFiles(lines), lineNum, nil
329+
}
330+
331+
// TranscriptChunker interface implementation
332+
333+
// ChunkTranscript splits a JSONL transcript at line boundaries.
334+
func (c *CodexCLIAgent) ChunkTranscript(content []byte, maxSize int) ([][]byte, error) {
335+
chunks, err := agent.ChunkJSONL(content, maxSize)
336+
if err != nil {
337+
return nil, fmt.Errorf("failed to chunk JSONL transcript: %w", err)
338+
}
339+
return chunks, nil
340+
}
341+
342+
// ReassembleTranscript concatenates JSONL chunks.
343+
//
344+
//nolint:unparam // error return is required by interface, kept for consistency
345+
func (c *CodexCLIAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) {
346+
return agent.ReassembleJSONL(chunks), nil
347+
}

0 commit comments

Comments
 (0)