Files
7d02596fc3 fix(agent): MUL-8307 drive OpenCode over ACP so long tools and subagents are not force-stopped (#9619)
* fix(agent): drive OpenCode over ACP so long tools and subagents are not force-stopped

`opencode run --format json` reports a tool only when it finishes, with the
call and its result in one event. The daemon never saw a tool in flight, so a
long silent tool or a delegated subagent was judged by the 10-minute OpenCode
idle window instead of the tool budget and force-stopped (GH #9614).

Built-in OpenCode 1.15+ and 2.x runtimes now run `opencode acp`, which on both
majors announces a tool when it starts executing. MessageToolUse is emitted
from that in_progress frame, so the daemon applies AgentToolWatchdog for the
whole tool. One code path covers both majors:

- model via session/set_config_option (2.x has no session/set_model); effort
  via the advertised `effort` option, splitting 2.x `model#variant`
- resume via session/load; a missing session is reported as ResumeRejected
- cancel via session/cancel, which aborts the running tool on both majors
- MCP: 1.x keeps OPENCODE_CONFIG_CONTENT; 2.x gets servers in session/new,
  which lifts the 2.x MCP refusal on this transport
- 2.x subagent text (`_meta["opencode/child-session"]`) stays out of the
  parent's output; its tool calls are kept
- usage summed from the session export over this turn's assistant messages,
  since the prompt result's usage is not per-turn on either major

The run transport stays as the fallback for custom profiles, older or
unknown versions, agents with custom_args, and MULTICA_OPENCODE_TRANSPORT=run.

Also fix the run transport on 2.x: tool_use parts carry the call id as `id`
(1.x: `callID`), so every 2.x tool message had an empty CallID.

Co-authored-by: multica-agent <[email protected]>

* fix(agent): bound OpenCode ACP teardown when the runtime stops reading

A runtime that stops reading stdin mid-prompt left the prompt write blocked
holding the client's write lock. The cancel path then waited on that lock to
send session/cancel, and the cancel grace and the process-group kill only ran
after the write, so the process could outlive the task indefinitely.

- session/cancel is sent only once the prompt is fully delivered, from its own
  goroutine, so the grace starts immediately and a stalled pipe cannot hold it
- a turn OpenCode never answered (or a cancelled/timed-out setup) is torn
  down forcibly: stdin closed first to release a blocked write, then SIGTERM to
  the group and SIGKILL after the grace; an answered turn still exits on EOF

Regression tests cover a stalled prompt write under a 1s timeout and a runtime
that ignores session/cancel.

Co-authored-by: multica-agent <[email protected]>

* fix(agent): bound OpenCode ACP teardown when a setup write stalls

Setup requests (initialize, session/new, session/load, set_config_option)
are synchronous stdin writes that check their context only after the write
returns. A runtime that stops reading after initialize, with a large MCP
payload in session/new, left the run goroutine blocked in that write, so
the deferred teardown never ran and the process outlived the timeout.

A watcher now force-stops the process as soon as the run ends before the
prompt is fully delivered; closing stdin releases the blocked write. Once
the prompt is delivered the existing session/cancel path owns shutdown.

Regression test: 256 KiB MCP env, runtime stalls after initialize, 1s
timeout; returns in ~1s (60s before the fix).

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Multica Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-28 15:11:27 +08:00

838 lines
29 KiB
Go

package agent
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
)
// OpenCode over ACP (`opencode acp`).
//
// `opencode run --format json` writes a tool_use event only once the tool has
// finished, with the call and its result in one line. The daemon therefore never
// sees a tool in flight on that transport, and a long silent tool — a build, a
// test suite, a delegated subagent — is judged by the short OpenCode idle window
// (DefaultOpenCodeIdleWatchdog, 10 min) instead of the tool budget, so healthy
// runs get force-stopped (GH #9614). Both majors stream tool starts on ACP
// instead: 1.x and 2.x send `tool_call` when the model emits the call and an
// `in_progress` `tool_call_update` carrying the real input the moment the tool
// starts executing. That second frame is where MessageToolUse is emitted (see
// hermesClient.toolInputArrivesInProgress).
//
// What differs between the majors on this transport, all verified against
// opencode-ai 1.19.4 and @opencode/cli 2.2.0 (handshake also on 1.15.0 and
// 2.0.0):
//
// - Model selection. Both advertise a `model` entry in configOptions and take
// session/set_config_option; 2.x has no session/set_model at all (-32601).
// Reasoning effort is the advertised `effort` option on both, so the 2.x
// `provider/model#variant` spelling is only split, never sent.
// - MCP. 1.x still honours OPENCODE_CONFIG_CONTENT, which carries every shape
// the OpenCode translator accepts, so it keeps that channel. 2.x ignores the
// env var; ACP session/new's `mcpServers` is the channel there, which keeps
// credentials in the process instead of in a file in the agent's workdir
// (the reason the run transport refuses MCP on 2.x).
// - Subagents. 2.x forwards a child session's updates into the parent with
// `_meta["opencode/child-session"]`. Its tool calls are kept (they are real
// work in flight); its text is dropped so the parent's output stays the
// parent's answer, matching the run transport.
// - Usage. The prompt result's usage is not a per-turn figure on either
// major: 1.x reports the last assistant message only, 2.x the session
// total. Usage is therefore summed from the session export, over the
// assistant messages this turn created (opencodeACPTurnUsage).
//
// Cancellation is `session/cancel`, which on both majors aborts the running
// tool and answers the prompt with stopReason "cancelled". On 2.x it is the
// only reliable stop: the ACP process hosts its server as a separate process
// group, so a group signal alone does not reach the tools.
//
// The run transport is kept as a fallback for everything this file cannot vouch
// for: custom runtime profiles, versions older than the ones checked, agents
// with custom_args (they are `opencode run` flags), and anyone who sets
// MULTICA_OPENCODE_TRANSPORT=run.
// opencodeTransportEnv selects the OpenCode transport. "run" forces the
// `opencode run --format json` path; anything else lets the backend choose.
// It is an operator setting read from the daemon's environment (the daemon
// strips MULTICA_* keys from agent custom_env); Config.Env is consulted first
// so tests can pin a transport per backend.
const opencodeTransportEnv = "MULTICA_OPENCODE_TRANSPORT"
// Oldest releases whose ACP surface (configOptions model + effort selectors,
// loadSession, session/cancel) matches what this file drives. 1.15.0 is the
// first 1.x release that advertises the effort option.
var (
opencodeACPMinV1 = semver{Major: 1, Minor: 15, Patch: 0}
opencodeACPMinV2 = semver{Major: 2, Minor: 0, Patch: 0}
)
// Timing knobs. Vars so tests can shorten them.
var (
// opencodeACPCancelGrace bounds the wait for the prompt to answer after
// session/cancel before the process is torn down regardless.
opencodeACPCancelGrace = 5 * time.Second
// opencodeACPExitGrace is how long the process gets to exit on its own
// after stdin closes before it is signalled.
opencodeACPExitGrace = 3 * time.Second
// opencodeACPDrainGrace bounds the trailing-notification drain after the
// prompt response, like the other ACP backends' reader drain.
opencodeACPDrainGrace = 2 * time.Second
// opencodeACPUsageTimeout bounds the post-run session export.
opencodeACPUsageTimeout = 15 * time.Second
)
// opencodeUsesACP reports whether this run should use the ACP transport, and if
// not, why (for the log line).
func opencodeUsesACP(cfg Config, opts ExecOptions) (bool, string) {
if strings.EqualFold(strings.TrimSpace(opencodeTransportSetting(cfg)), "run") {
return false, opencodeTransportEnv + "=run"
}
// A custom profile wraps a binary this package did not choose, so its
// version string does not establish which protocol it speaks — the same
// rule opencodeUsesV2Contract applies.
if !cfg.BuiltinRuntime || len(cfg.LaunchPrefix) != 0 {
return false, "custom runtime profile"
}
version, err := parseSemver(strings.TrimSpace(cfg.CLIVersion))
if err != nil {
return false, "unknown OpenCode version"
}
switch {
case version.Major == 1 && version.lessThan(opencodeACPMinV1):
return false, "OpenCode older than " + fmt.Sprintf("%d.%d.%d", opencodeACPMinV1.Major, opencodeACPMinV1.Minor, opencodeACPMinV1.Patch)
case version.Major < 1:
return false, "OpenCode older than 1.x"
case version.Major >= 2 && version.lessThan(opencodeACPMinV2):
return false, "unrecognised OpenCode version"
}
// custom_args are `opencode run` flags (--agent, --attach, …); `opencode
// acp` would reject or ignore them, so honour them on the transport they
// were written for.
if len(opts.CustomArgs) != 0 {
return false, "agent has custom_args for `opencode run`"
}
return true, ""
}
func opencodeTransportSetting(cfg Config) string {
if v, ok := cfg.Env[opencodeTransportEnv]; ok {
return v
}
return os.Getenv(opencodeTransportEnv)
}
// opencodeACPToolName keeps OpenCode's own tool ids (bash, read, task, …) so
// the transcript reads the same as on the run transport. 2.x sends the id as
// `name`; 1.x's start frame sends it as `title`.
func opencodeACPToolName(name, title, kind string) string {
if name = strings.TrimSpace(name); name != "" {
return name
}
if title = strings.TrimSpace(title); title != "" {
return title
}
return kind
}
// opencodeACPChildUpdate reports whether a session/update came from a 2.x
// subagent's child session rather than the session Multica prompted.
func opencodeACPChildUpdate(update json.RawMessage) bool {
var u struct {
Meta map[string]json.RawMessage `json:"_meta"`
}
if json.Unmarshal(update, &u) != nil {
return false
}
_, ok := u.Meta["opencode/child-session"]
return ok
}
// opencodeACPSplitModel splits a 2.x `provider/model#variant` selector. The
// pinned variant wins over the agent-level thinking level, as on the run
// transport (opencodeModelArg).
func opencodeACPSplitModel(model, thinkingLevel string) (string, string) {
model = strings.TrimSpace(model)
if base, variant, ok := strings.Cut(model, "#"); ok {
if variant = strings.TrimSpace(variant); variant != "" {
return strings.TrimSpace(base), variant
}
return strings.TrimSpace(base), thinkingLevel
}
return model, thinkingLevel
}
// opencodeACPSelectModel puts the session on model and returns the session's
// configOptions as they stand afterwards, which is what the effort option has
// to be read from: the effort vocabulary belongs to the current model.
func opencodeACPSelectModel(ctx context.Context, request acpRequestFn, sessionID, model string, sessionResult json.RawMessage) (json.RawMessage, error) {
if model == "" {
return sessionResult, nil
}
if _, ok := findACPConfigOption(sessionResult, "model"); !ok {
// Not seen on any version this transport is enabled for; kept so an
// unexpected build degrades to the older ACP model call.
if _, err := request(ctx, "session/set_model", map[string]any{"sessionId": sessionID, "modelId": model}); err != nil {
return nil, fmt.Errorf("select model %q: %w", model, err)
}
return nil, nil
}
result, err := request(ctx, "session/set_config_option", map[string]any{
"sessionId": sessionID,
"configId": "model",
"value": model,
})
if err != nil {
return nil, fmt.Errorf("select model %q: %w", model, err)
}
if current, ok := acpConfigOptionCurrentValue(result, "model"); ok && current != model {
return nil, fmt.Errorf("select model %q: OpenCode kept the session on %q", model, current)
}
return result, nil
}
// opencodeACPMcpServers translates mcp_config for a 2.x ACP session. It goes
// through the OpenCode translator first so the accepted input shapes (including
// OpenCode-native `mcp` entries) and the validation are the same as on 1.x.
//
// Two OpenCode features have no ACP representation and fail the run rather
// than silently change what the agent gets: remote OAuth, and a bare
// {"enabled": …} override of a server the user configured elsewhere. A
// per-server timeout is dropped with a warning.
func opencodeACPMcpServers(raw json.RawMessage, cfg Config) ([]any, error) {
if len(bytes.TrimSpace(raw)) == 0 {
return []any{}, nil
}
servers, err := translateMCPConfigForOpenCode(raw)
if err != nil {
return nil, err
}
names := make([]string, 0, len(servers))
for name := range servers {
names = append(names, name)
}
sort.Strings(names)
out := make([]any, 0, len(names))
for _, name := range names {
entry, _ := servers[name].(map[string]any)
if enabled, ok := entry["enabled"].(bool); ok && !enabled {
if _, typed := entry["type"]; typed {
continue // a disabled server is simply not sent
}
}
if _, ok := entry["timeout"]; ok && cfg.Logger != nil {
cfg.Logger.Warn("opencode: MCP server timeout cannot be passed over ACP; using OpenCode's default", "server", name)
}
switch entry["type"] {
case "local":
command := stringsFromAny(entry["command"])
if len(command) == 0 {
return nil, fmt.Errorf("opencode mcp_config: server %q: local server missing `command`", name)
}
out = append(out, map[string]any{
"name": name,
"command": command[0],
"args": append([]string{}, command[1:]...),
"env": acpNameValues(entry["environment"]),
})
case "remote":
if oauth, ok := entry["oauth"]; ok && oauth != false {
return nil, fmt.Errorf("opencode mcp_config: server %q uses OAuth, which OpenCode 2.x cannot receive over ACP; use headers, an OpenCode 1.x runtime, or %s=run", name, opencodeTransportEnv)
}
url, _ := entry["url"].(string)
out = append(out, map[string]any{
"type": "http",
"name": name,
"url": url,
"headers": acpNameValues(entry["headers"]),
})
default:
return nil, fmt.Errorf("opencode mcp_config: server %q is an {\"enabled\": …} override, which OpenCode 2.x cannot receive over ACP", name)
}
}
return out, nil
}
func stringsFromAny(v any) []string {
switch items := v.(type) {
case []string:
return items
case []any:
out := make([]string, 0, len(items))
for _, item := range items {
s, ok := item.(string)
if !ok {
return nil
}
out = append(out, s)
}
return out
}
return nil
}
// acpNameValues turns a JSON object of strings into ACP's sorted
// [{name, value}] list.
func acpNameValues(v any) []map[string]any {
m, _ := v.(map[string]any)
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
out := make([]map[string]any, 0, len(keys))
for _, k := range keys {
out = append(out, map[string]any{"name": k, "value": fmt.Sprint(m[k])})
}
return out
}
func (b *opencodeBackend) executeACP(ctx context.Context, execPath, prompt string, opts ExecOptions) (*Session, error) {
usesV2 := opencodeUsesV2Contract(b.cfg)
cwd := opts.Cwd
if cwd == "" {
cwd = "."
}
cwd, err := filepath.Abs(cwd)
if err != nil {
return nil, fmt.Errorf("opencode working directory: %w", err)
}
// 1.x: MCP rides OPENCODE_CONFIG_CONTENT exactly as on the run transport.
// 2.x: MCP goes into session/new, and the env carries none.
envOpts := opts
mcpServers := []any{}
if usesV2 {
envOpts = ExecOptions{Cwd: opts.Cwd}
if mcpServers, err = opencodeACPMcpServers(opts.McpConfig, b.cfg); err != nil {
return nil, err
}
}
env, err := openCodeEnvironment(b.cfg, envOpts)
if err != nil {
return nil, err
}
if opts.MaxTurns > 0 {
b.cfg.Logger.Warn("opencode does not support --max-turns; ignoring", "maxTurns", opts.MaxTurns)
}
timeout := opts.Timeout
runCtx, cancel := runContext(ctx, timeout)
// The process runs on its own context so cancelling the task can go
// through session/cancel first; procCancel is the last-resort group kill.
procCtx, procCancel := context.WithCancel(context.Background())
args := []string{"acp"}
cmd := b.cfg.commandAt(execPath).exec(procCtx, args...)
hideAgentWindow(cmd)
cmd.WaitDelay = 5 * time.Second
cmd.Dir = cwd
cmd.Env = env
b.cfg.logAgentCommand(cmd, newAgentCommandLogArgs(args, trustAgentCommandPositional(0, "acp")))
fail := func(err error) (*Session, error) {
procCancel()
cancel()
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fail(fmt.Errorf("opencode stdout pipe: %w", err))
}
stdin, err := cmd.StdinPipe()
if err != nil {
return fail(fmt.Errorf("opencode stdin pipe: %w", err))
}
stderr, err := cmd.StderrPipe()
if err != nil {
return fail(fmt.Errorf("opencode stderr pipe: %w", err))
}
if err := startOwnedProcessTree(cmd, b.cfg.Logger); err != nil {
return fail(fmt.Errorf("start opencode: %w", err))
}
b.cfg.Logger.Info("opencode acp started", "pid", cmd.Process.Pid, "cwd", cwd, "model", opts.Model)
providerErr := newACPProviderErrorSniffer("opencode")
stderrDone := make(chan struct{})
go func() {
defer close(stderrDone)
_, _ = io.Copy(io.MultiWriter(newLogWriter(b.cfg.Logger, "[opencode:stderr] "), providerErr), stderr)
}()
msgCh := make(chan Message, 256)
resCh := make(chan Result, 1)
var streaming atomic.Bool
var sawText, sawTool atomic.Bool
var outputMu sync.Mutex
var output strings.Builder
activity := make(chan struct{}, 1)
promptDone := make(chan hermesPromptResult, 1)
c := &hermesClient{
cfg: b.cfg,
stdin: stdin,
pending: make(map[int]*pendingRPC),
pendingTools: make(map[string]*pendingToolCall),
toolInputArrivesInProgress: true,
toolOutputFromContent: true,
toolName: opencodeACPToolName,
// session/load replays the whole conversation as session/update;
// only the turn this run prompts is this run's transcript.
acceptNotification: func(string) bool { return streaming.Load() },
acceptUpdate: func(updateType string, update json.RawMessage) bool {
if updateType == "tool_call" || updateType == "tool_call_update" {
return true
}
return !opencodeACPChildUpdate(update)
},
onActivity: func() {
select {
case activity <- struct{}{}:
default:
}
},
onMessage: func(msg Message) {
if !streaming.Load() {
return
}
switch msg.Type {
case MessageText:
sawText.Store(true)
outputMu.Lock()
output.WriteString(msg.Content)
outputMu.Unlock()
case MessageToolUse:
sawTool.Store(true)
}
trySend(msgCh, msg)
},
onPromptDone: func(result hermesPromptResult) {
select {
case promptDone <- result:
default:
}
},
}
readerDone := make(chan struct{})
go func() {
defer close(readerDone)
scanner := newAgentStreamScanner(stdout)
for scanner.Scan() {
if line := strings.TrimSpace(scanner.Text()); line != "" {
c.handleLine(line)
}
}
c.closeAllPending(errors.New("opencode process exited"))
}()
// stop tears the process down exactly once. A graceful stop closes stdin
// and lets OpenCode exit on EOF before signalling. A forced stop is for a
// run that was cancelled or timed out without OpenCode answering: closing
// stdin first releases a prompt write blocked on a full pipe (it holds the
// client's write lock), and the group is signalled at once, so an
// unresponsive runtime is gone within opencodeTerminateGrace instead of
// being waited on.
// promptSent flips once the whole prompt is in the pipe. Until then
// OpenCode has not received the turn, so there is nothing for
// session/cancel to stop.
var promptSent atomic.Bool
var stopOnce sync.Once
stop := func(force bool) {
stopOnce.Do(func() {
_ = stdin.Close()
if !force {
select {
case <-readerDone:
case <-time.After(opencodeACPExitGrace):
force = true
}
}
if force {
signalProcessGroup(cmd, syscall.SIGTERM)
select {
case <-readerDone:
case <-time.After(opencodeTerminateGrace()):
}
}
procCancel() // group SIGKILL; a no-op for a group that already exited
_ = cmd.Wait()
<-readerDone
<-stderrDone
releaseProcessGroup(cmd)
})
}
go func() {
defer close(msgCh)
defer close(resCh)
defer cancel()
// Setup paths return early; a cancelled or timed-out setup is torn
// down without the graceful wait.
defer func() { stop(runCtx.Err() != nil) }()
// Until the prompt is fully delivered there is no turn for
// session/cancel to stop, and every request so far is a synchronous
// write that checks its context only after the write returns. A
// runtime that stops reading (a large session/new MCP payload, a
// large prompt) would hold this goroutine in that write, so the
// deferred stop above would never run. The watcher tears the process
// down as soon as the run ends in that window; closing stdin is what
// releases the blocked write. Once the prompt is delivered, the
// cancel path below owns the shutdown.
watcherDone := make(chan struct{})
defer close(watcherDone)
go func() {
select {
case <-runCtx.Done():
if !promptSent.Load() {
stop(true)
}
case <-watcherDone:
}
}()
startTime := time.Now()
result := func(status, errText, sessionID string, rejected bool) {
resCh <- Result{Status: status, Error: errText, SessionID: sessionID, ResumeRejected: rejected, DurationMs: time.Since(startTime).Milliseconds()}
}
initResult, err := c.request(runCtx, "initialize", map[string]any{
"protocolVersion": 1,
"clientInfo": map[string]any{"name": "multica-agent-sdk", "version": "0.2.0"},
"clientCapabilities": map[string]any{
"fs": map[string]any{"readTextFile": false, "writeTextFile": false},
"terminal": false,
},
})
if err != nil {
status, errText, _ := classifyACPResumeFailure(runCtx, "opencode", "initialize", err, timeout, b.cfg.Logger)
result(status, errText, "", false)
return
}
mcpServers = filterACPMcpServersByCapability(mcpServers, extractACPMcpCapabilities(initResult), "opencode", b.cfg)
var sessionID string
var sessionResult json.RawMessage
if opts.ResumeSessionID != "" {
loaded, err := c.request(runCtx, "session/load", map[string]any{
"cwd": cwd,
"sessionId": opts.ResumeSessionID,
"mcpServers": mcpServers,
})
if err != nil {
status, errText, rejected := classifyACPResumeFailure(runCtx, "opencode", "session/load", err, timeout, b.cfg.Logger)
result(status, errText, "", rejected)
return
}
var changed bool
sessionID, changed = resolveResumedSessionID(opts.ResumeSessionID, loaded)
if changed {
b.cfg.Logger.Warn("opencode returned a different session id on resume; continuing with the new id",
"requested", opts.ResumeSessionID, "actual", sessionID)
}
sessionResult = loaded
} else {
created, err := c.request(runCtx, "session/new", map[string]any{
"cwd": cwd,
"mcpServers": mcpServers,
})
if err != nil {
status, errText, _ := classifyACPResumeFailure(runCtx, "opencode", "session/new", err, timeout, b.cfg.Logger)
result(status, errText, "", false)
return
}
if sessionID = extractACPSessionID(created); sessionID == "" {
result("failed", "opencode session/new returned no session ID", "", false)
return
}
sessionResult = created
}
c.sessionID = sessionID
model, level := opencodeACPSplitModel(opts.Model, opts.ThinkingLevel)
options, err := opencodeACPSelectModel(runCtx, c.request, sessionID, model, sessionResult)
if err != nil {
status, errText, _ := classifyACPResumeFailure(runCtx, "opencode", "model selection", err, timeout, b.cfg.Logger)
keep := sessionID
if setupFailureWithholdsSessionID(opts) {
keep = ""
}
result(status, errText, keep, false)
return
}
if options != nil {
applyACPEffortOption(runCtx, c.request, "opencode", b.cfg.Logger, sessionID, options, level, true)
} else if level != "" {
b.cfg.Logger.Warn("opencode session advertises no configuration after model selection; ignoring thinking level", "thinkingLevel", level)
}
b.cfg.Logger.Info("opencode session ready", "session_id", sessionID, "resumed", opts.ResumeSessionID != "")
trySend(msgCh, Message{Type: MessageStatus, Status: "running", SessionID: sessionID})
// OpenCode reads the runtime brief from the AGENTS.md the daemon writes
// into the workdir, so SystemPrompt is not prepended — same as the run
// transport (MUL-5392).
streaming.Store(true)
turnStart := time.Now()
type promptOutcome struct{ err error }
promptCh := make(chan promptOutcome, 1)
go func() {
_, err := c.requestAndNotifySent(context.Background(), "session/prompt", map[string]any{
"sessionId": sessionID,
"prompt": []map[string]any{{"type": "text", "text": prompt}},
}, func() { promptSent.Store(true) })
promptCh <- promptOutcome{err: err}
}()
finalStatus := "completed"
var finalError string
var promptErr error
answered := false
select {
case out := <-promptCh:
promptErr, answered = out.err, true
case <-runCtx.Done():
if !promptSent.Load() {
b.cfg.Logger.Warn("opencode: run ended before the prompt was delivered; stopping the process")
break
}
// Ask OpenCode to stop the turn; it aborts the running tool and
// answers the prompt. The write runs on its own goroutine so a
// stalled pipe cannot hold the teardown, and the grace starts now.
go func() {
if err := opencodeACPNotify(c, "session/cancel", map[string]any{"sessionId": sessionID}); err != nil {
b.cfg.Logger.Warn("opencode: session/cancel could not be sent", "error", err)
}
}()
select {
case out := <-promptCh:
promptErr, answered = out.err, true
case <-time.After(opencodeACPCancelGrace):
b.cfg.Logger.Warn("opencode did not answer session/cancel in time; stopping the process", "grace", opencodeACPCancelGrace.String())
}
}
var pr hermesPromptResult
havePrompt := false
if answered && promptErr == nil {
select {
case pr = <-promptDone:
havePrompt = true
default:
}
}
switch {
case runCtx.Err() == context.DeadlineExceeded:
finalStatus, finalError = "timeout", fmt.Sprintf("opencode timed out after %s", timeout)
case runCtx.Err() == context.Canceled:
finalStatus, finalError = "aborted", "execution cancelled"
case promptErr != nil:
finalStatus, finalError = "failed", fmt.Sprintf("opencode session/prompt failed: %v", promptErr)
case havePrompt:
switch pr.stopReason {
case "", "end_turn":
case "cancelled":
finalStatus, finalError = "aborted", "opencode cancelled the turn"
case "max_tokens":
finalStatus, finalError = "failed", "opencode stopped: the model hit its output length limit"
case "refusal":
finalStatus, finalError = "failed", "opencode stopped: the provider refused the request"
default:
finalStatus, finalError = "failed", "opencode stopped: "+pr.stopReason
}
}
if answered && runCtx.Err() == nil {
waitForACPNotificationQuiescence(runCtx, activity, readerDone, acpNotificationQuietTime, opencodeACPDrainGrace)
}
streaming.Store(false)
// A turn OpenCode never answered is forced down; an answered one,
// including an acknowledged cancel, exits gracefully.
stop(!answered)
providerErr.Finalize()
outputMu.Lock()
finalOutput := output.String()
outputMu.Unlock()
finalStatus, finalError = promoteACPResultOnProviderError(finalStatus, finalError, finalOutput, providerErr)
usage, usageErr := opencodeACPTurnUsage(b.cfg, execPath, env, cwd, sessionID, turnStart, usesV2)
if usageErr != nil {
usage = TokenUsage{}
// Fall back to what the prompt reported only where that figure is
// not an overcount: 1.x reports one message (an undercount), and a
// fresh 2.x session's total is this turn.
if havePrompt && (!usesV2 || opts.ResumeSessionID == "") {
usage = pr.usage.TokenUsage
usage.CostUSDTicks = 0
}
b.cfg.Logger.Warn("opencode: could not read this turn's token usage from the session export", "error", usageErr, "fallback", acpTokenUsagePresent(usage))
}
// The run transport's empty-step guard (#6522): a turn that closes
// cleanly with no text, no tool call and no usage at all never reached
// the provider, and must not be reported as a success.
if finalStatus == "completed" && usageErr == nil && !sawText.Load() && !sawTool.Load() && !acpTokenUsagePresent(usage) {
finalStatus = "failed"
finalError = "opencode turn ended with no text, no tool call and no reported usage — the provider produced nothing"
}
var usageMap map[string]TokenUsage
if acpTokenUsagePresent(usage) {
name := opts.Model
if name == "" {
name = "unknown"
}
usageMap = map[string]TokenUsage{name: usage}
}
duration := time.Since(startTime)
b.cfg.Logger.Info("opencode finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
resCh <- Result{
Status: finalStatus,
Output: finalOutput,
Error: finalError,
DurationMs: duration.Milliseconds(),
SessionID: sessionID,
Usage: usageMap,
}
}()
return &Session{Messages: msgCh, Result: resCh}, nil
}
// opencodeACPNotify writes a JSON-RPC notification (no id, no response).
func opencodeACPNotify(c *hermesClient, method string, params any) error {
data, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "method": method, "params": params})
if err != nil {
return err
}
return c.writeLine(append(data, '\n'))
}
// opencodeACPTurnUsage sums the token usage of the assistant messages this turn
// created, read from the session export after the process has exited.
//
// Each OpenCode step is its own assistant message with its own provider call,
// so the turn's usage is their sum — the same figure the run transport adds up
// from step_finish events. The turn's messages are the ones created at or after
// turnStart; the export's timestamps come from the same host clock.
func opencodeACPTurnUsage(cfg Config, execPath string, env []string, cwd, sessionID string, turnStart time.Time, usesV2 bool) (TokenUsage, error) {
if sessionID == "" || strings.HasPrefix(sessionID, "-") {
return TokenUsage{}, errors.New("no session id")
}
ctx, cancel := context.WithTimeout(context.Background(), opencodeACPUsageTimeout)
defer cancel()
args := []string{"export", sessionID}
if usesV2 {
// --standalone keeps the export off the resident background service:
// this run never started one, and should not leave one behind.
args = []string{"session", "export", sessionID, "--standalone"}
}
run := func(args []string) ([]byte, error) {
cmd := cfg.commandAt(execPath).exec(ctx, args...)
hideAgentWindow(cmd)
cmd.Dir, cmd.Env = cwd, env
cmd.Stderr = &tailBuffer{max: 4096}
limited := &limitedBuffer{max: openCodeClockExportMaxBytes}
cmd.Stdout = limited
err := runOwned(cmd, cfg.Logger)
if limited.overflow {
return nil, errors.New("session export exceeds the size limit")
}
return limited.buf.Bytes(), err
}
// --sanitize redacts message text, which this never needs. Older CLIs
// lack the flag; retry without it.
data, err := run(append(args, "--sanitize"))
if err != nil {
data, err = run(args)
}
if err != nil {
return TokenUsage{}, fmt.Errorf("session export: %w", err)
}
return opencodeExportTurnUsage(data, turnStart.UnixMilli(), opencodeSeparatesReasoning(cfg))
}
// opencodeExportTurnUsage reads both export shapes: 1.x nests each message's
// metadata under `info` with a `role`; 2.x flattens it and names the kind
// `type`.
func opencodeExportTurnUsage(data []byte, sinceMs int64, separateReasoning bool) (TokenUsage, error) {
type msgTime struct {
Created int64 `json:"created"`
}
type meta struct {
Role string `json:"role"`
Type string `json:"type"`
Time msgTime `json:"time"`
Tokens *opencodeTokens `json:"tokens"`
}
var export struct {
Messages []struct {
meta
Info *meta `json:"info"`
} `json:"messages"`
}
start := bytes.IndexByte(data, '{')
if start < 0 {
return TokenUsage{}, errors.New("session export is not JSON")
}
if err := json.Unmarshal(data[start:], &export); err != nil {
return TokenUsage{}, fmt.Errorf("parse session export: %w", err)
}
var usage TokenUsage
for _, m := range export.Messages {
info := m.meta
if m.Info != nil {
info = *m.Info
}
if info.Role != "assistant" && info.Type != "assistant" {
continue
}
if info.Time.Created < sinceMs || info.Tokens == nil {
continue
}
t := info.Tokens
usage.InputTokens += t.Input
usage.OutputTokens += t.Output
if separateReasoning {
usage.OutputTokens += t.Reasoning
}
if t.Cache != nil {
usage.CacheReadTokens += t.Cache.Read
usage.CacheWriteTokens += t.Cache.Write
}
}
return usage, nil
}
// limitedBuffer keeps at most max bytes and records whether more arrived.
type limitedBuffer struct {
buf bytes.Buffer
max int
overflow bool
}
func (l *limitedBuffer) Write(p []byte) (int, error) {
if room := l.max - l.buf.Len(); room < len(p) {
l.overflow = true
if room > 0 {
l.buf.Write(p[:room])
}
return len(p), nil
}
return l.buf.Write(p)
}