package agent import ( "bufio" "context" "encoding/json" "fmt" "io" "os" "os/exec" "path/filepath" "runtime" "strings" "time" ) // 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 } 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) 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. if opts.Cwd != "" { args = append(args, "--dir", opts.Cwd) } if opts.Model != "" { args = append(args, "--model", opts.Model) } if opts.ThinkingLevel != "" { args = append(args, "--variant", opts.ThinkingLevel) } if opts.SystemPrompt != "" { args = append(args, "--prompt", opts.SystemPrompt) } 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)...) args = append(args, prompt) cmd := exec.CommandContext(runCtx, execPath, 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 } 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 via 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. // // This deliberately leaves /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. 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 stdout, err := cmd.StdoutPipe() if err != nil { cancel() return nil, fmt.Errorf("opencode stdout pipe: %w", err) } cmd.Stderr = newLogWriter(b.cfg.Logger, "[opencode:stderr] ") if err := cmd.Start(); err != nil { 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) // Close stdout when the context is cancelled so the scanner unblocks. go func() { <-runCtx.Done() _ = stdout.Close() }() go func() { defer cancel() defer close(msgCh) defer close(resCh) startTime := time.Now() scanResult := b.processEvents(stdout, msgCh) // Wait for process exit. exitErr := cmd.Wait() duration := time.Since(startTime) 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) } 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 } // 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 finalStatus := "completed" var finalError string scanner := bufio.NewScanner(r) scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) 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 } switch event.Type { case "text": b.handleTextEvent(event, ch, &output) case "tool_use": b.handleToolUseEvent(event, ch) case "error": b.handleErrorEvent(event, ch, &finalStatus, &finalError) case "step_start": trySend(ch, Message{Type: MessageStatus, Status: "running"}) case "step_finish": // Accumulate token usage from step_finish events. if t := event.Part.Tokens; t != nil { usage.InputTokens += t.Input usage.OutputTokens += t.Output if t.Cache != nil { usage.CacheReadTokens += t.Cache.Read usage.CacheWriteTokens += t.Cache.Write } } } } // 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) } } return eventResult{ status: finalStatus, errMsg: finalError, output: output.String(), sessionID: sessionID, usage: usage, } } 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 has completed (state.status == "completed"). 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, }) // If the tool has completed, also emit a tool-result message. if event.Part.State != nil && event.Part.State.Status == "completed" { outputStr := extractToolOutput(event.Part.State.Output) 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`: // // \opencode.cmd (shim) // \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"` // step_finish token usage Tokens *opencodeTokens `json:"tokens,omitempty"` } // opencodeTokens represents token usage in a step_finish event. type opencodeTokens struct { Input int64 `json:"input"` Output int64 `json:"output"` 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"` } // 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"` }