• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / ralphex / 29892218780

22 Jul 2026 04:52AM UTC coverage: 83.811% (+0.09%) from 83.718%
29892218780

push

github

umputun
chore(deps): bump actions/setup-go from 6 to 7

Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6 to 7.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

7833 of 9346 relevant lines covered (83.81%)

226.5 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.94
/pkg/executor/codex.go
1
package executor
2

3
import (
4
        "bytes"
5
        "context"
6
        "encoding/json"
7
        "errors"
8
        "fmt"
9
        "io"
10
        "log"
11
        "os"
12
        "os/exec"
13
        "path/filepath"
14
        "regexp"
15
        "strings"
16
        "sync/atomic"
17
        "time"
18
)
19

20
// CodexStreams holds both stderr and stdout from codex command.
21
type CodexStreams struct {
22
        Stderr io.Reader
23
        Stdout io.Reader
24
}
25

26
// CodexRunner abstracts command execution for codex.
27
// Returns both stderr (streaming progress) and stdout (final response).
28
type CodexRunner interface {
29
        Run(ctx context.Context, name string, args ...string) (streams CodexStreams, wait func() error, err error)
30
}
31

32
// execCodexRunner is the default command runner using os/exec for codex.
33
// codex outputs streaming progress to stderr, final response to stdout.
34
// when stdin is non-nil, it is connected to the child process's stdin (used to pass
35
// the prompt via pipe instead of a CLI argument to avoid Windows 8191-char cmd limit).
36
// stripAnthropicKey scopes ANTHROPIC_API_KEY filtering to first-class --codex runs;
37
// external codex review in default claude mode keeps the host env intact so custom
38
// codex wrappers proxying through Anthropic (e.g., scripts/codex-as-claude/codex-as-claude.sh) keep
39
// authenticating. CLAUDECODE is always stripped regardless of mode to prevent
40
// nested-session errors when codex is launched from inside a Claude Code session.
41
type execCodexRunner struct {
42
        stdin             io.Reader
43
        stripAnthropicKey bool
44
}
45

46
// childEnv builds the codex child-process env. CLAUDECODE is always stripped to
47
// prevent nested-session errors. ANTHROPIC_API_KEY is stripped only when the
48
// caller requested it (first-class --codex mode); default-claude external codex
49
// review passes the key through so custom Anthropic-proxying wrappers keep working.
50
func (r *execCodexRunner) childEnv(env []string) []string {
8✔
51
        if r.stripAnthropicKey {
11✔
52
                return filterEnv(env, "ANTHROPIC_API_KEY", "CLAUDECODE")
3✔
53
        }
3✔
54
        return filterEnv(env, "CLAUDECODE")
5✔
55
}
56

57
func (r *execCodexRunner) Run(ctx context.Context, name string, args ...string) (CodexStreams, func() error, error) {
3✔
58
        // check context before starting to avoid spawning a process that will be immediately killed
3✔
59
        if err := ctx.Err(); err != nil {
3✔
60
                return CodexStreams{}, nil, fmt.Errorf("context already canceled: %w", err)
×
61
        }
×
62

63
        // use exec.Command (not CommandContext) because we handle cancellation ourselves
64
        // to ensure the entire process group is killed, not just the direct child
65
        cmd := exec.Command(name, args...) //nolint:noctx // intentional: we handle context cancellation via process group kill
3✔
66

3✔
67
        cmd.Env = r.childEnv(os.Environ())
3✔
68

3✔
69
        // pass prompt via stdin when set (avoids Windows 8191-char command-line limit)
3✔
70
        if r.stdin != nil {
4✔
71
                cmd.Stdin = r.stdin
1✔
72
        }
1✔
73

74
        // create new process group so we can kill all descendants on cleanup
75
        setupProcessGroup(cmd)
3✔
76

3✔
77
        stderr, err := cmd.StderrPipe()
3✔
78
        if err != nil {
3✔
79
                return CodexStreams{}, nil, fmt.Errorf("stderr pipe: %w", err)
×
80
        }
×
81

82
        stdout, err := cmd.StdoutPipe()
3✔
83
        if err != nil {
3✔
84
                return CodexStreams{}, nil, fmt.Errorf("stdout pipe: %w", err)
×
85
        }
×
86

87
        if err := cmd.Start(); err != nil {
4✔
88
                return CodexStreams{}, nil, fmt.Errorf("start command: %w", err)
1✔
89
        }
1✔
90

91
        // setup process group cleanup with graceful shutdown on context cancellation
92
        cleanup := newProcessGroupCleanup(cmd, ctx.Done())
2✔
93

2✔
94
        return CodexStreams{Stderr: stderr, Stdout: stdout}, cleanup.Wait, nil
2✔
95
}
96

97
// CodexExecutor runs codex CLI commands and filters output.
98
type CodexExecutor struct {
99
        Command         string            // command to execute, defaults to "codex"
100
        Model           string            // model override; empty means inherit from ~/.codex/config.toml (no -c model= flag emitted)
101
        ReasoningEffort string            // reasoning effort override; empty means inherit from ~/.codex/config.toml
102
        TimeoutMs       int               // stream idle timeout in ms, defaults to 3600000
103
        Sandbox         string            // sandbox mode, defaults to "read-only"
104
        ProjectDoc      string            // path to project documentation file
105
        OutputHandler   func(text string) // called for each filtered output line in real-time
106
        Debug           bool              // enable debug output
107
        ErrorPatterns   []string          // patterns to detect in output (e.g., rate limit messages)
108
        LimitPatterns   []string          // patterns to detect rate limits (checked before error patterns)
109
        MultiAgent      bool              // enable codex multi_agent feature + reviewer agent registration; set to true on the review-phase codex instance built by processor.New() for first-class --codex mode
110
        PassClaudeMd    bool              // pass project-level CLAUDE.md to codex via project_doc_fallback_filenames (set by processor.New() only when cfg.AppConfig.Executor == ExecutorCodex)
111
        IdleTimeout     time.Duration     // kill session after this duration of no output, zero = disabled
112
        headerEmitted   atomic.Bool       // tracks first invocation across Run() calls; false until first task/review then suppressed permanently — used to emit codex's resolved model/sandbox/effort once at the top of the run
113
        runner          CodexRunner       // for testing, nil uses default
114
}
115

