Vectrum
Sign In
408 lines · 12.3 KB · Go
package main

// What `-p --output-format ...` prints, in the shapes `claude -p` prints.
//
// The reader is a program: a CI step that runs `jq -r .result`, a wrapper that
// reads stream-json line by line to show progress, an SDK that was written
// against Claude Code. None of them can be asked to change, so the shapes are
// Claude Code's — the same type and subtype names, the same field names, the
// assistant message as the Messages API returns it — and where Codey has
// nothing to put in a field Claude Code fills, the field is left out rather
// than filled with something made up.
//
// Nothing else may reach stdout in these modes. Notices go to stderr, and so
// do approval prompts; see cliRun.UIOut.

import (
	"bytes"
	"context"
	"crypto/rand"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"sort"
	"strings"
	"sync"
	"time"

	"github.com/johannSo/codey/internal/agent"
	"github.com/johannSo/codey/internal/llm"
)

// Result subtypes, as Claude Code names them.
const (
	resultSuccess   = "success"
	resultMaxTurns  = "error_max_turns"
	resultExecution = "error_during_execution"
)

// printer writes one run in a Claude Code output format. It is the agent's
// observer for the length of the run, and the event log of it as far as the
// reader on stdout is concerned.
type printer struct {
	mu     sync.Mutex
	w      io.Writer
	notes  io.Writer
	format string
	// array is json with --verbose, which Claude Code prints as every line
	// stream-json would have printed, collected into one JSON array.
	array   bool
	records []any

	session string
	model   string
	start   time.Time
	// api is the time spent waiting on the model, measured from the start of
	// each turn to the usage that ends it. Compaction is a model call too and
	// falls inside that window, which is where it belongs.
	api       time.Duration
	turnStart time.Time
	done      bool

	// now and newID are the clock and the uuid source, replaceable so a test
	// can compare output byte for byte.
	now   func() time.Time
	newID func() string
}

func newPrinter(w, notes io.Writer, format string, verbose bool) *printer {
	return &printer{
		w: w, notes: notes, format: format,
		array: format == formatJSON && verbose,
		start: time.Now(), now: time.Now, newID: uuid4,
	}
}

// claudeOut is the printer of a Claude-shaped -p run, once there is one. It is
// a package variable for main, which reports a run that failed before it got
// as far as the model — no credentials, a config error — and whose reader is
// still waiting for a result object.
var claudeOut *printer

// reportFailure gives the reader on stdout its result object when the run
// ended before printing one.
func reportFailure(err error) {
	if claudeOut != nil {
		claudeOut.finish(outcome{Err: err})
	}
}

// begin prints the init line, which only stream-json has.
func (p *printer) begin(cwd, model, mode string, tools []string) {
	p.mu.Lock()
	defer p.mu.Unlock()
	p.model = model
	p.start = p.now()
	if p.format != formatStreamJSON && !p.array {
		return
	}
	p.emit(initLine{
		Type: "system", Subtype: "init", CWD: cwd, SessionID: p.session,
		Tools: tools, MCPServers: mcpServers(tools), Model: model,
		PermissionMode: mode, UUID: p.newID(),
	})
}

// observe is the agent's Observe callback.
func (p *printer) observe(ev agent.Event) {
	p.mu.Lock()
	defer p.mu.Unlock()
	switch ev.Kind {
	case agent.EventTurnStart:
		p.turnStart = p.now()
	case agent.EventUsage:
		if !p.turnStart.IsZero() {
			p.api += p.now().Sub(p.turnStart)
			p.turnStart = time.Time{}
		}
	case agent.EventNotice:
		fmt.Fprintln(p.notes, "codey: "+strings.TrimSpace(ev.Text))
	}
	if p.format != formatStreamJSON && !p.array {
		return
	}
	switch ev.Kind {
	case agent.EventMessage:
		if r := ev.Response; r != nil {
			model := r.Model
			if model == "" {
				model = p.model
			}
			p.emit(messageLine{
				Type: "assistant",
				Message: apiMessage{
					ID: r.ID, Type: "message", Role: llm.RoleAssistant, Model: model,
					Content: nonNil(r.Content), StopReason: r.StopReason, Usage: r.Usage,
				},
				SessionID: p.session, UUID: p.newID(),
			})
		}
		if m := ev.Message; m != nil {
			p.emit(messageLine{
				Type:      "user",
				Message:   userMessage{Role: m.Role, Content: nonNil(m.Content)},
				SessionID: p.session, UUID: p.newID(),
			})
		}
	case agent.EventCompact:
		p.emit(systemLine{Type: "system", Subtype: "compact_boundary", SessionID: p.session, UUID: p.newID()})
	}
}

