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,
}
}