116
// CodexReviewerAgentName is the agent name registered with codex when
117
// features.multi_agent is enabled. shared with pkg/processor so the
118
// spawn_agent(agent=...) call in review prompts stays in sync with the
119
// registration here — if either side drifts, codex silently fails to
120
// resolve the agent and the review phase breaks.
121
const CodexReviewerAgentName = "reviewer"
122

123
// codexReviewerDescription is the description registered for the reviewer
124
// agent when features.multi_agent is enabled. behavior is driven by the task
125
// argument, so the description stays generic and stable.
126
//
127
// MUST stay ASCII without backslashes, control characters, or non-printable bytes:
128
// codexConfigOpts.cliArgs serializes this via fmt.Sprintf("...=%q", ...) which
129
// emits Go string-literal escapes; only the printable ASCII subset round-trips
130
// safely through TOML basic-string syntax.
131
const codexReviewerDescription = "general code review specialist; behavior driven by the task argument"
132

133
// configOverrides returns the -c key=value arg slice to splice into the codex CLI
134
// invocation based on the executor's MultiAgent and PassClaudeMd flags. All overrides
135
// are additive on top of the user's ~/.codex/config.toml.
136
func (e *CodexExecutor) configOverrides() []string {
57✔
137
        var args []string
57✔
138
        if e.MultiAgent {
61✔
139
                args = append(args,
4✔
140
                        "-c", "features.multi_agent=true",
4✔
141
                        "-c", fmt.Sprintf("agents.%s.description=%q", CodexReviewerAgentName, codexReviewerDescription),
4✔
142
                )
4✔
143
        }
4✔
144
        if e.PassClaudeMd {
61✔
145
                args = append(args, "-c", `project_doc_fallback_filenames=["CLAUDE.md"]`)
4✔
146
        }
4✔
147
        return args
57✔
148
}
149

150
// codexFilterState tracks header separator count for filtering.
151
type codexFilterState struct {
152
        headerCount int  // tracks "--------" separators seen (show config between first two)
153
        firstRun    bool // when true, whitelist model/sandbox/effort lines from the header block so the user sees codex's resolved config once at the top of the run
154
}
155

