mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 02:16:45 +08:00
* fix(daemon): treat custom runtime fixed_args as the argv prefix (MUL-6260) A custom runtime profile configured as `ccms start q36` failed every task with `unknown option '-p'`. The profile's fixed_args were merged into ExecOptions.ExtraArgs, which each backend appends *after* the protocol flags it hardcodes, so the wrapper saw `-p` before its own `start` subcommand had been selected and rejected it (GH #7046). Ordering was only half the defect. ExtraArgs is opt-in — six of the twenty-one backends read it — so on the other fifteen families, including every ACP one, fixed_args were silently discarded rather than misordered. Model discovery and `--version` probing carried the profile's path but not its arguments, so even a fixed launch would have left the model picker and the reported version describing the wrapper instead of the CLI behind it. fixed_args now travel as agent.Config.LaunchPrefix and are spliced in directly after the executable, ahead of everything a backend builds. The prefix is applied by a new Command boundary in launch.go through which every runtime process in the package is constructed — launches, version probes and discovery alike — so a backend cannot forget it. A guard test fails the build if any file outside launch.go calls os/exec directly; distributed opt-in is what let ExtraArgs rot, and repeating it would rot the same way. Flag-style commands (`agent --model composer-2.5`) are unaffected: a CLI takes its own flags in any position, which is why prefix-first can be the single order rather than a per-runtime setting. One deliberate behaviour change. The prefix used to sit last and win every last-wins parse, so a profile pinning `--model` silently overrode the model chosen on the agent — the UI showed one model and ran another. Prefix-first inverts that: the more specific per-agent choice is the later token and wins. Overlaps are logged. Protocol-critical flags are filtered out of the prefix once in New(), which knows the family; positional tokens always pass through. Codex additionally strips `-c` overrides aimed at daemon-managed config namespaces, since those beat config.toml from any argv position. The order was never documented, and every shipped fixed_args example is flag-style, so this fills in a contract rather than breaking one. Co-authored-by: multica-agent <[email protected]> * fix(daemon): close the launch-prefix gaps found in review (MUL-6260) Four defects from the first round of review on the fixed_args-as-argv-prefix change. Hermes could escape its own task overlay. The profile selection was resolved from custom_args alone and stripped from custom_args alone, so a profile configured as `hermes -p research` kept that flag in the launch prefix and re-pointed HERMES_HOME past the overlay the daemon had just built. Resolution now parses the argv hermes actually sees — launch prefix then custom args, in process order — and the strip covers both regions. It also removes every selection rather than the first: taking out the first promotes the second, and two separately-configured argv regions make "two selections" an ordinary configuration rather than a user mistake. Positional tokens were not passing through as documented. filterLaunchPrefix delegated to filterCustomArgs, whose blocklists deliberately carry positional tokens — hermes and qwenpaw block `acp`, traecli blocks `acp`/`serve`, grok blocks `agent`/`stdio`/`serve` — because in custom_args those are someone re-issuing the backend's own subcommand. In a launch prefix the same token is the command's identity, so `hermes-wrapper acp tenant` was being rewritten to `tenant`. Only a leading `-` now makes a token eligible for the blocklist; a blocked flag still consumes its separate value so no stray value survives to be read as a subcommand. Codex fast mode lost to the prefix. `--disable fast_mode` beats `--enable fast_mode` regardless of argv order and `-c features.fast_mode=false` beats config.toml from any position, so prefix-first does not protect an explicitly selected priority tier. The removal half of enforceCodexFastMode is now a separate helper applied to the prefix too; the managed enable is still appended once, on the final args. Windows CI could not compile the daemon tests: the Command migration left an unused `path := runtimeCmd.Path` in a hook whose body never referenced it. Verified with GOOS=windows and GOOS=linux vet, which is what should have caught it the first time. Also softens the docs and the Command doc comment: flag-style prefixes usually parse the same in either position, but a CLI that separates global from subcommand flags can care, so the runtime docs now name the position change and tell operators with existing fixed_args to re-run one task after upgrading instead of promising the move is invisible. Co-authored-by: multica-agent <[email protected]> * fix(daemon): resolve and strip the Hermes profile from the real launch argv (MUL-6260) Second-round review found the Hermes profile chain still reasoning about a hand-assembled approximation of the command line instead of the command line itself. The backend launches `<prefix> acp <filtered custom args>`, but the daemon assembled `<prefix> <custom args>` — no `acp`, no filtering — and that token participates in Hermes' own argv scan. With fixed_args `--model` and custom_args `-p research`, the approximation reads `-p` as `--model`'s value and finds no selection, so the overlay is seeded from the default home; the real `--model acp -p research` skips `--model acp` and selects `research`. The selector was then stripped, so the task stayed inside the overlay — but an overlay built from the wrong profile, silently. Stripping had the matching defect, and this one was still an escape rather than a mismatch. A launch prefix ending in a bare `-p` captures the backend's `acp` as its profile value, so neither region holds a complete selection and per-region stripping removes nothing: `-p acp research --yolo` reaches Hermes intact and re-points HERMES_HOME straight out of the overlay. Both halves now go through one assembly. agent.HermesLaunchArgv builds exactly what hermesBackend.Execute builds — the backend calls the same helper, so they cannot drift — and the daemon resolves from it. StripHermesProfileSelectors strips against that same argv and hands surviving tokens back to the region they came from, iterating to a fixed point because removing one selection promotes the next. The `acp` token is never removed; the backend re-adds it, and dropping the flag that captured it is what breaks the selection. StripAllHermesProfileArgs, hermesLaunchArgs and hermesEffectiveArgs are gone: each was only ever correct on a complete argv, and keeping them would leave the same footgun for the next caller. Tests cover the case the review named (prefix ending in a value-taking flag, custom args opening with a selector), the straddling-selection escape, multiple occurrences across both regions, and an invalid value left untouched because Hermes discards it anyway. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]>
607 lines
19 KiB
Go
607 lines
19 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.Logger.Info("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("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 := cmd.Start(); 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
|
|
var sessionID string
|
|
usage := make(map[string]TokenUsage)
|
|
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":
|
|
assistantEventCount++
|
|
turn := b.handleAssistant(msg, msgCh, usage)
|
|
toolUseCount += turn.toolUses
|
|
if !turn.understood {
|
|
unreadableAssistantCount++
|
|
}
|
|
lastAssistantText = turn.resolveFallback(lastAssistantText)
|
|
case "user":
|
|
b.handleUser(msg, msgCh)
|
|
case "system":
|
|
if msg.SessionID != "" {
|
|
sessionID = msg.SessionID
|
|
}
|
|
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 "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()
|
|
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
|
|
|
|
finalStatus, finalOutput, finalError := finalizeStreamResult(
|
|
"codebuddy",
|
|
timeout,
|
|
runCtx.Err(),
|
|
writeErr,
|
|
exitErr,
|
|
sessionID,
|
|
streamTerminalState{
|
|
lastAssistantText: lastAssistantText,
|
|
finalResultText: finalResultText,
|
|
sawResult: sawResult,
|
|
resultIsError: resultIsError,
|
|
scanErr: scanErr,
|
|
},
|
|
"",
|
|
)
|
|
|
|
if finalError != "" {
|
|
finalError = withAgentStderr(finalError, "codebuddy", stderrBuf.Tail())
|
|
}
|
|
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)
|
|
reportedSessionID := resolveSessionID(opts.ResumeSessionID, sessionID, finalStatus == "failed", finalError)
|
|
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) 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
|
|
|
|
// 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 != "" {
|
|
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{}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// ── 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"`
|
|
|
|
// 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"`
|
|
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 {
|
|
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
|
|
}
|