Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions config_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"strconv"
"strings"

"github.com/qualitymax/qmax-code/internal/agent"
"github.com/qualitymax/qmax-code/internal/api"
Expand Down Expand Up @@ -158,8 +159,17 @@ func printConfig() {
} else {
fmt.Print(" (WARNING: CEREBRAS_API_KEY not set)")
}
case "opencode":
if bin := agent.FindOpenCode(); bin != "" {
fmt.Printf(" (opencode found: %s; model: %s)", bin, cfg.ModelOverride)
} else {
fmt.Print(" (WARNING: opencode binary not found in PATH)")
}
}
fmt.Println()
if len(cfg.EnabledProviders) > 0 {
fmt.Printf(" providers = %s (opencode; manage with /providers)\n", strings.Join(cfg.EnabledProviders, ", "))
}
}

// setConfigField writes the given value into the Config. Empty value
Expand Down Expand Up @@ -236,10 +246,10 @@ func setConfigField(key, value string) error {

case "backend":
switch value {
case "", "api", "cc", "codex", "cerebras":
case "", "api", "cc", "codex", "cerebras", "opencode":
cfg.Backend = value
default:
return fmt.Errorf("invalid backend %q; allowed: api, cc, codex, cerebras", value)
return fmt.Errorf("invalid backend %q; allowed: api, cc, codex, cerebras, opencode", value)
}

case "cerebras_key":
Expand Down
308 changes: 308 additions & 0 deletions internal/agent/opencode_agent.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
package agent

import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"

"github.com/qualitymax/qmax-code/internal/api"
"github.com/qualitymax/qmax-code/internal/tui"
)

// OpenCodeAgent orchestrates an opencode CLI subprocess for LLM inference.
// Inference runs through whichever provider the user opted into (Z.AI, Groq,
// OpenRouter, …) using the user's own key — qmax-code consumes no QM-held
// tokens. qmax tools are exposed to opencode via an MCP server entry in the
// managed opencode config, so opencode can call them natively.
//
// opencode supports native session resume (--session) and a rich NDJSON event
// stream (opencode run --format json), so this mirrors the CCAgent design
// rather than CodexAgent's self-managed history.
//
// Per-message flow:
// 1. qmax-code writes ~/.qmax-code/opencode.json (provider blocks + qmax MCP)
// 2. qmax-code spawns: opencode run --format json --model <provider>/<model>
// [--session <id>] [--auto] -- "msg" with OPENCODE_CONFIG + key env set
// 3. opencode picks up the MCP config and spawns qmax-code serve --mcp
// 4. opencode runs the turn on the user's provider, using qmax tools via MCP
// 5. qmax-code parses opencode's NDJSON and renders it; session id → --session
type OpenCodeAgent struct {
openCodeBin string
modelID string // "provider/model"; "" lets opencode use its default
effort string // "low" | "medium" | "high"
outputVerbose bool
permissionMode string // "standard" | "unattended" (--auto)
sessionID string // opencode session id, for --session resume
cfg *api.Config
sctx *api.SessionContext
lastToolName string
mu sync.Mutex
runMu sync.Mutex
runCancel context.CancelFunc // non-nil while Run() is active
}

// FindOpenCode returns the path to the opencode CLI binary, or "" if not found.
func FindOpenCode() string {
if path, err := exec.LookPath("opencode"); err == nil {
return path
}
for _, p := range []string{
filepath.Join(os.Getenv("HOME"), ".opencode/bin/opencode"),
"/usr/local/bin/opencode",
"/opt/homebrew/bin/opencode",
filepath.Join(os.Getenv("HOME"), ".local/bin/opencode"),
} {
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}

// NewOpenCodeAgent creates an opencode subprocess orchestrator.
// modelID is the full "provider/model" string selected via the picker.
// effort is "low" | "medium" | "high" (empty defaults to "high").
// permissionMode is "standard" or "unattended" (adds --auto).
func NewOpenCodeAgent(bin, modelID, effort, permissionMode string, outputVerbose bool, cfg *api.Config, sctx *api.SessionContext) *OpenCodeAgent {
if effort == "" {
effort = "high"
}
if permissionMode == "" {
permissionMode = "standard"
}
return &OpenCodeAgent{
openCodeBin: bin,
modelID: modelID,
effort: effort,
outputVerbose: outputVerbose,
permissionMode: permissionMode,
cfg: cfg,
sctx: sctx,
}
}

// validOpenCodeSessionID guards the --session argument. opencode session ids
// look like "ses_0a91c2141ffe8FiFOZVFulDUUM": a "ses_" prefix followed by
// alphanumeric characters.
func validOpenCodeSessionID(id string) bool {
if !strings.HasPrefix(id, "ses_") || len(id) > 64 {
return false
}
rest := id[len("ses_"):]
if rest == "" {
return false
}
for _, r := range rest {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
return false
}
}
return true
}