156
// Run executes codex CLI with the given prompt and returns filtered output.
157
// stderr is streamed line-by-line to OutputHandler for progress indication.
158
// stdout is captured entirely as the final response (returned in Result.Output).
159
func (e *CodexExecutor) Run(ctx context.Context, prompt string) Result {
52✔
160
        cmd := e.Command
52✔
161
        if cmd == "" {
103✔
162
                cmd = "codex"
51✔
163
        }
51✔
164

165
        timeoutMs := e.TimeoutMs
52✔
166
        if timeoutMs <= 0 {
103✔
167
                timeoutMs = 3600000
51✔
168
        }
51✔
169

170
        sandbox := e.Sandbox
52✔
171
        if sandbox == "" {
101✔
172
                sandbox = "read-only"
49✔
173
        }
49✔
174
        // disable sandbox in docker (landlock doesn't work in containers)
175
        if os.Getenv("RALPHEX_DOCKER") == "1" {
52✔
176
                sandbox = "danger-full-access"
×
177
        }
×
178

179
        args := []string{"exec"}
52✔
180
        args = append(args, e.configOverrides()...)
52✔
181
        // --dangerously-bypass-approvals-and-sandbox is required for unattended first-class
52✔
182
        // --codex runs (which use danger-full-access by default). External codex review in
52✔
183
        // claude mode worked on master without this flag and adding it would silently change
52✔
184
        // approval semantics for default-claude users (esp. Docker mode where the sandbox is
52✔
185
        // forced to danger-full-access); gate the flag on MultiAgent which is true only in
52✔
186
        // first-class --codex (set by processor.buildCodexExecutor).
52✔
187
        if sandbox == "danger-full-access" && e.MultiAgent {
53✔
188
                args = append(args, "--dangerously-bypass-approvals-and-sandbox")
1✔
189
        }
1✔
190
        args = append(args, "--sandbox", sandbox)
52✔
191
        // model and reasoning effort are emitted only when explicitly set in ralphex config,
52✔
192
        // so the user's ~/.codex/config.toml choice is preserved otherwise (matches the
52✔
193
        // "additive -c overrides" promise documented in CLAUDE.md / llms.txt).
52✔
194
        if e.Model != "" {
53✔
195
                args = append(args, "-c", fmt.Sprintf("model=%q", e.Model))
1✔
196
        }
1✔
197
        if e.ReasoningEffort != "" {
53✔
198
                args = append(args, "-c", "model_reasoning_effort="+e.ReasoningEffort)
1✔
199
        }
1✔
200
        args = append(args, "-c", fmt.Sprintf("stream_idle_timeout_ms=%d", timeoutMs))
52✔
201

52✔
202
        if e.ProjectDoc != "" {
53✔
203
                args = append(args, "-c", fmt.Sprintf("project_doc=%q", e.ProjectDoc))
1✔
204
        }
1✔
205

206
        // pass prompt via stdin to avoid Windows 8191-char command-line limit;
207
        // codex reads from stdin when no positional prompt argument is given.
208
        // MultiAgent signals first-class --codex (set by processor.buildCodexExecutor only;
209
        // external codex review built by buildExternalCodexExecutor leaves it false), so it
210
        // also gates ANTHROPIC_API_KEY stripping — default-claude external codex review
211
        // preserves the host env so wrappers proxying through Anthropic keep working.
212
        stdinReader := strings.NewReader(prompt)
52✔
213
        runner := e.runner
52✔
214
        if runner == nil {
52✔
215
                runner = &execCodexRunner{stdin: stdinReader, stripAnthropicKey: e.MultiAgent}
×
216
        }
×
217

218
        // set up idle timeout: derive a cancellable context that fires when no output
219
        // is received for IdleTimeout duration. the touch closure resets the timer on
220
        // each stderr line and on each stdout read; mirrors the ClaudeExecutor pattern.
221
        execCtx := ctx
52✔
222
        idleTouch := func() {} // no-op by default
576✔
223
        if e.IdleTimeout > 0 {
56✔
224
                var idleCancel context.CancelFunc
4✔
225
                execCtx, idleCancel = context.WithCancel(ctx)
4✔
226
                defer idleCancel()
4✔
227
                timer := time.AfterFunc(e.IdleTimeout, idleCancel)
4✔
228
                defer timer.Stop()
4✔
229
                idleTouch = func() { timer.Reset(e.IdleTimeout) }
17✔
230
        }
231

232
        streams, wait, err := runner.Run(execCtx, cmd, args...)
52✔
233
        if err != nil {
53✔
234
                return Result{Error: fmt.Errorf("start codex: %w", err)}
1✔
235
        }
1✔
236

237
        // process stderr for progress display (header block + bold summaries).
238
        // sessionIDCh receives the session id once stderr's header block surfaces
239
        // it; the tail goroutine below uses it to follow the rollout file.
240
        // firstRun is true exactly once across all Run() calls on this executor —
241
        // gives shouldDisplay license to leak codex's resolved model/sandbox/effort
242
        // once at the top of the run instead of repeating the full banner per phase.
243
        firstRun := e.headerEmitted.CompareAndSwap(false, true)
51✔
244
        sessionIDCh := make(chan string, 1)
51✔
245
        stderrDone := make(chan stderrResult, 1)
51✔
246
        go func() {
102✔
247
                stderrDone <- e.processStderr(execCtx, streams.Stderr, stderrStreamOpts{
51✔
248
                        idleTouch:   idleTouch,
51✔
249
                        sessionIDCh: sessionIDCh,
51✔
250
                        firstRun:    firstRun,
51✔
251
                })
51✔
252
        }()
51✔
253

254
        tailCancel, tailDone := e.startRolloutTail(execCtx, sessionIDCh, idleTouch)
51✔
255

51✔
256
        // read stdout entirely as final response; wrap with touch-on-read so reads
51✔
257
        // keep the idle timer alive even while stderr is quiet.
51✔
258
        stdoutReader := streams.Stdout
51✔
259
        if e.IdleTimeout > 0 {
55✔
260
                stdoutReader = &touchReader{r: streams.Stdout, touch: idleTouch}
4✔
261
        }
4✔
262
        stdoutContent, stdoutErr := e.readStdout(stdoutReader)
51✔
263

51✔
264
        // wait for stderr processing to complete
51✔
265
        stderrRes := <-stderrDone
51✔
266

51✔
267
        // wait for command completion; once wait() returns the codex process has
51✔
268
        // fully exited and flushed the last assistant message to its rollout file
51✔
269
        waitErr := wait()
51✔
270

51✔
271
        // codex has exited; signal tailer to do its final drain and stop. done
51✔
272
        // after wait() so the tailer keeps following until the rollout file is
51✔
273
        // guaranteed complete and the final assistant line is not dropped.
51✔
274
        tailCancel()
51✔
275
        <-tailDone
51✔
276

51✔
277
        // detect signal in stdout (the actual response)
51✔
278
        signal := detectSignal(stdoutContent)
51✔
279

51✔
280
        // idle timeout: derived context canceled but parent is alive — not an error.
51✔
281
        // mirrors the ClaudeExecutor idle-timeout completion path so callers see uniform behavior.
51✔
282
        if e.IdleTimeout > 0 && execCtx.Err() != nil && ctx.Err() == nil {
53✔
283
                e.logDroppedIdleErrors(stdoutErr, waitErr)
2✔
284
                return e.idleTimeoutResult(stdoutContent, signal, stderrRes)
2✔
285
        }
2✔
286

287
        finalErr := e.finalError(ctx, stderrRes, stdoutErr, waitErr)
49✔
288

49✔
289
        // only check error/limit patterns when the process failed (non-zero exit or stream error).
49✔
290
        // when codex exits cleanly, pattern matches in output are false positives from findings
49✔
291
        // (e.g., reviewing code that handles rate limits).
49✔
292
        // skip pattern checks on context cancellation — cancellation must propagate as-is.
49✔
293
        if finalErr != nil && ctx.Err() == nil {
71✔
294
                if patternErr := e.checkPatterns(stdoutContent, stderrRes); patternErr != nil {
37✔
295
                        return Result{Output: stdoutContent, Signal: signal, Error: patternErr}
15✔
296
                }
15✔
297
        }
298

299
        // return stdout content as the result (the actual answer from codex)
300
        return Result{Output: stdoutContent, Signal: signal, Error: finalErr}
34✔
301
}
302

