package agent import ( "bufio" "context" "encoding/json" "fmt" "io" "log/slog" "os" "os/exec" "strings" "time" ) // claudeBackend implements Backend by spawning the Claude Code CLI // with --output-format stream-json. type claudeBackend struct { cfg Config } func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) { execPath := b.cfg.ExecutablePath if execPath == "" { execPath = "claude" } if _, err := exec.LookPath(execPath); err != nil { return nil, fmt.Errorf("claude executable not found at %q: %w", execPath, err) } timeout := opts.Timeout if timeout == 0 { timeout = 20 * time.Minute } runCtx, cancel := context.WithTimeout(ctx, timeout) args := buildClaudeArgs(opts, b.cfg.Logger) cmd := exec.CommandContext(runCtx, execPath, args...) b.cfg.Logger.Debug("agent command", "exec", execPath, "args", args) cmd.WaitDelay = 10 * time.Second 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("claude stdout pipe: %w", err) } stdin, err := cmd.StdinPipe() if err != nil { cancel() return nil, fmt.Errorf("claude stdin pipe: %w", err) } closeStdin := func() { if stdin != nil { _ = stdin.Close() stdin = nil } } cmd.Stderr = newLogWriter(b.cfg.Logger, "[claude:stderr] ") if err := cmd.Start(); err != nil { closeStdin() cancel() return nil, fmt.Errorf("start claude: %w", err) } if err := writeClaudeInput(stdin, prompt); err != nil { closeStdin() cancel() _ = cmd.Wait() return nil, fmt.Errorf("write claude input: %w", err) } closeStdin() b.cfg.Logger.Info("claude started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model) msgCh := make(chan Message, 256) resCh := make(chan Result, 1) go func() { defer cancel() defer close(msgCh) defer close(resCh) startTime := time.Now() var output strings.Builder var sessionID string finalStatus := "completed" var finalError string usage := make(map[string]TokenUsage) // Close stdout when the context is cancelled so scanner.Scan() unblocks. go func() { <-runCtx.Done() _ = stdout.Close() }() 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 } var msg claudeSDKMessage if err := json.Unmarshal([]byte(line), &msg); err != nil { continue } switch msg.Type { case "assistant": b.handleAssistant(msg, msgCh, &output, usage) case "user": b.handleUser(msg, msgCh) case "system": if msg.SessionID != "" { sessionID = msg.SessionID } trySend(msgCh, Message{Type: MessageStatus, Status: "running"}) case "result": closeStdin() sessionID = msg.SessionID if msg.ResultText != "" { output.Reset() output.WriteString(msg.ResultText) } if msg.IsError { finalStatus = "failed" finalError = msg.ResultText } case "log": if msg.Log != nil { trySend(msgCh, Message{ Type: MessageLog, Level: msg.Log.Level, Content: msg.Log.Message, }) } } } // Wait for process exit exitErr := cmd.Wait() duration := time.Since(startTime) if runCtx.Err() == context.DeadlineExceeded { finalStatus = "timeout" finalError = fmt.Sprintf("claude timed out after %s", timeout) } else if runCtx.Err() == context.Canceled { finalStatus = "aborted" finalError = "execution cancelled" } else if exitErr != nil && finalStatus == "completed" { finalStatus = "failed" finalError = fmt.Sprintf("claude exited with error: %v", exitErr) } b.cfg.Logger.Info("claude finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) resCh <- Result{ Status: finalStatus, Output: output.String(), Error: finalError, DurationMs: duration.Milliseconds(), SessionID: sessionID, Usage: usage, } }() return &Session{Messages: msgCh, Result: resCh}, nil } func (b *claudeBackend) handleAssistant(msg claudeSDKMessage, ch chan<- Message, output *strings.Builder, usage map[string]TokenUsage) { var content claudeMessageContent if err := json.Unmarshal(msg.Message, &content); err != nil { return } // Accumulate token usage per model. if content.Usage != nil && content.Model != "" { u := usage[content.Model] u.InputTokens += content.Usage.InputTokens u.OutputTokens += content.Usage.OutputTokens u.CacheReadTokens += content.Usage.CacheReadInputTokens u.CacheWriteTokens += content.Usage.CacheCreationInputTokens usage[content.Model] = u } for _, block := range content.Content { switch block.Type { case "text": if block.Text != "" { output.WriteString(block.Text) trySend(ch, Message{Type: MessageText, Content: block.Text}) } case "thinking": if block.Text != "" { trySend(ch, Message{Type: MessageThinking, Content: block.Text}) } case "tool_use": var input map[string]any if block.Input != nil { _ = json.Unmarshal(block.Input, &input) } trySend(ch, Message{ Type: MessageToolUse, Tool: block.Name, CallID: block.ID, Input: input, }) } } } func (b *claudeBackend) handleUser(msg claudeSDKMessage, ch chan<- Message) { var content claudeMessageContent if err := json.Unmarshal(msg.Message, &content); err != nil { return } for _, block := range content.Content { if block.Type == "tool_result" { resultStr := "" if block.Content != nil { resultStr = string(block.Content) } trySend(ch, Message{ Type: MessageToolResult, CallID: block.ToolUseID, Output: resultStr, }) } } } func (b *claudeBackend) handleControlRequest(msg claudeSDKMessage, stdin interface{ Write([]byte) (int, error) }) { // Auto-approve all tool uses in autonomous/daemon mode. var req claudeControlRequestPayload if err := json.Unmarshal(msg.Request, &req); err != nil { return } var inputMap map[string]any if req.Input != nil { _ = json.Unmarshal(req.Input, &inputMap) } if inputMap == nil { inputMap = map[string]any{} } response := map[string]any{ "type": "control_response", "response": map[string]any{ "subtype": "success", "request_id": msg.RequestID, "response": map[string]any{ "behavior": "allow", "updatedInput": inputMap, }, }, } data, err := json.Marshal(response) if err != nil { b.cfg.Logger.Warn("claude: failed to marshal control response", "error", err) return } data = append(data, '\n') if _, err := stdin.Write(data); err != nil { b.cfg.Logger.Warn("claude: failed to write control response", "error", err) } } // ── Claude SDK JSON types ── type claudeSDKMessage struct { Type string `json:"type"` Message json.RawMessage `json:"message,omitempty"` Subtype string `json:"subtype,omitempty"` SessionID string `json:"session_id,omitempty"` // result fields ResultText string `json:"result,omitempty"` IsError bool `json:"is_error,omitempty"` DurationMs float64 `json:"duration_ms,omitempty"` NumTurns int `json:"num_turns,omitempty"` // log fields Log *claudeLogEntry `json:"log,omitempty"` // control request fields RequestID string `json:"request_id,omitempty"` Request json.RawMessage `json:"request,omitempty"` } type claudeLogEntry struct { Level string `json:"level"` Message string `json:"message"` } type claudeMessageContent struct { Role string `json:"role"` Model string `json:"model"` Content []claudeContentBlock `json:"content"` Usage *claudeUsage `json:"usage,omitempty"` } type claudeUsage struct { InputTokens int64 `json:"input_tokens"` OutputTokens int64 `json:"output_tokens"` CacheReadInputTokens int64 `json:"cache_read_input_tokens"` CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"` } type claudeContentBlock struct { Type string `json:"type"` Text string `json:"text,omitempty"` ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Input json.RawMessage `json:"input,omitempty"` ToolUseID string `json:"tool_use_id,omitempty"` Content json.RawMessage `json:"content,omitempty"` } type claudeControlRequestPayload struct { Subtype string `json:"subtype"` ToolName string `json:"tool_name,omitempty"` Input json.RawMessage `json:"input,omitempty"` } // ── Shared helpers ── func trySend(ch chan<- Message, msg Message) { select { case ch <- msg: default: // Channel full — drop message. Final output is accumulated separately // in Result.Output, so only streaming consumers are affected. } } // claudeBlockedArgs are flags hardcoded by the daemon that must not be // overridden by user-configured custom_args. Overriding these would break // the daemon↔Claude communication protocol. var claudeBlockedArgs = map[string]blockedArgMode{ "-p": blockedStandalone, // non-interactive mode "--output-format": blockedWithValue, // stream-json protocol "--input-format": blockedWithValue, // stream-json protocol "--permission-mode": blockedWithValue, // bypassPermissions for autonomous operation } func buildClaudeArgs(opts ExecOptions, logger *slog.Logger) []string { args := []string{ "-p", "--output-format", "stream-json", "--input-format", "stream-json", "--verbose", "--strict-mcp-config", "--permission-mode", "bypassPermissions", } if opts.Model != "" { args = append(args, "--model", opts.Model) } if opts.MaxTurns > 0 { args = append(args, "--max-turns", fmt.Sprintf("%d", opts.MaxTurns)) } if opts.SystemPrompt != "" { args = append(args, "--append-system-prompt", opts.SystemPrompt) } if opts.ResumeSessionID != "" { args = append(args, "--resume", opts.ResumeSessionID) } args = append(args, filterCustomArgs(opts.CustomArgs, claudeBlockedArgs, logger)...) return args } func writeClaudeInput(w io.Writer, prompt string) error { data, err := buildClaudeInput(prompt) if err != nil { return err } if _, err := w.Write(data); err != nil { return err } return nil } func buildClaudeInput(prompt string) ([]byte, error) { payload := map[string]any{ "type": "user", "message": map[string]any{ "role": "user", "content": []map[string]string{ { "type": "text", "text": prompt, }, }, }, } data, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("marshal claude input: %w", err) } return append(data, '\n'), nil } func buildEnv(extra map[string]string) []string { return mergeEnv(os.Environ(), extra) } func mergeEnv(base []string, extra map[string]string) []string { env := make([]string, 0, len(base)+len(extra)) for _, entry := range base { key, _, _ := strings.Cut(entry, "=") if isFilteredChildEnvKey(key) { continue } env = append(env, entry) } for k, v := range extra { env = append(env, k+"="+v) } return env } func isFilteredChildEnvKey(key string) bool { return key == "CLAUDECODE" || strings.HasPrefix(key, "CLAUDECODE_") || strings.HasPrefix(key, "CLAUDE_CODE_") } // blockedArgMode specifies whether a blocked arg takes a value or is standalone. type blockedArgMode int const ( blockedWithValue blockedArgMode = iota // flag takes a value (next arg or =value) blockedStandalone // flag is boolean, no value ) // filterCustomArgs removes protocol-critical flags from user-configured custom // args to prevent breaking daemon↔agent communication. Each backend defines its // own blocked set (the flags it hardcodes). This is intentionally narrow — we // only block args that would break the communication protocol, not every // possible dangerous flag. Workspace members are trusted to configure agents // sensibly, same as with custom_env. func filterCustomArgs(args []string, blocked map[string]blockedArgMode, logger *slog.Logger) []string { if len(args) == 0 { return args } filtered := make([]string, 0, len(args)) skip := false for _, arg := range args { if skip { skip = false continue } // Check if this arg is a blocked flag or starts with "blockedFlag=". flag := arg hasInlineValue := false if idx := strings.Index(arg, "="); idx > 0 { flag = arg[:idx] hasInlineValue = true } mode, isBlocked := blocked[flag] if isBlocked { logger.Warn("custom_args: blocked protocol-critical flag, skipping", "flag", flag) if mode == blockedWithValue && !hasInlineValue { // The next arg is the value for this flag — skip it too. skip = true } continue } filtered = append(filtered, arg) } return filtered } func detectCLIVersion(ctx context.Context, execPath string) (string, error) { cmd := exec.CommandContext(ctx, execPath, "--version") data, err := cmd.Output() if err != nil { return "", fmt.Errorf("detect version for %s: %w", execPath, err) } return strings.TrimSpace(string(data)), nil } // logWriter adapts a *slog.Logger to an io.Writer for capturing stderr. type logWriter struct { logger *slog.Logger prefix string } func newLogWriter(logger *slog.Logger, prefix string) *logWriter { return &logWriter{logger: logger, prefix: prefix} } func (w *logWriter) Write(p []byte) (int, error) { text := strings.TrimSpace(string(p)) if text != "" { w.logger.Debug(w.prefix + text) } return len(p), nil }