// outcome is how a run ended, for the result object.
type outcome struct {
	Text  string
	Err   error
	Turns int
	Usage llm.Usage
}

// finish prints the result, once. A second call — main reporting the error
// the result already carries — prints nothing.
func (p *printer) finish(o outcome) {
	p.mu.Lock()
	defer p.mu.Unlock()
	if p.done {
		return
	}
	p.done = true

	if p.format == formatText {
		// The answer and nothing else, which is what a script that captures
		// `claude -p` wants. A failure has no answer; its message goes to
		// stderr with the exit status.
		if o.Err == nil {
			fmt.Fprintln(p.w, o.Text)
		}
		return
	}

	res := resultLine{
		Type: "result", Subtype: resultSuccess,
		DurationMS: p.now().Sub(p.start).Milliseconds(), DurationAPIMS: p.api.Milliseconds(),
		NumTurns: o.Turns, Result: o.Text, SessionID: p.session, Usage: o.Usage, UUID: p.newID(),
	}
	if cost, ok := o.Usage.Cost(p.model); ok {
		res.TotalCostUSD = cost
	}
	if o.Err != nil {
		res.IsError = true
		res.Subtype = resultExecution
		if errors.Is(o.Err, agent.ErrMaxTurns) {
			res.Subtype = resultMaxTurns
		}
		res.Errors = []string{o.Err.Error()}
		// A run that stopped part-way still hands back what the model last
		// said, as the line front end does; a run with nothing to hand back
		// says why, because `.result` is the field everybody prints.
		if res.Result == "" {
			res.Result = o.Err.Error()
		}
	}
	p.emit(res)

	if p.array {
		p.write(p.records)
	}
}

// emit prints one record, or holds it for the array.
func (p *printer) emit(v any) {
	if p.array {
		p.records = append(p.records, v)
		return
	}
	p.write(v)
}

// write prints one JSON value on a line of its own. HTML is not escaped,
// because the reader is not a browser and Claude Code does not escape it:
// a diff full of < would match nothing a script greps for.
func (p *printer) write(v any) {
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	if err := enc.Encode(v); err != nil {
		fmt.Fprintln(p.notes, "codey: encoding output:", err)
		return
	}
	p.w.Write(unescapeHTML(buf.Bytes()))
}

// unescapeHTML undoes the escapes json.Marshal writes for <, > and & — a
// backslash, a u, and the code point in four hex digits (003c, 003e, 0026).
// The encoder's own setting does not reach them: a content block marshals
// itself, with json.Marshal, before the encoder sees it. The scan steps over
// every escape as a pair, so an escaped backslash followed by the letters
// u003c is left alone.
func unescapeHTML(b []byte) []byte {
	if !bytes.Contains(b, []byte(`\u00`)) {
		return b
	}
	out := make([]byte, 0, len(b))
	for i := 0; i < len(b); i++ {
		if b[i] != '\\' || i+1 == len(b) {
			out = append(out, b[i])
			continue
		}
		if b[i+1] == 'u' && i+6 <= len(b) {
			switch string(b[i+2 : i+6]) {
			case "003c":
				out, i = append(out, '<'), i+5
				continue
			case "003e":
				out, i = append(out, '>'), i+5
				continue
			case "0026":
				out, i = append(out, '&'), i+5
				continue
			}
		}
		out = append(out, b[i], b[i+1])
		i++
	}
	return out
}

type initLine struct {
	Type           string      `json:"type"`
	Subtype        string      `json:"subtype"`
	CWD            string      `json:"cwd"`
	SessionID      string      `json:"session_id"`
	Tools          []string    `json:"tools"`
	MCPServers     []mcpServer `json:"mcp_servers"`
	Model          string      `json:"model"`
	PermissionMode string      `json:"permissionMode"`
	UUID           string      `json:"uuid"`
}

type mcpServer struct {
	Name   string `json:"name"`
	Status string `json:"status"`
}

type systemLine struct {
	Type      string `json:"type"`
	Subtype   string `json:"subtype"`
	SessionID string `json:"session_id"`
	UUID      string `json:"uuid"`
}