303
// finalError reconciles stderr/stdout/wait errors into the single error returned
304
// from Run. stderr and stdout errors win over wait errors so callers see the
305
// root cause rather than the cascade exit code; ctx.Err() short-circuits to
306
// preserve cancellation semantics; non-zero exit with stderr tail produces a
307
// readable diagnostic that includes the last few stderr lines.
308
func (e *CodexExecutor) finalError(ctx context.Context, stderrRes stderrResult, stdoutErr, waitErr error) error {
56✔
309
        switch {
56✔
310
        case stderrRes.err != nil && !errors.Is(stderrRes.err, context.Canceled):
2✔
311
                return stderrRes.err
2✔
312
        case stdoutErr != nil:
2✔
313
                return stdoutErr
2✔
314
        case waitErr != nil:
27✔
315
                if ctx.Err() != nil {
31✔
316
                        return fmt.Errorf("context error: %w", ctx.Err())
4✔
317
                }
4✔
318
                if len(stderrRes.lastLines) > 0 {
34✔
319
                        return fmt.Errorf("codex exited with error: %w\nstderr: %s",
11✔
320
                                waitErr, strings.Join(stderrRes.lastLines, "\n"))
11✔
321
                }
11✔
322
                return fmt.Errorf("codex exited with error: %w", waitErr)
12✔
323
        }
324
        return nil
25✔
325
}
326

327
// touchReader wraps an io.Reader to invoke touch on each successful Read.
328
// used to keep the idle-timeout timer alive while stdout is being drained.
329
type touchReader struct {
330
        r     io.Reader
331
        touch func()
332
}
333

334
func (t *touchReader) Read(p []byte) (int, error) {
13✔
335
        n, err := t.r.Read(p)
13✔
336
        if n > 0 && t.touch != nil {
20✔
337
                t.touch()
7✔
338
        }
7✔
339
        return n, err //nolint:wrapcheck // pass-through reader; preserve EOF and original error semantics
13✔
340
}
341

342
// logDroppedIdleErrors surfaces concurrent stream/wait errors that would otherwise
343
// be discarded by the idle-timeout completion path. operators need this to
344
// distinguish "agent went silent" from "stream broke" before retrying.
345
func (e *CodexExecutor) logDroppedIdleErrors(stdoutErr, waitErr error) {
2✔
346
        if stdoutErr != nil {
2✔
347
                log.Printf("codex idle timeout fired with concurrent stdout error: %v", stdoutErr)
×
348
        }
×
349
        if waitErr != nil {
4✔
350
                log.Printf("codex idle timeout fired with concurrent wait error: %v", waitErr)
2✔
351
        }
2✔
352
}
353

354
// idleTimeoutResult builds the Result returned when the idle-timeout timer
355
// canceled the derived execution context (parent ctx still alive). limit and
356
// error patterns are still checked across stdout and stderr so a wait-and-retry
357
// triggered by a real quota diagnostic survives idle-timeout cancellation;
358
// otherwise IdleTimedOut is set and the caller treats this as a soft kill.
359
func (e *CodexExecutor) idleTimeoutResult(stdoutContent, signal string, stderr stderrResult) Result {
2✔
360
        if patternErr := e.checkPatterns(stdoutContent, stderr); patternErr != nil {
3✔
361
                return Result{Output: stdoutContent, Signal: signal, Error: patternErr}
1✔
362
        }
1✔
363
        return Result{Output: stdoutContent, Signal: signal, IdleTimedOut: true}
1✔
364
}
365

366
// checkPatterns scans stdout AND the stderr matches captured live during streaming
367
// for limit/error patterns. codex emits OpenAI/ChatGPT plan-quota errors (e.g.,
368
// "ERROR: You've hit your usage limit") to stderr while stdout is empty on failure;
369
// processStderr matches each line on the fly so detection is not subject to the
370
// 5-line / 256-rune tail truncation used for human-readable error context.
371
//
372
// Priority is limit-first across both sources before any error match: a real
373
// stderr quota diagnostic (already filtered through the CLI-error prefix gate
374
// in processStderr) must not be downgraded to a non-retryable PatternMatchError
375
// just because partial stdout happens to match a configured ErrorPattern. Within
376
// each severity class, stdout wins over stderr so an explicit stdout limit/error
377
// takes precedence when both sources fire.
378
//
379
// Order:
380
//  1. stdout LimitPatterns
381
//  2. stderr.limitMatch (prefix-gated)
382
//  3. stdout ErrorPatterns
383
//  4. stderr.errorMatch (prefix-gated)
384
//
385
// returns LimitPatternError or PatternMatchError when a pattern matches; nil otherwise.
386
func (e *CodexExecutor) checkPatterns(stdoutContent string, stderr stderrResult) error {
24✔
387
        // limit-class first — across both sources
24✔
388
        if pattern := matchPattern(stdoutContent, e.LimitPatterns); pattern != "" {
28✔
389
                return &LimitPatternError{Pattern: pattern, HelpCmd: "codex /status"}
4✔
390
        }
4✔
391
        if stderr.limitMatch != "" {
26✔
392
                return &LimitPatternError{Pattern: stderr.limitMatch, HelpCmd: "codex /status"}
6✔
393
        }
6✔
394

395
        // error-class second
396
        if pattern := matchPattern(stdoutContent, e.ErrorPatterns); pattern != "" {
19✔
397
                return &PatternMatchError{Pattern: pattern, HelpCmd: "codex /status"}
5✔
398
        }
5✔
399
        if stderr.errorMatch != "" {
10✔
400
                return &PatternMatchError{Pattern: stderr.errorMatch, HelpCmd: "codex /status"}
1✔
401
        }
1✔
402

403
        return nil
8✔
404
}
405

