Files
sanspie 5eb1832907
ci-platforms / darwin amd64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 15m28s
ci / release (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 7m47s
Complete Stage 12 for v0.12.0
2026-11-05 11:46:53 +03:00

302 lines
7.5 KiB
Go

package backend
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"os"
"os/exec"
"strings"
"sync"
"time"
)
// Claude runs the Claude Code CLI with stream-json in and out; only the aego MCP tools are available to it.
type Claude struct{}
func (Claude) Name() string {
return "claude"
}
func binary(cfg Config) string {
if cfg.Command != "" {
return cfg.Command
}
return "claude"
}
func (Claude) Available(cfg Config) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, binary(cfg), "--version").Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
func mcpConfig(cfg Config) (string, error) {
f, err := os.CreateTemp("", "aego-mcp-*.json")
if err != nil {
return "", err
}
defer f.Close()
conf := map[string]any{"mcpServers": map[string]any{"aego": map[string]any{
"type": "http", "url": cfg.MCPURL,
"headers": map[string]string{"Authorization": "Bearer " + cfg.Token},
}}}
if err := json.NewEncoder(f).Encode(conf); err != nil {
os.Remove(f.Name())
return "", err
}
return f.Name(), nil
}
// args turns off the built-in tools and every other MCP server, so the model reaches the project only through aego.
func args(cfg Config, conf string) []string {
out := []string{"-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose",
"--strict-mcp-config", "--mcp-config", conf, "--tools", "", "--allowedTools", "mcp__aego"}
if cfg.System != "" {
out = append(out, "--append-system-prompt", cfg.System)
}
if cfg.Model != "" {
out = append(out, "--model", cfg.Model)
}
return out
}
func (Claude) Start(cfg Config) (Conversation, error) {
conf, err := mcpConfig(cfg)
if err != nil {
return nil, err
}
cmd := exec.Command(binary(cfg), args(cfg, conf)...)
cmd.Dir = cfg.Dir
cmd.Env = append(os.Environ(), "MCP_TOOL_TIMEOUT=660000")
c := &claudeConv{cmd: cmd, conf: conf, events: make(chan Event, 64), done: make(chan struct{}), record: cfg.Record}
cmd.Stderr = &c.stderr
if c.in, err = cmd.StdinPipe(); err != nil {
os.Remove(conf)
return nil, err
}
out, err := cmd.StdoutPipe()
if err != nil {
os.Remove(conf)
return nil, err
}
if err := cmd.Start(); err != nil {
os.Remove(conf)
return nil, err
}
go c.read(out)
return c, nil
}
type claudeConv struct {
conn Connection
cmd *exec.Cmd
in io.WriteCloser
conf string
stderr bytes.Buffer
events chan Event
done chan struct{}
mu sync.Mutex
closed bool
record io.Writer
}
func (c *claudeConv) Send(text string) error { return c.SendImages(text, nil) }
func (c *claudeConv) SendImages(text string, pngs [][]byte) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return errors.New("the conversation is closed")
}
items := []any{map[string]string{"type": "text", "text": text}}
for _, img := range pngs {
items = append(items, map[string]any{"type": "image", "source": map[string]string{"type": "base64", "media_type": "image/png", "data": base64.StdEncoding.EncodeToString(img)}})
}
msg := map[string]any{"type": "user", "message": map[string]any{"role": "user",
"content": items}}
b, err := json.Marshal(msg)
if err != nil {
return err
}
_, err = c.in.Write(append(b, '\n'))
return err
}
func (c *claudeConv) read(r io.Reader) {
defer close(c.events)
defer os.Remove(c.conf)
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 1<<20), 16<<20)
read:
for sc.Scan() {
if c.record != nil {
c.record.Write(append(sc.Bytes(), '\n'))
}
c.status(sc.Bytes())
for _, e := range Decode(sc.Bytes()) {
if !c.emit(e) {
break read
}
}
}
if err := sc.Err(); err != nil {
_ = c.cmd.Process.Kill()
c.emit(Event{Kind: EvError, Err: err})
}
if err := c.cmd.Wait(); err != nil {
msg := strings.TrimSpace(c.stderr.String())
if msg == "" {
msg = err.Error()
}
c.emit(Event{Kind: EvError, Err: errors.New(msg)})
}
}
func (c *claudeConv) emit(e Event) bool {
select {
case c.events <- e:
return true
case <-c.done:
return false
}
}
func (c *claudeConv) Events() <-chan Event {
return c.events
}
func (c *claudeConv) Cancel() {
if c.cmd.Process != nil {
c.cmd.Process.Signal(os.Interrupt)
}
}
func (c *claudeConv) Close() error {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil
}
c.closed = true
close(c.done)
c.mu.Unlock()
c.in.Close()
// A replaced provider must not keep executing in the background. read owns Wait.
if c.cmd.Process != nil {
_ = c.cmd.Process.Kill()
}
os.Remove(c.conf)
return nil
}
type block struct {
Type string `json:"type"`
Text string `json:"text"`
ID string `json:"id"`
Name string `json:"name"`
Input json.RawMessage `json:"input"`
ToolUseID string `json:"tool_use_id"`
IsError bool `json:"is_error"`
Content json.RawMessage `json:"content"`
}
type usage struct {
Input int `json:"input_tokens"`
CacheCreate int `json:"cache_creation_input_tokens"`
CacheRead int `json:"cache_read_input_tokens"`
Output int `json:"output_tokens"`
}
type streamLine struct {
Type string `json:"type"`
Subtype string `json:"subtype"`
Model string `json:"model"`
IsError bool `json:"is_error"`
Result string `json:"result"`
Cost float64 `json:"total_cost_usd"`
Millis int `json:"duration_ms"`
Message struct {
Content []block `json:"content"`
Usage *usage `json:"usage"`
Model string `json:"model"`
} `json:"message"`
ModelUsage map[string]struct {
Window int `json:"contextWindow"`
} `json:"modelUsage"`
Compact struct {
PreTokens int `json:"pre_tokens"`
} `json:"compact_metadata"`
// ParentToolUseID is set on the lines of a subagent; their usage is not the main context.
ParentToolUseID string `json:"parent_tool_use_id"`
}
func textOf(raw json.RawMessage) string {
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
var parts []struct {
Text string `json:"text"`
}
json.Unmarshal(raw, &parts)
var b strings.Builder
for _, p := range parts {
b.WriteString(p.Text)
}
return b.String()
}
// Decode turns one stream-json line of Claude Code into events; lines it does not know give none.
func Decode(line []byte) []Event {
var l streamLine
if err := json.Unmarshal(line, &l); err != nil {
return nil
}
var out []Event
switch l.Type {
case "system":
switch l.Subtype {
case "init":
out = append(out, Event{Kind: EvUsage, Model: l.Model})
case "compact_boundary":
out = append(out, Event{Kind: EvCompact, Tokens: l.Compact.PreTokens})
}
case "assistant":
if u := l.Message.Usage; u != nil && l.ParentToolUseID == "" {
out = append(out, Event{Kind: EvUsage, Tokens: u.Input + u.CacheCreate + u.CacheRead, Model: l.Message.Model})
}
for _, b := range l.Message.Content {
switch b.Type {
case "text":
out = append(out, Event{Kind: EvText, Text: b.Text})
case "tool_use":
out = append(out, Event{Kind: EvToolStart, Tool: strings.TrimPrefix(b.Name, ToolPrefix), CallID: b.ID, Args: b.Input})
}
}
case "user":
for _, b := range l.Message.Content {
if b.Type == "tool_result" {
out = append(out, Event{Kind: EvToolResult, CallID: b.ToolUseID, Result: textOf(b.Content), Images: toolImages(b.Content), Failed: b.IsError})
}
}
case "result":
if l.IsError {
out = append(out, Event{Kind: EvError, Err: errors.New(l.Result)})
}
end := Event{Kind: EvTurnEnd, Cost: l.Cost, Millis: l.Millis}
for _, m := range l.ModelUsage {
end.Window = max(end.Window, m.Window)
}
out = append(out, end)
}
return out
}