// Run executes one conversation turn through an opencode subprocess.
func (a *OpenCodeAgent) Run(userMsg string, term *tui.Terminal) (string, error) {
// Regenerate the managed config each turn so newly enabled/disabled
// providers (and the permission policy) take effect without a restart.
configPath, err := WriteOpenCodeConfig(a.cfg, a.sctx, a.permissionMode)
if err != nil {
return "", fmt.Errorf("opencode config: %w", err)
}

safeUserMsg, err := sanitizeCCUserPrompt(userMsg)
if err != nil {
return "", err
}

a.mu.Lock()
sessionID := a.sessionID
a.mu.Unlock()

// On the first turn of a session, prepend the QA system prompt + effort/output
// directives. opencode persists conversation state per session, so later turns
// resume via --session and don't need it re-injected.
message := safeUserMsg
if sessionID == "" {
message = codexQASystemPrompt + effortDirective(a.effort) + outputStyleDirective(a.outputVerbose) + "\n\n" + safeUserMsg
}

args := []string{"run", "--format", "json"}
if a.modelID != "" {
args = append(args, "--model", a.modelID)
}
// --auto auto-approves anything not explicitly denied. In standard mode the
// managed config denies edits + destructive shell (openCodeStandardPermission),
// so --auto is safe there too; unattended has no denies (full autonomy). Both
// need --auto because `opencode run` is non-interactive — without it, tools
// that would prompt simply block.
args = append(args, "--auto")
if sessionID != "" && validOpenCodeSessionID(sessionID) {
args = append(args, "--session", sessionID)
}
// "--" terminates flag parsing so a message starting with "-" is treated as
// the positional prompt rather than an unknown flag.
args = append(args, "--", message)

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()

a.runMu.Lock()
a.runCancel = cancel
a.runMu.Unlock()
defer func() {
a.runMu.Lock()
a.runCancel = nil
a.runMu.Unlock()
}()

cmd := exec.CommandContext(ctx, a.openCodeBin, args...)
cmd.Stdin = strings.NewReader("")
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(), "OPENCODE_CONFIG="+configPath)
for k, v := range OpenCodeProviderEnv(a.cfg) {
cmd.Env = append(cmd.Env, k+"="+v)
}

stdout, err := cmd.StdoutPipe()
if err != nil {
return "", fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("start opencode: %w", err)
}

result := a.parseStream(stdout, term)

if err := cmd.Wait(); err != nil {
// Intentional cancel (user pressed Enter to interrupt) — not an error.
if ctx.Err() != nil {
return result, nil
}
if result == "" {
return "", fmt.Errorf("opencode exited with error: %w", err)
}
}
return result, nil
}

// --- NDJSON stream parsing ---

type ocEvent struct {
Type string `json:"type"`
SessionID string `json:"sessionID"`
Part ocPart `json:"part"`
}

type ocPart struct {
ID string `json:"id"`
Type string `json:"type"`
Text string `json:"text"`
Tool string `json:"tool"`
}

// parseStream reads opencode's NDJSON output, renders it, captures the session
// id for --session resume, and returns the full text of the final response.
func (a *OpenCodeAgent) parseStream(stdout interface{ Read([]byte) (int, error) }, term *tui.Terminal) string {
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 1<<20), 1<<20)

textByPart := map[string]string{} // part id → latest full text
var order []string // text part ids in first-seen order
seenTool := map[string]bool{} // tool part ids already announced

for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var ev ocEvent
if err := json.Unmarshal(line, &ev); err != nil {
continue
}

if ev.SessionID != "" && validOpenCodeSessionID(ev.SessionID) {
a.mu.Lock()
a.sessionID = ev.SessionID
a.mu.Unlock()
}