406
// stderrResult holds processed stderr output and any error from reading.
407
// limitMatch and errorMatch capture the FIRST limit/error pattern that fires
408
// during streaming, on the untruncated, un-evicted line — so detection is not
409
// subject to the lastLines tail truncation (5 lines, 256 runes per line).
410
type stderrResult struct {
411
        lastLines  []string // last few lines of stderr for error context
412
        limitMatch string   // first matched limit pattern seen on stderr (live scan)
413
        errorMatch string   // first matched error pattern seen on stderr (live scan)
414
        err        error
415
}
416

417
// stderrStreamOpts bundles the per-invocation streaming inputs for processStderr.
418
type stderrStreamOpts struct {
419
        idleTouch   func()        // invoked for every stderr line to reset the idle-timeout timer; pass a no-op when idle timeout is disabled
420
        sessionIDCh chan<- string // when non-nil, receives the first detected "session id: <uuid>" (non-blocking, buffered channel expected)
421
        firstRun    bool          // gates the one-time emission of codex's resolved model/sandbox/effort header lines
422
}
423

424
// processStderr reads stderr line-by-line, filters for progress display, and
425
// scans each line for configured limit/error patterns. shows header block
426
// (between first two "--------" separators) and bold summaries. captures last
427
// lines of unfiltered output for error reporting AND records the first
428
// limit/error pattern hit (untruncated, un-evicted) so callers can rely on it
429
// regardless of how much chatter follows. see stderrStreamOpts for the
430
// per-invocation streaming inputs.
431
func (e *CodexExecutor) processStderr(ctx context.Context, r io.Reader, opts stderrStreamOpts) stderrResult {
63✔
432
        const maxTailLines = 5    // keep last N lines for error context
63✔
433
        const maxLineLength = 256 // truncate long lines to avoid oversized error strings
63✔
434

63✔
435
        state := &codexFilterState{firstRun: opts.firstRun}
63✔
436
        var tail []string
63✔
437
        var limitMatch, errorMatch string
63✔
438
        sessionIDSent := false
63✔
439

63✔
440
        err := readLines(ctx, r, func(line string) {
616✔
441
                if opts.idleTouch != nil {
1,085✔
442
                        opts.idleTouch() // reset idle timer on every stderr line
532✔
443
                }
532✔
444
                // scan untruncated line for patterns first; record only the first hit
445
                // per category so detection is eviction- and truncation-resistant.
446
                // restricted to CLI-error-prefixed lines (see scanLineForPatterns).
447
                e.scanLineForPatterns(line, &limitMatch, &errorMatch)
553✔
448

553✔
449
                // surface session id from header block to caller (once) so the rollout
553✔
450
                // file can be tailed in parallel for assistant-message streaming.
553✔
451
                if !sessionIDSent && opts.sessionIDCh != nil {
1,082✔
452
                        if id := e.extractSessionID(line); id != "" {
533✔
453
                                select {
4✔
454
                                case opts.sessionIDCh <- id:
4✔
455
                                default:
×
456
                                }
457
                                sessionIDSent = true
4✔
458
                        }
459
                }
460

461
                // capture non-empty lines for error context, preserving original formatting
462
                if strings.TrimSpace(line) != "" {
1,106✔
463
                        stored := line
553✔
464
                        if runes := []rune(stored); len(runes) > maxLineLength {
560✔
465
                                stored = string(runes[:maxLineLength]) + "..."
7✔
466
                        }
7✔
467
                        tail = append(tail, stored)
553✔
468
                        if len(tail) > maxTailLines {
993✔
469
                                copy(tail, tail[1:])
440✔
470
                                tail = tail[:maxTailLines]
440✔
471
                        }
440✔
472
                }
473

474
                if show, filtered := e.shouldDisplay(line, state); show {
562✔
475
                        if e.OutputHandler != nil {
17✔
476
                                e.OutputHandler(filtered + "\n")
8✔
477
                        }
8✔
478
                }
479
        })
480

481
        if err != nil {
71✔
482
                return stderrResult{lastLines: tail, limitMatch: limitMatch, errorMatch: errorMatch, err: fmt.Errorf("read stderr: %w", err)}
8✔
483
        }
8✔
484
        return stderrResult{lastLines: tail, limitMatch: limitMatch, errorMatch: errorMatch}
55✔
485
}
486

487
// scanLineForPatterns updates limitMatch / errorMatch with the first matching
488
// limit/error pattern found in line, gated by isCodexErrorLine so progress
489
// chatter cannot trigger false positives. Once each match has been recorded
490
// it sticks for the rest of the run.
491
func (e *CodexExecutor) scanLineForPatterns(line string, limitMatch, errorMatch *string) {
553✔
492
        if !isCodexErrorLine(line) {
1,094✔
493
                return
541✔
494
        }
541✔
495
        if *limitMatch == "" {
24✔
496
                if pattern := matchPattern(line, e.LimitPatterns); pattern != "" {
20✔
497
                        *limitMatch = pattern
8✔
498
                }
8✔
499
        }
500
        if *errorMatch == "" {
24✔
501
                if pattern := matchPattern(line, e.ErrorPatterns); pattern != "" {
17✔
502
                        *errorMatch = pattern
5✔
503
                }
5✔
504
        }
505
}
506