// messageLine is an assistant or a user line. parent_tool_use_id is always
// null: Codey's subagents report through a tool result, not by speaking in
// the parent's stream, so nothing printed here belongs to a tool call.
type messageLine struct {
	Type            string  `json:"type"`
	Message         any     `json:"message"`
	ParentToolUseID *string `json:"parent_tool_use_id"`
	SessionID       string  `json:"session_id"`
	UUID            string  `json:"uuid"`
}

// apiMessage is an assistant message as the Messages API returns it.
type apiMessage struct {
	ID           string             `json:"id"`
	Type         string             `json:"type"`
	Role         string             `json:"role"`
	Model        string             `json:"model"`
	Content      []llm.ContentBlock `json:"content"`
	StopReason   string             `json:"stop_reason"`
	StopSequence *string            `json:"stop_sequence"`
	Usage        llm.Usage          `json:"usage"`
}

type userMessage struct {
	Role    string             `json:"role"`
	Content []llm.ContentBlock `json:"content"`
}

type resultLine struct {
	Type          string    `json:"type"`
	Subtype       string    `json:"subtype"`
	IsError       bool      `json:"is_error"`
	DurationMS    int64     `json:"duration_ms"`
	DurationAPIMS int64     `json:"duration_api_ms"`
	NumTurns      int       `json:"num_turns"`
	Result        string    `json:"result"`
	SessionID     string    `json:"session_id"`
	TotalCostUSD  float64   `json:"total_cost_usd"`
	Usage         llm.Usage `json:"usage"`
	Errors        []string  `json:"errors,omitempty"`
	UUID          string    `json:"uuid"`
}

// mcpServers names the servers whose tools are on offer. A server that failed
// to start has none, and is left out rather than reported in a state this
// list cannot tell apart from not configured.
func mcpServers(tools []string) []mcpServer {
	seen := map[string]bool{}
	for _, t := range tools {
		rest, ok := strings.CutPrefix(t, "mcp__")
		if !ok {
			continue
		}
		if name, _, ok := strings.Cut(rest, "__"); ok {
			seen[name] = true
		}
	}
	out := []mcpServer{}
	for name := range seen {
		out = append(out, mcpServer{Name: name, Status: "connected"})
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
	return out
}

// nonNil keeps an empty content list printing as [] rather than null.
func nonNil(blocks []llm.ContentBlock) []llm.ContentBlock {
	if blocks == nil {
		return []llm.ContentBlock{}
	}
	return blocks
}

// uuid4 is a random version-4 UUID, which is what Claude Code stamps on every
// line. Nothing reads them back; they are there so a reader that keys lines by
// uuid has something to key on.
func uuid4() string {
	var b [16]byte
	rand.Read(b[:])
	b[6] = b[6]&0x0f | 0x40
	b[8] = b[8]&0x3f | 0x80
	return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}

// printOnce is once for a Claude-shaped run: the prompt, then the result in
// the chosen format. The usage and turns are this run's alone, so a resumed
// session reports what this invocation cost rather than everything before it.
func printOnce(ctx context.Context, a *agent.Agent, p *printer, sessionDir, id, root string, client *llm.Client, prompt string) error {
	before, turnsBefore := a.State().Usage()

	turnCtx, endTurn := beginTurn(ctx)
	text, runErr := a.Run(turnCtx, prompt)
	endTurn()

	after, turnsAfter := a.State().Usage()
	if err := a.Save(sessionDir, id, root, "", client.Model()); err != nil {
		fmt.Fprintln(os.Stderr, "codey: saving session:", err)
	}
	if errors.Is(runErr, context.Canceled) {
		// Unlike the line front end, an interrupted -p run is a failure: the
		// script that started it did not get its answer. The session is saved
		// all the same, so -c picks it up.
		runErr = errors.New("interrupted — the session is saved; continue it with -c")
	}
	p.finish(outcome{Text: text, Err: runErr, Turns: turnsAfter - turnsBefore, Usage: usageSince(after, before)})
	return runErr
}

func usageSince(after, before llm.Usage) llm.Usage {
	return llm.Usage{
		InputTokens:              after.InputTokens - before.InputTokens,
		OutputTokens:             after.OutputTokens - before.OutputTokens,
		CacheReadInputTokens:     after.CacheReadInputTokens - before.CacheReadInputTokens,
		CacheCreationInputTokens: after.CacheCreationInputTokens - before.CacheCreationInputTokens,
	}
}