switch {
case ev.Type == "text" || ev.Part.Type == "text":
id := ev.Part.ID
text := ev.Part.Text

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 TESTINGGROUNDED opencode NDJSON parser drops text payloads that arrive on tool events

parseStream scans NDJSON and discriminates ev.Type == "text" || ev.Part.Type == "text" as one case, and ev.Part.Type == "tool" || ev.Type == "tool" as the other. A tool event that carries both a Part.Tool and a non-empty Part.Text (for example, a tool result event where opencode emits type:"tool" plus a text payload containing the tool output) is consumed only by the tool branch, and the text payload is silently dropped — the new text never enters textByPart and never appears in the final result. This silently loses tool-result content. The fix is to check ev.Part.Text regardless of Type; the most natural forward-proof shape is a switch on ev.Part.Type that falls through to a shared if text := ev.Part.Text; text != "" append.

Example:

input: NDJSON line `{"type":"tool","part":{"id":"p1","type":"tool","tool":"edit","text":"Applied edit..."}}`
actual: tool icon shown; "Applied edit..." never rendered and not contributed to finalResult
expected: text is streamed/accumulated and included in finalResult alongside any streamed text from prior text-only parts

Suggested fix:

switch ev.Part.Type {
case "text":
  // handle text-by-part accumulator and delta streaming as today
case "tool":
  // handle tool-icon display
}
// shared: always process text regardless of Part.Type
if text := ev.Part.Text; text != "" {
  // accumulate and stream delta
}
More Info
  • Threat model: A user asking opencode to run a tool and incorporate its output in the conversation receives an answer that is missing the tool result. The impact is correctness — assistant reasoning that depends on the tool output degrades or is lost. This is not a security or auth boundary.
  • Specific code citations: parseStream in internal/agent/opencode_agent.go lines 217–260: the top-level switch branches are exclusive — the text branch only runs when ev.Type == "text" || ev.Part.Type == "text", and the tool branch only runs when ev.Part.Type == "tool" || ev.Type == "tool". No common path processes ev.Part.Text when ev.Part.Type == "tool" && ev.Part.Text != "".
  • Existing protections: None — the parser has no fallback that captures text on tool-typed events.
  • Proposed mitigation: Refactor the event loop to always process ev.Part.Text when non-empty, independent of ev.Part.Type. A switch on ev.Part.Type for type-specific handling (tool icon, etc.) followed by a shared text-accumulation block is the cleanest fix.
  • Alternative mitigations considered: Keep the current branch structure but duplicate the text-accumulation logic in the tool branch — more error-prone and harder to maintain.
  • Severity calibration: Score 4 because this is a reachable correctness bug on a core conversation path (tool use) with no test coverage; it silently corrupts assistant output when opencode emits tool-result text.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/agent/opencode_agent.go
Line: 239

Comment:
**opencode NDJSON parser drops text payloads that arrive on tool events**

`parseStream` scans NDJSON and discriminates `ev.Type == "text" || ev.Part.Type == "text"` as one case, and `ev.Part.Type == "tool" || ev.Type == "tool"` as the other. A tool event that carries both a `Part.Tool` and a non-empty `Part.Text` (for example, a tool result event where opencode emits `type:"tool"` plus a text payload containing the tool output) is consumed only by the tool branch, and the text payload is silently dropped — the new text never enters `textByPart` and never appears in the final result. This silently loses tool-result content. The fix is to check `ev.Part.Text` regardless of `Type`; the most natural forward-proof shape is a `switch` on `ev.Part.Type` that falls through to a shared `if text := ev.Part.Text; text != ""` append.

Example:
input: NDJSON line `{"type":"tool","part":{"id":"p1","type":"tool","tool":"edit","text":"Applied edit..."}}`
actual: tool icon shown; "Applied edit..." never rendered and not contributed to finalResult
expected: text is streamed/accumulated and included in finalResult alongside any streamed text from prior text-only parts

Threat model:
A user asking opencode to run a tool and incorporate its output in the conversation receives an answer that is missing the tool result. The impact is correctness — assistant reasoning that depends on the tool output degrades or is lost. This is not a security or auth boundary.

Specific code citations:
`parseStream` in `internal/agent/opencode_agent.go` lines 217–260: the top-level `switch` branches are exclusive — the `text` branch only runs when `ev.Type == "text" || ev.Part.Type == "text"`, and the `tool` branch only runs when `ev.Part.Type == "tool" || ev.Type == "tool"`. No common path processes `ev.Part.Text` when `ev.Part.Type == "tool" && ev.Part.Text != ""`.

Existing protections:
None — the parser has no fallback that captures text on tool-typed events.

Proposed mitigation:
Refactor the event loop to always process `ev.Part.Text` when non-empty, independent of `ev.Part.Type`. A `switch` on `ev.Part.Type` for type-specific handling (tool icon, etc.) followed by a shared text-accumulation block is the cleanest fix.

Alternative mitigations considered:
Keep the current branch structure but duplicate the text-accumulation logic in the tool branch — more error-prone and harder to maintain.

Severity calibration:
Score 4 because this is a reachable correctness bug on a core conversation path (tool use) with no test coverage; it silently corrupts assistant output when opencode emits tool-result text.

Suggested fix shape:
switch ev.Part.Type {
case "text":
  // handle text-by-part accumulator and delta streaming as today
case "tool":
  // handle tool-icon display
}
// shared: always process text regardless of Part.Type
if text := ev.Part.Text; text != "" {
  // accumulate and stream delta
}

How can I resolve this? If you propose a fix, please make it concise.

if text == "" {
continue
}
prev, seen := textByPart[id]
if !seen {
order = append(order, id)
}
// opencode may re-emit a growing snapshot for the same part id;
// stream only the delta to avoid duplication.
if strings.HasPrefix(text, prev) {
if delta := text[len(prev):]; delta != "" {
term.StreamText(delta)
}
} else {
term.StreamText(text)
}
textByPart[id] = text

case ev.Part.Type == "tool" || ev.Type == "tool":
if ev.Part.Tool == "" || seenTool[ev.Part.ID] {
continue
}
seenTool[ev.Part.ID] = true
displayName := stripMCPPrefix(ev.Part.Tool)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 LOGICGROUNDED parseStream silently drops oversized NDJSON lines

The scanner is capped at 1 MiB per line, but parseStream never checks scanner.Err() after the loop. If opencode emits a single NDJSON event larger than that, Scan() stops and the remainder of the stream is discarded without surfacing an error.

Example:

input: opencode emits a 2 MiB tool-result event
actual: scanner stops, remaining response is lost, no error returned
expected: the oversized-line error is propagated or logged

Current:

			displayName := stripMCPPrefix(ev.Part.Tool)

Proposed:

term.FinishMarkdown(finalResult)
return finalResult
Suggested change
displayName := stripMCPPrefix(ev.Part.Tool)
term.FinishMarkdown(finalResult)
return finalResult
More Info
  • Threat model: An oversized tool-result event from opencode causes silent truncation of the assistant's response.
  • Specific code citations: parseStream creates bufio.Scanner with Buffer(make([]byte, 1<<20), 1<<20) but lacks a scanner.Err() check after the for scanner.Scan() loop.
  • Existing protections: None — the error is silently swallowed.
  • Proposed mitigation: Check scanner.Err() after the loop and return an error wrapping it.
  • Alternative mitigations considered: Increase the buffer cap, but that only raises the threshold; the error check is still required.
  • Severity calibration: Score 2: requires an unusually large single event to trigger, but when it does the failure is silent data loss.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/agent/opencode_agent.go
Line: 260

Comment:
**`parseStream` silently drops oversized NDJSON lines**

The scanner is capped at 1 MiB per line, but `parseStream` never checks `scanner.Err()` after the loop. If opencode emits a single NDJSON event larger than that, `Scan()` stops and the remainder of the stream is discarded without surfacing an error.

Example:
input: opencode emits a 2 MiB tool-result event
actual: scanner stops, remaining response is lost, no error returned
expected: the oversized-line error is propagated or logged

Threat model:
An oversized tool-result event from opencode causes silent truncation of the assistant's response.

Specific code citations:
`parseStream` creates `bufio.Scanner` with `Buffer(make([]byte, 1<<20), 1<<20)` but lacks a `scanner.Err()` check after the `for scanner.Scan()` loop.

Existing protections:
None — the error is silently swallowed.

Proposed mitigation:
Check `scanner.Err()` after the loop and return an error wrapping it.

Alternative mitigations considered:
Increase the buffer cap, but that only raises the threshold; the error check is still required.

Severity calibration:
Score 2: requires an unusually large single event to trigger, but when it does the failure is silent data loss.

Suggested fix shape:
if err := scanner.Err(); err != nil {
  return finalResult, fmt.Errorf("opencode stream: %w", err)
}

How can I resolve this? If you propose a fix, please make it concise.

a.mu.Lock()
a.lastToolName = displayName
a.mu.Unlock()
term.PrintToolIcon(displayName)
if !a.outputVerbose {
fmt.Println()
}
}
}