507
// isCodexErrorLine reports whether a stderr line looks like a CLI error message
508
// codex reliably prefixes diagnostics. limit/error pattern matching is gated on
509
// this prefix so progress text on stderr (header banners, bold summaries, model
510
// chatter that may legitimately mention "rate limit" while reviewing code) does
511
// not trigger false-positive matches.
512
func isCodexErrorLine(line string) bool {
566✔
513
        s := strings.TrimSpace(line)
566✔
514
        if s == "" {
568✔
515
                return false
2✔
516
        }
2✔
517
        // case-insensitive prefix match; codex uses "ERROR:" today, others are
518
        // defensive against possible future variants.
519
        lower := strings.ToLower(s)
564✔
520
        return strings.HasPrefix(lower, "error:") ||
564✔
521
                strings.HasPrefix(lower, "fatal:") ||
564✔
522
                strings.HasPrefix(lower, "panic:")
564✔
523
}
524

525
// readStdout reads the entire stdout content as the final response.
526
func (e *CodexExecutor) readStdout(r io.Reader) (string, error) {
53✔
527
        data, err := io.ReadAll(r)
53✔
528
        if err != nil {
54✔
529
                return "", fmt.Errorf("read stdout: %w", err)
1✔
530
        }
1✔
531
        return string(data), nil
52✔
532
}
533

534
// shouldDisplay implements a simple filter for codex stderr output. it forwards
535
// ONLY codex's resolved model/sandbox/effort lines, and only on the very first
536
// codex invocation across this executor's lifetime (state.firstRun), so the user
537
// sees what codex picked from ~/.codex/config.toml. everything else on stderr is
538
// suppressed: per-iteration header repetition (workdir/provider/approval/session
539
// id), exec-command output, hook lifecycle lines, and the live reasoning stream.
540
//
541
// reasoning is deliberately NOT taken from stderr. codex echoes loaded skill and
542
// tool-call markdown verbatim onto the same stderr stream, and skill headers like
543
// "**Detect stale base:**" are shape-identical to genuine reasoning titles, so no
544
// text-shape filter can separate them. clean reasoning-summary titles come from
545
// the rollout file's typed `reasoning` records instead (see formatRolloutEvent),
546
// which never contain that echo. session id detection in processStderr is
547
// independent of display so the rollout tailer still works either way.
548
func (e *CodexExecutor) shouldDisplay(line string, state *codexFilterState) (bool, string) {
576✔
549
        s := strings.TrimSpace(line)
576✔
550
        if s == "" {
580✔
551
                return false, ""
4✔
552
        }
4✔
553

554
        var show bool
572✔
555
        var filtered string
572✔
556

572✔
557
        switch {
572✔
558
        case strings.HasPrefix(s, "--------"):
49✔
559
                // track separators only so subsequent header lines stay suppressed;
49✔
560
                // never displayed.
49✔
561
                state.headerCount++
49✔
562
        case state.headerCount == 1:
46✔
563
                // inside the header block. on the first run let codex's resolved
46✔
564
                // config (model / sandbox / reasoning effort) leak through so the
46✔
565
                // banner reflects what codex actually picked when ralphex did not
46✔
566
                // explicitly override these fields.
46✔
567
                if state.firstRun && e.isHeaderConfigLine(s) {
55✔
568
                        show = true
9✔
569
                        filtered = s
9✔
570
                }
9✔
571
        }
572

573
        return show, filtered
572✔
574
}
575

576
// isHeaderConfigLine returns true when line is one of codex's header-block
577
// lines describing the resolved per-session config that ralphex doesn't know
578
// up front (model picked from ~/.codex/config.toml, sandbox, reasoning effort).
579
// other header lines (workdir, provider, approval, reasoning summaries,
580
// session id) are either obvious from context or not useful to the user.
581
func (e *CodexExecutor) isHeaderConfigLine(s string) bool {
33✔
582
        return strings.HasPrefix(s, "model:") ||
33✔
583
                strings.HasPrefix(s, "sandbox:") ||
33✔
584
                strings.HasPrefix(s, "reasoning effort:")
33✔
585
}
33✔
586

587
// stripBold removes markdown bold markers (**text**) from text.
588
func (e *CodexExecutor) stripBold(s string) string {
11✔
589
        // replace **text** with text
11✔
590
        result := s
11✔
591
        for {
32✔
592
                start := strings.Index(result, "**")
21✔
593
                if start == -1 {
31✔
594
                        break
10✔
595
                }
596
                end := strings.Index(result[start+2:], "**")
11✔
597
                if end == -1 {
12✔
598
                        break
1✔
599
                }
600
                // remove both markers
601
                result = result[:start] + result[start+2:start+2+end] + result[start+2+end+2:]
10✔
602
        }
603
        return result
11✔
604
}
605

606
// sessionIDPattern matches the "session id: <uuid>" line codex emits in its
607
// startup banner. capture group 1 is the session id (lowercase hex + dashes).
608
var sessionIDPattern = regexp.MustCompile(`(?i)\bsession id:\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b`)
609

