package agents // The pure half of the bridge: one stream-json line from a headless Claude // Code session (`claude -p --input-format stream-json --output-format // stream-json --verbose`) in, zero or more Acts out. Kept free of I/O so the // translation is table-testable against fixture lines (translate_test.go) — // the same pure-logic-out-of-the-plumbing split as format.js/ui.js. import ( "encoding/json" "fmt" "strings" ) // Act is one thing the bridge should do in response to a stream line. type Act struct { Kind string // "init" | "turn" | "tool" | "error" // init: the CLI's own session id (what `claude --resume` takes later). ClaudeSessionID string // turn: the assistant's final text for the completed turn; error: detail. Text string // tool: a one-line activity description ("Ran `go test ./...`"). Tool string } // streamLine is the envelope every stream-json stdout line shares. type streamLine struct { Type string `json:"type"` Subtype string `json:"subtype"` SessionID string `json:"session_id"` Result string `json:"result"` IsError bool `json:"is_error"` Message struct { Content []contentBlock `json:"content"` } `json:"message"` } type contentBlock struct { Type string `json:"type"` Text string `json:"text"` Name string `json:"name"` Input json.RawMessage `json:"input"` } // Translate parses one stdout line. Unknown or partial event types translate // to nothing — the stream carries plenty a fleet board doesn't need, and an // unrecognised future event must never wedge a session. func Translate(line []byte) []Act { var sl streamLine if json.Unmarshal(line, &sl) != nil { return nil } switch sl.Type { case "system": if sl.Subtype == "init" && sl.SessionID != "" { return []Act{{Kind: "init", ClaudeSessionID: sl.SessionID}} } case "assistant": var acts []Act for _, b := range sl.Message.Content { if b.Type == "tool_use" && b.Name != "" { acts = append(acts, Act{Kind: "tool", Tool: toolLine(b)}) } } return acts case "result": text := strings.TrimSpace(sl.Result) if sl.IsError { if text == "" { text = "the turn failed" } return []Act{{Kind: "error", Text: text}} } if text != "" { return []Act{{Kind: "turn", Text: text}} } } return nil } // toolLine renders one tool_use block as the activity line members see. // Bash gets its command surfaced (that's the line a room actually wants to // read); everything else names the tool and, when short, its primary input. func toolLine(b contentBlock) string { var input map[string]any _ = json.Unmarshal(b.Input, &input) if b.Name == "Bash" { if cmd, _ := input["command"].(string); cmd != "" { return "Ran " + clampLine(cmd, 160) } } for _, k := range []string{"file_path", "path", "pattern", "url", "query", "description"} { if v, _ := input[k].(string); v != "" { return fmt.Sprintf("%s %s", b.Name, clampLine(v, 120)) } } return b.Name } // clampLine flattens newlines and truncates — activity lines are one line by // contract (the fold clamps to 200 chars; we stay under it). func clampLine(s string, max int) string { s = strings.Join(strings.Fields(s), " ") if len(s) > max { s = s[:max] + "…" } return s }