Skip to content
Open
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
162 changes: 70 additions & 92 deletions internal/agent/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,6 @@ func (c Claude) bin() string {

// RunPhase spawns a fresh Claude Code print-mode process for one Phase.
func (c Claude) RunPhase(ctx context.Context, req PhaseRequest) error {
if req.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, req.Timeout)
defer cancel()
}

args := []string{
"-p",
"--dangerously-skip-permissions",
Expand All @@ -44,30 +38,10 @@ func (c Claude) RunPhase(ctx context.Context, req PhaseRequest) error {
args = append(args, "--model", req.Model)
}

cmd := exec.CommandContext(ctx, c.bin(), args...) // #nosec G204 -- Claude Code CLI; args built by Ship
cmd := exec.Command(c.bin(), args...) // #nosec G204 -- Claude Code CLI; args built by Ship
cmd.Dir = req.Workspace
cmd.Stdin = strings.NewReader(req.Prompt)

var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr

err := cmd.Run()
if err != nil {
if ctx.Err() != nil {
return fmt.Errorf("phase: %w", ctx.Err())
}
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return fmt.Errorf("phase: %s", msg)
}

if err := consumeClaudeStreamJSON(stdout.Bytes(), req.Events); err != nil {
return err
}
return nil
return runStreamedPhase(ctx, req.Timeout, cmd, newClaudeStream(req.Events))
}