610
// extractSessionID returns the codex session id from a stderr line that
611
// includes "session id: <uuid>", or "" when the line does not match. used
612
// by processStderr to surface the id to the rollout-tail goroutine.
613
func (e *CodexExecutor) extractSessionID(line string) string {
535✔
614
        m := sessionIDPattern.FindStringSubmatch(line)
535✔
615
        if len(m) < 2 {
1,063✔
616
                return ""
528✔
617
        }
528✔
618
        return m[1]
7✔
619
}
620

621
// startRolloutTail spawns the rollout-tail goroutine and returns a cancel
622
// function plus a done channel. tail goroutine waits for the session id on
623
// sessionIDCh, then follows codex's session rollout file until the returned
624
// cancel is called. caller must invoke tailCancel and wait on tailDone before
625
// returning so the tailer drains remaining file content and exits cleanly.
626
// the goroutine is a no-op when OutputHandler is nil — extracted from Run()
627
// to keep its cyclomatic complexity in check.
628
func (e *CodexExecutor) startRolloutTail(parent context.Context, sessionIDCh <-chan string, idleTouch func()) (context.CancelFunc, <-chan struct{}) {
51✔
629
        tailCtx, tailCancel := context.WithCancel(parent)
51✔
630
        done := make(chan struct{})
51✔
631
        go func() {
102✔
632
                defer close(done)
51✔
633
                select {
51✔
634
                case <-tailCtx.Done():
48✔
635
                        return
48✔
636
                case id := <-sessionIDCh:
3✔
637
                        e.tailRolloutFile(tailCtx, id, idleTouch)
3✔
638
                }
639
        }()
640
        return tailCancel, done
51✔
641
}
642

643
// findRolloutFile resolves the path to codex's session-rollout JSONL file
644
// for the given session id. codex stores the file under
645
// ~/.codex/sessions/<year>/<month>/<day>/rollout-<timestamp>-<session-id>.jsonl
646
// and may take a brief moment to create it after printing the session-id
647
// banner, so we poll up to ~5s. returns "" when the file cannot be located.
648
func (e *CodexExecutor) findRolloutFile(ctx context.Context, sessionID string) string {
7✔
649
        home, err := os.UserHomeDir()
7✔
650
        if err != nil {
7✔
651
                return ""
×
652
        }
×
653
        pattern := filepath.Join(home, ".codex", "sessions", "*", "*", "*", "rollout-*-"+sessionID+".jsonl")
7✔
654

7✔
655
        deadline := time.Now().Add(5 * time.Second)
7✔
656
        for {
15✔
657
                matches, _ := filepath.Glob(pattern)
8✔
658
                if len(matches) > 0 {
10✔
659
                        return matches[0]
2✔
660
                }
2✔
661
                if time.Now().After(deadline) {
6✔
662
                        return ""
×
663
                }
×
664
                select {
6✔
665
                case <-ctx.Done():
5✔
666
                        return ""
5✔
667
                case <-time.After(100 * time.Millisecond):
1✔
668
                }
669
        }
670
}
671

672
// tailRolloutFile follows codex's session rollout JSONL file like `tail -f`,
673
// parses each event, and emits human-readable progress lines via OutputHandler.
674
// runs until ctx is canceled. on cancellation, drains any remaining buffered
675
// lines before returning so late writes (e.g. codex flushing the final
676
// assistant message just before exit) are not lost.
677
func (e *CodexExecutor) tailRolloutFile(ctx context.Context, sessionID string, idleTouch func()) {
4✔
678
        if e.OutputHandler == nil {
4✔
679
                return
×
680
        }
×
681
        path := e.findRolloutFile(ctx, sessionID)
4✔
682
        if path == "" {
7✔
683
                // suppress the diagnostic when the session was canceled — findRolloutFile
3✔
684
                // also returns "" on ctx.Done(), and that is not a failure worth logging.
3✔
685
                if ctx.Err() == nil {
3✔
686
                        log.Printf("codex rollout file not found for session %s; assistant output streaming disabled for this session", sessionID)
×
687
                }
×
688
                return
3✔
689
        }
690
        f, err := os.Open(path) //nolint:gosec // path comes from codex's own session id
1✔
691
        if err != nil {
1✔
692
                log.Printf("codex rollout file open failed (%s): %v; assistant output streaming disabled for this session", path, err)
×
693
                return
×
694
        }
×
695
        defer func() { _ = f.Close() }()
2✔
696

697
        // accumulator holds bytes that may not yet form a complete line, so partial
698
        // reads at EOF do not lose content — the next Read after codex appends more
699
        // will complete the line.
700
        var acc []byte
1✔
701
        chunk := make([]byte, 4096)
1✔
702
        drainOnce := func() {
4✔
703
                for {
8✔
704
                        n, readErr := f.Read(chunk)
5✔
705
                        if n > 0 {
7✔
706
                                // any rollout bytes count as liveness — reset the idle timer
2✔
707
                                // before display filtering so a session actively dispatching
2✔
708
                                // tool calls (function_call records that formatRolloutEvent
2✔
709
                                // drops) is not killed as idle while still making progress.
2✔
710
                                if idleTouch != nil {
2✔
711
                                        idleTouch()
×
712
                                }
×
713
                                acc = append(acc, chunk[:n]...)
2✔
714
                                for {
7✔
715
                                        i := bytes.IndexByte(acc, '\n')
5✔
716
                                        if i < 0 {
7✔
717
                                                break
2✔
718
                                        }
719
                                        if msg := e.formatRolloutEvent(acc[:i]); msg != "" {
6✔
720
                                                e.OutputHandler(msg)
3✔
721
                                        }
3✔
722
                                        acc = acc[i+1:]
3✔
723
                                }
724
                        }
725
                        if readErr == io.EOF || n == 0 {
8✔
726
                                return
3✔
727
                        }
3✔
728
                        if readErr != nil {
2✔
729
                                return
×
730
                        }
×
731
                }
732
        }
733

734
        for {
3✔
735
                drainOnce()
2✔
736
                select {
2✔
737
                case <-ctx.Done():
1✔
738
                        // final drain after codex exits — pick up any late-flushed events
1✔
739
                        drainOnce()
1✔
740
                        return
1✔
741
                case <-time.After(200 * time.Millisecond):
1✔
742
                }
743
        }
744
}
745