var sb strings.Builder
for _, id := range order {
sb.WriteString(textByPart[id])
}
finalResult := sb.String()
term.FinishMarkdown(finalResult)
return finalResult
}

// ClearSession forgets the opencode session id so the next turn starts fresh
// (used when the user types /clear).
func (a *OpenCodeAgent) ClearSession() {
a.mu.Lock()
a.sessionID = ""
a.mu.Unlock()
}

func (a *OpenCodeAgent) SetOutputVerbose(verbose bool) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 LOGICGROUNDED Unlocked read of outputVerbose races with SetOutputVerbose

SetOutputVerbose writes outputVerbose under a.mu, but parseStream reads it without the mutex when deciding whether to emit the trailing newline, and Run reads it without the mutex when building the system prompt. This breaks the intended synchronization and is a data race under Go's memory model; a concurrent verbose toggle could stream stale state or be flagged by the race detector.

Example:

input: user toggles verbose mode while a long opencode turn is streaming
actual: race detector reports read/write race on outputVerbose
expected: all reads and writes of outputVerbose are synchronized

Current:

func (a *OpenCodeAgent) SetOutputVerbose(verbose bool) {

Proposed:

if !a.outputVerboseLocked() {
Suggested change
func (a *OpenCodeAgent) SetOutputVerbose(verbose bool) {
if !a.outputVerboseLocked() {
More Info
  • Threat model: A concurrent call to SetOutputVerbose (e.g., from a TUI/config toggle) while a turn is running reads a value that is not happens-before ordered with the write, producing undefined behavior in Go.
  • Specific code citations: SetOutputVerbose locks a.mu around the write; parseStream accesses a.outputVerbose directly at the tool-icon newline branch; Run passes a.outputVerbose to outputStyleDirective.
  • Existing protections: Other mutable fields (sessionID, lastToolName) are correctly accessed under a.mu; only outputVerbose is read outside the lock.
  • Proposed mitigation: Add a locked getter for outputVerbose and use it in both Run and parseStream, or wrap each read in a.mu.Lock()/Unlock().
  • Alternative mitigations considered: Remove the mutex from SetOutputVerbose if the field is never mutated concurrently, but that contradicts the existing lock and leaves the method racy if callers do use it concurrently.
  • Severity calibration: Score 3: the blast radius is bounded to output formatting, but it is a genuine data race on mutable agent state.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/agent/opencode_agent.go
Line: 288

Comment:
**Unlocked read of `outputVerbose` races with `SetOutputVerbose`**

`SetOutputVerbose` writes `outputVerbose` under `a.mu`, but `parseStream` reads it without the mutex when deciding whether to emit the trailing newline, and `Run` reads it without the mutex when building the system prompt. This breaks the intended synchronization and is a data race under Go's memory model; a concurrent verbose toggle could stream stale state or be flagged by the race detector.

Example:
input: user toggles verbose mode while a long opencode turn is streaming
actual: race detector reports read/write race on outputVerbose
expected: all reads and writes of outputVerbose are synchronized

Threat model:
A concurrent call to `SetOutputVerbose` (e.g., from a TUI/config toggle) while a turn is running reads a value that is not happens-before ordered with the write, producing undefined behavior in Go.

Specific code citations:
`SetOutputVerbose` locks `a.mu` around the write; `parseStream` accesses `a.outputVerbose` directly at the tool-icon newline branch; `Run` passes `a.outputVerbose` to `outputStyleDirective`.

Existing protections:
Other mutable fields (`sessionID`, `lastToolName`) are correctly accessed under `a.mu`; only `outputVerbose` is read outside the lock.

Proposed mitigation:
Add a locked getter for `outputVerbose` and use it in both `Run` and `parseStream`, or wrap each read in `a.mu.Lock()/Unlock()`.

Alternative mitigations considered:
Remove the mutex from `SetOutputVerbose` if the field is never mutated concurrently, but that contradicts the existing lock and leaves the method racy if callers do use it concurrently.

Severity calibration:
Score 3: the blast radius is bounded to output formatting, but it is a genuine data race on mutable agent state.

Suggested fix shape:
func (a *OpenCodeAgent) outputVerboseLocked() bool {
  a.mu.Lock()
  defer a.mu.Unlock()
  return a.outputVerbose
}

How can I resolve this? If you propose a fix, please make it concise.

a.mu.Lock()
a.outputVerbose = verbose
a.mu.Unlock()
}

// Cancel interrupts a Run call that is in progress. Safe to call from any goroutine.
func (a *OpenCodeAgent) Cancel() {
a.runMu.Lock()
if a.runCancel != nil {
a.runCancel()
}
a.runMu.Unlock()
}

// Cleanup is a no-op: the managed opencode config is persistent and syncable,
// not a per-session temp file.
func (a *OpenCodeAgent) Cleanup() {}
Loading
Loading