Fresh repository history for elektrine/tarakan-client hosted at https://git.elektrine.com/elektrine/tarakan-client.
213 lines
5.5 KiB
Go
213 lines
5.5 KiB
Go
package agent
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// runCodex streams Codex exec --json events (command executions, messages)
|
|
// into Progress for live TUI visibility.
|
|
func runCodex(ctx context.Context, provider Provider, request Request) (string, error) {
|
|
if provider.Path == "" {
|
|
return "", ErrUnavailable
|
|
}
|
|
args := []string{
|
|
"exec",
|
|
"--json",
|
|
"--skip-git-repo-check",
|
|
// Read-only sandbox matches Tarakan's isolated review snapshot.
|
|
"--sandbox", "read-only",
|
|
securityPrompt(request.Prompt),
|
|
}
|
|
command := exec.CommandContext(ctx, provider.Path, args...)
|
|
command.Dir = request.Directory
|
|
command.Env = subprocessEnvironment(os.Environ())
|
|
// Codex may try to read stdin when it thinks input is piped.
|
|
command.Stdin = bytes.NewReader(nil)
|
|
|
|
stdout, err := command.StdoutPipe()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var stderrBuf bytes.Buffer
|
|
command.Stderr = &stderrBuf
|
|
|
|
if err := command.Start(); err != nil {
|
|
return "", fmt.Errorf("%s failed to start: %w", provider.Description, err)
|
|
}
|
|
|
|
progress := request.Progress
|
|
report := func(line string) {
|
|
if progress == nil {
|
|
return
|
|
}
|
|
if line = strings.TrimSpace(line); line != "" {
|
|
progress(line)
|
|
}
|
|
}
|
|
var (
|
|
lastFooter string
|
|
footerMu sync.Mutex
|
|
)
|
|
reportFooter := func(line string) {
|
|
if progress == nil {
|
|
return
|
|
}
|
|
footerMu.Lock()
|
|
defer footerMu.Unlock()
|
|
if line == lastFooter {
|
|
return
|
|
}
|
|
lastFooter = line
|
|
progress(line)
|
|
}
|
|
|
|
var (
|
|
lastMessage string
|
|
seen = map[string]struct{}{}
|
|
)
|
|
emit := func(line string) {
|
|
key := normalizeActivityKey(line)
|
|
if _, ok := seen[key]; ok {
|
|
return
|
|
}
|
|
seen[key] = struct{}{}
|
|
report(line)
|
|
}
|
|
|
|
scanner := bufio.NewScanner(stdout)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
|
for scanner.Scan() {
|
|
ev := parseCodexStreamLine(scanner.Text())
|
|
if !ev.ok {
|
|
continue
|
|
}
|
|
if ev.activity != "" {
|
|
emit(ev.activity)
|
|
}
|
|
if ev.footer != "" {
|
|
reportFooter(ev.footer)
|
|
}
|
|
if ev.message != "" {
|
|
lastMessage = ev.message
|
|
}
|
|
}
|
|
_ = scanner.Err()
|
|
|
|
waitErr := command.Wait()
|
|
output := strings.TrimSpace(lastMessage)
|
|
if waitErr != nil {
|
|
errText := strings.TrimSpace(stderrBuf.String())
|
|
if output == "" && errText != "" {
|
|
output = errText
|
|
}
|
|
return output, fmt.Errorf("%s failed: %w", provider.Description, waitErr)
|
|
}
|
|
return output, nil
|
|
}
|
|
|
|
type codexStreamEvent struct {
|
|
ok bool
|
|
activity string
|
|
footer string
|
|
message string
|
|
}
|
|
|
|
func parseCodexStreamLine(line string) codexStreamEvent {
|
|
var row map[string]any
|
|
if err := json.Unmarshal([]byte(line), &row); err != nil {
|
|
return codexStreamEvent{}
|
|
}
|
|
typ, _ := row["type"].(string)
|
|
switch typ {
|
|
case "thread.started":
|
|
return codexStreamEvent{ok: true, activity: "→ Codex session started"}
|
|
case "turn.started":
|
|
return codexStreamEvent{ok: true, footer: "… turn started"}
|
|
case "turn.completed":
|
|
return codexStreamEvent{ok: true, footer: "… turn completed"}
|
|
case "item.started", "item.completed":
|
|
item, _ := row["item"].(map[string]any)
|
|
if item == nil {
|
|
return codexStreamEvent{}
|
|
}
|
|
return formatCodexItem(typ == "item.completed", item)
|
|
case "error":
|
|
if msg, _ := row["message"].(string); msg != "" {
|
|
return codexStreamEvent{ok: true, activity: "Codex error: " + msg}
|
|
}
|
|
}
|
|
return codexStreamEvent{}
|
|
}
|
|
|
|
func formatCodexItem(completed bool, item map[string]any) codexStreamEvent {
|
|
itemType, _ := item["type"].(string)
|
|
prefix := "→ "
|
|
if completed {
|
|
prefix = "✓ "
|
|
}
|
|
switch itemType {
|
|
case "command_execution":
|
|
cmd := firstString(item, "command")
|
|
// Codex often wraps: /usr/bin/bash -lc "..."
|
|
cmd = simplifyCodexCommand(cmd)
|
|
status, _ := item["status"].(string)
|
|
if status == "failed" || status == "error" {
|
|
prefix = "✗ "
|
|
}
|
|
if cmd != "" {
|
|
return codexStreamEvent{ok: true, activity: prefix + "Shell: " + truncateRunes(cmd, 100)}
|
|
}
|
|
return codexStreamEvent{ok: true, activity: prefix + "Shell"}
|
|
case "file_change", "file_edit":
|
|
path := firstString(item, "path", "file", "filename")
|
|
if path != "" {
|
|
return codexStreamEvent{ok: true, activity: prefix + "Edit " + path}
|
|
}
|
|
return codexStreamEvent{ok: true, activity: prefix + "Edit file"}
|
|
case "agent_message", "message":
|
|
text := firstString(item, "text", "content")
|
|
if text != "" {
|
|
return codexStreamEvent{ok: true, footer: "… writing response", message: text}
|
|
}
|
|
return codexStreamEvent{ok: true, footer: "… writing response"}
|
|
case "reasoning", "thought":
|
|
return codexStreamEvent{ok: true, footer: "… thinking"}
|
|
case "mcp_tool_call", "tool_call":
|
|
name := firstString(item, "name", "tool", "tool_name")
|
|
if name != "" {
|
|
return codexStreamEvent{ok: true, activity: prefix + name}
|
|
}
|
|
case "todo_list", "web_search":
|
|
return codexStreamEvent{ok: true, activity: prefix + itemType}
|
|
default:
|
|
if itemType != "" {
|
|
// Unknown item types still surface so new Codex versions stay visible.
|
|
summary := itemType
|
|
if cmd := firstString(item, "command", "path", "text"); cmd != "" {
|
|
summary += " " + truncateRunes(cmd, 60)
|
|
}
|
|
return codexStreamEvent{ok: true, activity: prefix + summary}
|
|
}
|
|
}
|
|
return codexStreamEvent{}
|
|
}
|
|
|
|
func simplifyCodexCommand(cmd string) string {
|
|
cmd = strings.TrimSpace(cmd)
|
|
// /usr/bin/bash -lc "real command"
|
|
for _, prefix := range []string{`/usr/bin/bash -lc "`, `bash -lc "`, `/bin/bash -lc "`} {
|
|
if strings.HasPrefix(cmd, prefix) && strings.HasSuffix(cmd, `"`) {
|
|
inner := strings.TrimSuffix(strings.TrimPrefix(cmd, prefix), `"`)
|
|
return strings.ReplaceAll(inner, `\"`, `"`)
|
|
}
|
|
}
|
|
return cmd
|
|
}
|