Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ jobs:
goarch: arm64
- goos: windows
goarch: amd64
- goos: windows
goarch: arm64

steps:
- uses: actions/checkout@v4
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,17 @@ faster with immediate critical feedback on your agents' work.

## Installation

**macOS / Linux:**
```bash
curl -fsSL https://roborev.io/install.sh | bash
```

Or with Go:
**Windows (PowerShell):**
```powershell
powershell -ExecutionPolicy ByPass -c "irm https://roborev.io/install.ps1 | iex"
```

**With Go:**
```bash
go install github.com/roborev-dev/roborev/cmd/roborev@latest
```
Expand Down
2 changes: 1 addition & 1 deletion cmd/roborev/main_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package main

// NOTE: Tests in this package mutate package-level variables (serverAddr,
// pollStartInterval, pollMaxInterval) and environment variables (HOME).
// pollStartInterval, pollMaxInterval) and environment variables (ROBOREV_DATA_DIR).
// Do not use t.Parallel() in this package as it will cause race conditions.

import (
Expand Down
10 changes: 5 additions & 5 deletions cmd/roborev/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,8 @@ type tuiModel struct {
flashView tuiView // View where flash was triggered (only show in same view)

// Track config reload notifications
lastConfigReloadedAt string // Last known ConfigReloadedAt from daemon status
statusFetchedOnce bool // True after first successful status fetch (for flash logic)
lastConfigReloadCounter uint64 // Last known ConfigReloadCounter from daemon status
statusFetchedOnce bool // True after first successful status fetch (for flash logic)
pendingReviewAddressed map[int64]pendingState // review ID -> pending state (for reviews without jobs)
addressedSeq uint64 // monotonic counter for request sequencing
}
Expand Down Expand Up @@ -1694,13 +1694,13 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.daemonVersion = m.status.Version
}
// Show flash notification when config is reloaded
// Only flash if: we've fetched status before AND the reload timestamp changed
if m.statusFetchedOnce && m.status.ConfigReloadedAt != m.lastConfigReloadedAt {
// Use counter (not timestamp) to detect reloads that happen within the same second
if m.statusFetchedOnce && m.status.ConfigReloadCounter != m.lastConfigReloadCounter {
m.flashMessage = "Config reloaded"
m.flashExpiresAt = time.Now().Add(5 * time.Second)
m.flashView = m.currentView
}
m.lastConfigReloadedAt = m.status.ConfigReloadedAt
m.lastConfigReloadCounter = m.status.ConfigReloadCounter
m.statusFetchedOnce = true

case tuiUpdateCheckMsg:
Expand Down
44 changes: 22 additions & 22 deletions cmd/roborev/tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5422,10 +5422,10 @@ func TestTUIConfigReloadFlash(t *testing.T) {
m := newTuiModel("http://localhost:7373")

t.Run("no flash on first status fetch", func(t *testing.T) {
// First status fetch with a ConfigReloadedAt should NOT flash
// First status fetch with a ConfigReloadCounter should NOT flash
status1 := tuiStatusMsg(storage.DaemonStatus{
Version: "1.0.0",
ConfigReloadedAt: "2026-01-23T10:00:00Z",
Version: "1.0.0",
ConfigReloadCounter: 1,
})

updated, _ := m.Update(status1)
Expand All @@ -5437,21 +5437,21 @@ func TestTUIConfigReloadFlash(t *testing.T) {
if !m2.statusFetchedOnce {
t.Error("Expected statusFetchedOnce to be true after first fetch")
}
if m2.lastConfigReloadedAt != "2026-01-23T10:00:00Z" {
t.Errorf("Expected lastConfigReloadedAt to be set, got %q", m2.lastConfigReloadedAt)
if m2.lastConfigReloadCounter != 1 {
t.Errorf("Expected lastConfigReloadCounter to be 1, got %d", m2.lastConfigReloadCounter)
}
})

t.Run("flash on config reload after first fetch", func(t *testing.T) {
// Start with a model that has already fetched status once
m := newTuiModel("http://localhost:7373")
m.statusFetchedOnce = true
m.lastConfigReloadedAt = "2026-01-23T10:00:00Z"
m.lastConfigReloadCounter = 1

// Second status with different ConfigReloadedAt should flash
// Second status with different ConfigReloadCounter should flash
status2 := tuiStatusMsg(storage.DaemonStatus{
Version: "1.0.0",
ConfigReloadedAt: "2026-01-23T10:05:00Z",
Version: "1.0.0",
ConfigReloadCounter: 2,
})

updated, _ := m.Update(status2)
Expand All @@ -5460,47 +5460,47 @@ func TestTUIConfigReloadFlash(t *testing.T) {
if m2.flashMessage != "Config reloaded" {
t.Errorf("Expected flash 'Config reloaded', got %q", m2.flashMessage)
}
if m2.lastConfigReloadedAt != "2026-01-23T10:05:00Z" {
t.Errorf("Expected lastConfigReloadedAt updated, got %q", m2.lastConfigReloadedAt)
if m2.lastConfigReloadCounter != 2 {
t.Errorf("Expected lastConfigReloadCounter updated to 2, got %d", m2.lastConfigReloadCounter)
}
})

t.Run("flash when ConfigReloadedAt changes from empty to non-empty", func(t *testing.T) {
t.Run("flash when ConfigReloadCounter changes from zero to non-zero", func(t *testing.T) {
// Model has fetched status once but daemon hadn't reloaded yet
m := newTuiModel("http://localhost:7373")
m.statusFetchedOnce = true
m.lastConfigReloadedAt = "" // No reload had occurred
m.lastConfigReloadCounter = 0 // No reload had occurred

// Now config is reloaded
status := tuiStatusMsg(storage.DaemonStatus{
Version: "1.0.0",
ConfigReloadedAt: "2026-01-23T10:00:00Z",
Version: "1.0.0",
ConfigReloadCounter: 1,
})

updated, _ := m.Update(status)
m2 := updated.(tuiModel)

if m2.flashMessage != "Config reloaded" {
t.Errorf("Expected flash when ConfigReloadedAt goes from empty to set, got %q", m2.flashMessage)
t.Errorf("Expected flash when ConfigReloadCounter goes from 0 to 1, got %q", m2.flashMessage)
}
})

t.Run("no flash when ConfigReloadedAt unchanged", func(t *testing.T) {
t.Run("no flash when ConfigReloadCounter unchanged", func(t *testing.T) {
m := newTuiModel("http://localhost:7373")
m.statusFetchedOnce = true
m.lastConfigReloadedAt = "2026-01-23T10:00:00Z"
m.lastConfigReloadCounter = 1

// Same timestamp
// Same counter
status := tuiStatusMsg(storage.DaemonStatus{
Version: "1.0.0",
ConfigReloadedAt: "2026-01-23T10:00:00Z",
Version: "1.0.0",
ConfigReloadCounter: 1,
})

updated, _ := m.Update(status)
m2 := updated.(tuiModel)

if m2.flashMessage != "" {
t.Errorf("Expected no flash when timestamp unchanged, got %q", m2.flashMessage)
t.Errorf("Expected no flash when counter unchanged, got %q", m2.flashMessage)
}
})
}
35 changes: 21 additions & 14 deletions internal/agent/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import (
"os/exec"
"strings"
"sync"

"github.com/mattn/go-isatty"
)

// CodexAgent runs code reviews using the Codex CLI
Expand Down Expand Up @@ -68,21 +66,26 @@ func (a *CodexAgent) CommandName() string {
return a.Command
}

func (a *CodexAgent) buildArgs(repoPath, outputFile, prompt string, agenticMode bool) []string {
func (a *CodexAgent) buildArgs(repoPath, outputFile string, agenticMode, autoApprove bool) []string {
args := []string{
"exec",
}
if agenticMode {
args = append(args, codexDangerousFlag)
}
if autoApprove {
args = append(args, codexAutoApproveFlag)
}
args = append(args,
"-C", repoPath,
"-o", outputFile,
)
if effort := a.codexReasoningEffort(); effort != "" {
args = append(args, "-c", fmt.Sprintf(`model_reasoning_effort="%s"`, effort))
}
args = append(args, prompt)
// "-" must come after all flags to read prompt from stdin
// This avoids Windows command line length limits (~32KB)
args = append(args, "-")
return args
}

Expand Down Expand Up @@ -137,27 +140,31 @@ func (a *CodexAgent) Review(ctx context.Context, repoPath, commitSHA, prompt str
}
}

// Use codex exec with output capture
// The prompt is constructed by the prompt builder with full context
args := a.buildArgs(repoPath, outputFile, prompt, agenticMode)
if !agenticMode && !isatty.IsTerminal(os.Stdin.Fd()) {
// When piping stdin, codex needs --full-auto to run non-interactively.
// Agentic mode uses --dangerously-bypass-approvals-and-sandbox which implies auto-approve.
autoApprove := false
if !agenticMode {
supported, err := codexSupportsAutoApproveFlag(ctx, a.Command)
if err != nil {
return "", err
}
if !supported {
return "", fmt.Errorf("codex requires a TTY or %s; rerun with --allow-unsafe-agents or upgrade codex", codexAutoApproveFlag)
}
if len(args) > 0 && args[0] == "exec" {
args = append(args[:1], append([]string{codexAutoApproveFlag}, args[1:]...)...)
} else {
args = append([]string{codexAutoApproveFlag}, args...)
return "", fmt.Errorf("codex requires %s for stdin input; upgrade codex or use --agentic", codexAutoApproveFlag)
}
autoApprove = true
}

// Use codex exec with output capture
// The prompt is piped via stdin using "-" to avoid command line length limits on Windows
args := a.buildArgs(repoPath, outputFile, agenticMode, autoApprove)

cmd := exec.CommandContext(ctx, a.Command, args...)
cmd.Dir = repoPath

// Pipe prompt via stdin to avoid command line length limits on Windows.
// Windows has a ~32KB limit on command line arguments, which large diffs easily exceed.
cmd.Stdin = strings.NewReader(prompt)

var stderr bytes.Buffer
if sw := newSyncWriter(output); sw != nil {
// Stream stderr (progress info) to output
Expand Down
70 changes: 51 additions & 19 deletions internal/agent/codex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,27 @@ import (
func TestCodexBuildArgsUnsafeOptIn(t *testing.T) {
a := NewCodexAgent("codex")

// Test non-agentic mode (no dangerous flag)
args := a.buildArgs("/repo", "/tmp/out", "prompt", false)
// Test non-agentic mode with auto-approve
args := a.buildArgs("/repo", "/tmp/out", false, true)
if containsString(args, codexDangerousFlag) {
t.Fatalf("expected no unsafe flag when disabled, got %v", args)
t.Fatalf("expected no unsafe flag when agentic=false, got %v", args)
}
if !containsString(args, codexAutoApproveFlag) {
t.Fatalf("expected auto-approve flag when autoApprove=true, got %v", args)
}

// Verify stdin marker "-" is at the end (after all flags)
if args[len(args)-1] != "-" {
t.Fatalf("expected stdin marker '-' at end of args, got %v", args)
}

// Test agentic mode (with dangerous flag)
args = a.buildArgs("/repo", "/tmp/out", "prompt", true)
// Test agentic mode (with dangerous flag, no auto-approve)
args = a.buildArgs("/repo", "/tmp/out", true, false)
if !containsString(args, codexDangerousFlag) {
t.Fatalf("expected unsafe flag when enabled, got %v", args)
t.Fatalf("expected unsafe flag when agentic=true, got %v", args)
}
if containsString(args, codexAutoApproveFlag) {
t.Fatalf("expected no auto-approve flag when autoApprove=false, got %v", args)
}
}

Expand Down Expand Up @@ -52,23 +63,12 @@ func TestCodexReviewUnsafeMissingFlagErrors(t *testing.T) {
}
}

func TestCodexReviewNonTTYAddsAutoApprove(t *testing.T) {
func TestCodexReviewAlwaysAddsAutoApprove(t *testing.T) {
// Since we always pipe stdin, --full-auto is always required in non-agentic mode
prevAllowUnsafe := AllowUnsafeAgents()
SetAllowUnsafeAgents(false)
t.Cleanup(func() { SetAllowUnsafeAgents(prevAllowUnsafe) })

stdin := os.Stdin
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
os.Stdin = r
t.Cleanup(func() {
os.Stdin = stdin
r.Close()
w.Close()
})

tmpDir := t.TempDir()
argsFile := filepath.Join(tmpDir, "args.txt")
t.Setenv("ARGS_FILE", argsFile)
Expand All @@ -88,3 +88,35 @@ func TestCodexReviewNonTTYAddsAutoApprove(t *testing.T) {
t.Fatalf("expected %s in args, got %s", codexAutoApproveFlag, strings.TrimSpace(string(args)))
}
}

func TestCodexReviewPipesPromptViaStdin(t *testing.T) {
// Verify prompt is actually delivered via stdin
prevAllowUnsafe := AllowUnsafeAgents()
SetAllowUnsafeAgents(false)
t.Cleanup(func() { SetAllowUnsafeAgents(prevAllowUnsafe) })

tmpDir := t.TempDir()
stdinFile := filepath.Join(tmpDir, "stdin.txt")
t.Setenv("STDIN_FILE", stdinFile)

// Script that reads stdin and writes to file
cmdPath := writeTempCommand(t, `#!/bin/sh
if [ "$1" = "--help" ]; then echo "usage `+codexAutoApproveFlag+`"; exit 0; fi
cat > "$STDIN_FILE"
exit 0
`)
a := NewCodexAgent(cmdPath)

testPrompt := "This is a test prompt with special chars: <>&\nand newlines"
if _, err := a.Review(context.Background(), t.TempDir(), "deadbeef", testPrompt, nil); err != nil {
t.Fatalf("expected no error, got %v", err)
}

received, err := os.ReadFile(stdinFile)
if err != nil {
t.Fatalf("read stdin file: %v", err)
}
if string(received) != testPrompt {
t.Fatalf("prompt not piped correctly via stdin\nexpected: %q\ngot: %q", testPrompt, string(received))
}
}
Loading
Loading