package cli import ( "bufio" "context" "encoding/json" "errors" "fmt" "io" "os" "os/exec" "strings" "time" "iop/apps/node/internal/runtime" "iop/packages/config" ) func (c *CLI) executeOneShot(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error { prompt := extractPrompt(spec.Input) args := append(append([]string{}, profile.Args...), prompt) _, err := c.executeCommand(ctx, spec, profile, args, prompt, sink) return err } func (c *CLI) executeCommand(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, args []string, prompt string, sink runtime.EventSink) (string, error) { cmd := exec.CommandContext(ctx, profile.Command, args...) if len(profile.Env) > 0 { cmd.Env = append(cmd.Environ(), profile.Env...) } stdout, err := cmd.StdoutPipe() if err != nil { return "", fmt.Errorf("cli adapter: stdout pipe: %w", err) } stderr, err := cmd.StderrPipe() if err != nil { return "", fmt.Errorf("cli adapter: stderr pipe: %w", err) } if err := cmd.Start(); err != nil { return "", fmt.Errorf("cli adapter: start %q: %w", profile.Command, err) } _ = sink.Emit(ctx, runtime.RuntimeEvent{ RunID: spec.RunID, Type: runtime.EventTypeStart, Timestamp: time.Now(), }) var errBuf strings.Builder var outBuf strings.Builder combinedOutput := func() string { return outBuf.String() + errBuf.String() } stderrDone := make(chan error, 1) go func() { _, err := io.Copy(&errBuf, stderr) stderrDone <- err }() var ( outputTokens int readErr error ) switch profile.OutputFormat { case "stream-json": outputTokens, readErr = emitStreamJSONLines(ctx, stdout, sink, spec.RunID, &outBuf) case "codex-json": outputTokens, readErr = emitCodexJSONLines(ctx, stdout, sink, spec.RunID, &outBuf) case "claude-json": outputTokens, readErr = emitClaudeJSONLines(ctx, stdout, sink, spec.RunID, &outBuf) case "opencode-json": outputTokens, readErr = emitOpencodeJSON(ctx, stdout, sink, spec.RunID, &outBuf) case "cline-json": outputTokens, readErr = emitClineJSON(ctx, stdout, sink, spec.RunID, &outBuf) default: outputTokens, readErr = emitStdoutChunks(ctx, stdout, sink, spec.RunID, &outBuf) } if readErr != nil { _ = <-stderrDone _ = cmd.Wait() _ = sink.Emit(ctx, runtime.RuntimeEvent{ RunID: spec.RunID, Type: runtime.EventTypeError, Error: fmt.Sprintf("read stdout: %v", readErr), Timestamp: time.Now(), }) return combinedOutput(), fmt.Errorf("cli adapter: read stdout: %w", readErr) } waitErr := cmd.Wait() stderrErr := <-stderrDone if waitErr != nil { if ctx.Err() != nil { _ = sink.Emit(context.Background(), runtime.RuntimeEvent{ RunID: spec.RunID, Type: runtime.EventTypeCancelled, Message: "cli execution cancelled", Timestamp: time.Now(), }) return combinedOutput(), runtime.ErrRunCancelled } _ = sink.Emit(ctx, runtime.RuntimeEvent{ RunID: spec.RunID, Type: runtime.EventTypeError, Error: fmt.Sprintf("command failed: %v — %s", waitErr, errBuf.String()), Timestamp: time.Now(), }) return combinedOutput(), fmt.Errorf("cli adapter: command exited with error: %w", waitErr) } if stderrErr != nil && !errors.Is(stderrErr, os.ErrClosed) { _ = sink.Emit(ctx, runtime.RuntimeEvent{ RunID: spec.RunID, Type: runtime.EventTypeError, Error: fmt.Sprintf("read stderr: %v", stderrErr), Timestamp: time.Now(), }) return combinedOutput(), fmt.Errorf("cli adapter: read stderr: %w", stderrErr) } return combinedOutput(), sink.Emit(ctx, runtime.RuntimeEvent{ RunID: spec.RunID, Type: runtime.EventTypeComplete, Message: "cli execution complete", Usage: &runtime.UsageStats{ InputTokens: len(strings.Fields(prompt)), OutputTokens: outputTokens, }, Timestamp: time.Now(), }) } func emitStdoutChunks(ctx context.Context, stdout io.Reader, sink runtime.EventSink, runID string, outBuf *strings.Builder) (int, error) { buf := make([]byte, 4096) outputTokens := 0 for { n, err := stdout.Read(buf) if n > 0 { delta := string(buf[:n]) outBuf.WriteString(delta) outputTokens += len(strings.Fields(delta)) _ = sink.Emit(ctx, runtime.RuntimeEvent{ RunID: runID, Type: runtime.EventTypeDelta, Delta: delta, Timestamp: time.Now(), }) } if errors.Is(err, io.EOF) { return outputTokens, nil } if err != nil { return outputTokens, err } } } // emitStreamJSONLines reads JSON Lines from stdout (one JSON object per line) // and emits assistant content (delta=true) as RuntimeEvent deltas. Other // event types are written to outBuf so they remain available for diagnostics // but are not pushed to the sink. func emitStreamJSONLines(ctx context.Context, stdout io.Reader, sink runtime.EventSink, runID string, outBuf *strings.Builder) (int, error) { scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) outputTokens := 0 for scanner.Scan() { line := scanner.Bytes() outBuf.Write(line) outBuf.WriteByte('\n') trimmed := strings.TrimSpace(string(line)) if trimmed == "" || trimmed[0] != '{' { continue } var ev struct { Type string `json:"type"` Role string `json:"role"` Content string `json:"content"` Delta bool `json:"delta"` Error string `json:"error"` } if err := json.Unmarshal(line, &ev); err != nil { continue } switch ev.Type { case "message": if ev.Role != "assistant" || ev.Content == "" { continue } outputTokens += len(strings.Fields(ev.Content)) _ = sink.Emit(ctx, runtime.RuntimeEvent{ RunID: runID, Type: runtime.EventTypeDelta, Delta: ev.Content, Timestamp: time.Now(), }) case "error": _ = sink.Emit(ctx, runtime.RuntimeEvent{ RunID: runID, Type: runtime.EventTypeError, Error: ev.Error, Timestamp: time.Now(), }) } } if err := scanner.Err(); err != nil { return outputTokens, err } return outputTokens, nil } // emitClaudeJSONLines parses `claude -p --output-format stream-json // --include-partial-messages` JSONL output. Token-level text arrives as // stream_event with event.type == "content_block_delta" and // event.delta.type == "text_delta". Other event types (system, assistant, // result, rate_limit_event, ...) are written to outBuf for diagnostics but // not pushed to the sink to avoid duplicate text. func emitClaudeJSONLines(ctx context.Context, stdout io.Reader, sink runtime.EventSink, runID string, outBuf *strings.Builder) (int, error) { scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 64*1024), 8*1024*1024) outputTokens := 0 for scanner.Scan() { line := scanner.Bytes() outBuf.Write(line) outBuf.WriteByte('\n') trimmed := strings.TrimSpace(string(line)) if trimmed == "" || trimmed[0] != '{' { continue } var ev struct { Type string `json:"type"` Event struct { Type string `json:"type"` Delta struct { Type string `json:"type"` Text string `json:"text"` } `json:"delta"` } `json:"event"` IsError bool `json:"is_error"` Result string `json:"result"` } if err := json.Unmarshal(line, &ev); err != nil { continue } switch ev.Type { case "stream_event": if ev.Event.Type != "content_block_delta" || ev.Event.Delta.Type != "text_delta" || ev.Event.Delta.Text == "" { continue } outputTokens += len(strings.Fields(ev.Event.Delta.Text)) _ = sink.Emit(ctx, runtime.RuntimeEvent{ RunID: runID, Type: runtime.EventTypeDelta, Delta: ev.Event.Delta.Text, Timestamp: time.Now(), }) case "result": if ev.IsError && ev.Result != "" { _ = sink.Emit(ctx, runtime.RuntimeEvent{ RunID: runID, Type: runtime.EventTypeError, Error: ev.Result, Timestamp: time.Now(), }) } } } if err := scanner.Err(); err != nil { return outputTokens, err } return outputTokens, nil } // emitCodexJSONLines parses `codex exec --json` JSONL output. Codex emits // one event per line; assistant text arrives in a single `item.completed` // event with item.type == "agent_message" rather than token-by-token. func emitCodexJSONLines(ctx context.Context, stdout io.Reader, sink runtime.EventSink, runID string, outBuf *strings.Builder) (int, error) { scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) outputTokens := 0 for scanner.Scan() { line := scanner.Bytes() outBuf.Write(line) outBuf.WriteByte('\n') trimmed := strings.TrimSpace(string(line)) if trimmed == "" || trimmed[0] != '{' { continue } var ev struct { Type string `json:"type"` Item struct { Type string `json:"type"` Text string `json:"text"` } `json:"item"` Message string `json:"message"` Error struct { Message string `json:"message"` } `json:"error"` } if err := json.Unmarshal(line, &ev); err != nil { continue } switch ev.Type { case "item.completed": if ev.Item.Type != "agent_message" || ev.Item.Text == "" { continue } outputTokens += len(strings.Fields(ev.Item.Text)) _ = sink.Emit(ctx, runtime.RuntimeEvent{ RunID: runID, Type: runtime.EventTypeDelta, Delta: ev.Item.Text, Timestamp: time.Now(), }) case "error": _ = sink.Emit(ctx, runtime.RuntimeEvent{ RunID: runID, Type: runtime.EventTypeError, Error: ev.Message, Timestamp: time.Now(), }) case "turn.failed": _ = sink.Emit(ctx, runtime.RuntimeEvent{ RunID: runID, Type: runtime.EventTypeError, Error: ev.Error.Message, Timestamp: time.Now(), }) } } if err := scanner.Err(); err != nil { return outputTokens, err } return outputTokens, nil } // emitOpencodeJSON parses `opencode run --format json` output. // Each line is a JSON event: step_start, text (with part.text), step_finish, or error. // Only type=="text" events with a non-empty part.text are emitted as deltas. func emitOpencodeJSON(ctx context.Context, stdout io.Reader, sink runtime.EventSink, runID string, outBuf *strings.Builder) (int, error) { scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) outputTokens := 0 for scanner.Scan() { line := scanner.Bytes() outBuf.Write(line) outBuf.WriteByte('\n') trimmed := strings.TrimSpace(string(line)) if trimmed == "" || trimmed[0] != '{' { continue } var ev struct { Type string `json:"type"` Error struct { Name string `json:"name"` Data struct { Message string `json:"message"` } `json:"data"` } `json:"error"` Part struct { Type string `json:"type"` Text string `json:"text"` } `json:"part"` } if err := json.Unmarshal(line, &ev); err != nil { continue } switch ev.Type { case "text": if ev.Part.Type == "text" && ev.Part.Text != "" { outputTokens += len(strings.Fields(ev.Part.Text)) _ = sink.Emit(ctx, runtime.RuntimeEvent{RunID: runID, Type: runtime.EventTypeDelta, Delta: ev.Part.Text, Timestamp: time.Now()}) } case "error": msg := ev.Error.Data.Message if msg == "" { msg = ev.Error.Name } if msg != "" { _ = sink.Emit(ctx, runtime.RuntimeEvent{RunID: runID, Type: runtime.EventTypeError, Error: msg, Timestamp: time.Now()}) } } } if err := scanner.Err(); err != nil { return outputTokens, err } return outputTokens, nil } // emitClineJSON parses `cline --json` headless output. Cline emits lifecycle // events as JSONL; assistant text is carried by say=="text" events. func emitClineJSON(ctx context.Context, stdout io.Reader, sink runtime.EventSink, runID string, outBuf *strings.Builder) (int, error) { scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 64*1024), 8*1024*1024) outputTokens := 0 for scanner.Scan() { line := scanner.Bytes() outBuf.Write(line) outBuf.WriteByte('\n') trimmed := strings.TrimSpace(string(line)) if trimmed == "" || trimmed[0] != '{' { continue } var ev struct { Type string `json:"type"` Say string `json:"say"` Ask string `json:"ask"` Text string `json:"text"` Message string `json:"message"` Status string `json:"status"` Error string `json:"error"` } if err := json.Unmarshal(line, &ev); err != nil { continue } switch { case ev.Type == "say" && ev.Say == "text" && ev.Text != "": outputTokens += len(strings.Fields(ev.Text)) _ = sink.Emit(ctx, runtime.RuntimeEvent{RunID: runID, Type: runtime.EventTypeDelta, Delta: ev.Text, Timestamp: time.Now()}) case ev.Type == "say" && ev.Say == "error": msg := ev.Text if msg == "" { msg = ev.Message } if msg != "" { _ = sink.Emit(ctx, runtime.RuntimeEvent{RunID: runID, Type: runtime.EventTypeError, Error: msg, Timestamp: time.Now()}) } case ev.Type == "completion" && ev.Status == "error": msg := ev.Error if msg == "" { msg = ev.Message } if msg == "" { msg = "cline task failed" } _ = sink.Emit(ctx, runtime.RuntimeEvent{RunID: runID, Type: runtime.EventTypeError, Error: msg, Timestamp: time.Now()}) case ev.Type == "ask" && ev.Ask == "api_req_failed": msg := ev.Text if msg == "" { msg = ev.Message } if msg != "" { _ = sink.Emit(ctx, runtime.RuntimeEvent{RunID: runID, Type: runtime.EventTypeError, Error: msg, Timestamp: time.Now()}) } } } if err := scanner.Err(); err != nil { return outputTokens, err } return outputTokens, nil } func extractPrompt(input map[string]any) string { if input == nil { return "" } if v, ok := input["prompt"]; ok { if s, ok := v.(string); ok { return s } } return fmt.Sprintf("%v", input) }