Files
kandev/apps/backend/internal/agentctl/server/adapter/codex_adapter.go
T

1017 lines
29 KiB
Go

package adapter
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/pkg/codex"
"github.com/pelletier/go-toml/v2"
"go.uber.org/zap"
)
// CodexAdapter implements AgentAdapter for agents using the OpenAI Codex app-server protocol.
// Codex uses a JSON-RPC 2.0 variant over stdio (omitting the jsonrpc field).
type CodexAdapter struct {
cfg *Config
logger *logger.Logger
// Subprocess stdin/stdout (set via Connect)
stdin io.Writer
stdout io.Reader
// Codex client for JSON-RPC communication
client *codex.Client
// Context for managing goroutine lifecycle
ctx context.Context
cancel context.CancelFunc
// Session state - Thread maps to Session, Turn maps to Operation
threadID string // session ID
turnID string // operation ID
// Agent info (populated after Initialize)
agentInfo *AgentInfo
// Update channel
updatesCh chan AgentEvent
// Permission handler
permissionHandler PermissionHandler
// Accumulators for streaming content
messageBuffer string
reasoningBuffer string
summaryBuffer string
// Turn completion signaling
turnCompleteCh chan turnCompleteResult
// Synchronization
mu sync.RWMutex
closed bool
}
// turnCompleteResult holds the result of a completed turn
type turnCompleteResult struct {
success bool
err string
}
// NewCodexAdapter creates a new Codex protocol adapter.
// Call Connect() after starting the subprocess to wire up stdin/stdout.
func NewCodexAdapter(cfg *Config, log *logger.Logger) *CodexAdapter {
ctx, cancel := context.WithCancel(context.Background())
return &CodexAdapter{
cfg: cfg,
logger: log.WithFields(zap.String("adapter", "codex")),
ctx: ctx,
cancel: cancel,
updatesCh: make(chan AgentEvent, 100),
}
}
// PrepareEnvironment writes MCP servers to the Codex config file.
// Codex reads MCP configuration from ~/.codex/config.toml at startup time.
func (a *CodexAdapter) PrepareEnvironment() error {
a.logger.Info("PrepareEnvironment called",
zap.Int("mcp_server_count", len(a.cfg.McpServers)))
for i, srv := range a.cfg.McpServers {
a.logger.Info("MCP server config",
zap.Int("index", i),
zap.String("name", srv.Name),
zap.String("url", srv.URL),
zap.String("type", srv.Type),
zap.String("command", srv.Command))
}
return WriteCodexMcpConfig(a.cfg.McpServers, "", a.logger)
}
// Connect wires up the stdin/stdout pipes from the running agent subprocess.
func (a *CodexAdapter) Connect(stdin io.Writer, stdout io.Reader) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.stdin != nil || a.stdout != nil {
return fmt.Errorf("adapter already connected")
}
a.stdin = stdin
a.stdout = stdout
return nil
}
// WriteCodexMcpConfig writes MCP server configuration to Codex's config.toml.
// It merges with existing config, preserving other settings and existing MCP servers.
// Codex reads MCP servers from ~/.codex/config.toml at startup time, not through the protocol.
// This function should be called before starting the Codex process.
// If homeDir is empty, it uses os.UserHomeDir().
func WriteCodexMcpConfig(mcpServers []McpServerConfig, homeDir string, log *logger.Logger) error {
if len(mcpServers) == 0 {
return nil // Nothing to configure
}
// Determine config directory
if homeDir == "" {
var err error
homeDir, err = os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
}
configDir := filepath.Join(homeDir, ".codex")
configPath := filepath.Join(configDir, "config.toml")
// Create config directory if it doesn't exist
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("failed to create codex config directory: %w", err)
}
// Read existing config if it exists
existingData, err := os.ReadFile(configPath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to read existing codex config: %w", err)
}
// Parse existing config into a generic map to preserve all fields
var rawConfig map[string]interface{}
if len(existingData) > 0 {
if err := toml.Unmarshal(existingData, &rawConfig); err != nil {
if log != nil {
log.Warn("failed to parse existing codex config, will create new",
zap.String("path", configPath),
zap.Error(err))
}
rawConfig = make(map[string]interface{})
}
} else {
rawConfig = make(map[string]interface{})
}
// Get or create mcp_servers section
mcpServersSection, ok := rawConfig["mcp_servers"].(map[string]interface{})
if !ok {
mcpServersSection = make(map[string]interface{})
}
// Add/update our MCP servers
for _, server := range mcpServers {
safeName := sanitizeCodexServerName(server.Name)
serverConfig := make(map[string]interface{})
if server.Type == "sse" || server.Type == "http" {
// HTTP/SSE transport - use url field
// Codex doesn't support SSE transport - it uses streamable HTTP which requires POST requests.
// Convert SSE URLs (/sse) to streamable HTTP URLs (/mcp) for Codex compatibility.
url := server.URL
if url != "" {
url = convertSSEToStreamableHTTP(url)
serverConfig["url"] = url
}
} else {
// STDIO transport - use command and args
if server.Command != "" {
serverConfig["command"] = server.Command
}
if len(server.Args) > 0 {
serverConfig["args"] = server.Args
}
}
mcpServersSection[safeName] = serverConfig
}
// Update the mcp_servers section in the config
rawConfig["mcp_servers"] = mcpServersSection
// Marshal back to TOML
output, err := toml.Marshal(rawConfig)
if err != nil {
return fmt.Errorf("failed to marshal codex config: %w", err)
}
// Write config file
if err := os.WriteFile(configPath, output, 0644); err != nil {
return fmt.Errorf("failed to write codex config: %w", err)
}
if log != nil {
log.Info("wrote Codex MCP config",
zap.String("path", configPath),
zap.Int("server_count", len(mcpServers)),
zap.Int("total_mcp_servers", len(mcpServersSection)))
}
return nil
}
// sanitizeCodexServerName converts a server name to a valid TOML table name.
// Replaces spaces and special characters with underscores.
func sanitizeCodexServerName(name string) string {
var result strings.Builder
for _, r := range name {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
result.WriteRune(r)
} else {
result.WriteRune('_')
}
}
sanitized := result.String()
if sanitized == "" {
return "server"
}
return sanitized
}
// convertSSEToStreamableHTTP converts an SSE endpoint URL to a streamable HTTP endpoint URL.
// Codex doesn't support SSE transport - it uses streamable HTTP which requires POST requests.
// This converts URLs ending in /sse to /mcp for Kandev MCP server compatibility.
// Example: http://localhost:9090/sse -> http://localhost:9090/mcp
func convertSSEToStreamableHTTP(url string) string {
if strings.HasSuffix(url, "/sse") {
return strings.TrimSuffix(url, "/sse") + "/mcp"
}
return url
}
// Initialize establishes the Codex connection with the agent subprocess.
func (a *CodexAdapter) Initialize(ctx context.Context) error {
a.logger.Info("initializing Codex adapter",
zap.String("workdir", a.cfg.WorkDir))
// Create Codex client
a.client = codex.NewClient(a.stdin, a.stdout, a.logger)
a.client.SetNotificationHandler(a.handleNotification)
a.client.SetRequestHandler(a.handleRequest)
// Start reading from stdout with the adapter's context
// The readLoop needs to stay alive for the entire lifecycle of the adapter,
// not just the initialize HTTP request. It will be cancelled when Close() is called.
a.client.Start(a.ctx)
// Perform Codex initialize handshake
resp, err := a.client.Call(ctx, codex.MethodInitialize, &codex.InitializeParams{
ClientInfo: &codex.ClientInfo{
Name: "kandev-agentctl",
Title: "Kandev Agent Controller",
Version: "1.0.0",
},
})
if err != nil {
return fmt.Errorf("codex initialize handshake failed: %w", err)
}
if resp.Error != nil {
return fmt.Errorf("codex initialize error: %s", resp.Error.Message)
}
// Parse initialize result
var initResult codex.InitializeResult
if resp.Result != nil {
if err := json.Unmarshal(resp.Result, &initResult); err != nil {
a.logger.Warn("failed to parse initialize result", zap.Error(err))
}
}
// Send initialized notification
if err := a.client.Notify(codex.MethodInitialized, nil); err != nil {
return fmt.Errorf("failed to send initialized notification: %w", err)
}
// Store agent info
a.agentInfo = &AgentInfo{
Name: "codex",
Version: initResult.UserAgent,
}
a.logger.Info("Codex adapter initialized",
zap.String("user_agent", initResult.UserAgent))
return nil
}
// GetAgentInfo returns information about the connected agent.
func (a *CodexAdapter) GetAgentInfo() *AgentInfo {
return a.agentInfo
}
// NewSession creates a new Codex thread (session).
// Note: The mcpServers parameter is ignored because Codex reads MCP configuration from
// ~/.codex/config.toml at startup time, not through the protocol. MCP servers are written
// to the config file by PrepareEnvironment() before the Codex process starts.
func (a *CodexAdapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) {
// Check client under lock, but don't hold lock during Call() to avoid deadlock
// with handleNotification which also needs the lock
a.mu.RLock()
client := a.client
a.mu.RUnlock()
if client == nil {
return "", fmt.Errorf("adapter not initialized")
}
// Determine approval policy - default to "untrusted" if not specified.
// "untrusted" forces Codex to request approval for all commands/writes.
// Other options: "on-failure", "on-request", "never"
approvalPolicy := a.cfg.ApprovalPolicy
if approvalPolicy == "" {
approvalPolicy = "untrusted"
}
a.logger.Info("starting codex thread with approval policy",
zap.String("approval_policy", approvalPolicy),
zap.String("work_dir", a.cfg.WorkDir))
resp, err := client.Call(ctx, codex.MethodThreadStart, &codex.ThreadStartParams{
Cwd: a.cfg.WorkDir,
ApprovalPolicy: approvalPolicy, // "untrusted", "on-failure", "on-request", "never"
SandboxPolicy: &codex.SandboxPolicy{
Type: "workspaceWrite", // Sandbox to workspace only
WritableRoots: []string{a.cfg.WorkDir}, // Allow writing to workspace
NetworkAccess: true,
},
})
if err != nil {
return "", fmt.Errorf("failed to start thread: %w", err)
}
if resp.Error != nil {
return "", fmt.Errorf("thread start error: %s", resp.Error.Message)
}
var result codex.ThreadStartResult
if err := json.Unmarshal(resp.Result, &result); err != nil {
return "", fmt.Errorf("failed to parse thread start result: %w", err)
}
a.mu.Lock()
a.threadID = result.Thread.ID
a.mu.Unlock()
a.logger.Info("created new thread", zap.String("thread_id", a.threadID))
return a.threadID, nil
}
// LoadSession resumes an existing Codex thread.
// It passes the same approval policy and sandbox settings as NewSession to ensure
// permission requirements are preserved across resume (see openai/codex#5322).
func (a *CodexAdapter) LoadSession(ctx context.Context, sessionID string) error {
// Check client under lock, but don't hold lock during Call() to avoid deadlock
// with handleNotification which also needs the lock
a.mu.RLock()
client := a.client
a.mu.RUnlock()
if client == nil {
return fmt.Errorf("adapter not initialized")
}
// Determine approval policy - same logic as NewSession
// "untrusted" forces Codex to request approval for all commands/writes.
approvalPolicy := a.cfg.ApprovalPolicy
if approvalPolicy == "" {
approvalPolicy = "untrusted"
}
a.logger.Info("resuming codex thread with approval policy",
zap.String("thread_id", sessionID),
zap.String("approval_policy", approvalPolicy),
zap.String("work_dir", a.cfg.WorkDir))
resp, err := client.Call(ctx, codex.MethodThreadResume, &codex.ThreadResumeParams{
ThreadID: sessionID,
Cwd: a.cfg.WorkDir,
ApprovalPolicy: approvalPolicy,
SandboxPolicy: &codex.SandboxPolicy{
Type: "workspaceWrite",
WritableRoots: []string{a.cfg.WorkDir},
NetworkAccess: true,
},
})
if err != nil {
return fmt.Errorf("failed to resume thread: %w", err)
}
if resp.Error != nil {
return fmt.Errorf("thread resume error: %s", resp.Error.Message)
}
a.mu.Lock()
a.threadID = sessionID
a.mu.Unlock()
a.logger.Info("resumed thread", zap.String("thread_id", a.threadID))
return nil
}
// Prompt sends a prompt to the agent, starting a new turn.
// This method blocks until the turn completes (turn/completed notification received).
func (a *CodexAdapter) Prompt(ctx context.Context, message string) error {
a.mu.Lock()
client := a.client
threadID := a.threadID
// Reset accumulators for new turn
a.messageBuffer = ""
a.reasoningBuffer = ""
a.summaryBuffer = ""
// Create channel to wait for turn completion
a.turnCompleteCh = make(chan turnCompleteResult, 1)
a.mu.Unlock()
if client == nil {
return fmt.Errorf("adapter not initialized")
}
a.logger.Info("sending prompt", zap.String("thread_id", threadID))
params := &codex.TurnStartParams{
ThreadID: threadID,
Input: []codex.UserInput{
{Type: "text", Text: message},
},
}
resp, err := client.Call(ctx, codex.MethodTurnStart, params)
if err != nil {
a.mu.Lock()
a.turnCompleteCh = nil
a.mu.Unlock()
return fmt.Errorf("failed to start turn: %w", err)
}
if resp.Error != nil {
a.mu.Lock()
a.turnCompleteCh = nil
a.mu.Unlock()
return fmt.Errorf("turn start error: %s", resp.Error.Message)
}
var result codex.TurnStartResult
if err := json.Unmarshal(resp.Result, &result); err != nil {
a.logger.Warn("failed to parse turn start result", zap.Error(err), zap.String("raw", string(resp.Result)))
}
turnID := ""
if result.Turn != nil {
turnID = result.Turn.ID
}
a.mu.Lock()
a.turnID = turnID
completeCh := a.turnCompleteCh
a.mu.Unlock()
if result.Turn != nil {
a.logger.Info("started turn, waiting for completion", zap.String("turn_id", turnID), zap.String("status", result.Turn.Status))
} else {
a.logger.Info("started turn, waiting for completion", zap.String("turn_id", turnID))
}
// Wait for turn completion or context cancellation
select {
case <-ctx.Done():
a.mu.Lock()
a.turnCompleteCh = nil
a.mu.Unlock()
return ctx.Err()
case completeResult := <-completeCh:
a.mu.Lock()
a.turnCompleteCh = nil
a.mu.Unlock()
if !completeResult.success && completeResult.err != "" {
return fmt.Errorf("turn failed: %s", completeResult.err)
}
a.logger.Info("turn completed", zap.String("turn_id", turnID), zap.Bool("success", completeResult.success))
return nil
}
}
// Cancel interrupts the current turn.
func (a *CodexAdapter) Cancel(ctx context.Context) error {
a.mu.RLock()
client := a.client
threadID := a.threadID
turnID := a.turnID
a.mu.RUnlock()
if client == nil {
return fmt.Errorf("adapter not initialized")
}
a.logger.Info("cancelling turn", zap.String("thread_id", threadID), zap.String("turn_id", turnID))
// Codex uses turn/interrupt to cancel
_, err := client.Call(ctx, codex.MethodTurnInterrupt, map[string]string{
"threadId": threadID,
"turnId": turnID,
})
return err
}
// Updates returns the channel for agent events.
func (a *CodexAdapter) Updates() <-chan AgentEvent {
return a.updatesCh
}
// GetSessionID returns the current thread ID (session).
func (a *CodexAdapter) GetSessionID() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.threadID
}
// GetOperationID returns the current turn ID (operation).
func (a *CodexAdapter) GetOperationID() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.turnID
}
// SetPermissionHandler sets the handler for permission requests.
func (a *CodexAdapter) SetPermissionHandler(handler PermissionHandler) {
a.mu.Lock()
defer a.mu.Unlock()
a.permissionHandler = handler
}
// Close releases resources held by the adapter.
func (a *CodexAdapter) Close() error {
a.mu.Lock()
defer a.mu.Unlock()
if a.closed {
return nil
}
a.closed = true
a.logger.Info("closing Codex adapter")
// Cancel the context to stop the read loop goroutine
if a.cancel != nil {
a.cancel()
}
// Stop the client
if a.client != nil {
a.client.Stop()
}
// Close update channel
close(a.updatesCh)
return nil
}
// sendUpdate safely sends an event to the updates channel.
func (a *CodexAdapter) sendUpdate(update AgentEvent) {
select {
case a.updatesCh <- update:
default:
a.logger.Warn("updates channel full, dropping notification")
}
}
// handleNotification processes Codex notifications and emits AgentEvents.
func (a *CodexAdapter) handleNotification(method string, params json.RawMessage) {
a.mu.RLock()
threadID := a.threadID
turnID := a.turnID
a.mu.RUnlock()
switch method {
// Standard notifications
case codex.NotifyItemAgentMessageDelta:
var p codex.AgentMessageDeltaParams
if err := json.Unmarshal(params, &p); err != nil {
a.logger.Warn("failed to parse agent message delta", zap.Error(err))
return
}
a.mu.Lock()
a.messageBuffer += p.Delta
a.mu.Unlock()
a.sendUpdate(AgentEvent{
Type: EventTypeMessageChunk,
SessionID: threadID,
OperationID: turnID,
Text: p.Delta,
})
case codex.NotifyItemReasoningTextDelta:
var p codex.ReasoningDeltaParams
if err := json.Unmarshal(params, &p); err != nil {
a.logger.Warn("failed to parse reasoning text delta", zap.Error(err))
return
}
a.mu.Lock()
a.reasoningBuffer += p.Delta
a.mu.Unlock()
a.sendUpdate(AgentEvent{
Type: EventTypeReasoning,
SessionID: threadID,
OperationID: turnID,
ReasoningText: p.Delta,
})
case codex.NotifyItemReasoningSummaryDelta:
var p codex.ReasoningDeltaParams
if err := json.Unmarshal(params, &p); err != nil {
a.logger.Warn("failed to parse reasoning summary delta", zap.Error(err))
return
}
a.mu.Lock()
a.summaryBuffer += p.Delta
a.mu.Unlock()
a.sendUpdate(AgentEvent{
Type: EventTypeReasoning,
SessionID: threadID,
OperationID: turnID,
ReasoningSummary: p.Delta,
})
case codex.NotifyTurnCompleted:
var p codex.TurnCompletedParams
if err := json.Unmarshal(params, &p); err != nil {
a.logger.Warn("failed to parse turn completed", zap.Error(err))
return
}
// Signal turn completion to the waiting Prompt() call
a.mu.RLock()
completeCh := a.turnCompleteCh
a.mu.RUnlock()
if completeCh != nil {
select {
case completeCh <- turnCompleteResult{success: p.Success, err: p.Error}:
a.logger.Debug("signaled turn completion", zap.String("turn_id", p.TurnID), zap.Bool("success", p.Success))
default:
a.logger.Warn("turn complete channel full, dropping signal")
}
}
// Send error event if the turn failed (for UI notification)
if !p.Success && p.Error != "" {
a.sendUpdate(AgentEvent{
Type: EventTypeError,
SessionID: threadID,
OperationID: p.TurnID,
Error: p.Error,
})
}
case codex.NotifyTurnDiffUpdated:
var p codex.TurnDiffUpdatedParams
if err := json.Unmarshal(params, &p); err != nil {
a.logger.Warn("failed to parse turn diff updated", zap.Error(err))
return
}
a.sendUpdate(AgentEvent{
Type: EventTypeMessageChunk,
SessionID: threadID,
OperationID: p.TurnID,
Diff: p.Diff,
})
case codex.NotifyTurnPlanUpdated:
var p codex.TurnPlanUpdatedParams
if err := json.Unmarshal(params, &p); err != nil {
a.logger.Warn("failed to parse turn plan updated", zap.Error(err))
return
}
entries := make([]PlanEntry, len(p.Plan))
for i, e := range p.Plan {
entries[i] = PlanEntry{
Description: e.Description,
Status: e.Status,
}
}
a.sendUpdate(AgentEvent{
Type: EventTypePlan,
SessionID: threadID,
OperationID: p.TurnID,
PlanEntries: entries,
})
case codex.NotifyError:
var p codex.ErrorParams
if err := json.Unmarshal(params, &p); err != nil {
a.logger.Warn("failed to parse error notification", zap.Error(err))
return
}
a.sendUpdate(AgentEvent{
Type: EventTypeError,
SessionID: threadID,
Error: p.Message,
})
case codex.NotifyItemStarted:
var p codex.ItemStartedParams
if err := json.Unmarshal(params, &p); err != nil {
a.logger.Warn("failed to parse item started", zap.Error(err))
return
}
if p.Item == nil {
return
}
// Map Codex item types to tool call updates
// Item types: "userMessage", "agentMessage", "commandExecution", "fileChange", "reasoning"
switch p.Item.Type {
case "commandExecution":
a.sendUpdate(AgentEvent{
Type: EventTypeToolCall,
SessionID: threadID,
OperationID: turnID,
ToolCallID: p.Item.ID,
ToolName: "commandExecution",
ToolTitle: p.Item.Command,
ToolStatus: "running",
ToolArgs: map[string]interface{}{
"command": p.Item.Command,
"cwd": p.Item.Cwd,
},
})
case "fileChange":
// Build title from file paths
var title string
if len(p.Item.Changes) > 0 {
title = p.Item.Changes[0].Path
if len(p.Item.Changes) > 1 {
title += fmt.Sprintf(" (+%d more)", len(p.Item.Changes)-1)
}
}
a.sendUpdate(AgentEvent{
Type: EventTypeToolCall,
SessionID: threadID,
OperationID: turnID,
ToolCallID: p.Item.ID,
ToolName: "fileChange",
ToolTitle: title,
ToolStatus: "running",
})
}
case codex.NotifyItemCompleted:
var p codex.ItemCompletedParams
if err := json.Unmarshal(params, &p); err != nil {
a.logger.Warn("failed to parse item completed", zap.Error(err))
return
}
if p.Item == nil {
return
}
// Only send updates for tool-like items
if p.Item.Type == "commandExecution" || p.Item.Type == "fileChange" {
status := "complete"
if p.Item.Status == "failed" {
status = "error"
}
update := AgentEvent{
Type: EventTypeToolUpdate,
SessionID: threadID,
OperationID: turnID,
ToolCallID: p.Item.ID,
ToolStatus: status,
}
// Include output for commands
if p.Item.Type == "commandExecution" && p.Item.AggregatedOutput != "" {
update.ToolResult = p.Item.AggregatedOutput
}
// Include diff for file changes
if p.Item.Type == "fileChange" && len(p.Item.Changes) > 0 {
var diffs []string
for _, c := range p.Item.Changes {
if c.Diff != "" {
diffs = append(diffs, c.Diff)
}
}
if len(diffs) > 0 {
update.Diff = strings.Join(diffs, "\n")
}
}
a.sendUpdate(update)
}
case codex.NotifyItemCmdExecOutputDelta:
var p codex.CommandOutputDeltaParams
if err := json.Unmarshal(params, &p); err != nil {
a.logger.Warn("failed to parse command output delta", zap.Error(err))
return
}
a.sendUpdate(AgentEvent{
Type: EventTypeToolUpdate,
SessionID: threadID,
OperationID: turnID,
ToolCallID: p.ItemID,
ToolResult: p.Delta,
})
case codex.NotifyThreadTokenUsageUpdated:
var p codex.ThreadTokenUsageUpdatedParams
if err := json.Unmarshal(params, &p); err != nil {
a.logger.Warn("failed to parse thread token usage updated notification", zap.Error(err))
return
}
// Extract context window information from the token usage update
if p.TokenUsage != nil && p.TokenUsage.ModelContextWindow > 0 {
contextWindowSize := p.TokenUsage.ModelContextWindow
contextWindowUsed := int64(p.TokenUsage.Last.TotalTokens)
remaining := contextWindowSize - contextWindowUsed
if remaining < 0 {
remaining = 0
}
efficiency := float64(contextWindowUsed) / float64(contextWindowSize) * 100
a.logger.Debug("emitting context window event",
zap.Int64("size", contextWindowSize),
zap.Int64("used", contextWindowUsed),
zap.Int64("remaining", remaining),
zap.Float64("efficiency", efficiency))
a.sendUpdate(AgentEvent{
Type: EventTypeContextWindow,
SessionID: threadID,
OperationID: turnID,
ContextWindowSize: contextWindowSize,
ContextWindowUsed: contextWindowUsed,
ContextWindowRemaining: remaining,
ContextEfficiency: efficiency,
})
}
case codex.NotifyTokenCount:
// Legacy token_count notification - ignore as we now use thread/tokenUsage/updated
a.logger.Debug("ignoring legacy token_count notification")
case codex.NotifyContextCompacted:
var p codex.ContextCompactedParams
if err := json.Unmarshal(params, &p); err != nil {
a.logger.Warn("failed to parse context compacted notification", zap.Error(err))
return
}
a.logger.Info("context compacted",
zap.String("thread_id", p.ThreadID),
zap.String("turn_id", p.TurnID))
// We could emit an event here if we want to notify the frontend about compaction
default:
// Log unhandled notifications at debug level
a.logger.Debug("unhandled notification", zap.String("method", method))
}
}
// handleRequest processes Codex requests (approval requests) and calls permissionHandler.
func (a *CodexAdapter) handleRequest(id interface{}, method string, params json.RawMessage) {
a.logger.Debug("codex: received request",
zap.Any("id", id),
zap.String("method", method))
a.mu.RLock()
handler := a.permissionHandler
a.mu.RUnlock()
switch method {
case codex.NotifyItemCmdExecRequestApproval:
var p codex.CommandApprovalParams
if err := json.Unmarshal(params, &p); err != nil {
a.logger.Warn("failed to parse command approval request", zap.Error(err))
if err := a.client.SendResponse(id, nil, &codex.Error{Code: codex.InvalidParams, Message: "invalid params"}); err != nil {
a.logger.Warn("failed to send invalid params response", zap.Error(err))
}
return
}
a.handleApprovalRequest(id, handler, p.ThreadID, p.ItemID, types.ActionTypeCommand, p.Command, map[string]interface{}{
"command": p.Command,
"cwd": p.Cwd,
"reasoning": p.Reasoning,
}, p.Options)
case codex.NotifyItemFileChangeRequestApproval:
var p codex.FileChangeApprovalParams
if err := json.Unmarshal(params, &p); err != nil {
a.logger.Warn("failed to parse file change approval request", zap.Error(err))
if err := a.client.SendResponse(id, nil, &codex.Error{Code: codex.InvalidParams, Message: "invalid params"}); err != nil {
a.logger.Warn("failed to send invalid params response", zap.Error(err))
}
return
}
a.handleApprovalRequest(id, handler, p.ThreadID, p.ItemID, types.ActionTypeFileWrite, p.Path, map[string]interface{}{
"path": p.Path,
"diff": p.Diff,
"reasoning": p.Reasoning,
}, p.Options)
default:
a.logger.Warn("unhandled request", zap.String("method", method))
if err := a.client.SendResponse(id, nil, &codex.Error{Code: codex.MethodNotFound, Message: "method not found"}); err != nil {
a.logger.Warn("failed to send method not found response", zap.Error(err))
}
}
}
// handleApprovalRequest handles permission request logic for both command and file change approvals.
func (a *CodexAdapter) handleApprovalRequest(
id interface{},
handler PermissionHandler,
threadID string,
itemID string,
actionType string,
title string,
details map[string]interface{},
optionStrings []string,
) {
// Build permission options from Codex options
options := make([]PermissionOption, len(optionStrings))
for i, opt := range optionStrings {
kind := "allow_once"
switch opt {
case "approveAlways":
kind = "allow_always"
case "reject":
kind = "reject_once"
}
options[i] = PermissionOption{
OptionID: opt,
Name: opt,
Kind: kind,
}
}
// If no options provided, use defaults
if len(options) == 0 {
options = []PermissionOption{
{OptionID: "approve", Name: "Approve", Kind: "allow_once"},
{OptionID: "reject", Name: "Reject", Kind: "reject_once"},
}
}
req := &PermissionRequest{
SessionID: threadID,
ToolCallID: itemID,
Title: title,
Options: options,
ActionType: actionType,
ActionDetails: details,
}
if handler == nil {
// Auto-approve if no handler
if err := a.client.SendResponse(id, &codex.CommandApprovalResponse{
Decision: "accept",
}, nil); err != nil {
a.logger.Warn("failed to send approval response", zap.Error(err))
}
return
}
ctx := context.Background()
resp, err := handler(ctx, req)
if err != nil {
a.logger.Error("permission handler error", zap.Error(err))
if err := a.client.SendResponse(id, &codex.CommandApprovalResponse{
Decision: "decline",
}, nil); err != nil {
a.logger.Warn("failed to send decline response", zap.Error(err))
}
return
}
// Map frontend option to Codex decision
// Codex accepts: "accept", "acceptForSession", "decline", "cancel"
decision := "accept"
if resp.Cancelled {
decision = "cancel"
} else {
switch resp.OptionID {
case "approve", "allow", "accept":
decision = "accept"
case "approveAlways", "allowAlways", "acceptForSession":
decision = "acceptForSession"
case "reject", "deny", "decline":
decision = "decline"
case "cancel":
decision = "cancel"
default:
if resp.OptionID != "" {
decision = resp.OptionID
}
}
}
if err := a.client.SendResponse(id, &codex.CommandApprovalResponse{
Decision: decision,
}, nil); err != nil {
a.logger.Warn("failed to send approval response", zap.Error(err))
}
}
// Verify interface implementation
var _ AgentAdapter = (*CodexAdapter)(nil)