mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* fix(agent): fail codebuddy runs that emit a top-level error event
CodeBuddy's startup-failure shape is one top-level {"type":"error"}
line followed by a process that stays alive and silent: with an
inherited SERVER__PORT (a user-scope config shared by several tools),
a second concurrent run of the same agent fails to listen, prints
{"type":"error","error":"listen EADDRINUSE: ..."} and never emits
anything again. The parser had no branch for that event type, so the
run sat at "waiting for agent response" until the idle watchdog
stopped it - 19 of 48 runs hung this way on the reporter's machine.
- type=error is now fatal when no terminal result has been seen: the
CLI's own message becomes the run error and the hung process is torn
down immediately instead of idling. A result already seen keeps
governing the outcome, so a late informational error line cannot
flip a finished run.
- buildCodebuddyEnv now also forces SERVER__PORT=0 (OS-assigned port
per run, so concurrent runs never share one) and
CODEBUDDY_BASH_AUTO_BACKGROUND_DISABLED=1 (CodeBuddy's PowerShell
tool moves timed-out commands to the background and only honors that
variable). Both are appended last like the existing background-task
disable so a caller-provided value cannot outvote them.
Fixes #9200
Generated-by: GLM-5.3-Flash (ZCode)
* fix(agent): keep CodeBuddy running after recoverable errors
MUL-7983
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: Yushen <[email protected]>
Co-authored-by: multica-agent <[email protected]>
729 lines
25 KiB
Go
729 lines
25 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// codebuddyBackend implements Backend by spawning the CodeBuddy CLI
|
|
// (a Claude Code fork) with --output-format stream-json.
|
|
// It mirrors claude.go's execution model: concurrent stdin/stdout to
|
|
// avoid pipe deadlocks, open stdin for control_request auto-approval,
|
|
// and runContext for zero-timeout = no-deadline semantics.
|
|
type codebuddyBackend struct {
|
|
cfg Config
|
|
}
|
|
|
|
// codebuddyBlockedArgs are flags hardcoded by the daemon that must not be
|
|
// overridden by user-configured custom_args. Overriding these would break
|
|
// the daemon↔codebuddy communication protocol.
|
|
var codebuddyBlockedArgs = 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
|
|
"--mcp-config": blockedWithValue, // set by daemon from agent.mcp_config
|
|
// `--effort` is owned by the per-agent thinking_level picker so a
|
|
// user-supplied custom_arg cannot silently outvote it.
|
|
"--effort": blockedWithValue,
|
|
}
|
|
|
|
func buildCodebuddyArgs(opts ExecOptions, logger *slog.Logger) []string {
|
|
args := []string{
|
|
"-p",
|
|
"--output-format", "stream-json",
|
|
"--input-format", "stream-json",
|
|
"--verbose",
|
|
"--permission-mode", "bypassPermissions",
|
|
// CodeBuddy's interactive tools have no UI to render in under the
|
|
// daemon's headless stream-json transport. AskUserQuestion and
|
|
// ExitPlanMode are both exempted from CodeBuddy's permission-mode
|
|
// finalization, so --permission-mode bypassPermissions does NOT
|
|
// auto-approve them — they always reach the permission bridge and
|
|
// stall the turn waiting for a confirmation nobody can give
|
|
// (GitHub #6012). EnterPlanMode is denied alongside them: leaving it
|
|
// enabled would let the model enter a plan mode it then has no tool
|
|
// to leave. Plan-shaped work still happens — the plan is written as
|
|
// ordinary assistant output instead of behind an approval gate.
|
|
//
|
|
// Pass one value per tool: --disallowedTools is variadic and
|
|
// CodeBuddy compares each entry against the tool name exactly
|
|
// (PermissionUtils.matchPermissionRules), so a comma-joined string
|
|
// would match nothing despite what the CLI's own help text claims.
|
|
"--disallowedTools", "AskUserQuestion", "EnterPlanMode", "ExitPlanMode",
|
|
}
|
|
// NOTE: --strict-mcp-config is deliberately never passed. It means "only
|
|
// use servers from --mcp-config", which drops CodeBuddy's user, project
|
|
// AND local scopes. Measured against CodeBuddy 2.x with one real MCP
|
|
// server registered per scope, using process spawns as the oracle:
|
|
//
|
|
// --mcp-config only ....... managed + user + local <- what we want
|
|
// --mcp-config + strict ... managed only
|
|
// strict only ............. nothing at all
|
|
//
|
|
// The union is also what mergeRuntimeAndAgentMcpConfig promises: adding
|
|
// one managed server must not disable the user's own. CodeBuddy applies
|
|
// it natively, and a managed entry already wins a same-name collision
|
|
// (verified), so the daemon does not pre-merge — see the codebuddy note
|
|
// in loadRuntimeMcpServerConfigs.
|
|
if opts.Model != "" {
|
|
args = append(args, "--model", opts.Model)
|
|
}
|
|
if opts.ThinkingLevel != "" {
|
|
args = append(args, "--effort", opts.ThinkingLevel)
|
|
}
|
|
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.ExtraArgs, codebuddyBlockedArgs, logger)...)
|
|
args = append(args, filterCustomArgs(opts.CustomArgs, codebuddyBlockedArgs, logger)...)
|
|
return args
|
|
}
|
|
|
|
func (b *codebuddyBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
|
|
execPath := b.cfg.ExecutablePath
|
|
if execPath == "" {
|
|
execPath = "codebuddy"
|
|
}
|
|
if _, err := exec.LookPath(execPath); err != nil {
|
|
return nil, fmt.Errorf("codebuddy executable not found at %q: %w", execPath, err)
|
|
}
|
|
|
|
timeout := opts.Timeout
|
|
runCtx, cancel := runContext(ctx, timeout)
|
|
|
|
args := buildCodebuddyArgs(opts, b.cfg.Logger)
|
|
|
|
// A managed agent config is written to a temp file and added with
|
|
// --mcp-config, which ADDS to whatever CodeBuddy loads from its own user,
|
|
// project and local scopes — buildCodebuddyArgs never passes
|
|
// --strict-mcp-config, so those stay on. A managed entry wins a same-name
|
|
// collision natively.
|
|
//
|
|
// Three-state, as everywhere else: a JSON `null` means "inherit the runtime
|
|
// configuration" and skips the flag entirely, while an explicitly empty
|
|
// object is a managed set that happens to contain no servers.
|
|
var mcpConfigPath string
|
|
var mcpFileCleanup func()
|
|
if hasManagedMcpConfig(opts.McpConfig) {
|
|
path, err := writeMcpConfigToTemp(opts.McpConfig)
|
|
if err != nil {
|
|
cancel()
|
|
return nil, err
|
|
}
|
|
mcpConfigPath = path
|
|
mcpFileCleanup = func() { cleanupMcpConfigTemp(mcpConfigPath) }
|
|
args = append(args, "--mcp-config", mcpConfigPath)
|
|
}
|
|
// Clean up the temp file if we return before the goroutine takes ownership.
|
|
defer func() {
|
|
if mcpFileCleanup != nil {
|
|
mcpFileCleanup()
|
|
}
|
|
}()
|
|
|
|
cmd := b.cfg.commandAt(execPath).exec(runCtx, args...)
|
|
hideAgentWindow(cmd)
|
|
b.cfg.logAgentCommand(cmd, newAgentCommandLogArgs(args))
|
|
cmd.WaitDelay = 10 * time.Second
|
|
if opts.Cwd != "" {
|
|
cmd.Dir = opts.Cwd
|
|
}
|
|
// Multica closes stdin after the first result and cannot wait for
|
|
// cross-turn task_notification events. Force CodeBuddy's documented
|
|
// headless disable so Bash/PowerShell/Agent never take the background
|
|
// path (CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS; see CLI headless docs).
|
|
cmd.Env = buildCodebuddyEnv(b.cfg.Env)
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("codebuddy stdout pipe: %w", err)
|
|
}
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("codebuddy stdin pipe: %w", err)
|
|
}
|
|
var closeStdinOnce sync.Once
|
|
closeStdin := func() { closeStdinOnce.Do(func() { _ = stdin.Close() }) }
|
|
|
|
stderrBuf := newStderrTail(newLogWriter(b.cfg.Logger, "[codebuddy:stderr] "), agentStderrTailBytes)
|
|
cmd.Stderr = stderrBuf
|
|
|
|
if err := startOwnedProcessTree(cmd, b.cfg.Logger); err != nil {
|
|
closeStdin()
|
|
cancel()
|
|
return nil, fmt.Errorf("start codebuddy: %w", err)
|
|
}
|
|
|
|
b.cfg.Logger.Info("codebuddy started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
|
|
|
|
// cmd.Start() succeeded — transfer temp file ownership to the goroutine.
|
|
mcpFileCleanup = nil
|
|
|
|
msgCh := make(chan Message, 256)
|
|
resCh := make(chan Result, 1)
|
|
|
|
// Write the prompt in a dedicated goroutine to prevent deadlock.
|
|
// CodeBuddy (like Claude Code) emits a startup banner to stdout before
|
|
// reading stdin; a synchronous write would block once the pipe buffer
|
|
// fills. Keep stdin open after writing so control_request events can
|
|
// be answered mid-run.
|
|
writeDone := make(chan error, 1)
|
|
go func() {
|
|
err := writeCodebuddyInput(stdin, prompt)
|
|
if err != nil {
|
|
closeStdin()
|
|
}
|
|
writeDone <- err
|
|
}()
|
|
|
|
go func() {
|
|
defer cancel()
|
|
defer close(msgCh)
|
|
defer close(resCh)
|
|
if mcpConfigPath != "" {
|
|
defer cleanupMcpConfigTemp(mcpConfigPath)
|
|
}
|
|
|
|
startTime := time.Now()
|
|
var lastAssistantText string
|
|
var finalResultText string
|
|
sawResult := false
|
|
resultIsError := false
|
|
sawBackgroundTask := false
|
|
sawStartedEvent := false
|
|
topLevelError := ""
|
|
fatalStartupError := false
|
|
var sessionID string
|
|
usage := make(map[string]TokenUsage)
|
|
seenUsage := make(map[string]struct{})
|
|
eventCount := 0
|
|
invalidEventCount := 0
|
|
assistantEventCount := 0
|
|
toolUseCount := 0
|
|
unreadableAssistantCount := 0
|
|
|
|
// Close stdout when the context is cancelled so scanner.Scan() unblocks.
|
|
go func() {
|
|
<-runCtx.Done()
|
|
closeStdin()
|
|
_ = stdout.Close()
|
|
}()
|
|
|
|
scanner := newAgentStreamScanner(stdout)
|
|
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var msg codebuddySDKMessage
|
|
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
|
invalidEventCount++
|
|
continue
|
|
}
|
|
eventCount++
|
|
|
|
switch msg.Type {
|
|
case "assistant":
|
|
sawStartedEvent = true
|
|
assistantEventCount++
|
|
turn := b.handleAssistant(msg, msgCh, usage, seenUsage)
|
|
toolUseCount += turn.toolUses
|
|
if !turn.understood {
|
|
unreadableAssistantCount++
|
|
}
|
|
lastAssistantText = turn.resolveFallback(lastAssistantText)
|
|
case "user":
|
|
sawStartedEvent = true
|
|
b.handleUser(msg, msgCh)
|
|
case "system":
|
|
sawStartedEvent = true
|
|
if msg.SessionID != "" {
|
|
sessionID = msg.SessionID
|
|
}
|
|
// CodeBuddy background lifecycle rides on system subtypes
|
|
// (task_started / task_progress / task_updated /
|
|
// task_notification), not Claude's async_launched tool_result.
|
|
// With CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS these should
|
|
// never appear; if they do, fail rather than report success.
|
|
if codebuddySystemIsBackgroundTask(msg.Subtype) {
|
|
sawBackgroundTask = true
|
|
}
|
|
trySend(msgCh, Message{Type: MessageStatus, Status: "running", SessionID: sessionID})
|
|
case "result":
|
|
sawResult = true
|
|
finalResultText = msg.ResultText
|
|
resultIsError = msg.IsError
|
|
sessionID = msg.SessionID
|
|
if resultUsage := codebuddyResultUsage(msg, opts.Model); len(resultUsage) > 0 {
|
|
usage = resultUsage
|
|
}
|
|
closeStdin()
|
|
case "error":
|
|
// Startup failures can leave CodeBuddy alive and silent (issue
|
|
// #9200). Once a run has emitted system/assistant activity, the
|
|
// same event can be recoverable, so keep the process running.
|
|
if sawResult || fatalStartupError {
|
|
break
|
|
}
|
|
topLevelError = strings.TrimSpace(msg.Error)
|
|
if topLevelError == "" {
|
|
topLevelError = "codebuddy emitted a top-level error event without details"
|
|
}
|
|
if !sawStartedEvent && runCtx.Err() == nil {
|
|
fatalStartupError = true
|
|
// Cancel only before the run starts; this also releases the
|
|
// scanner and cmd.Wait when the CLI remains alive.
|
|
cancel()
|
|
} else {
|
|
trySend(msgCh, Message{Type: MessageLog, Level: "error", Content: topLevelError})
|
|
}
|
|
case "log":
|
|
if msg.Log != nil {
|
|
trySend(msgCh, Message{
|
|
Type: MessageLog,
|
|
Level: msg.Log.Level,
|
|
Content: msg.Log.Message,
|
|
})
|
|
}
|
|
case "control_request":
|
|
b.handleControlRequest(msg, stdin)
|
|
}
|
|
}
|
|
scanErr := scanner.Err()
|
|
if scanErr != nil {
|
|
// Stop a malformed/oversized writer from blocking cmd.Wait after the
|
|
// scanner has stopped consuming stdout.
|
|
_ = stdout.Close()
|
|
}
|
|
|
|
closeStdin()
|
|
|
|
// Wait for process exit.
|
|
exitErr := cmd.Wait()
|
|
releaseProcessGroup(cmd)
|
|
duration := time.Since(startTime)
|
|
// writeDone is buffered (cap 1) and the writer always sends — by the
|
|
// time cmd has exited, the prompt write has either succeeded, hit a
|
|
// broken pipe, or been unblocked by the kill that ended cmd.
|
|
writeErr := <-writeDone
|
|
|
|
completionGuardError := ""
|
|
if sawBackgroundTask {
|
|
completionGuardError = "codebuddy emitted a background task system event; Multica-managed runs require foreground execution (set CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS=1)"
|
|
}
|
|
finalStatus, finalOutput, finalError := finalizeStreamResult(
|
|
"codebuddy",
|
|
timeout,
|
|
runCtx.Err(),
|
|
writeErr,
|
|
exitErr,
|
|
sessionID,
|
|
streamTerminalState{
|
|
lastAssistantText: lastAssistantText,
|
|
finalResultText: finalResultText,
|
|
sawResult: sawResult,
|
|
resultIsError: resultIsError,
|
|
scanErr: scanErr,
|
|
},
|
|
completionGuardError,
|
|
)
|
|
// A startup error explains our own cancellation. After the run has
|
|
// started, use its last error only if it exited without a result;
|
|
// preserve an external cancellation or timeout otherwise.
|
|
if topLevelError != "" && !sawResult && (fatalStartupError || runCtx.Err() == nil) {
|
|
finalStatus = "failed"
|
|
finalOutput = ""
|
|
finalError = topLevelError
|
|
}
|
|
|
|
// cmd.Wait() has returned — stderrBuf.Tail() is complete. Resume
|
|
// rejection phrases often land only on stderr (mirror Claude MUL-4966).
|
|
stderrTail := stderrBuf.Tail()
|
|
if finalError != "" {
|
|
finalError = withAgentStderr(finalError, "codebuddy", stderrTail)
|
|
}
|
|
logStreamProtocolObservation(b.cfg.Logger, streamProtocolObservation{
|
|
provider: "codebuddy",
|
|
cliVersion: b.cfg.CLIVersion,
|
|
model: opts.Model,
|
|
exitCode: streamProcessExitCode(exitErr),
|
|
eventCount: eventCount,
|
|
invalidEventCount: invalidEventCount,
|
|
assistantEventCount: assistantEventCount,
|
|
toolUseCount: toolUseCount,
|
|
sawResult: sawResult,
|
|
resultIsError: resultIsError,
|
|
resultBytes: len(finalResultText),
|
|
lastAssistantBytes: len(lastAssistantText),
|
|
scannerError: scanErr != nil,
|
|
unreadableAssistantCount: unreadableAssistantCount,
|
|
anthropicBaseURLConfigured: strings.TrimSpace(b.cfg.Env["ANTHROPIC_BASE_URL"]) != "",
|
|
})
|
|
|
|
b.cfg.Logger.Info("codebuddy finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
|
|
|
|
resumeRejected := resumeWasRejected(opts.ResumeSessionID, sessionID, finalStatus == "failed", finalError, stderrTail)
|
|
reportedSessionID := resolveSessionID(opts.ResumeSessionID, sessionID, finalStatus == "failed", finalError, stderrTail)
|
|
if resumeRejected {
|
|
b.cfg.Logger.Info("codebuddy resume was rejected; dropping session id and signalling fresh-session retry",
|
|
"requested_resume", opts.ResumeSessionID,
|
|
"emitted_session", sessionID,
|
|
)
|
|
}
|
|
|
|
resCh <- Result{
|
|
Status: finalStatus,
|
|
Output: finalOutput,
|
|
Error: finalError,
|
|
DurationMs: duration.Milliseconds(),
|
|
SessionID: reportedSessionID,
|
|
Usage: usage,
|
|
ResumeRejected: resumeRejected,
|
|
}
|
|
}()
|
|
|
|
return &Session{Messages: msgCh, Result: resCh}, nil
|
|
}
|
|
|
|
func (b *codebuddyBackend) handleAssistant(msg codebuddySDKMessage, ch chan<- Message, usage map[string]TokenUsage, seenUsage map[string]struct{}) assistantTurn {
|
|
var content codebuddyMessageContent
|
|
if err := json.Unmarshal(msg.Message, &content); err != nil {
|
|
// Unreadable body: understood stays false so the caller drops any
|
|
// fallback rather than let an older turn stand in for this one.
|
|
return assistantTurn{}
|
|
}
|
|
turn := assistantTurn{understood: true}
|
|
var assistantText strings.Builder
|
|
toolUseCount := 0
|
|
|
|
// CodeBuddy is a Claude Code fork and emits the same stream-json shapes, so
|
|
// it inherits the same accounting rule: one response can emit several
|
|
// assistant blocks carrying the same message ID and the same usage. Count
|
|
// its input/cache tokens once, without dropping any of the blocks below.
|
|
// Missing IDs retain best-effort per-event accounting. This fallback covers
|
|
// only the main loop: assistant output_tokens is a placeholder, and subagent
|
|
// totals require the final result's modelUsage. Mirrors claude.go.
|
|
// https://code.claude.com/docs/en/agent-sdk/cost-tracking#track-per-step-usage
|
|
_, counted := seenUsage[content.ID]
|
|
if msg.ParentToolUseID == "" && content.Usage != nil && content.Model != "" &&
|
|
(content.ID == "" || !counted) && codebuddyUsageHasTokens(
|
|
content.Usage.InputTokens, 0, content.Usage.CacheReadInputTokens, content.Usage.CacheCreationInputTokens,
|
|
) {
|
|
if content.ID != "" {
|
|
seenUsage[content.ID] = struct{}{}
|
|
}
|
|
u := usage[content.Model]
|
|
u.InputTokens += content.Usage.InputTokens
|
|
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 != "" {
|
|
assistantText.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":
|
|
toolUseCount++
|
|
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,
|
|
})
|
|
default:
|
|
// A block type we do not render may be carrying the model's answer
|
|
// in a shape we cannot read, so we must not claim this turn was
|
|
// silent. Recognising a new no-text block is a deliberate one-line
|
|
// addition here, not an accident of falling through.
|
|
turn.understood = false
|
|
}
|
|
}
|
|
turn.text = assistantText.String()
|
|
turn.toolUses = toolUseCount
|
|
return turn
|
|
}
|
|
|
|
func (b *codebuddyBackend) handleUser(msg codebuddySDKMessage, ch chan<- Message) {
|
|
var content codebuddyMessageContent
|
|
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 *codebuddyBackend) handleControlRequest(msg codebuddySDKMessage, stdin interface{ Write([]byte) (int, error) }) {
|
|
// Auto-approve all tool uses in autonomous/daemon mode.
|
|
var req codebuddyControlRequestPayload
|
|
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{}
|
|
}
|
|
// Do not rewrite run_in_background here: under bypassPermissions most
|
|
// tools never emit control_request, and CodeBuddy does not use Claude's
|
|
// async_launched tool_result shape. Background work is disabled via
|
|
// CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS before tool execution.
|
|
|
|
response := map[string]any{
|
|
"type": "control_response",
|
|
"response": map[string]any{
|
|
"subtype": "success",
|
|
"request_id": msg.RequestID,
|
|
"response": map[string]any{
|
|
// CodeBuddy's SdkPermissionClient reads `allowed` and treats a
|
|
// missing key as a denial; `behavior` is Claude Code's spelling,
|
|
// which the fork still honours on its other permission paths.
|
|
// Send both so an approval is never read as a silent reject.
|
|
"allowed": true,
|
|
"behavior": "allow",
|
|
"updatedInput": inputMap,
|
|
},
|
|
},
|
|
}
|
|
|
|
data, err := json.Marshal(response)
|
|
if err != nil {
|
|
b.cfg.Logger.Warn("codebuddy: failed to marshal control response", "error", err)
|
|
return
|
|
}
|
|
data = append(data, '\n')
|
|
if _, err := stdin.Write(data); err != nil {
|
|
b.cfg.Logger.Warn("codebuddy: failed to write control response", "error", err)
|
|
}
|
|
}
|
|
|
|
func writeCodebuddyInput(w io.Writer, prompt string) 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 fmt.Errorf("marshal codebuddy input: %w", err)
|
|
}
|
|
data = append(data, '\n')
|
|
if _, err := w.Write(data); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const codebuddyDisableBackgroundTasksEnv = "CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS"
|
|
|
|
// CodeBuddy's PowerShell tool moves a command to the background when it hits
|
|
// its foreground timeout and only honors this variable — the headless
|
|
// background-task disable above does not stop it (issue #9200).
|
|
const codebuddyDisableBashAutoBackgroundEnv = "CODEBUDDY_BASH_AUTO_BACKGROUND_DISABLED"
|
|
|
|
// A user-scope SERVER__PORT (shared by several tools' configs) pins every
|
|
// codebuddy run's internal HTTP server to one port; a second concurrent run
|
|
// then dies with EADDRINUSE before doing any work (issue #9200). Port 0 lets
|
|
// the OS assign a free port per run.
|
|
const codebuddyInternalPortEnv = "SERVER__PORT"
|
|
|
|
// buildCodebuddyEnv merges task env and always forces background tasks off,
|
|
// a random internal HTTP port and the PowerShell auto-background disable.
|
|
// Multica's adapter closes stdin after the first result; CodeBuddy's
|
|
// background lifecycle can push task_notification after that point, which we
|
|
// cannot observe. The official CLI documents the disable variable for exactly
|
|
// that headless shape (https://www.codebuddy.ai/docs/cli/headless).
|
|
//
|
|
// Append the forced entries last so os/exec's platform-aware dedup
|
|
// (case-insensitive on Windows, last-wins) keeps them even when the
|
|
// inherited or custom_env key differs only by case.
|
|
func buildCodebuddyEnv(extra map[string]string) []string {
|
|
return append(buildEnv(extra),
|
|
codebuddyInternalPortEnv+"=0",
|
|
codebuddyDisableBashAutoBackgroundEnv+"=1",
|
|
codebuddyDisableBackgroundTasksEnv+"=1",
|
|
)
|
|
}
|
|
|
|
// codebuddySystemIsBackgroundTask reports CodeBuddy's real background-task
|
|
// system subtypes (not Claude's async_launched tool_result).
|
|
func codebuddySystemIsBackgroundTask(subtype string) bool {
|
|
switch strings.TrimSpace(subtype) {
|
|
case "task_started", "task_progress", "task_updated", "task_notification":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// ── Codebuddy SDK JSON types ──
|
|
|
|
type codebuddySDKMessage struct {
|
|
Type string `json:"type"`
|
|
Message json.RawMessage `json:"message,omitempty"`
|
|
Subtype string `json:"subtype,omitempty"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
Model string `json:"model,omitempty"`
|
|
ParentToolUseID string `json:"parent_tool_use_id,omitempty"`
|
|
|
|
// Top-level error events can be fatal during startup or recoverable once
|
|
// the run has begun; the message is inline rather than in a result event.
|
|
Error string `json:"error,omitempty"`
|
|
|
|
// result fields — CodeBuddy failures use is_error + errors/errors_info;
|
|
// there is no Claude-style terminal_reason=prompt_too_long signal in the
|
|
// shipped @tencent-ai/codebuddy-code 2.150.0 protocol.
|
|
ResultText string `json:"result,omitempty"`
|
|
IsError bool `json:"is_error,omitempty"`
|
|
DurationMs float64 `json:"duration_ms,omitempty"`
|
|
NumTurns int `json:"num_turns,omitempty"`
|
|
Usage *codebuddyUsage `json:"usage,omitempty"`
|
|
ModelUsage map[string]codebuddyResultModelUsage `json:"modelUsage,omitempty"`
|
|
|
|
// log fields
|
|
Log *codebuddyLogEntry `json:"log,omitempty"`
|
|
|
|
// control request fields
|
|
RequestID string `json:"request_id,omitempty"`
|
|
Request json.RawMessage `json:"request,omitempty"`
|
|
}
|
|
|
|
type codebuddyLogEntry struct {
|
|
Level string `json:"level"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type codebuddyMessageContent struct {
|
|
ID string `json:"id"`
|
|
Role string `json:"role"`
|
|
Model string `json:"model"`
|
|
Content []codebuddyContentBlock `json:"content"`
|
|
Usage *codebuddyUsage `json:"usage,omitempty"`
|
|
}
|
|
|
|
type codebuddyUsage 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 codebuddyResultModelUsage struct {
|
|
InputTokens int64 `json:"inputTokens"`
|
|
OutputTokens int64 `json:"outputTokens"`
|
|
CacheReadInputTokens int64 `json:"cacheReadInputTokens"`
|
|
CacheCreationInputTokens int64 `json:"cacheCreationInputTokens"`
|
|
}
|
|
|
|
type codebuddyContentBlock 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 codebuddyControlRequestPayload struct {
|
|
Subtype string `json:"subtype"`
|
|
ToolName string `json:"tool_name,omitempty"`
|
|
Input json.RawMessage `json:"input,omitempty"`
|
|
}
|
|
|
|
func codebuddyResultUsage(msg codebuddySDKMessage, fallbackModel string) map[string]TokenUsage {
|
|
if len(msg.ModelUsage) > 0 {
|
|
usage := make(map[string]TokenUsage, len(msg.ModelUsage))
|
|
for model, u := range msg.ModelUsage {
|
|
if model == "" || !codebuddyUsageHasTokens(u.InputTokens, u.OutputTokens, u.CacheReadInputTokens, u.CacheCreationInputTokens) {
|
|
continue
|
|
}
|
|
usage[model] = TokenUsage{
|
|
InputTokens: u.InputTokens,
|
|
OutputTokens: u.OutputTokens,
|
|
CacheReadTokens: u.CacheReadInputTokens,
|
|
CacheWriteTokens: u.CacheCreationInputTokens,
|
|
}
|
|
}
|
|
if len(usage) > 0 {
|
|
return usage
|
|
}
|
|
}
|
|
|
|
model := msg.Model
|
|
if model == "" {
|
|
model = fallbackModel
|
|
}
|
|
if msg.Usage == nil || model == "" || !codebuddyUsageHasTokens(
|
|
msg.Usage.InputTokens,
|
|
msg.Usage.OutputTokens,
|
|
msg.Usage.CacheReadInputTokens,
|
|
msg.Usage.CacheCreationInputTokens,
|
|
) {
|
|
return nil
|
|
}
|
|
return map[string]TokenUsage{
|
|
model: {
|
|
InputTokens: msg.Usage.InputTokens,
|
|
OutputTokens: msg.Usage.OutputTokens,
|
|
CacheReadTokens: msg.Usage.CacheReadInputTokens,
|
|
CacheWriteTokens: msg.Usage.CacheCreationInputTokens,
|
|
},
|
|
}
|
|
}
|
|
|
|
func codebuddyUsageHasTokens(input, output, cacheRead, cacheWrite int64) bool {
|
|
return input > 0 || output > 0 || cacheRead > 0 || cacheWrite > 0
|
|
}
|