Co-authored-by: Deeka Wong <8337659+huangdijia@users.noreply.github.com>
463 lines
12 KiB
Go
463 lines
12 KiB
Go
package codex
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/chenhg5/cc-connect/core"
|
|
)
|
|
|
|
func init() {
|
|
core.RegisterAgent("codex", New)
|
|
}
|
|
|
|
// Agent drives OpenAI Codex CLI using `codex exec --json`.
|
|
//
|
|
// Modes (maps to codex exec flags):
|
|
// - "suggest": default, no special flags (safe commands only)
|
|
// - "auto-edit": --full-auto (sandbox-protected auto execution)
|
|
// - "full-auto": --full-auto (sandbox-protected auto execution)
|
|
// - "yolo": --dangerously-bypass-approvals-and-sandbox
|
|
type Agent struct {
|
|
workDir string
|
|
model string
|
|
reasoningEffort string
|
|
mode string // "suggest" | "auto-edit" | "full-auto" | "yolo"
|
|
providers []core.ProviderConfig
|
|
activeIdx int // -1 = no provider set
|
|
sessionEnv []string
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
func New(opts map[string]any) (core.Agent, error) {
|
|
workDir, _ := opts["work_dir"].(string)
|
|
if workDir == "" {
|
|
workDir = "."
|
|
}
|
|
model, _ := opts["model"].(string)
|
|
reasoningEffort, _ := opts["reasoning_effort"].(string)
|
|
mode, _ := opts["mode"].(string)
|
|
mode = normalizeMode(mode)
|
|
|
|
if _, err := exec.LookPath("codex"); err != nil {
|
|
return nil, fmt.Errorf("codex: 'codex' CLI not found in PATH, install with: npm install -g @openai/codex")
|
|
}
|
|
|
|
return &Agent{
|
|
workDir: workDir,
|
|
model: model,
|
|
reasoningEffort: normalizeReasoningEffort(reasoningEffort),
|
|
mode: mode,
|
|
activeIdx: -1,
|
|
}, nil
|
|
}
|
|
|
|
func normalizeMode(raw string) string {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "auto-edit", "autoedit", "auto_edit", "edit":
|
|
return "auto-edit"
|
|
case "full-auto", "fullauto", "full_auto", "auto":
|
|
return "full-auto"
|
|
case "yolo", "bypass", "dangerously-bypass":
|
|
return "yolo"
|
|
default:
|
|
return "suggest"
|
|
}
|
|
}
|
|
|
|
func normalizeReasoningEffort(raw string) string {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "":
|
|
return ""
|
|
case "low":
|
|
return "low"
|
|
case "medium", "med":
|
|
return "medium"
|
|
case "high":
|
|
return "high"
|
|
case "xhigh", "x-high", "very-high":
|
|
return "xhigh"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (a *Agent) Name() string { return "codex" }
|
|
|
|
func (a *Agent) SetWorkDir(dir string) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.workDir = dir
|
|
slog.Info("codex: work_dir changed", "work_dir", dir)
|
|
}
|
|
|
|
func (a *Agent) GetWorkDir() string {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
return a.workDir
|
|
}
|
|
|
|
func (a *Agent) SetModel(model string) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.model = model
|
|
slog.Info("codex: model changed", "model", model)
|
|
}
|
|
|
|
func (a *Agent) GetModel() string {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
return core.GetProviderModel(a.providers, a.activeIdx, a.model)
|
|
}
|
|
|
|
func (a *Agent) SetReasoningEffort(effort string) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.reasoningEffort = normalizeReasoningEffort(effort)
|
|
slog.Info("codex: reasoning effort changed", "reasoning_effort", a.reasoningEffort)
|
|
}
|
|
|
|
func (a *Agent) GetReasoningEffort() string {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
return a.reasoningEffort
|
|
}
|
|
|
|
func (a *Agent) AvailableReasoningEfforts() []string {
|
|
return []string{"low", "medium", "high", "xhigh"}
|
|
}
|
|
|
|
func (a *Agent) configuredModels() []core.ModelOption {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
return core.GetProviderModels(a.providers, a.activeIdx)
|
|
}
|
|
|
|
func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption {
|
|
if models := a.configuredModels(); len(models) > 0 {
|
|
return models
|
|
}
|
|
if models := a.fetchModelsFromAPI(ctx); len(models) > 0 {
|
|
return models
|
|
}
|
|
if models := readCodexCachedModels(); len(models) > 0 {
|
|
return models
|
|
}
|
|
return []core.ModelOption{
|
|
{Name: "o4-mini", Desc: "O4 Mini (fast reasoning)"},
|
|
{Name: "o3", Desc: "O3 (most capable reasoning)"},
|
|
{Name: "gpt-4.1", Desc: "GPT-4.1 (balanced)"},
|
|
{Name: "gpt-4.1-mini", Desc: "GPT-4.1 Mini (fast)"},
|
|
{Name: "gpt-4.1-nano", Desc: "GPT-4.1 Nano (fastest)"},
|
|
{Name: "codex-mini-latest", Desc: "Codex Mini (code-optimized)"},
|
|
}
|
|
}
|
|
|
|
var openaiChatModels = map[string]bool{
|
|
"o4-mini": true, "o3": true, "o3-mini": true, "o1": true, "o1-mini": true,
|
|
"gpt-4.1": true, "gpt-4.1-mini": true, "gpt-4.1-nano": true,
|
|
"gpt-4o": true, "gpt-4o-mini": true,
|
|
"codex-mini-latest": true,
|
|
}
|
|
|
|
func (a *Agent) fetchModelsFromAPI(ctx context.Context) []core.ModelOption {
|
|
a.mu.Lock()
|
|
apiKey := ""
|
|
baseURL := ""
|
|
if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
|
|
apiKey = a.providers[a.activeIdx].APIKey
|
|
baseURL = a.providers[a.activeIdx].BaseURL
|
|
}
|
|
a.mu.Unlock()
|
|
|
|
if apiKey == "" {
|
|
apiKey = os.Getenv("OPENAI_API_KEY")
|
|
}
|
|
if apiKey == "" {
|
|
return nil
|
|
}
|
|
if baseURL == "" {
|
|
baseURL = os.Getenv("OPENAI_BASE_URL")
|
|
}
|
|
if baseURL == "" {
|
|
baseURL = "https://api.openai.com"
|
|
}
|
|
baseURL = strings.TrimRight(baseURL, "/")
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/v1/models", nil)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
slog.Debug("codex: failed to fetch models", "error", err)
|
|
return nil
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil
|
|
}
|
|
|
|
var result struct {
|
|
Data []struct {
|
|
ID string `json:"id"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil
|
|
}
|
|
|
|
var models []core.ModelOption
|
|
for _, m := range result.Data {
|
|
if openaiChatModels[m.ID] {
|
|
models = append(models, core.ModelOption{Name: m.ID})
|
|
}
|
|
}
|
|
sort.Slice(models, func(i, j int) bool { return models[i].Name < models[j].Name })
|
|
return models
|
|
}
|
|
|
|
func readCodexCachedModels() []core.ModelOption {
|
|
codexHome := os.Getenv("CODEX_HOME")
|
|
if codexHome == "" {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
codexHome = filepath.Join(home, ".codex")
|
|
}
|
|
path := filepath.Join(codexHome, "models_cache.json")
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var payload struct {
|
|
Models []struct {
|
|
Slug string `json:"slug"`
|
|
DisplayName string `json:"display_name"`
|
|
Description string `json:"description"`
|
|
Visibility string `json:"visibility"`
|
|
SupportedInAPI bool `json:"supported_in_api"`
|
|
} `json:"models"`
|
|
}
|
|
if err := json.Unmarshal(b, &payload); err != nil {
|
|
return nil
|
|
}
|
|
|
|
var models []core.ModelOption
|
|
seen := make(map[string]struct{}, len(payload.Models))
|
|
for _, m := range payload.Models {
|
|
name := strings.TrimSpace(m.Slug)
|
|
if name == "" {
|
|
name = strings.TrimSpace(m.DisplayName)
|
|
}
|
|
if name == "" {
|
|
continue
|
|
}
|
|
if m.Visibility != "" && m.Visibility != "list" {
|
|
continue
|
|
}
|
|
if !m.SupportedInAPI {
|
|
continue
|
|
}
|
|
if _, ok := seen[name]; ok {
|
|
continue
|
|
}
|
|
seen[name] = struct{}{}
|
|
models = append(models, core.ModelOption{
|
|
Name: name,
|
|
Desc: strings.TrimSpace(m.Description),
|
|
})
|
|
}
|
|
return models
|
|
}
|
|
|
|
func (a *Agent) SetSessionEnv(env []string) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.sessionEnv = env
|
|
}
|
|
|
|
func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) {
|
|
a.mu.Lock()
|
|
mode := a.mode
|
|
model := a.model
|
|
reasoningEffort := a.reasoningEffort
|
|
extraEnv := a.providerEnvLocked()
|
|
extraEnv = append(extraEnv, a.sessionEnv...)
|
|
if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
|
|
if m := a.providers[a.activeIdx].Model; m != "" {
|
|
model = m
|
|
}
|
|
}
|
|
a.mu.Unlock()
|
|
|
|
return newCodexSession(ctx, a.workDir, model, reasoningEffort, mode, sessionID, extraEnv)
|
|
}
|
|
|
|
func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
|
|
return listCodexSessions(a.workDir)
|
|
}
|
|
|
|
func (a *Agent) GetSessionHistory(_ context.Context, sessionID string, limit int) ([]core.HistoryEntry, error) {
|
|
return getSessionHistory(sessionID, limit)
|
|
}
|
|
|
|
func (a *Agent) DeleteSession(_ context.Context, sessionID string) error {
|
|
path := findSessionFile(sessionID)
|
|
if path == "" {
|
|
return fmt.Errorf("session file not found: %s", sessionID)
|
|
}
|
|
return os.Remove(path)
|
|
}
|
|
|
|
func (a *Agent) Stop() error { return nil }
|
|
|
|
// SetMode changes the approval mode for future sessions.
|
|
func (a *Agent) SetMode(mode string) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.mode = normalizeMode(mode)
|
|
slog.Info("codex: approval mode changed", "mode", a.mode)
|
|
}
|
|
|
|
func (a *Agent) GetMode() string {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
return a.mode
|
|
}
|
|
|
|
// ── SkillProvider implementation ──────────────────────────────
|
|
|
|
func (a *Agent) SkillDirs() []string {
|
|
absDir, err := filepath.Abs(a.workDir)
|
|
if err != nil {
|
|
absDir = a.workDir
|
|
}
|
|
dirs := []string{
|
|
filepath.Join(absDir, ".codex", "skills"),
|
|
filepath.Join(absDir, ".claude", "skills"),
|
|
}
|
|
codexHome := os.Getenv("CODEX_HOME")
|
|
if codexHome == "" {
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
codexHome = filepath.Join(home, ".codex")
|
|
}
|
|
}
|
|
if codexHome != "" {
|
|
dirs = append(dirs, filepath.Join(codexHome, "skills"))
|
|
}
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
dirs = append(dirs, filepath.Join(home, ".claude", "skills"))
|
|
}
|
|
return dirs
|
|
}
|
|
|
|
// ── ContextCompressor implementation ──────────────────────────
|
|
|
|
func (a *Agent) CompressCommand() string { return "/compact" }
|
|
|
|
// ── MemoryFileProvider implementation ─────────────────────────
|
|
|
|
func (a *Agent) ProjectMemoryFile() string {
|
|
absDir, err := filepath.Abs(a.workDir)
|
|
if err != nil {
|
|
absDir = a.workDir
|
|
}
|
|
return filepath.Join(absDir, "AGENTS.md")
|
|
}
|
|
|
|
func (a *Agent) GlobalMemoryFile() string {
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
codexHome := os.Getenv("CODEX_HOME")
|
|
if codexHome == "" {
|
|
codexHome = filepath.Join(homeDir, ".codex")
|
|
}
|
|
return filepath.Join(codexHome, "AGENTS.md")
|
|
}
|
|
|
|
// ── ProviderSwitcher implementation ──────────────────────────
|
|
|
|
func (a *Agent) SetProviders(providers []core.ProviderConfig) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.providers = providers
|
|
}
|
|
|
|
func (a *Agent) SetActiveProvider(name string) bool {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if name == "" {
|
|
a.activeIdx = -1
|
|
slog.Info("codex: provider cleared")
|
|
return true
|
|
}
|
|
for i, p := range a.providers {
|
|
if p.Name == name {
|
|
a.activeIdx = i
|
|
slog.Info("codex: provider switched", "provider", name)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (a *Agent) GetActiveProvider() *core.ProviderConfig {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
|
|
return nil
|
|
}
|
|
p := a.providers[a.activeIdx]
|
|
return &p
|
|
}
|
|
|
|
func (a *Agent) ListProviders() []core.ProviderConfig {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
result := make([]core.ProviderConfig, len(a.providers))
|
|
copy(result, a.providers)
|
|
return result
|
|
}
|
|
|
|
func (a *Agent) providerEnvLocked() []string {
|
|
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
|
|
return nil
|
|
}
|
|
p := a.providers[a.activeIdx]
|
|
var env []string
|
|
if p.APIKey != "" {
|
|
env = append(env, "OPENAI_API_KEY="+p.APIKey)
|
|
}
|
|
if p.BaseURL != "" {
|
|
env = append(env, "OPENAI_BASE_URL="+p.BaseURL)
|
|
}
|
|
for k, v := range p.Env {
|
|
env = append(env, k+"="+v)
|
|
}
|
|
return env
|
|
}
|
|
|
|
func (a *Agent) PermissionModes() []core.PermissionModeInfo {
|
|
return []core.PermissionModeInfo{
|
|
{Key: "suggest", Name: "Suggest", NameZh: "建议", Desc: "Ask permission for every tool call", DescZh: "每次工具调用都需确认"},
|
|
{Key: "auto-edit", Name: "Auto Edit", NameZh: "自动编辑", Desc: "Auto-approve file edits, ask for shell commands", DescZh: "自动允许文件编辑,Shell 命令需确认"},
|
|
{Key: "full-auto", Name: "Full Auto", NameZh: "全自动", Desc: "Auto-approve with workspace sandbox", DescZh: "自动通过(工作区沙箱)"},
|
|
{Key: "yolo", Name: "YOLO", NameZh: "YOLO 模式", Desc: "Bypass all approvals and sandbox", DescZh: "跳过所有审批和沙箱"},
|
|
}
|
|
}
|