type claudeEvent struct {
Expand Down Expand Up @@ -97,89 +71,93 @@ type claudeUsage struct {
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
}

func consumeClaudeStreamJSON(stdout []byte, sink observe.Sink) error {
lines := bytes.Split(stdout, []byte("\n"))
var last *claudeEvent
toolCount := 0
pending := map[string]string{} // tool_use id -> name
for _, line := range lines {
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
var ev claudeEvent
if err := json.Unmarshal(line, &ev); err != nil {
continue
type claudeStream struct {
sink observe.Sink
last *claudeEvent
toolCount int
pending map[string]string
}

func newClaudeStream(sink observe.Sink) *claudeStream {
return &claudeStream{sink: sink, pending: map[string]string{}}
}

func (s *claudeStream) ProcessLine(line []byte) {
line = bytes.TrimSpace(line)
if len(line) == 0 {
return
}
var ev claudeEvent
if err := json.Unmarshal(line, &ev); err != nil {
return
}
switch ev.Type {
case "assistant":
if ev.Message == nil {
return
}
switch ev.Type {
case "assistant":
if ev.Message == nil {
for _, block := range ev.Message.Content {
if block.Type != "tool_use" {
continue
}
for _, block := range ev.Message.Content {
if block.Type != "tool_use" {
continue
}
name := block.Name
if name == "" {
name = "unknown"
}
if block.ID != "" {
pending[block.ID] = name
}
name := block.Name
if name == "" {
name = "unknown"
}
if block.ID != "" {
s.pending[block.ID] = name
}
}
case "user":
if ev.Message == nil {
return
}
for _, block := range ev.Message.Content {
if block.Type != "tool_result" {
continue
}
case "user":
if ev.Message == nil {
s.toolCount++
if s.sink == nil {
continue
}
for _, block := range ev.Message.Content {
if block.Type != "tool_result" {
continue
}
toolCount++
if sink == nil {
continue
}
name := pending[block.ToolUseID]
if name == "" {
name = "unknown"
}
status := observe.ToolOK
if block.IsError {
status = observe.ToolError
}
sink.Emit(observe.Event{
Kind: observe.KindTool,
Name: name,
Status: status,
})
name := s.pending[block.ToolUseID]
if name == "" {
name = "unknown"
}
case "result":
last = &ev
status := observe.ToolOK
if block.IsError {
status = observe.ToolError
}
s.sink.Emit(observe.Event{Kind: observe.KindTool, Name: name, Status: status})
}
case "result":
s.last = &ev
}
if last == nil {
}

func (s *claudeStream) Finish() error {
if s.last == nil {
return fmt.Errorf("phase: missing terminal result event in stream-json output")
}
if last.Subtype != "success" {
return fmt.Errorf("phase: result subtype %q, want success", last.Subtype)
if s.last.Subtype != "success" {
return fmt.Errorf("phase: result subtype %q, want success", s.last.Subtype)
}
if sink != nil {
if s.sink != nil {
end := observe.Event{
Kind: observe.KindPhaseEnd,
Outcome: observe.OutcomeSuccess,
DurationMS: last.DurationMS,
ToolCount: toolCount,
DurationMS: s.last.DurationMS,
ToolCount: s.toolCount,
}
if last.Usage != nil {
if s.last.Usage != nil {
end.Tokens = &observe.TokenCounts{
Input: last.Usage.InputTokens,
Output: last.Usage.OutputTokens,
CacheRead: last.Usage.CacheReadInputTokens,
CacheWrite: last.Usage.CacheCreationInputTokens,
Input: s.last.Usage.InputTokens,
Output: s.last.Usage.OutputTokens,
CacheRead: s.last.Usage.CacheReadInputTokens,
CacheWrite: s.last.Usage.CacheCreationInputTokens,
}
}
sink.Emit(end)
s.sink.Emit(end)
}
return nil
}
132 changes: 53 additions & 79 deletions internal/agent/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,6 @@ func (c Codex) bin() string {

// RunPhase spawns a fresh ephemeral Codex exec process for one Phase.
func (c Codex) RunPhase(ctx context.Context, req PhaseRequest) error {
if req.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, req.Timeout)
defer cancel()
}

args := []string{
"exec",
"--ephemeral",
Expand All @@ -46,36 +40,16 @@ func (c Codex) RunPhase(ctx context.Context, req PhaseRequest) error {
// "-" forces the full Phase prompt from stdin (not prompt+context mode).
args = append(args, "-")

cmd := exec.CommandContext(ctx, c.bin(), args...) // #nosec G204 -- Codex agent CLI; args built by Ship
cmd := exec.Command(c.bin(), args...) // #nosec G204 -- Codex agent CLI; args built by Ship
cmd.Dir = req.Workspace
cmd.Stdin = strings.NewReader(req.Prompt)

var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr

err := cmd.Run()
if err != nil {
if ctx.Err() != nil {
return fmt.Errorf("phase: %w", ctx.Err())
}
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return fmt.Errorf("phase: %s", msg)
}

if err := consumeCodexJSON(stdout.Bytes(), req.Events); err != nil {
return err
}
return nil
return runStreamedPhase(ctx, req.Timeout, cmd, newCodexStream(req.Events))
}

type codexEvent struct {
Type string `json:"type"`
Item *codexItem `json:"item"`
Usage *codexUsage `json:"usage"`
Type string `json:"type"`
Item *codexItem `json:"item"`
Usage *codexUsage `json:"usage"`
}

type codexItem struct {
Expand All @@ -92,64 +66,64 @@ type codexUsage struct {
ReasoningOutputTokens int64 `json:"reasoning_output_tokens"`
}

func consumeCodexJSON(stdout []byte, sink observe.Sink) error {
lines := bytes.Split(stdout, []byte("\n"))
sawCompleted := false
sawFailed := false
toolCount := 0
var tokens *observe.TokenCounts
for _, line := range lines {
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
var ev codexEvent
if err := json.Unmarshal(line, &ev); err != nil {
continue
type codexStream struct {
sink observe.Sink
sawCompleted bool
sawFailed bool
toolCount int
tokens *observe.TokenCounts
}

func newCodexStream(sink observe.Sink) *codexStream {
return &codexStream{sink: sink}
}

func (s *codexStream) ProcessLine(line []byte) {
line = bytes.TrimSpace(line)
if len(line) == 0 {
return
}
var ev codexEvent
if err := json.Unmarshal(line, &ev); err != nil {
return
}
switch ev.Type {
case "item.completed":
if ev.Item == nil || !codexItemIsTool(ev.Item.Type) {
return
}
switch ev.Type {
case "item.completed":
if ev.Item == nil || !codexItemIsTool(ev.Item.Type) {
continue
s.toolCount++
if s.sink != nil {
name := codexToolName(ev.Item)
status := observe.ToolOK
if ev.Item.Status == "failed" || ev.Item.Status == "error" {
status = observe.ToolError
}
toolCount++
if sink != nil {
name := codexToolName(ev.Item)
status := observe.ToolOK
if ev.Item.Status == "failed" || ev.Item.Status == "error" {
status = observe.ToolError
}
sink.Emit(observe.Event{
Kind: observe.KindTool,
Name: name,
Status: status,
})
}
case "turn.completed":
sawCompleted = true
if ev.Usage != nil {
tokens = &observe.TokenCounts{
Input: ev.Usage.InputTokens,
Output: ev.Usage.OutputTokens + ev.Usage.ReasoningOutputTokens,
CacheRead: ev.Usage.CachedInputTokens,
}
}
case "turn.failed":
sawFailed = true
s.sink.Emit(observe.Event{Kind: observe.KindTool, Name: name, Status: status})
}
case "turn.completed":
s.sawCompleted = true
if ev.Usage != nil {
s.tokens = &observe.TokenCounts{Input: ev.Usage.InputTokens, Output: ev.Usage.OutputTokens + ev.Usage.ReasoningOutputTokens, CacheRead: ev.Usage.CachedInputTokens}
}
case "turn.failed":
s.sawFailed = true
}
if sawFailed {
}

func (s *codexStream) Finish() error {
if s.sawFailed {
return fmt.Errorf("phase: turn.failed")
}
if !sawCompleted {
if !s.sawCompleted {
return fmt.Errorf("phase: missing terminal turn.completed event in json output")
}
if sink != nil {
sink.Emit(observe.Event{
if s.sink != nil {
s.sink.Emit(observe.Event{
Kind: observe.KindPhaseEnd,
Outcome: observe.OutcomeSuccess,
ToolCount: toolCount,
Tokens: tokens,
ToolCount: s.toolCount,
Tokens: s.tokens,
})
}
return nil
Expand Down
Loading
Loading