2026-10-15 13:52:25 -07:00
|
|
|
package codex
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bufio"
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/base64"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"os"
|
|
|
|
|
"os/exec"
|
|
|
|
|
"regexp"
|
|
|
|
|
"strconv"
|
|
|
|
|
"sync"
|
|
|
|
|
"sync/atomic"
|
2026-10-18 22:33:47 -07:00
|
|
|
"syscall"
|
2026-10-15 13:52:25 -07:00
|
|
|
"time"
|
2026-10-15 14:55:58 -07:00
|
|
|
|
2026-10-18 15:32:07 -07:00
|
|
|
"g.fc.run/ai/byre/internal/mcp"
|
2026-10-15 13:52:25 -07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type Options struct {
|
|
|
|
|
Cwd string
|
|
|
|
|
Model string
|
|
|
|
|
Resume string
|
2026-10-15 14:55:58 -07:00
|
|
|
ForkFrom string
|
|
|
|
|
ForkAt string
|
2026-10-15 13:52:25 -07:00
|
|
|
GitName string
|
|
|
|
|
GitEmail string
|
|
|
|
|
Env []string
|
|
|
|
|
Guidance string
|
2026-10-15 14:55:58 -07:00
|
|
|
Reminder string
|
|
|
|
|
MCPServers []*mcp.Server
|
2026-10-15 19:02:33 -07:00
|
|
|
StdioMCP []mcp.Stdio
|
2026-10-15 13:52:25 -07:00
|
|
|
Transcript io.Writer
|
|
|
|
|
OnBadLine func(line []byte, reason string)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Event struct {
|
|
|
|
|
Method string
|
|
|
|
|
Params json.RawMessage
|
|
|
|
|
Read time.Time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e Event) Decode() (any, error) {
|
|
|
|
|
ctor, ok := notificationParams[e.Method]
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("unknown notification %q", e.Method)
|
|
|
|
|
}
|
|
|
|
|
if ctor == nil {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
v := ctor()
|
|
|
|
|
err := json.Unmarshal(e.Params, v)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("decode %s: %w", e.Method, err)
|
|
|
|
|
}
|
|
|
|
|
return v, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Image struct {
|
|
|
|
|
MediaType string
|
|
|
|
|
Data []byte
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type RPCError struct {
|
|
|
|
|
Code int64 `json:"code"`
|
|
|
|
|
Message string `json:"message"`
|
|
|
|
|
Data json.RawMessage `json:"data,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *RPCError) Error() string {
|
|
|
|
|
return fmt.Sprintf("codex rpc error %d: %s", e.Code, e.Message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type message struct {
|
|
|
|
|
ID json.RawMessage `json:"id,omitempty"`
|
|
|
|
|
Method string `json:"method,omitempty"`
|
|
|
|
|
Params json.RawMessage `json:"params,omitempty"`
|
|
|
|
|
Result json.RawMessage `json:"result,omitempty"`
|
|
|
|
|
Error *RPCError `json:"error,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type rpcResult struct {
|
|
|
|
|
result json.RawMessage
|
|
|
|
|
err error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Session struct {
|
|
|
|
|
cmd *exec.Cmd
|
2026-10-16 14:21:23 -07:00
|
|
|
stdin *os.File
|
2026-10-15 13:52:25 -07:00
|
|
|
events chan Event
|
|
|
|
|
done chan struct{}
|
|
|
|
|
|
2026-10-16 14:21:23 -07:00
|
|
|
writeLock chan struct{}
|
2026-10-15 13:52:25 -07:00
|
|
|
recordMu sync.Mutex
|
|
|
|
|
transcript io.Writer
|
|
|
|
|
|
|
|
|
|
nextID atomic.Int64
|
|
|
|
|
pendingMu sync.Mutex
|
|
|
|
|
pending map[int64]chan rpcResult
|
|
|
|
|
|
|
|
|
|
stateMu sync.Mutex
|
|
|
|
|
threadID string
|
|
|
|
|
turnID string
|
|
|
|
|
model string
|
|
|
|
|
userAgent string
|
|
|
|
|
|
2026-10-15 14:55:58 -07:00
|
|
|
servers map[string]*mcp.Server
|
|
|
|
|
reminder string
|
|
|
|
|
|
2026-10-15 13:52:25 -07:00
|
|
|
onBadLine func(line []byte, reason string)
|
|
|
|
|
lastLine atomic.Int64
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func Start(ctx context.Context, opts Options) (*Session, error) {
|
2026-10-15 22:30:32 -07:00
|
|
|
s, err := spawn(opts)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
err = s.open(ctx, opts)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.Close()
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return s, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func ReadRateLimits(ctx context.Context, cwd string, env []string) (RateLimitSnapshot, error) {
|
|
|
|
|
s, err := spawn(Options{Cwd: cwd, Env: env})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return RateLimitSnapshot{}, err
|
|
|
|
|
}
|
|
|
|
|
defer s.Close()
|
|
|
|
|
|
|
|
|
|
go func() {
|
|
|
|
|
for range s.events {
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
err = s.initialize(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return RateLimitSnapshot{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp := GetAccountRateLimitsResponse{}
|
|
|
|
|
err = s.Call(ctx, MethodAccountRateLimitsRead, GetAccountRateLimitsParams{ExcludeResetCreditDetails: true}, &resp)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return RateLimitSnapshot{}, fmt.Errorf("read rate limits: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return resp.RateLimits, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func spawn(opts Options) (*Session, error) {
|
2026-10-15 13:52:25 -07:00
|
|
|
cmd := exec.Command("codex", "app-server", "-c", "check_for_update_on_startup=false")
|
|
|
|
|
cmd.Dir = opts.Cwd
|
|
|
|
|
cmd.Stderr = os.Stderr
|
|
|
|
|
cmd.Env = buildEnv(opts)
|
2026-10-18 22:33:47 -07:00
|
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
2026-10-15 13:52:25 -07:00
|
|
|
|
2026-10-16 14:21:23 -07:00
|
|
|
stdinR, stdin, err := os.Pipe()
|
2026-10-15 13:52:25 -07:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-10-16 14:21:23 -07:00
|
|
|
cmd.Stdin = stdinR
|
2026-10-15 13:52:25 -07:00
|
|
|
|
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
|
|
|
if err != nil {
|
2026-10-16 14:21:23 -07:00
|
|
|
stdinR.Close()
|
|
|
|
|
stdin.Close()
|
2026-10-15 13:52:25 -07:00
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
err = cmd.Start()
|
2026-10-16 14:21:23 -07:00
|
|
|
stdinR.Close()
|
2026-10-15 13:52:25 -07:00
|
|
|
if err != nil {
|
2026-10-16 14:21:23 -07:00
|
|
|
stdin.Close()
|
2026-10-15 13:52:25 -07:00
|
|
|
return nil, fmt.Errorf("start codex: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s := newSession(opts, stdin, stdout)
|
|
|
|
|
s.cmd = cmd
|
|
|
|
|
return s, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 14:21:23 -07:00
|
|
|
func newSession(opts Options, stdin *os.File, stdout io.Reader) *Session {
|
2026-10-15 13:52:25 -07:00
|
|
|
s := &Session{
|
|
|
|
|
stdin: stdin,
|
|
|
|
|
events: make(chan Event, 256),
|
2026-10-16 14:21:23 -07:00
|
|
|
writeLock: make(chan struct{}, 1),
|
2026-10-15 13:52:25 -07:00
|
|
|
done: make(chan struct{}),
|
|
|
|
|
transcript: opts.Transcript,
|
|
|
|
|
pending: map[int64]chan rpcResult{},
|
2026-10-15 14:55:58 -07:00
|
|
|
servers: map[string]*mcp.Server{},
|
|
|
|
|
reminder: opts.Reminder,
|
2026-10-15 13:52:25 -07:00
|
|
|
onBadLine: opts.OnBadLine,
|
|
|
|
|
}
|
2026-10-15 14:55:58 -07:00
|
|
|
for _, srv := range opts.MCPServers {
|
|
|
|
|
s.servers[srv.Name] = srv
|
|
|
|
|
}
|
2026-10-15 13:52:25 -07:00
|
|
|
s.lastLine.Store(time.Now().UnixNano())
|
|
|
|
|
go s.readLoop(stdout)
|
|
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Session) open(ctx context.Context, opts Options) error {
|
2026-10-15 22:30:32 -07:00
|
|
|
err := s.initialize(ctx)
|
2026-10-15 13:52:25 -07:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if opts.Resume != "" {
|
|
|
|
|
resp := ThreadResumeResponse{}
|
|
|
|
|
err = s.Call(ctx, MethodThreadResume, ThreadResumeParams{
|
|
|
|
|
ThreadID: opts.Resume,
|
|
|
|
|
Cwd: opts.Cwd,
|
|
|
|
|
Model: opts.Model,
|
|
|
|
|
ApprovalPolicy: json.RawMessage(`"never"`),
|
|
|
|
|
Sandbox: SandboxModeDangerFullAccess,
|
|
|
|
|
DeveloperInstructions: opts.Guidance,
|
2026-10-15 19:02:33 -07:00
|
|
|
Config: stdioConfig(opts.StdioMCP),
|
2026-10-15 13:52:25 -07:00
|
|
|
}, &resp)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("resume thread: %w", err)
|
|
|
|
|
}
|
|
|
|
|
s.setThread(resp.Thread.ID)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 14:55:58 -07:00
|
|
|
if opts.ForkFrom != "" {
|
|
|
|
|
resp := ThreadForkResponse{}
|
|
|
|
|
err = s.Call(ctx, MethodThreadFork, ThreadForkParams{
|
|
|
|
|
ThreadID: opts.ForkFrom,
|
|
|
|
|
LastTurnID: opts.ForkAt,
|
|
|
|
|
Cwd: opts.Cwd,
|
|
|
|
|
Model: opts.Model,
|
|
|
|
|
ApprovalPolicy: json.RawMessage(`"never"`),
|
|
|
|
|
Sandbox: SandboxModeDangerFullAccess,
|
|
|
|
|
DeveloperInstructions: opts.Guidance,
|
2026-10-15 19:02:33 -07:00
|
|
|
Config: stdioConfig(opts.StdioMCP),
|
2026-10-15 14:55:58 -07:00
|
|
|
}, &resp)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("fork thread: %w", err)
|
|
|
|
|
}
|
|
|
|
|
s.setThread(resp.Thread.ID)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 13:52:25 -07:00
|
|
|
resp := ThreadStartResponse{}
|
|
|
|
|
err = s.Call(ctx, MethodThreadStart, ThreadStartParams{
|
|
|
|
|
Cwd: opts.Cwd,
|
|
|
|
|
Model: opts.Model,
|
|
|
|
|
ApprovalPolicy: json.RawMessage(`"never"`),
|
|
|
|
|
Sandbox: SandboxModeDangerFullAccess,
|
|
|
|
|
DeveloperInstructions: opts.Guidance,
|
2026-10-15 14:55:58 -07:00
|
|
|
DynamicTools: dynamicTools(opts.MCPServers),
|
2026-10-15 19:02:33 -07:00
|
|
|
Config: stdioConfig(opts.StdioMCP),
|
2026-10-15 13:52:25 -07:00
|
|
|
}, &resp)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("start thread: %w", err)
|
|
|
|
|
}
|
|
|
|
|
s.setThread(resp.Thread.ID)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 22:30:32 -07:00
|
|
|
func (s *Session) initialize(ctx context.Context) error {
|
|
|
|
|
init := InitializeResponse{}
|
|
|
|
|
err := s.Call(ctx, MethodInitialize, InitializeParams{
|
|
|
|
|
ClientInfo: ClientInfo{Name: "byre", Title: "byre", Version: "0.0.0"},
|
|
|
|
|
Capabilities: &InitializeCapabilities{ExperimentalApi: true},
|
|
|
|
|
}, &init)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("initialize: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s.stateMu.Lock()
|
|
|
|
|
s.userAgent = init.UserAgent
|
|
|
|
|
s.stateMu.Unlock()
|
|
|
|
|
|
|
|
|
|
return s.notify("initialized", nil)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 19:02:33 -07:00
|
|
|
func stdioConfig(servers []mcp.Stdio) map[string]any {
|
|
|
|
|
if len(servers) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
cfg := map[string]any{}
|
|
|
|
|
for _, srv := range servers {
|
|
|
|
|
cfg[srv.Name] = map[string]any{"command": srv.Command, "args": srv.Args}
|
|
|
|
|
}
|
|
|
|
|
return map[string]any{"mcp_servers": cfg}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 14:55:58 -07:00
|
|
|
func dynamicTools(servers []*mcp.Server) []DynamicToolSpec {
|
|
|
|
|
specs := []DynamicToolSpec{}
|
|
|
|
|
for _, srv := range servers {
|
|
|
|
|
tools := []DynamicToolNamespaceTool{}
|
|
|
|
|
for _, t := range srv.Tools {
|
|
|
|
|
tools = append(tools, DynamicToolNamespaceTool{
|
|
|
|
|
Type: DynamicToolSpecTypeFunction,
|
|
|
|
|
Name: t.Name,
|
|
|
|
|
Description: t.Description,
|
|
|
|
|
InputSchema: t.InputSchema,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
specs = append(specs, DynamicToolSpec{
|
|
|
|
|
Type: DynamicToolSpecTypeNamespace,
|
|
|
|
|
Name: srv.Name,
|
|
|
|
|
Description: srv.Name + "'s tools",
|
|
|
|
|
Tools: tools,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return specs
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 13:52:25 -07:00
|
|
|
func (s *Session) setThread(id string) {
|
|
|
|
|
s.stateMu.Lock()
|
|
|
|
|
s.threadID = id
|
|
|
|
|
s.stateMu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func buildEnv(opts Options) []string {
|
|
|
|
|
env := append(os.Environ(),
|
|
|
|
|
"GIT_AUTHOR_NAME="+opts.GitName,
|
|
|
|
|
"GIT_AUTHOR_EMAIL="+opts.GitEmail,
|
|
|
|
|
"GIT_COMMITTER_NAME="+opts.GitName,
|
|
|
|
|
"GIT_COMMITTER_EMAIL="+opts.GitEmail,
|
|
|
|
|
)
|
|
|
|
|
return append(env, opts.Env...)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Session) Events() <-chan Event {
|
|
|
|
|
return s.events
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Session) Done() <-chan struct{} {
|
|
|
|
|
return s.done
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Session) Pid() int {
|
|
|
|
|
return s.cmd.Process.Pid
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Session) LastLine() time.Time {
|
|
|
|
|
return time.Unix(0, s.lastLine.Load())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Session) Exited() bool {
|
|
|
|
|
select {
|
|
|
|
|
case <-s.done:
|
|
|
|
|
return true
|
|
|
|
|
default:
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Session) ID() string {
|
|
|
|
|
s.stateMu.Lock()
|
|
|
|
|
defer s.stateMu.Unlock()
|
|
|
|
|
return s.threadID
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Session) TurnID() string {
|
|
|
|
|
s.stateMu.Lock()
|
|
|
|
|
defer s.stateMu.Unlock()
|
|
|
|
|
return s.turnID
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Session) UserAgent() string {
|
|
|
|
|
s.stateMu.Lock()
|
|
|
|
|
defer s.stateMu.Unlock()
|
|
|
|
|
return s.userAgent
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Session) SetModel(model string) {
|
|
|
|
|
s.stateMu.Lock()
|
|
|
|
|
s.model = model
|
|
|
|
|
s.stateMu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Session) Send(ctx context.Context, text string, images ...Image) (Turn, error) {
|
|
|
|
|
input := []UserInput{}
|
|
|
|
|
for _, img := range images {
|
|
|
|
|
input = append(input, UserInput{
|
|
|
|
|
Type: "image",
|
|
|
|
|
URL: "data:" + img.MediaType + ";base64," + base64.StdEncoding.EncodeToString(img.Data),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if text != "" {
|
|
|
|
|
input = append(input, UserInput{Type: "text", Text: text})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s.stateMu.Lock()
|
|
|
|
|
params := TurnStartParams{ThreadID: s.threadID, Input: input, Model: s.model}
|
|
|
|
|
s.model = ""
|
|
|
|
|
s.stateMu.Unlock()
|
2026-10-15 14:55:58 -07:00
|
|
|
if s.reminder != "" {
|
|
|
|
|
params.AdditionalContext = map[string]AdditionalContextEntry{
|
|
|
|
|
"byre": {Kind: AdditionalContextKindApplication, Value: s.reminder},
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-15 13:52:25 -07:00
|
|
|
|
|
|
|
|
resp := TurnStartResponse{}
|
|
|
|
|
err := s.Call(ctx, MethodTurnStart, params, &resp)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Turn{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s.stateMu.Lock()
|
|
|
|
|
if s.turnID == "" {
|
|
|
|
|
s.turnID = resp.Turn.ID
|
|
|
|
|
}
|
|
|
|
|
s.stateMu.Unlock()
|
|
|
|
|
return resp.Turn, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Session) Interrupt(ctx context.Context) error {
|
|
|
|
|
s.stateMu.Lock()
|
|
|
|
|
params := TurnInterruptParams{ThreadID: s.threadID, TurnID: s.turnID}
|
|
|
|
|
s.stateMu.Unlock()
|
|
|
|
|
|
|
|
|
|
if params.TurnID == "" {
|
|
|
|
|
return errors.New("no turn in progress")
|
|
|
|
|
}
|
|
|
|
|
return s.Call(ctx, MethodTurnInterrupt, params, nil)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 14:21:23 -07:00
|
|
|
func (s *Session) Steer(text string) (*Reply, error) {
|
2026-10-15 14:55:58 -07:00
|
|
|
s.stateMu.Lock()
|
|
|
|
|
params := TurnSteerParams{ThreadID: s.threadID, ExpectedTurnID: s.turnID, Input: []UserInput{{Type: UserInputTypeText, Text: text}}}
|
|
|
|
|
s.stateMu.Unlock()
|
|
|
|
|
|
|
|
|
|
if params.ExpectedTurnID == "" {
|
2026-10-16 14:21:23 -07:00
|
|
|
return nil, errors.New("no turn in progress")
|
2026-10-15 14:55:58 -07:00
|
|
|
}
|
2026-10-16 14:21:23 -07:00
|
|
|
return s.request(context.Background(), MethodTurnSteer, params)
|
2026-10-15 14:55:58 -07:00
|
|
|
}
|
|
|
|
|
|
2026-10-15 13:52:25 -07:00
|
|
|
func (s *Session) Compact(ctx context.Context) error {
|
|
|
|
|
return s.Call(ctx, MethodThreadCompactStart, ThreadCompactStartParams{ThreadID: s.ID()}, nil)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 14:21:23 -07:00
|
|
|
const rpcTimeout = time.Minute
|
2026-10-15 13:52:25 -07:00
|
|
|
|
2026-10-16 14:21:23 -07:00
|
|
|
type Reply struct {
|
|
|
|
|
s *Session
|
|
|
|
|
id int64
|
|
|
|
|
ch chan rpcResult
|
|
|
|
|
deadline time.Time
|
|
|
|
|
}
|
2026-10-15 13:52:25 -07:00
|
|
|
|
2026-10-16 14:21:23 -07:00
|
|
|
func (s *Session) Call(ctx context.Context, method string, params any, result any) error {
|
|
|
|
|
r, err := s.request(ctx, method, params)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return r.Wait(ctx, result)
|
|
|
|
|
}
|
2026-10-15 13:52:25 -07:00
|
|
|
|
2026-10-16 14:21:23 -07:00
|
|
|
func rpcDeadline(ctx context.Context) time.Time {
|
|
|
|
|
deadline := time.Now().Add(rpcTimeout)
|
|
|
|
|
d, ok := ctx.Deadline()
|
|
|
|
|
if ok && d.Before(deadline) {
|
|
|
|
|
return d
|
|
|
|
|
}
|
|
|
|
|
return deadline
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Session) request(ctx context.Context, method string, params any) (*Reply, error) {
|
2026-10-15 13:52:25 -07:00
|
|
|
raw, err := json.Marshal(params)
|
|
|
|
|
if err != nil {
|
2026-10-16 14:21:23 -07:00
|
|
|
return nil, err
|
2026-10-15 13:52:25 -07:00
|
|
|
}
|
|
|
|
|
|
2026-10-16 14:21:23 -07:00
|
|
|
r := &Reply{s: s, id: s.nextID.Add(1), ch: make(chan rpcResult, 1), deadline: rpcDeadline(ctx)}
|
|
|
|
|
|
|
|
|
|
s.pendingMu.Lock()
|
|
|
|
|
s.pending[r.id] = r.ch
|
|
|
|
|
s.pendingMu.Unlock()
|
|
|
|
|
|
|
|
|
|
err = s.write(message{ID: json.RawMessage(strconv.FormatInt(r.id, 10)), Method: method, Params: raw}, r.deadline)
|
2026-10-15 13:52:25 -07:00
|
|
|
if err != nil {
|
2026-10-16 14:21:23 -07:00
|
|
|
r.forget()
|
|
|
|
|
return nil, err
|
2026-10-15 13:52:25 -07:00
|
|
|
}
|
2026-10-16 14:21:23 -07:00
|
|
|
return r, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (r *Reply) forget() {
|
|
|
|
|
r.s.pendingMu.Lock()
|
|
|
|
|
delete(r.s.pending, r.id)
|
|
|
|
|
r.s.pendingMu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (r *Reply) Wait(ctx context.Context, result any) error {
|
|
|
|
|
defer r.forget()
|
|
|
|
|
|
|
|
|
|
ctx, cancel := context.WithDeadline(ctx, r.deadline)
|
|
|
|
|
defer cancel()
|
2026-10-15 13:52:25 -07:00
|
|
|
|
|
|
|
|
select {
|
2026-10-16 14:21:23 -07:00
|
|
|
case res := <-r.ch:
|
2026-10-15 13:52:25 -07:00
|
|
|
if res.err != nil {
|
|
|
|
|
return res.err
|
|
|
|
|
}
|
|
|
|
|
if result == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return json.Unmarshal(res.result, result)
|
2026-10-16 14:21:23 -07:00
|
|
|
case <-r.s.done:
|
2026-10-15 13:52:25 -07:00
|
|
|
return errors.New("codex exited")
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return ctx.Err()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Session) notify(method string, params any) error {
|
|
|
|
|
m := message{Method: method}
|
|
|
|
|
if params != nil {
|
|
|
|
|
raw, err := json.Marshal(params)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
m.Params = raw
|
|
|
|
|
}
|
2026-10-16 14:21:23 -07:00
|
|
|
return s.write(m, time.Now().Add(rpcTimeout))
|
2026-10-15 13:52:25 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Session) Close() error {
|
2026-10-16 14:21:23 -07:00
|
|
|
s.writeLock <- struct{}{}
|
2026-10-15 13:52:25 -07:00
|
|
|
s.stdin.Close()
|
2026-10-16 14:21:23 -07:00
|
|
|
<-s.writeLock
|
2026-10-15 13:52:25 -07:00
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case <-s.done:
|
|
|
|
|
case <-time.After(5 * time.Second):
|
2026-10-18 22:33:47 -07:00
|
|
|
syscall.Kill(-s.cmd.Process.Pid, syscall.SIGKILL)
|
2026-10-15 13:52:25 -07:00
|
|
|
<-s.done
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return s.cmd.Wait()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Session) Kill() {
|
2026-10-18 22:33:47 -07:00
|
|
|
syscall.Kill(-s.cmd.Process.Pid, syscall.SIGKILL)
|
2026-10-15 13:52:25 -07:00
|
|
|
<-s.done
|
|
|
|
|
s.cmd.Wait()
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 14:21:23 -07:00
|
|
|
func (s *Session) write(m message, deadline time.Time) error {
|
2026-10-15 13:52:25 -07:00
|
|
|
b, err := json.Marshal(m)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 14:21:23 -07:00
|
|
|
wait := time.NewTimer(time.Until(deadline))
|
|
|
|
|
defer wait.Stop()
|
|
|
|
|
select {
|
|
|
|
|
case s.writeLock <- struct{}{}:
|
|
|
|
|
case <-wait.C:
|
|
|
|
|
return os.ErrDeadlineExceeded
|
|
|
|
|
}
|
|
|
|
|
defer func() { <-s.writeLock }()
|
|
|
|
|
|
|
|
|
|
if !time.Now().Before(deadline) {
|
|
|
|
|
return os.ErrDeadlineExceeded
|
|
|
|
|
}
|
2026-10-15 13:52:25 -07:00
|
|
|
|
|
|
|
|
s.record("send", b)
|
|
|
|
|
|
2026-10-16 14:21:23 -07:00
|
|
|
err = s.stdin.SetWriteDeadline(deadline)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
n, err := s.stdin.Write(append(b, '\n'))
|
|
|
|
|
if n > 0 && errors.Is(err, os.ErrDeadlineExceeded) {
|
|
|
|
|
slog.Error("codex stopped reading its stdin mid-line; killing it", "pid", s.cmd.Process.Pid, "written", n, "len", len(b)+1)
|
2026-10-18 22:33:47 -07:00
|
|
|
syscall.Kill(-s.cmd.Process.Pid, syscall.SIGKILL)
|
2026-10-16 14:21:23 -07:00
|
|
|
}
|
2026-10-15 13:52:25 -07:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var emailField = regexp.MustCompile(`"email":"[^"]*"`)
|
|
|
|
|
|
|
|
|
|
func (s *Session) record(dir string, line []byte) {
|
|
|
|
|
if s.transcript == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
line = emailField.ReplaceAll(line, []byte(`"email":"[redacted]"`))
|
|
|
|
|
|
|
|
|
|
b, err := json.Marshal(map[string]any{"dir": dir, "msg": json.RawMessage(line)})
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("record transcript", "err", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s.recordMu.Lock()
|
|
|
|
|
defer s.recordMu.Unlock()
|
|
|
|
|
s.transcript.Write(append(b, '\n'))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Session) readLoop(stdout io.Reader) {
|
|
|
|
|
defer close(s.done)
|
|
|
|
|
defer close(s.events)
|
|
|
|
|
|
|
|
|
|
r := bufio.NewReader(stdout)
|
|
|
|
|
for {
|
|
|
|
|
line, err := r.ReadBytes('\n')
|
|
|
|
|
line = bytes.TrimRight(line, "\r\n")
|
|
|
|
|
if len(line) > 0 {
|
|
|
|
|
s.lastLine.Store(time.Now().UnixNano())
|
|
|
|
|
}
|
|
|
|
|
if len(line) > 0 && line[0] == '{' {
|
|
|
|
|
s.handleLine(line)
|
|
|
|
|
} else if len(line) > 0 {
|
|
|
|
|
s.badLine(line, "not a JSON object")
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
if err != io.EOF {
|
|
|
|
|
slog.Error("read codex stdout", "err", err)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Session) handleLine(line []byte) {
|
|
|
|
|
read := time.Now()
|
|
|
|
|
m := message{}
|
|
|
|
|
err := json.Unmarshal(line, &m)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.badLine(line, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s.record("recv", line)
|
|
|
|
|
|
|
|
|
|
if m.Method == "" {
|
|
|
|
|
s.handleResponse(line, m)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(m.ID) > 0 {
|
|
|
|
|
go s.handleServerRequest(m)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if _, ok := notificationParams[m.Method]; !ok {
|
|
|
|
|
s.badLine(line, fmt.Sprintf("unrecognised method %q", m.Method))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s.trackTurn(m)
|
|
|
|
|
s.events <- Event{Method: m.Method, Params: append(json.RawMessage{}, m.Params...), Read: read}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Session) trackTurn(m message) {
|
|
|
|
|
switch m.Method {
|
|
|
|
|
case MethodTurnStarted:
|
|
|
|
|
n := TurnStartedNotification{}
|
|
|
|
|
if json.Unmarshal(m.Params, &n) == nil {
|
|
|
|
|
s.stateMu.Lock()
|
|
|
|
|
s.turnID = n.Turn.ID
|
|
|
|
|
s.stateMu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
case MethodTurnCompleted:
|
|
|
|
|
n := TurnCompletedNotification{}
|
|
|
|
|
if json.Unmarshal(m.Params, &n) == nil {
|
|
|
|
|
s.stateMu.Lock()
|
|
|
|
|
if s.turnID == n.Turn.ID {
|
|
|
|
|
s.turnID = ""
|
|
|
|
|
}
|
|
|
|
|
s.stateMu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Session) handleResponse(line []byte, m message) {
|
|
|
|
|
id, err := strconv.ParseInt(string(m.ID), 10, 64)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.badLine(line, "response with a non-numeric id")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s.pendingMu.Lock()
|
|
|
|
|
ch := s.pending[id]
|
|
|
|
|
s.pendingMu.Unlock()
|
|
|
|
|
|
|
|
|
|
if ch == nil {
|
|
|
|
|
slog.Warn("codex response for unknown request", "id", id)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if m.Error != nil {
|
|
|
|
|
ch <- rpcResult{err: m.Error}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ch <- rpcResult{result: m.Result}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Session) handleServerRequest(m message) {
|
|
|
|
|
result, err := s.answerServerRequest(m)
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("codex server request failed", "method", m.Method, "err", err)
|
2026-10-16 14:21:23 -07:00
|
|
|
s.write(message{ID: m.ID, Error: &RPCError{Code: -32601, Message: err.Error()}}, time.Now().Add(rpcTimeout))
|
2026-10-15 13:52:25 -07:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
raw, err := json.Marshal(result)
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("encode codex server response", "method", m.Method, "err", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-16 14:21:23 -07:00
|
|
|
s.write(message{ID: m.ID, Result: raw}, time.Now().Add(rpcTimeout))
|
2026-10-15 13:52:25 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Session) answerServerRequest(m message) (any, error) {
|
|
|
|
|
switch m.Method {
|
|
|
|
|
case MethodItemCommandExecutionRequestApproval:
|
|
|
|
|
return CommandExecutionRequestApprovalResponse{Decision: json.RawMessage(`"accept"`)}, nil
|
|
|
|
|
case MethodItemFileChangeRequestApproval:
|
|
|
|
|
return FileChangeRequestApprovalResponse{Decision: "accept"}, nil
|
2026-10-15 14:55:58 -07:00
|
|
|
case MethodItemToolCall:
|
|
|
|
|
return s.callTool(m.Params)
|
2026-10-15 13:52:25 -07:00
|
|
|
}
|
|
|
|
|
return nil, fmt.Errorf("unsupported server request %s", m.Method)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 14:55:58 -07:00
|
|
|
func (s *Session) callTool(raw json.RawMessage) (any, error) {
|
|
|
|
|
p := DynamicToolCallParams{}
|
|
|
|
|
err := json.Unmarshal(raw, &p)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
srv := s.servers[p.Namespace]
|
|
|
|
|
if srv == nil {
|
|
|
|
|
return nil, fmt.Errorf("unknown tool namespace %q", p.Namespace)
|
|
|
|
|
}
|
|
|
|
|
t, ok := srv.Tool(p.Tool)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("unknown tool %s in %s", p.Tool, p.Namespace)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 23:45:31 -07:00
|
|
|
res := t.Call(context.Background(), p.Arguments)
|
|
|
|
|
items := []DynamicToolCallOutputContentItem{}
|
|
|
|
|
for _, c := range res.Content {
|
|
|
|
|
if c.Type == "image" {
|
|
|
|
|
items = append(items, DynamicToolCallOutputContentItem{Type: DynamicToolCallOutputContentItemTypeInputImage, ImageURL: "data:" + c.MimeType + ";base64," + c.Data})
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
items = append(items, DynamicToolCallOutputContentItem{Type: DynamicToolCallOutputContentItemTypeInputText, Text: c.Text})
|
2026-10-15 14:55:58 -07:00
|
|
|
}
|
2026-10-17 23:45:31 -07:00
|
|
|
return DynamicToolCallResponse{Success: !res.IsError, ContentItems: items}, nil
|
2026-10-15 14:55:58 -07:00
|
|
|
}
|
|
|
|
|
|
2026-10-15 13:52:25 -07:00
|
|
|
func (s *Session) badLine(line []byte, reason string) {
|
|
|
|
|
slog.Warn("bad codex line", "reason", reason, "line", string(line))
|
|
|
|
|
if s.onBadLine != nil {
|
|
|
|
|
s.onBadLine(line, reason)
|
|
|
|
|
}
|
|
|
|
|
}
|