eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package agents
2
3// The pure half of the bridge: one stream-json line from a headless Claude
4// Code session (`claude -p --input-format stream-json --output-format
5// stream-json --verbose`) in, zero or more Acts out. Kept free of I/O so the
6// translation is table-testable against fixture lines (translate_test.go) —
7// the same pure-logic-out-of-the-plumbing split as format.js/ui.js.
8
9import (
10 "encoding/json"
11 "fmt"
12 "strings"
13)
14
15// Act is one thing the bridge should do in response to a stream line.
16type Act struct {
17 Kind string // "init" | "turn" | "tool" | "error"
18 // init: the CLI's own session id (what `claude --resume` takes later).
19 ClaudeSessionID string
20 // turn: the assistant's final text for the completed turn; error: detail.
21 Text string
22 // tool: a one-line activity description ("Ran `go test ./...`").
23 Tool string
24}
25
26// streamLine is the envelope every stream-json stdout line shares.
27type streamLine struct {
28 Type string `json:"type"`
29 Subtype string `json:"subtype"`
30 SessionID string `json:"session_id"`
31 Result string `json:"result"`
32 IsError bool `json:"is_error"`
33 Message struct {
34 Content []contentBlock `json:"content"`
35 } `json:"message"`
36}
37
38type contentBlock struct {
39 Type string `json:"type"`
40 Text string `json:"text"`
41 Name string `json:"name"`
42 Input json.RawMessage `json:"input"`
43}
44
45// Translate parses one stdout line. Unknown or partial event types translate
46// to nothing — the stream carries plenty a fleet board doesn't need, and an
47// unrecognised future event must never wedge a session.
48func Translate(line []byte) []Act {
49 var sl streamLine
50 if json.Unmarshal(line, &sl) != nil {
51 return nil
52 }
53 switch sl.Type {
54 case "system":
55 if sl.Subtype == "init" && sl.SessionID != "" {
56 return []Act{{Kind: "init", ClaudeSessionID: sl.SessionID}}
57 }
58 case "assistant":
59 var acts []Act
60 for _, b := range sl.Message.Content {
61 if b.Type == "tool_use" && b.Name != "" {
62 acts = append(acts, Act{Kind: "tool", Tool: toolLine(b)})
63 }
64 }
65 return acts
66 case "result":
67 text := strings.TrimSpace(sl.Result)
68 if sl.IsError {
69 if text == "" {
70 text = "the turn failed"
71 }
72 return []Act{{Kind: "error", Text: text}}
73 }
74 if text != "" {
75 return []Act{{Kind: "turn", Text: text}}
76 }
77 }
78 return nil
79}
80
81// toolLine renders one tool_use block as the activity line members see.
82// Bash gets its command surfaced (that's the line a room actually wants to
83// read); everything else names the tool and, when short, its primary input.
84func toolLine(b contentBlock) string {
85 var input map[string]any
86 _ = json.Unmarshal(b.Input, &input)
87 if b.Name == "Bash" {
88 if cmd, _ := input["command"].(string); cmd != "" {
89 return "Ran " + clampLine(cmd, 160)
90 }
91 }
92 for _, k := range []string{"file_path", "path", "pattern", "url", "query", "description"} {
93 if v, _ := input[k].(string); v != "" {
94 return fmt.Sprintf("%s %s", b.Name, clampLine(v, 120))
95 }
96 }
97 return b.Name
98}
99
100// clampLine flattens newlines and truncates — activity lines are one line by
101// contract (the fold clamps to 200 chars; we stay under it).
102func clampLine(s string, max int) string {
103 s = strings.Join(strings.Fields(s), " ")
104 if len(s) > max {
105 s = s[:max] + "…"
106 }
107 return s
108}
109