746
// rolloutEvent is the outer wrapper for each line in codex's session rollout
747
// JSONL file. only `type` and `payload` are needed; we re-parse payload based
748
// on the type.
749
type rolloutEvent struct {
750
        Type    string          `json:"type"`
751
        Payload json.RawMessage `json:"payload"`
752
}
753

754
// rolloutPayload covers the response_item payload shapes we render: assistant
755
// messages (payload.type=message, role=assistant, text in Content) and reasoning
756
// summaries (payload.type=reasoning, titles in Summary). function_call and
757
// custom_tool_call_output records are dropped by formatRolloutEvent before any of
758
// these fields would be read, so the struct only carries the subset we consume.
759
type rolloutPayload struct {
760
        Type    string `json:"type"`
761
        Role    string `json:"role"`
762
        Content []struct {
763
                Type string `json:"type"`
764
                Text string `json:"text"`
765
        } `json:"content"`
766
        Summary []struct {
767
                Type string `json:"type"`
768
                Text string `json:"text"`
769
        } `json:"summary"`
770
}
771

772
// formatRolloutEvent turns one JSONL rollout line into a display string for
773
// OutputHandler, or "" when the event has no user-visible substance. two record
774
// types are forwarded:
775
//
776
//   - assistant messages (payload.type=message, role=assistant): the model's
777
//     actual reply text, the codex equivalent of claude's stream-json text blocks.
778
//   - reasoning summaries (payload.type=reasoning): the short bold "thinking"
779
//     titles, stripped of their ** markers. these come from the rollout rather
780
//     than the live stderr reasoning stream on purpose — stderr echoes loaded
781
//     skill/tool markdown verbatim and skill headers ("**Detect stale base:**")
782
//     are shape-indistinguishable from real titles, whereas the rollout's typed
783
//     reasoning records never carry that echo.
784
//
785
// function_call records (exec_command, spawn_agent) and custom_tool_call_output
786
// records (skill/tool payloads) are dropped as tool-machinery noise — the
787
// assistant text and reasoning titles already narrate progress.
788
func (e *CodexExecutor) formatRolloutEvent(line []byte) string {
20✔
789
        if len(bytes.TrimSpace(line)) == 0 {
22✔
790
                return ""
2✔
791
        }
2✔
792
        var ev rolloutEvent
18✔
793
        if err := json.Unmarshal(line, &ev); err != nil {
19✔
794
                return ""
1✔
795
        }
1✔
796
        if ev.Type != "response_item" {
20✔
797
                return ""
3✔
798
        }
3✔
799
        var p rolloutPayload
14✔
800
        if err := json.Unmarshal(ev.Payload, &p); err != nil {
14✔
801
                return ""
×
802
        }
×
803
        switch {
14✔
804
        case p.Type == "reasoning":
5✔
805
                return e.formatReasoningSummary(p)
5✔
806
        case p.Type == "message" && p.Role == "assistant":
4✔
807
                return e.formatAssistantMessage(p)
4✔
808
        default:
5✔
809
                return ""
5✔
810
        }
811
}
812

813
// formatReasoningSummary joins a reasoning record's summary titles into a display
814
// string, stripping the ** markers codex wraps each title in. only the first
815
// non-empty line of each summary_text is forwarded: codex 0.144.6 emits a single
816
// bold title, but other codex versions can append a full paragraph after it, and
817
// forwarding the whole value would reintroduce the reasoning flood this filter
818
// exists to prevent. returns "" when the record carries no summary text.
819
func (e *CodexExecutor) formatReasoningSummary(p rolloutPayload) string {
5✔
820
        var sb strings.Builder
5✔
821
        for _, s := range p.Summary {
10✔
822
                title := strings.TrimSpace(s.Text)
5✔
823
                if i := strings.IndexByte(title, '\n'); i >= 0 {
6✔
824
                        title = strings.TrimSpace(title[:i])
1✔
825
                }
1✔
826
                if title == "" {
5✔
827
                        continue
×
828
                }
829
                if sb.Len() > 0 {
6✔
830
                        sb.WriteByte('\n')
1✔
831
                }
1✔
832
                sb.WriteString(e.stripBold(title))
5✔
833
        }
834
        return sb.String()
5✔
835
}
836

837
// formatAssistantMessage joins an assistant message's output_text blocks into a
838
// display string.
839
func (e *CodexExecutor) formatAssistantMessage(p rolloutPayload) string {
4✔
840
        var sb strings.Builder
4✔
841
        for _, c := range p.Content {
9✔
842
                if c.Type != "output_text" || c.Text == "" {
5✔
843
                        continue
×
844
                }
845
                if sb.Len() > 0 {
6✔
846
                        sb.WriteByte('\n')
1✔
847
                }
1✔
848
                sb.WriteString(c.Text)
5✔
849
        }
850
        return sb.String()
4✔
851
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc