package agent import ( "bufio" "context" "fmt" "io" "os/exec" "strings" "sync" "sync/atomic" "time" ) // traecliBlockedArgs are flags hardcoded by the daemon that must not be // overridden by user-configured custom_args. `acp` and `serve` are the // protocol subcommand/action; `-y`/`--yolo` is daemon-owned so headless ACP // always runs in bypass-permissions mode (the official traecli gates non-read // tools behind a permission prompt otherwise — see // https://docs.trae.cn/cli_permission-mode). `--print`/`-p` and // `--output-format` would switch the binary out of ACP into print mode and // break the daemon↔traecli transport, and `--permission-mode` is owned by the // daemon via --yolo. var traecliBlockedArgs = map[string]blockedArgMode{ "acp": blockedStandalone, "serve": blockedStandalone, "-y": blockedStandalone, "--yolo": blockedStandalone, "-p": blockedStandalone, "--print": blockedStandalone, "--output-format": blockedWithValue, "--permission-mode": blockedWithValue, } // traecliBackend implements Backend by spawning `traecli acp serve --yolo` and // communicating via the standard ACP (Agent Client Protocol) JSON-RPC 2.0 // transport over stdin/stdout. // // This targets ByteDance's official TRAE CLI (the `traecli` binary documented // at https://docs.trae.cn/cli — NOT the open-source bytedance/trae-agent // `trae-cli`, which has no ACP transport). traecli is ACP-native: it advertises // `loadSession: true` and `mcpCapabilities: {http, sse}` from `initialize`, // returns its model catalog from `session/new`, and supports `session/load` // (resume), `session/set_model`, and `session/prompt`. That lets the existing // Hermes/Kimi/Kiro/Qoder ACP client (hermesClient) drive it with only // provider-specific launch args and tool-name normalization. // // The `initialize` capabilities above were captured from the real traecli // v0.120.42 binary; the streaming gate mirrors the other ACP backends so // history replay flushed during session setup does not corrupt the streamed // output. type traecliBackend struct { cfg Config } var traecliReaderDrainGrace = 2 * time.Second // traecliMessageStream serializes sends and the final close so a late stdout // reader (traecli may keep the process and its pipes open briefly after // session/prompt returns) cannot send on a closed channel. Mirrors qoder. type traecliMessageStream struct { ch chan Message mu sync.Mutex closed bool } func newTraecliMessageStream(size int) *traecliMessageStream { return &traecliMessageStream{ch: make(chan Message, size)} } func (s *traecliMessageStream) send(msg Message) { s.mu.Lock() defer s.mu.Unlock() if s.closed { return } trySend(s.ch, msg) } func (s *traecliMessageStream) close() { s.mu.Lock() defer s.mu.Unlock() if s.closed { return } s.closed = true close(s.ch) } func (b *traecliBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) { execPath := b.cfg.ExecutablePath if execPath == "" { execPath = "traecli" } if _, err := exec.LookPath(execPath); err != nil { return nil, fmt.Errorf("traecli executable not found at %q: %w", execPath, err) } // Translate the agent's mcp_config (Claude-style object of objects) into // the array shape ACP session/new and session/load expect. Fail closed on // malformed JSON so the launch surfaces the real error instead of silently // dropping every MCP server. mcpServers, err := buildACPMcpServers(opts.McpConfig, b.cfg.Logger) if err != nil { return nil, fmt.Errorf("traecli: invalid mcp_config: %w", err) } timeout := opts.Timeout runCtx, cancel := runContext(ctx, timeout) traecliArgs := append( []string{"acp", "serve", "--yolo"}, filterCustomArgs(opts.CustomArgs, traecliBlockedArgs, b.cfg.Logger)..., ) cmd := exec.CommandContext(runCtx, execPath, traecliArgs...) hideAgentWindow(cmd) b.cfg.Logger.Info("agent command", "exec", execPath, "args", traecliArgs) if opts.Cwd != "" { cmd.Dir = opts.Cwd } cmd.Env = buildEnv(b.cfg.Env) stdout, err := cmd.StdoutPipe() if err != nil { cancel() return nil, fmt.Errorf("traecli stdout pipe: %w", err) } stdin, err := cmd.StdinPipe() if err != nil { cancel() return nil, fmt.Errorf("traecli stdin pipe: %w", err) } // StderrPipe + an explicit copier give us a join point (`stderrDone`) that // fires before the failure-promotion decision; see the matching comment in // hermes.go for why the io.MultiWriter form races with stopReason=end_turn // under load. providerErr := newACPProviderErrorSniffer("traecli") stderr, err := cmd.StderrPipe() if err != nil { cancel() return nil, fmt.Errorf("traecli stderr pipe: %w", err) } if err := cmd.Start(); err != nil { cancel() return nil, fmt.Errorf("start traecli: %w", err) } stderrSink := io.MultiWriter(newLogWriter(b.cfg.Logger, "[traecli:stderr] "), providerErr) stderrDone := make(chan struct{}) go func() { defer close(stderrDone) _, _ = io.Copy(stderrSink, stderr) }() b.cfg.Logger.Info("traecli acp started", "pid", cmd.Process.Pid, "cwd", opts.Cwd) msgStream := newTraecliMessageStream(256) resCh := make(chan Result, 1) var outputMu sync.Mutex var output strings.Builder var streamingCurrentTurn atomic.Bool promptDone := make(chan hermesPromptResult, 1) c := &hermesClient{ cfg: b.cfg, stdin: stdin, pending: make(map[int]*pendingRPC), pendingTools: make(map[string]*pendingToolCall), acceptNotification: func(string) bool { return streamingCurrentTurn.Load() }, onMessage: func(msg Message) { if !streamingCurrentTurn.Load() { return } if msg.Type == MessageToolUse { msg.Tool = kimiToolNameFromTitle(msg.Tool) } if msg.Type == MessageText { outputMu.Lock() output.WriteString(msg.Content) outputMu.Unlock() } msgStream.send(msg) }, onPromptDone: func(result hermesPromptResult) { if !streamingCurrentTurn.Load() { return } select { case promptDone <- result: default: } }, } readerDone := make(chan struct{}) go func() { defer close(readerDone) scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } c.handleLine(line) } c.closeAllPending(fmt.Errorf("traecli process exited")) }() go func() { defer cancel() defer msgStream.close() defer close(resCh) defer func() { stdin.Close() _ = cmd.Wait() }() startTime := time.Now() finalStatus := "completed" var finalError string var sessionID string effectiveModel := strings.TrimSpace(opts.Model) initResult, err := c.request(runCtx, "initialize", map[string]any{ "protocolVersion": 1, "clientInfo": map[string]any{ "name": "multica-agent-sdk", "version": "0.2.0", }, "clientCapabilities": map[string]any{}, }) if err != nil { finalStatus = "failed" finalError = fmt.Sprintf("traecli initialize failed: %v", err) resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()} return } // Drop MCP entries whose remote transport the runtime didn't advertise // (traecli advertises mcpCapabilities {http, sse}). See hermes.go for // why sending an unsupported transport tanks the whole session/new. mcpServers = filterACPMcpServersByCapability(mcpServers, extractACPMcpCapabilities(initResult), "traecli", b.cfg.Logger) cwd := opts.Cwd if cwd == "" { cwd = "." } if opts.ResumeSessionID != "" { // traecli advertises loadSession:true, so resume goes through the // standard ACP session/load (same as Kiro). result, err := c.request(runCtx, "session/load", map[string]any{ "cwd": cwd, "sessionId": opts.ResumeSessionID, "mcpServers": mcpServers, }) if err != nil { finalStatus = "failed" finalError = fmt.Sprintf("traecli session/load failed: %v", err) resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()} return } var changed bool sessionID, changed = resolveResumedSessionID(opts.ResumeSessionID, result) if changed { b.cfg.Logger.Warn("agent returned a different session id on resume — original was likely lost; continuing with the new id", "backend", "traecli", "requested", opts.ResumeSessionID, "actual", sessionID, ) } if effectiveModel == "" { effectiveModel = extractACPCurrentModelID(result) } } else { result, err := c.request(runCtx, "session/new", map[string]any{ "cwd": cwd, "mcpServers": mcpServers, }) if err != nil { finalStatus = "failed" finalError = fmt.Sprintf("traecli session/new failed: %v", err) resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()} return } sessionID = extractACPSessionID(result) if sessionID == "" { finalStatus = "failed" finalError = "traecli session/new returned no session ID" resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()} return } if effectiveModel == "" { effectiveModel = extractACPCurrentModelID(result) } } c.sessionID = sessionID b.cfg.Logger.Info("traecli session created", "session_id", sessionID) if opts.Model != "" { if _, err := c.request(runCtx, "session/set_model", map[string]any{ "sessionId": sessionID, "modelId": opts.Model, }); err != nil { b.cfg.Logger.Warn("traecli set_session_model failed", "error", err, "requested_model", opts.Model) finalStatus = "failed" finalError = fmt.Sprintf("traecli could not switch to model %q: %v", opts.Model, err) if opts.ResumeSessionID != "" && isACPSessionNotFound(err) { b.cfg.Logger.Warn("resumed session not found at set_model time; clearing session id so the daemon retries fresh", "backend", "traecli", "session_id", sessionID, ) sessionID = "" } resCh <- Result{ Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds(), SessionID: sessionID, } return } b.cfg.Logger.Info("traecli session model set", "model", opts.Model) } userText := prompt if opts.SystemPrompt != "" { userText = opts.SystemPrompt + "\n\n---\n\n" + prompt } streamingCurrentTurn.Store(true) _, err = c.request(runCtx, "session/prompt", map[string]any{ "sessionId": sessionID, "prompt": []map[string]any{ {"type": "text", "text": userText}, }, }) if err != nil { if runCtx.Err() == context.DeadlineExceeded { finalStatus = "timeout" finalError = fmt.Sprintf("traecli timed out after %s", timeout) } else if runCtx.Err() == context.Canceled { finalStatus = "aborted" finalError = "execution cancelled" } else { finalStatus = "failed" finalError = fmt.Sprintf("traecli session/prompt failed: %v", err) if opts.ResumeSessionID != "" && isACPSessionNotFound(err) { b.cfg.Logger.Warn("resumed session not found at prompt time; clearing session id so the daemon retries fresh", "backend", "traecli", "session_id", sessionID, ) sessionID = "" } } } else { select { case pr := <-promptDone: if pr.stopReason == "cancelled" { finalStatus = "aborted" finalError = "traecli cancelled the prompt" } c.usageMu.Lock() c.usage.InputTokens += pr.usage.InputTokens c.usage.OutputTokens += pr.usage.OutputTokens c.usage.CacheReadTokens += pr.usage.CacheReadTokens c.usageMu.Unlock() default: } } duration := time.Since(startTime) b.cfg.Logger.Info("traecli finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) stdin.Close() cancel() // traecli ACP may keep the process — and the stdout/stderr pipes — open // briefly after session/prompt returns. The prompt response is already // terminal, so bound the drain: wait for the stdout reader and the // stderr copier, but no longer than the grace window (CommandContext // cancellation tears the process down). Draining stderr is what makes // the provider-error promotion below see a terminal marker. drainCtx, drainCancel := context.WithTimeout(context.Background(), traecliReaderDrainGrace) select { case <-readerDone: case <-drainCtx.Done(): } select { case <-stderrDone: case <-drainCtx.Done(): } drainCancel() // Flip the gate before the defer closes msgStream; a late reader that // already passed the gate is serialized by traecliMessageStream so the // late send is dropped instead of panicking. streamingCurrentTurn.Store(false) outputMu.Lock() finalOutput := output.String() outputMu.Unlock() // Promote completed→failed when stderr or the agent text stream show a // terminal upstream-LLM failure (HTTP 4xx / rate-limit / expired token). // Mirrors hermes/kimi/kiro/qoder. finalStatus, finalError = promoteACPResultOnProviderError(finalStatus, finalError, finalOutput, providerErr) c.usageMu.Lock() u := c.usage c.usageMu.Unlock() var usageMap map[string]TokenUsage if u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadTokens > 0 || u.CacheWriteTokens > 0 { model := effectiveModel if model == "" { model = "unknown" } usageMap = map[string]TokenUsage{model: u} } resCh <- Result{ Status: finalStatus, Output: finalOutput, Error: finalError, DurationMs: duration.Milliseconds(), SessionID: sessionID, Usage: usageMap, } }() return &Session{Messages: msgStream.ch, Result: resCh}, nil }