mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
OpenCode 2.0 changed four things the daemon depends on. Two fail loudly — `--dir` and `--variant` both exit 1 on an unrecognized flag, which is why every task on a 2.x runtime died in ~130ms with empty output (GH #8586). Two are silent: MCP config had no working channel left, and `opencode run` became a thin client in front of a resident service, so signalling the client's process group no longer stopped the run. - `--dir` is dropped on 2.x; the workdir comes from cmd.Dir + PWD. - The thinking level folds into the model string as `provider/model#variant`. - Cancellation calls POST /api/session/<id>/interrupt in front of the existing SIGTERM→SIGKILL, which is the only thing that stops a 2.x run. - Runs carrying Multica-managed MCP servers are refused with an error naming every source, rather than started without them: 2.x accepts MCP only through a file in the task workdir, where the agent's own commits would capture the credentials. Restoring MCP is tracked separately. The 1.x path is unchanged, pinned by argv and MCP regression tests. Verified against @opencode/cli 2.0.10 and opencode-ai 1.18.31 on macOS/arm64; Windows cancellation is not yet verified end to end.
837 lines
34 KiB
Go
837 lines
34 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// opencodeTerminateGraceNanos optionally overrides, in nanoseconds, how long a
|
|
// cancelled opencode process is given to exit after SIGTERM before it (and its
|
|
// whole process group) is SIGKILLed. Zero means use the default. It is atomic
|
|
// so tests can shorten the grace without racing the cancellation goroutine that
|
|
// reads it. See the cancellation handler in Execute for why termination must
|
|
// precede closing the stdout pipe (#4533).
|
|
var opencodeTerminateGraceNanos atomic.Int64
|
|
|
|
func opencodeTerminateGrace() time.Duration {
|
|
if n := opencodeTerminateGraceNanos.Load(); n > 0 {
|
|
return time.Duration(n)
|
|
}
|
|
return 5 * time.Second
|
|
}
|
|
|
|
// opencodeBlockedArgs are flags hardcoded by the daemon that must not be
|
|
// overridden by user-configured custom_args.
|
|
var opencodeBlockedArgs = map[string]blockedArgMode{
|
|
"--format": blockedWithValue, // json output format for daemon communication
|
|
"--dir": blockedWithValue, // task workdir anchor for skill / AGENTS.md discovery
|
|
"--variant": blockedWithValue, // owned by agent.thinking_level
|
|
"--dangerously-skip-permissions": blockedStandalone, // daemon manages non-interactive permission prompts
|
|
}
|
|
|
|
// opencodeBackend implements Backend by spawning `opencode run --format json`
|
|
// and reading streaming JSON events from stdout — the same pattern as Claude.
|
|
type opencodeBackend struct {
|
|
cfg Config
|
|
// session is per-run state, set by Execute on the copy it scans with, so
|
|
// the cancellation handler can interrupt the session server-side. It is nil
|
|
// on the Backend New returns and on any backend built directly by a test.
|
|
session *opencodeSessionTracker
|
|
}
|
|
|
|
// opencodeSeparatesReasoning recognizes the released OpenCode 1.x wire contract.
|
|
// v1.3.15 included reasoning in output; v1.3.16 separated it in upstream #21047:
|
|
// https://github.com/anomalyco/opencode/pull/21047
|
|
// Use the daemon's already-resolved version, never an extra per-run probe.
|
|
// Unknown/dev versions and custom commands keep the existing output count:
|
|
// their version string does not establish which usage convention they speak.
|
|
func opencodeSeparatesReasoning(cfg Config) bool {
|
|
if !cfg.BuiltinRuntime {
|
|
return false
|
|
}
|
|
raw := strings.TrimSpace(cfg.CLIVersion)
|
|
if raw == "" || versionRe.FindString(raw) != raw {
|
|
return false
|
|
}
|
|
version, err := parseSemver(raw)
|
|
return err == nil && version.Major == 1 && !version.lessThan(semver{Major: 1, Minor: 3, Patch: 16})
|
|
}
|
|
|
|
func (b *opencodeBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
|
|
execPath := b.cfg.ExecutablePath
|
|
if execPath == "" {
|
|
execPath = "opencode"
|
|
}
|
|
resolved, err := exec.LookPath(execPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("opencode executable not found at %q: %w", execPath, err)
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
if native := resolveOpenCodeNativeFromShim(resolved, os.Stat); native != "" {
|
|
b.cfg.Logger.Info("opencode resolved to native binary to avoid .cmd shim argv truncation", "shim", resolved, "native", native)
|
|
resolved = native
|
|
}
|
|
}
|
|
execPath = resolved
|
|
|
|
timeout := opts.Timeout
|
|
runCtx, cancel := runContext(ctx, timeout)
|
|
|
|
// See opencode_v2.go for what 2.x changed and why the differences are
|
|
// handled together rather than one flag at a time.
|
|
usesV2 := opencodeUsesV2Contract(b.cfg)
|
|
|
|
args := []string{"run", "--format", "json", "--dangerously-skip-permissions"}
|
|
// Anchor OpenCode's project discovery (AGENTS.md walk-up + .opencode/skills/
|
|
// project config scan) at the task workdir. Without this, OpenCode falls
|
|
// back to PWD (inherited from the daemon process) or process.cwd(), which
|
|
// in self-host deployments can resolve to the user's shell working
|
|
// directory and silently bypass the per-task workdir — agents lose
|
|
// visibility into their assigned skills and AGENTS.md instructions.
|
|
// PWD is also overridden below because OpenCode prefers PWD over cwd when
|
|
// `--dir` is absent and uses it as the starting point for any further
|
|
// path resolution.
|
|
//
|
|
// 2.x removed `--dir` outright and anchors on the process cwd instead, which
|
|
// cmd.Dir and the PWD override below already provide. Passing it there is
|
|
// not a no-op: the CLI rejects the unknown flag and the run dies before it
|
|
// starts (GH #8586).
|
|
if opts.Cwd != "" && !usesV2 {
|
|
args = append(args, "--dir", opts.Cwd)
|
|
}
|
|
model := opts.Model
|
|
if usesV2 {
|
|
// 2.x dropped `--variant` and reads the variant off the model string.
|
|
folded, ok := opencodeModelArg(model, opts.ThinkingLevel)
|
|
if !ok {
|
|
b.cfg.Logger.Warn("opencode: thinking level needs an explicit model on OpenCode 2.x; ignoring",
|
|
"thinkingLevel", opts.ThinkingLevel)
|
|
}
|
|
model = folded
|
|
}
|
|
if model != "" {
|
|
args = append(args, "--model", model)
|
|
}
|
|
if opts.ThinkingLevel != "" && !usesV2 {
|
|
args = append(args, "--variant", opts.ThinkingLevel)
|
|
}
|
|
// OpenCode's `run` subcommand has no --prompt flag — passing one makes the
|
|
// CLI exit 1 with a usage dump before sending anything (checked against
|
|
// OpenCode 1.17.7). SystemPrompt is therefore never forwarded; the runtime
|
|
// brief reaches the agent through the per-task AGENTS.md the daemon writes
|
|
// into the workdir, which OpenCode loads itself (MUL-5392). Same constraint
|
|
// as the DevEco backend, which was forked from this one.
|
|
if opts.MaxTurns > 0 {
|
|
b.cfg.Logger.Warn("opencode does not support --max-turns; ignoring", "maxTurns", opts.MaxTurns)
|
|
}
|
|
if opts.ResumeSessionID != "" {
|
|
args = append(args, "--session", opts.ResumeSessionID)
|
|
}
|
|
args = append(args, filterCustomArgs(opts.CustomArgs, opencodeBlockedArgs, b.cfg.Logger)...)
|
|
// The task prompt is delivered on stdin, never argv — see the StdinPipe
|
|
// wiring below. `opencode run` merges its variadic [message..] positional
|
|
// with whatever is piped in, so an invocation that passes no positional
|
|
// makes the piped text the entire run message. Inlining it instead fails
|
|
// hard on Windows: CreateProcess caps lpCommandLine at 32,767 characters
|
|
// (8,191 when a .cmd shim routes the call through cmd.exe), and a prompt
|
|
// carrying the workspace's models and skills clears that on its own — the
|
|
// process then never starts and Go surfaces the misleading "The filename or
|
|
// extension is too long" (#6538). Keeping the prompt off argv also keeps it
|
|
// out of OS process listings; the shared command logger separately redacts
|
|
// argv values.
|
|
|
|
runtimeCmd := b.cfg.commandAt(execPath)
|
|
// run carries this invocation's session id. Execute scans with it instead of
|
|
// b so two runs sharing a Backend value cannot observe each other's session.
|
|
run := &opencodeBackend{cfg: b.cfg, session: &opencodeSessionTracker{}}
|
|
cmd := runtimeCmd.exec(runCtx, args...)
|
|
hideAgentWindow(cmd)
|
|
// Take over context cancellation. The default kills the whole group the
|
|
// instant runCtx is done; we instead drive a graceful, group-wide
|
|
// SIGTERM→SIGKILL from the cancellation goroutine below and close the
|
|
// stdout read end only after the tree has been signalled — otherwise a
|
|
// cancelled run can orphan a descendant that keeps spinning (#4533).
|
|
// Returning nil here keeps os/exec from racing us with its own kill;
|
|
// WaitDelay remains the hard backstop.
|
|
cmd.Cancel = func() error { return nil }
|
|
b.cfg.logAgentCommandWithPrompt(cmd, newAgentCommandLogArgs(args, trustAgentCommandPositional(0, "run")), len(prompt))
|
|
cmd.WaitDelay = 10 * time.Second
|
|
if opts.Cwd != "" {
|
|
cmd.Dir = opts.Cwd
|
|
}
|
|
|
|
env := buildEnv(b.cfg.Env)
|
|
// Keep daemon-mode runs non-interactive without relying on
|
|
// OPENCODE_PERMISSION. OpenCode deep-merges that env override into user
|
|
// config while preserving existing key order, so a pre-existing
|
|
// permission.question key can be followed by a wildcard allow and bypass
|
|
// the intended question deny. Current OpenCode run sessions inject their
|
|
// own question/plan deny rules after agent config; this flag only answers
|
|
// prompts that survive those explicit denies.
|
|
// Override PWD so the child OpenCode process resolves its discovery root
|
|
// to the task workdir. cmd.Dir alone is not enough: OpenCode reads PWD
|
|
// (inherited from the parent daemon) before falling back to process.cwd()
|
|
// when computing the directory it walks for AGENTS.md / .opencode/skills.
|
|
// See packages/opencode/src/cli/cmd/run.ts in the upstream source.
|
|
if opts.Cwd != "" {
|
|
env = append(env, "PWD="+opts.Cwd)
|
|
}
|
|
// Project agent.mcp_config into OpenCode. The channel differs by major:
|
|
//
|
|
// On 1.x, OPENCODE_CONFIG_CONTENT — OpenCode's general inline-config
|
|
// injection mechanism that merges at "local" scope (after the
|
|
// project-config loop, before remote / managed configs). MCP is the only
|
|
// field we currently project there; if a future Multica field needs the
|
|
// same channel it would assemble a combined OpenCode config slice before
|
|
// the env append. That path deliberately leaves <workdir>/opencode.json
|
|
// untouched — the workdir is reused across turns for the same
|
|
// (agent, issue), and any agent- or user-written model / tools /
|
|
// permission settings in it must survive across runs.
|
|
//
|
|
// 2.x stopped honouring that env var, and the only channel it left puts the
|
|
// credentials in the agent's own working tree, where the agent can commit
|
|
// them. Such runs are refused rather than started without their servers —
|
|
// see ErrOpenCodeV2MCPUnsupported.
|
|
if usesV2 {
|
|
if err := opencodeCheckMCPSupport(opts.McpConfig); err != nil {
|
|
cancel()
|
|
return nil, err
|
|
}
|
|
} else {
|
|
mcpContent, err := buildOpenCodeMCPConfigContent(opts.McpConfig)
|
|
if err != nil {
|
|
cancel()
|
|
return nil, err
|
|
}
|
|
if mcpContent != "" {
|
|
if _, dup := b.cfg.Env["OPENCODE_CONFIG_CONTENT"]; dup {
|
|
b.cfg.Logger.Warn("agent.custom_env sets OPENCODE_CONFIG_CONTENT but agent.mcp_config takes precedence and overrides it")
|
|
}
|
|
env = append(env, "OPENCODE_CONFIG_CONTENT="+mcpContent)
|
|
}
|
|
}
|
|
cmd.Env = env
|
|
|
|
// Capture how this run reaches its OpenCode service, so a later interrupt
|
|
// talks to the same one. `--server` can arrive through agent.custom_args, and
|
|
// the default background service is resolved from the process environment and
|
|
// working directory — an interrupt missing any of that would report success
|
|
// against a different service while this session kept running.
|
|
interruptServer, interruptStandalone := opencodeConnectionFromArgs(args)
|
|
interruptConn := opencodeRunConnection{
|
|
cmd: runtimeCmd,
|
|
server: interruptServer,
|
|
standalone: interruptStandalone,
|
|
env: env,
|
|
dir: opts.Cwd,
|
|
}
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("opencode stdout pipe: %w", err)
|
|
}
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("opencode stdin pipe: %w", err)
|
|
}
|
|
var closeStdinOnce sync.Once
|
|
closeStdin := func() { closeStdinOnce.Do(func() { _ = stdin.Close() }) }
|
|
cmd.Stderr = newLogWriter(b.cfg.Logger, "[opencode:stderr] ")
|
|
|
|
if err := startOwnedProcessTree(cmd, b.cfg.Logger); err != nil {
|
|
closeStdin()
|
|
cancel()
|
|
return nil, fmt.Errorf("start opencode: %w", err)
|
|
}
|
|
|
|
b.cfg.Logger.Info("opencode started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
|
|
|
|
msgCh := make(chan Message, 256)
|
|
resCh := make(chan Result, 1)
|
|
|
|
// procDone closes once cmd.Wait() returns, letting the cancellation handler
|
|
// skip a process that already exited and avoid signalling a dead pid.
|
|
procDone := make(chan struct{})
|
|
|
|
// Write the prompt from its own goroutine so it cannot deadlock against the
|
|
// stdout reader below: a prompt larger than the OS pipe buffer (~64 KiB)
|
|
// blocks mid-write until OpenCode drains it, and OpenCode cannot drain while
|
|
// nobody is consuming its stdout. Closing stdin is what ends the prompt —
|
|
// OpenCode reads it to EOF (`await Bun.stdin.text()`), so a stdin left open
|
|
// hangs the run forever. Close on every path, success or error.
|
|
writeErrCh := make(chan error, 1)
|
|
go func() {
|
|
_, err := io.WriteString(stdin, prompt)
|
|
closeStdin()
|
|
writeErrCh <- err
|
|
}()
|
|
|
|
// On cancellation / timeout, terminate opencode (and the tool subprocesses
|
|
// it spawned) BEFORE unblocking the scanner. The previous implementation
|
|
// closed the stdout read end immediately, which left opencode writing into
|
|
// a closed pipe: every write returns EPIPE and, per anomalyco/opencode#33653,
|
|
// can spin the orphaned process at 100% CPU. Instead we SIGTERM the whole
|
|
// process group, give it a grace period to exit cleanly, then SIGKILL it.
|
|
// SIGKILL is uncatchable, so once it is delivered no group member can run
|
|
// (or write) again — only then is it safe to close the stdout read end as a
|
|
// last-resort unblock for a scanner that a wedged descendant still keeps
|
|
// open. WaitDelay is the final backstop (#4533).
|
|
go func() {
|
|
select {
|
|
case <-procDone:
|
|
return // finished on its own; nothing to terminate
|
|
case <-runCtx.Done():
|
|
}
|
|
// Release a prompt write still blocked on a full stdin pipe — an
|
|
// OpenCode that stopped reading before draining it would otherwise
|
|
// strand that goroutine for the lifetime of the daemon.
|
|
closeStdin()
|
|
// On 2.x the process below is only a client; the run itself belongs to a
|
|
// background service that survives every signal sent here. Ask the
|
|
// service to stop the session first, otherwise the agent keeps working —
|
|
// calling tools and writing to the workdir — after this task is already
|
|
// recorded as cancelled. Best effort: the signalling below is unchanged
|
|
// and still runs whether or not this succeeds.
|
|
if usesV2 {
|
|
opencodeInterruptSession(interruptConn, run.session.get(), b.cfg.Logger)
|
|
}
|
|
if cmd.Process != nil {
|
|
signalProcessGroup(cmd, syscall.SIGTERM)
|
|
select {
|
|
case <-procDone: // exited within the grace window
|
|
case <-time.After(opencodeTerminateGrace()):
|
|
signalProcessGroup(cmd, syscall.SIGKILL)
|
|
}
|
|
}
|
|
_ = stdout.Close()
|
|
}()
|
|
|
|
go func() {
|
|
defer cancel()
|
|
defer close(msgCh)
|
|
defer close(resCh)
|
|
|
|
startTime := time.Now()
|
|
scanResult := run.processEvents(stdout, msgCh)
|
|
|
|
// Wait for process exit, then release the cancellation handler.
|
|
exitErr := cmd.Wait()
|
|
close(procDone)
|
|
releaseProcessGroup(cmd)
|
|
duration := time.Since(startTime)
|
|
|
|
// Wait closes the process pipes, so a prompt write still blocked when
|
|
// OpenCode exited has returned by now. The writer sends exactly once.
|
|
writeErr := <-writeErrCh
|
|
|
|
if runCtx.Err() == context.DeadlineExceeded {
|
|
scanResult.status = "timeout"
|
|
scanResult.errMsg = fmt.Sprintf("opencode timed out after %s", timeout)
|
|
} else if runCtx.Err() == context.Canceled {
|
|
scanResult.status = "aborted"
|
|
scanResult.errMsg = "execution cancelled"
|
|
} else if exitErr != nil && scanResult.status == "completed" {
|
|
scanResult.status = "failed"
|
|
scanResult.errMsg = fmt.Sprintf("opencode exited with error: %v", exitErr)
|
|
} else if exitErr != nil && scanResult.noTerminalSignal {
|
|
// Status is already "failed" from the terminal-signal guard; append
|
|
// the process exit detail so a mid-step crash still surfaces the
|
|
// signal / exit code that killed it.
|
|
scanResult.errMsg = fmt.Sprintf("%s; opencode exited with error: %v", scanResult.errMsg, exitErr)
|
|
} else if writeErr != nil && !scanResult.sawTerminalSignal {
|
|
// A failed prompt write is only benign once the run is PROVEN to have
|
|
// finished: OpenCode reads stdin to EOF before it does any work, so a
|
|
// run that reached a terminal signal necessarily received the whole
|
|
// prompt, and an EPIPE recorded after that just means the pipe closed
|
|
// on its way out — failing on it would discard a successful result.
|
|
//
|
|
// Absence of failure is not that proof. status starts at "completed"
|
|
// and processEvents only fails closed on structural evidence, so a
|
|
// child that emits nothing and exits 0 still reports "completed". If
|
|
// the prompt never landed, that is precisely the run we must not pass
|
|
// off as a clean success, so key on sawTerminalSignal instead.
|
|
// Append rather than overwrite so the stream's own diagnosis survives.
|
|
if scanResult.errMsg == "" {
|
|
scanResult.errMsg = fmt.Sprintf("opencode prompt write failed: %v", writeErr)
|
|
} else {
|
|
scanResult.errMsg = fmt.Sprintf("%s; opencode prompt write failed: %v", scanResult.errMsg, writeErr)
|
|
}
|
|
scanResult.status = "failed"
|
|
}
|
|
|
|
b.cfg.Logger.Info("opencode finished", "pid", cmd.Process.Pid, "status", scanResult.status, "duration", duration.Round(time.Millisecond).String())
|
|
|
|
// Build usage map. OpenCode doesn't report model per-step, so we
|
|
// attribute all usage to the configured model (or "unknown").
|
|
var usage map[string]TokenUsage
|
|
u := scanResult.usage
|
|
if u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadTokens > 0 || u.CacheWriteTokens > 0 {
|
|
model := opts.Model
|
|
if model == "" {
|
|
model = "unknown"
|
|
}
|
|
usage = map[string]TokenUsage{model: u}
|
|
}
|
|
|
|
resCh <- Result{
|
|
Status: scanResult.status,
|
|
Output: scanResult.output,
|
|
Error: scanResult.errMsg,
|
|
DurationMs: duration.Milliseconds(),
|
|
SessionID: scanResult.sessionID,
|
|
Usage: usage,
|
|
}
|
|
}()
|
|
|
|
return &Session{Messages: msgCh, Result: resCh}, nil
|
|
}
|
|
|
|
// ── Event handlers ──
|
|
|
|
// eventResult holds the accumulated state from processing the event stream.
|
|
type eventResult struct {
|
|
status string
|
|
errMsg string
|
|
output string
|
|
sessionID string
|
|
usage TokenUsage // accumulated token usage across all steps
|
|
noTerminalSignal bool // guard fired: the stream ended without evidence the run actually finished
|
|
// sawTerminalSignal is positive evidence that the run actually finished: a
|
|
// step_finish closed the last step with no continuation pending and with
|
|
// something to show for it. It is NOT the negation of noTerminalSignal — a
|
|
// stream with no events at all sets neither, because there is nothing to
|
|
// fail closed on and nothing that proves completion either. Callers that
|
|
// need "this run really completed" must test this field; status defaults to
|
|
// "completed" and cannot carry that meaning on its own.
|
|
sawTerminalSignal bool
|
|
}
|
|
|
|
// processEvents reads JSON lines from r, dispatches events to ch, and returns
|
|
// the accumulated result. This is the core scanner loop, extracted for testability.
|
|
func (b *opencodeBackend) processEvents(r io.Reader, ch chan<- Message) eventResult {
|
|
var output strings.Builder
|
|
var sessionID string
|
|
var usage TokenUsage
|
|
separateReasoning := opencodeSeparatesReasoning(b.cfg)
|
|
finalStatus := "completed"
|
|
var finalError string
|
|
|
|
// Track step bracketing so a stream that ends mid-step is not mistaken for a
|
|
// clean completion. OpenCode's JSON stream has no terminal result event
|
|
// (unlike Claude's type:"result"), so "no error seen" is not proof the run
|
|
// finished. opencode emits tool_use only on terminal states (completed or
|
|
// error), so a dangling tool call implies an unclosed step — step bracketing
|
|
// is the positive terminal signal. Recovered tool errors (state.status ==
|
|
// "error") are normal in healthy runs and must not affect status.
|
|
//
|
|
// Step bracketing alone is not enough: step_finish carries a reason
|
|
// (FinishReason: "stop", "tool-calls", …), and a run that still has tool
|
|
// results to feed back normally closes its step with reason "tool-calls"
|
|
// before the next step_start. Some providers return "stop" despite emitting
|
|
// tool calls, though, and OpenCode deliberately continues those runs when a
|
|
// non-provider-executed tool result must be fed back to the model. Track both
|
|
// signals so EOF in either continuation gap fails closed. A missing reason
|
|
// retains the older step-bracketing behavior for protocol compatibility.
|
|
openStep := false // between a step_start and its step_finish
|
|
stepHasContinuationTool := false // current step has a local tool result OpenCode must feed back
|
|
awaitingContinuation := false // the last step_finish still required another step
|
|
sawStepFinish := false // at least one step closed; see eventResult.sawTerminalSignal
|
|
|
|
// Step bracketing still misses a third shape: a step that opens and closes
|
|
// cleanly while carrying nothing at all — no text, no tool call, and no
|
|
// reported usage whatsoever (#6522, observed as step_finish reason "unknown"
|
|
// with every token counter and the cost at 0). No usage means the provider
|
|
// round-trip never happened, so that step is a dead stream wearing a clean
|
|
// finish, and ending a run on one is another false-green completion.
|
|
//
|
|
// The criterion is deliberately "this step produced nothing", NOT "the run
|
|
// produced no text": a task whose only deliverable is a tool side effect is
|
|
// legitimate and must stay green. Any single sign of life — text, a tool
|
|
// call, or any usage field the protocol reports — keeps the step productive.
|
|
// This is also why the reason itself is not consulted: a missing or
|
|
// unrecognised reason stays terminal for protocol compatibility (see the
|
|
// back-compat regression), and voidness is orthogonal to it.
|
|
stepProducedOutput := false // current step emitted text, a tool call, or reported usage
|
|
lastStepVoid := false // the most recently closed step produced nothing at all
|
|
|
|
scanner := newAgentStreamScanner(r)
|
|
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var event opencodeEvent
|
|
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
|
continue
|
|
}
|
|
|
|
if event.SessionID != "" {
|
|
sessionID = event.SessionID
|
|
// Publish it for the cancellation handler, which needs a session id
|
|
// to interrupt a 2.x run server-side. No-op when b has no tracker.
|
|
b.session.set(event.SessionID)
|
|
}
|
|
|
|
switch event.Type {
|
|
case "text":
|
|
b.handleTextEvent(event, ch, &output)
|
|
if event.Part.Text != "" {
|
|
stepProducedOutput = true
|
|
}
|
|
case "tool_use":
|
|
b.handleToolUseEvent(event, ch)
|
|
stepProducedOutput = true
|
|
if event.Part.Metadata == nil || !event.Part.Metadata.ProviderExecuted {
|
|
stepHasContinuationTool = true
|
|
}
|
|
case "error":
|
|
b.handleErrorEvent(event, ch, &finalStatus, &finalError)
|
|
case "step_start":
|
|
openStep = true
|
|
stepHasContinuationTool = false
|
|
awaitingContinuation = false
|
|
stepProducedOutput = false
|
|
trySend(ch, Message{Type: MessageStatus, Status: "running"})
|
|
case "step_finish":
|
|
openStep = false
|
|
sawStepFinish = true
|
|
awaitingContinuation = event.Part.Reason == "tool-calls" ||
|
|
(event.Part.Reason != "" && stepHasContinuationTool)
|
|
stepHasContinuationTool = false
|
|
// Accumulate token usage from step_finish events. Only the fields
|
|
// TokenUsage models are billed; every reported field additionally
|
|
// counts as proof the provider round-trip happened, which is what
|
|
// keeps a productive step out of the void-step guard below.
|
|
if t := event.Part.Tokens; t != nil {
|
|
usage.InputTokens += t.Input
|
|
usage.OutputTokens += t.Output
|
|
if separateReasoning && t.Reasoning > 0 {
|
|
usage.OutputTokens += t.Reasoning
|
|
}
|
|
if t.Cache != nil {
|
|
usage.CacheReadTokens += t.Cache.Read
|
|
usage.CacheWriteTokens += t.Cache.Write
|
|
}
|
|
}
|
|
if stepReportedUsage(&event.Part) {
|
|
stepProducedOutput = true
|
|
}
|
|
lastStepVoid = !stepProducedOutput
|
|
}
|
|
}
|
|
|
|
// Check for scanner errors (e.g. broken pipe, read errors).
|
|
if scanErr := scanner.Err(); scanErr != nil {
|
|
b.cfg.Logger.Warn("opencode stdout scanner error", "error", scanErr)
|
|
if finalStatus == "completed" {
|
|
finalStatus = "failed"
|
|
finalError = fmt.Sprintf("stdout read error: %v", scanErr)
|
|
}
|
|
}
|
|
|
|
// Require a positive terminal signal. A clean EOF while a step is still
|
|
// open — right after a step that finished with reason "tool-calls", whose
|
|
// continuation step never started — or on a step that carried nothing at
|
|
// all means the run did not finish: its provider stream died and
|
|
// `opencode run` exited without emitting an error event. Fail closed on
|
|
// that structural evidence rather than reporting a false-green completion.
|
|
noTerminalSignal := false
|
|
if finalStatus == "completed" {
|
|
switch {
|
|
case openStep:
|
|
finalStatus = "failed"
|
|
finalError = "opencode stream ended without a terminal signal (step still open at EOF)"
|
|
noTerminalSignal = true
|
|
case awaitingContinuation:
|
|
finalStatus = "failed"
|
|
finalError = "opencode stream ended without a terminal signal (last step required a continuation that never started)"
|
|
noTerminalSignal = true
|
|
case lastStepVoid:
|
|
finalStatus = "failed"
|
|
finalError = "opencode stream ended on an empty step (no text, no tool call, no reported usage) — the provider produced nothing"
|
|
noTerminalSignal = true
|
|
}
|
|
}
|
|
|
|
return eventResult{
|
|
status: finalStatus,
|
|
errMsg: finalError,
|
|
output: output.String(),
|
|
sessionID: sessionID,
|
|
usage: usage,
|
|
noTerminalSignal: noTerminalSignal,
|
|
sawTerminalSignal: sawStepFinish && !noTerminalSignal,
|
|
}
|
|
}
|
|
|
|
// stepReportedUsage reports whether a step_finish part carries any evidence
|
|
// that the provider round-trip actually happened.
|
|
//
|
|
// OpenCode's protocol keeps reasoning and the aggregate total in fields of
|
|
// their own alongside input/output/cache, and reports cost as a sibling of the
|
|
// whole token block — a step can legitimately land with reasoning or cost
|
|
// positive while input and output are both zero. Checking only input/output
|
|
// would therefore call such a step void and fail a healthy run, so every field
|
|
// the protocol reports counts. Only an across-the-board zero means no model
|
|
// call happened.
|
|
//
|
|
// This predicate only checks for evidence of a provider round-trip.
|
|
// processEvents separately normalizes reasoning into output for releases whose
|
|
// output excludes it; the aggregate total is never added to the usage buckets.
|
|
func stepReportedUsage(part *opencodeEventPart) bool {
|
|
if part.Cost > 0 {
|
|
return true
|
|
}
|
|
t := part.Tokens
|
|
if t == nil {
|
|
return false
|
|
}
|
|
if t.Input > 0 || t.Output > 0 || t.Reasoning > 0 || t.Total > 0 {
|
|
return true
|
|
}
|
|
return t.Cache != nil && (t.Cache.Read > 0 || t.Cache.Write > 0)
|
|
}
|
|
|
|
func (b *opencodeBackend) handleTextEvent(event opencodeEvent, ch chan<- Message, output *strings.Builder) {
|
|
text := event.Part.Text
|
|
if text != "" {
|
|
output.WriteString(text)
|
|
trySend(ch, Message{Type: MessageText, Content: text})
|
|
}
|
|
}
|
|
|
|
// handleToolUseEvent processes "tool_use" events from opencode. A single
|
|
// tool_use event contains both the call and result in part.state when the
|
|
// tool reaches a terminal state (state.status is "completed" or "error").
|
|
func (b *opencodeBackend) handleToolUseEvent(event opencodeEvent, ch chan<- Message) {
|
|
// Extract input from state.input (the tool invocation parameters).
|
|
var input map[string]any
|
|
if event.Part.State != nil && event.Part.State.Input != nil {
|
|
_ = json.Unmarshal(event.Part.State.Input, &input)
|
|
}
|
|
|
|
// Emit the tool-use message.
|
|
trySend(ch, Message{
|
|
Type: MessageToolUse,
|
|
Tool: event.Part.Tool,
|
|
CallID: event.Part.CallID,
|
|
Input: input,
|
|
})
|
|
|
|
// Pair every terminal tool-use with a tool-result. The daemon uses this
|
|
// pair to track in-flight tools, so dropping error results would leave its
|
|
// counter permanently elevated and suppress the normal idle watchdog.
|
|
state := event.Part.State
|
|
if state != nil && (state.Status == "completed" || state.Status == "error") {
|
|
outputStr := extractToolOutput(state.Output)
|
|
if state.Status == "error" && state.Error != "" {
|
|
outputStr = state.Error
|
|
}
|
|
trySend(ch, Message{
|
|
Type: MessageToolResult,
|
|
Tool: event.Part.Tool,
|
|
CallID: event.Part.CallID,
|
|
Output: outputStr,
|
|
})
|
|
}
|
|
}
|
|
|
|
// handleErrorEvent processes "error" events from opencode. OpenCode can exit
|
|
// with RC=0 even on errors (e.g. invalid model), so error events are the
|
|
// reliable signal for failures.
|
|
func (b *opencodeBackend) handleErrorEvent(event opencodeEvent, ch chan<- Message, finalStatus, finalError *string) {
|
|
errMsg := ""
|
|
if event.Error != nil {
|
|
errMsg = event.Error.Message()
|
|
}
|
|
if errMsg == "" {
|
|
errMsg = "unknown opencode error"
|
|
}
|
|
|
|
b.cfg.Logger.Warn("opencode error event", "error", errMsg)
|
|
trySend(ch, Message{Type: MessageError, Content: errMsg})
|
|
|
|
*finalStatus = "failed"
|
|
*finalError = errMsg
|
|
}
|
|
|
|
// resolveOpenCodeNativeFromShim returns the path to the native OpenCode
|
|
// executable bundled inside the npm package, given the path to the npm
|
|
// `opencode.cmd` shim that PATH lookup found on Windows. Returns "" if shim
|
|
// doesn't end in `.cmd` or no candidate npm platform package has a bundled
|
|
// native binary present.
|
|
//
|
|
// Windows batch argument forwarding via `%*` does not preserve newlines, so
|
|
// multi-line positional argv is truncated at the first newline before the
|
|
// shim hands off to the JS entrypoint. Daemon prompts can include literal
|
|
// newlines (system prompt + user message), which makes the agent see only
|
|
// the first line. Native binary spawn skips the cmd.exe layer entirely.
|
|
//
|
|
// Layout when installed via `npm install -g opencode-ai`:
|
|
//
|
|
// <prefix>\opencode.cmd (shim)
|
|
// <prefix>\node_modules\opencode-ai\node_modules\opencode-windows-{x64,x64-baseline,arm64}\bin\opencode.exe (native)
|
|
//
|
|
// `opencode-windows-x64-baseline` ships for older CPUs without AVX2;
|
|
// `opencode-windows-arm64` ships for Surface / Copilot+ PC hosts.
|
|
// Candidates are tried in GOARCH-preferred order so the most likely match
|
|
// for the current host comes first.
|
|
//
|
|
// statFn is injected so this is testable on non-Windows hosts.
|
|
func resolveOpenCodeNativeFromShim(shimPath string, statFn func(string) (os.FileInfo, error)) string {
|
|
if !strings.EqualFold(filepath.Ext(shimPath), ".cmd") {
|
|
return ""
|
|
}
|
|
prefix := filepath.Dir(shimPath)
|
|
for _, pkg := range opencodeWindowsPackageCandidates(runtime.GOARCH) {
|
|
candidate := filepath.Join(prefix, "node_modules", "opencode-ai", "node_modules", pkg, "bin", "opencode.exe")
|
|
if _, err := statFn(candidate); err == nil {
|
|
return candidate
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// opencodeWindowsPackageCandidates returns the npm platform package names
|
|
// that may host the bundled `opencode.exe` on Windows, ordered so the most
|
|
// likely match for the given GOARCH comes first. ARM64 hosts try the arm64
|
|
// build first; everything else tries x64, then the baseline x64 build for
|
|
// older CPUs without AVX2, then arm64 as a final fallback. Cost is one
|
|
// extra statFn call per miss when the GOARCH-preferred package isn't
|
|
// installed.
|
|
func opencodeWindowsPackageCandidates(goarch string) []string {
|
|
switch goarch {
|
|
case "arm64":
|
|
return []string{"opencode-windows-arm64", "opencode-windows-x64", "opencode-windows-x64-baseline"}
|
|
default:
|
|
return []string{"opencode-windows-x64", "opencode-windows-x64-baseline", "opencode-windows-arm64"}
|
|
}
|
|
}
|
|
|
|
// extractToolOutput converts the tool state output (which may be a string or
|
|
// structured object) into a string.
|
|
func extractToolOutput(output any) string {
|
|
if output == nil {
|
|
return ""
|
|
}
|
|
if s, ok := output.(string); ok {
|
|
return s
|
|
}
|
|
data, _ := json.Marshal(output)
|
|
return string(data)
|
|
}
|
|
|
|
// ── JSON types for `opencode run --format json` stdout events ──
|
|
|
|
// opencodeEvent represents a single JSON line from `opencode run --format json`.
|
|
//
|
|
// Event types observed in real output:
|
|
//
|
|
// "step_start" — agent step begins
|
|
// "text" — text output from agent (part.text)
|
|
// "tool_use" — tool invocation with call and result (part.tool, part.callID, part.state)
|
|
// "error" — error from opencode (error.name, error.data.message)
|
|
// "step_finish" — agent step completes (includes token usage)
|
|
type opencodeEvent struct {
|
|
Type string `json:"type"`
|
|
Timestamp int64 `json:"timestamp,omitempty"`
|
|
SessionID string `json:"sessionID,omitempty"`
|
|
Part opencodeEventPart `json:"part"`
|
|
Error *opencodeError `json:"error,omitempty"`
|
|
}
|
|
|
|
// opencodeEventPart represents the part field in an opencode event.
|
|
type opencodeEventPart struct {
|
|
ID string `json:"id,omitempty"`
|
|
MessageID string `json:"messageID,omitempty"`
|
|
SessionID string `json:"sessionID,omitempty"`
|
|
Type string `json:"type,omitempty"`
|
|
|
|
// Text events
|
|
Text string `json:"text,omitempty"`
|
|
|
|
// Tool use events
|
|
Tool string `json:"tool,omitempty"`
|
|
CallID string `json:"callID,omitempty"`
|
|
State *opencodeToolState `json:"state,omitempty"`
|
|
// OpenCode excludes provider-executed tools when deciding whether a tool
|
|
// result requires another model step.
|
|
Metadata *opencodePartMetadata `json:"metadata,omitempty"`
|
|
|
|
// step_finish token usage
|
|
Tokens *opencodeTokens `json:"tokens,omitempty"`
|
|
|
|
// step_finish cost, a sibling of the token block rather than a member of
|
|
// it. Read only as round-trip evidence by stepReportedUsage; opencode's
|
|
// billing figures come from the token counters above.
|
|
Cost float64 `json:"cost,omitempty"`
|
|
|
|
// step_finish reason (FinishReason: "stop", "tool-calls", …). Absent on
|
|
// older opencode versions whose step-finish parts predate the field.
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
type opencodePartMetadata struct {
|
|
ProviderExecuted bool `json:"providerExecuted,omitempty"`
|
|
}
|
|
|
|
// opencodeTokens represents token usage in a step_finish event. OpenCode 1.x
|
|
// since v1.3.16 separates Reasoning from Output; older releases include it.
|
|
// Total is an aggregate, and both fields also provide step-liveness evidence.
|
|
type opencodeTokens struct {
|
|
Input int64 `json:"input"`
|
|
Output int64 `json:"output"`
|
|
Reasoning int64 `json:"reasoning,omitempty"`
|
|
Total int64 `json:"total,omitempty"`
|
|
Cache *opencodeCacheTokens `json:"cache,omitempty"`
|
|
}
|
|
|
|
type opencodeCacheTokens struct {
|
|
Read int64 `json:"read"`
|
|
Write int64 `json:"write"`
|
|
}
|
|
|
|
// opencodeToolState represents the state of a tool invocation.
|
|
type opencodeToolState struct {
|
|
Status string `json:"status,omitempty"`
|
|
Input json.RawMessage `json:"input,omitempty"`
|
|
Output any `json:"output,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// opencodeError represents an error event from opencode.
|
|
type opencodeError struct {
|
|
Name string `json:"name,omitempty"`
|
|
Data *opencodeErrData `json:"data,omitempty"`
|
|
}
|
|
|
|
// Message returns the human-readable error message.
|
|
func (e *opencodeError) Message() string {
|
|
if e.Data != nil && e.Data.Message != "" {
|
|
return e.Data.Message
|
|
}
|
|
if e.Name != "" {
|
|
return e.Name
|
|
}
|
|
return ""
|
|
}
|
|
|
|
type opencodeErrData struct {
|
|
Message string `json:"message,omitempty"`
|
|
}
|