Files
cc-connect/agent/opencode/session.go
T
kevinWangSheng be74fddca2 fix(agent): prevent panic from closing events channel while readLoop may still send
In 6 agent session implementations (codex, opencode, qoder, iflow, cursor,
gemini), Close() unconditionally calls close(events) after an 8-second
timeout, even when the readLoop goroutine may still be running and
sending events to the channel. Due to Go's select non-determinism, the
readLoop goroutine can choose the send case over the ctx.Done() case,
causing a "send on closed channel" panic.

Move close(events) into the case where wg.Wait() succeeds (readLoop has
fully exited), and skip closing on timeout to prevent the race. This
follows the same safe pattern already used by claudeSession, where the
events channel is only closed after the readLoop goroutine has finished.
2026-03-14 08:36:29 -07:00

409 lines
9.6 KiB
Go

package opencode
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/chenhg5/cc-connect/core"
)
// opencodeSession manages multi-turn conversations with the OpenCode CLI.
// Each Send() launches a new `opencode run --format json` process
// with --session for conversation continuity.
type opencodeSession struct {
cmd string
workDir string
model string
mode string
extraEnv []string
events chan core.Event
chatID atomic.Value // stores string — OpenCode session ID
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
alive atomic.Bool
}
func newOpencodeSession(ctx context.Context, cmd, workDir, model, mode, resumeID string, extraEnv []string) (*opencodeSession, error) {
sessionCtx, cancel := context.WithCancel(ctx)
s := &opencodeSession{
cmd: cmd,
workDir: workDir,
model: model,
mode: mode,
extraEnv: extraEnv,
events: make(chan core.Event, 64),
ctx: sessionCtx,
cancel: cancel,
}
s.alive.Store(true)
if resumeID != "" {
s.chatID.Store(resumeID)
}
return s, nil
}
func (s *opencodeSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error {
if len(files) > 0 {
filePaths := core.SaveFilesToDisk(s.workDir, files)
prompt = core.AppendFileRefs(prompt, filePaths)
}
if !s.alive.Load() {
return fmt.Errorf("session is closed")
}
chatID := s.CurrentSessionID()
isResume := chatID != ""
args := []string{"run", "--format", "json"}
if isResume {
args = append(args, "--session", chatID)
}
if s.model != "" {
args = append(args, "--model", s.model)
}
if s.workDir != "" {
args = append(args, "--dir", s.workDir)
}
// Enable thinking blocks
args = append(args, "--thinking")
// Append prompt as positional arg
args = append(args, prompt)
slog.Debug("opencodeSession: launching", "resume", isResume, "args", core.RedactArgs(args))
cmd := exec.CommandContext(s.ctx, s.cmd, args...)
cmd.Dir = s.workDir
env := os.Environ()
if len(s.extraEnv) > 0 {
env = core.MergeEnv(env, s.extraEnv)
}
cmd.Env = env
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("opencodeSession: stdout pipe: %w", err)
}
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
return fmt.Errorf("opencodeSession: start: %w", err)
}
s.wg.Add(1)
go s.readLoop(cmd, stdout, &stderrBuf)
return nil
}
func (s *opencodeSession) readLoop(cmd *exec.Cmd, stdout io.ReadCloser, stderrBuf *bytes.Buffer) {
defer s.wg.Done()
defer func() {
if err := cmd.Wait(); err != nil {
stderrMsg := stderrBuf.String()
if stderrMsg != "" {
slog.Error("opencodeSession: process failed", "error", err, "stderr", stderrMsg)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)}
select {
case s.events <- evt:
case <-s.ctx.Done():
return
}
}
}
}()
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
var raw map[string]any
if err := json.Unmarshal([]byte(line), &raw); err != nil {
slog.Debug("opencodeSession: non-JSON line", "line", line)
continue
}
s.handleEvent(raw)
}
if err := scanner.Err(); err != nil {
slog.Error("opencodeSession: scanner error", "error", err)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", err)}
select {
case s.events <- evt:
case <-s.ctx.Done():
return
}
}
// Emit EventResult after all steps are done and the process has finished writing.
sid := s.CurrentSessionID()
evt := core.Event{Type: core.EventResult, SessionID: sid, Done: true}
select {
case s.events <- evt:
case <-s.ctx.Done():
}
}
// OpenCode NDJSON event structure:
//
// { "type": "text|tool_use|reasoning|step_start|step_finish",
// "part": { "type": "text|tool|reasoning|step-start|step-finish", ... } }
func (s *opencodeSession) handleEvent(raw map[string]any) {
eventType, _ := raw["type"].(string)
switch eventType {
case "text":
s.handleText(raw)
case "tool_use":
s.handleToolUse(raw)
case "reasoning":
s.handleReasoning(raw)
case "step_start":
s.handleStepStart(raw)
case "step_finish":
s.handleStepFinish(raw)
case "error":
s.handleError(raw)
default:
b, _ := json.Marshal(raw)
slog.Debug("opencodeSession: unhandled event", "type", eventType, "raw", string(b))
}
}
func (s *opencodeSession) handleText(raw map[string]any) {
part, _ := raw["part"].(map[string]any)
if part == nil {
return
}
text, _ := part["text"].(string)
if text != "" {
evt := core.Event{Type: core.EventText, Content: text}
select {
case s.events <- evt:
case <-s.ctx.Done():
return
}
}
}
func (s *opencodeSession) handleToolUse(raw map[string]any) {
part, _ := raw["part"].(map[string]any)
if part == nil {
return
}
toolName, _ := part["tool"].(string)
state, _ := part["state"].(map[string]any)
status := ""
if state != nil {
status, _ = state["status"].(string)
}
// Extract tool input summary for display
input := extractToolInput(state)
if status == "completed" {
// OpenCode bundles call + result in one event; emit both for UI.
useEvt := core.Event{Type: core.EventToolUse, ToolName: toolName, ToolInput: input}
select {
case s.events <- useEvt:
case <-s.ctx.Done():
return
}
output, _ := state["output"].(string)
resultEvt := core.Event{Type: core.EventToolResult, ToolName: toolName, Content: truncate(output, 500)}
select {
case s.events <- resultEvt:
case <-s.ctx.Done():
return
}
} else {
evt := core.Event{Type: core.EventToolUse, ToolName: toolName, ToolInput: input}
select {
case s.events <- evt:
case <-s.ctx.Done():
return
}
}
}
func extractToolInput(state map[string]any) string {
if state == nil {
return ""
}
// Prefer title as a concise description (e.g. "List files in current directory")
if title, ok := state["title"].(string); ok && title != "" {
return title
}
switch input := state["input"].(type) {
case string:
return input
case map[string]any:
// Use "description" or "command" fields if available
if desc, ok := input["description"].(string); ok && desc != "" {
return desc
}
if cmd, ok := input["command"].(string); ok && cmd != "" {
return cmd
}
b, _ := json.Marshal(input)
return truncate(string(b), 200)
}
return ""
}
func (s *opencodeSession) handleReasoning(raw map[string]any) {
part, _ := raw["part"].(map[string]any)
if part == nil {
return
}
text, _ := part["text"].(string)
if text != "" {
evt := core.Event{Type: core.EventThinking, Content: text}
select {
case s.events <- evt:
case <-s.ctx.Done():
return
}
}
}
func (s *opencodeSession) handleError(raw map[string]any) {
errMsg := extractErrorMessage(raw)
slog.Error("opencodeSession: agent error", "error", errMsg)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", errMsg)}
select {
case s.events <- evt:
case <-s.ctx.Done():
return
}
}
// extractErrorMessage tries to pull a human-readable message from various
// OpenCode error JSON shapes.
func extractErrorMessage(raw map[string]any) string {
// Shape: {"error": {"data": {"message": "..."}, "name": "..."}}
if errObj, ok := raw["error"].(map[string]any); ok {
if data, ok := errObj["data"].(map[string]any); ok {
if msg, ok := data["message"].(string); ok && msg != "" {
name, _ := errObj["name"].(string)
if name != "" {
return name + ": " + msg
}
return msg
}
}
if msg, ok := errObj["message"].(string); ok && msg != "" {
return msg
}
if name, ok := errObj["name"].(string); ok && name != "" {
return name
}
}
// Shape: {"error": "string message"}
if errStr, ok := raw["error"].(string); ok && errStr != "" {
return errStr
}
// Shape: {"part": {"error": "...", "message": "..."}}
if part, ok := raw["part"].(map[string]any); ok {
if msg, ok := part["error"].(string); ok && msg != "" {
return msg
}
if msg, ok := part["message"].(string); ok && msg != "" {
return msg
}
}
if msg, ok := raw["message"].(string); ok && msg != "" {
return msg
}
b, _ := json.Marshal(raw)
return string(b)
}
func (s *opencodeSession) handleStepStart(raw map[string]any) {
part, _ := raw["part"].(map[string]any)
if part == nil {
return
}
sessionID, _ := part["sessionID"].(string)
if sessionID != "" {
s.chatID.Store(sessionID)
slog.Debug("opencodeSession: session started", "session_id", sessionID)
}
}
func (s *opencodeSession) handleStepFinish(raw map[string]any) {
part, _ := raw["part"].(map[string]any)
reason, _ := part["reason"].(string)
slog.Debug("opencodeSession: step finished", "reason", reason, "session_id", s.CurrentSessionID())
}
// RespondPermission is a no-op — OpenCode handles permissions internally.
func (s *opencodeSession) RespondPermission(_ string, _ core.PermissionResult) error {
return nil
}
func (s *opencodeSession) Events() <-chan core.Event {
return s.events
}
func (s *opencodeSession) CurrentSessionID() string {
v, _ := s.chatID.Load().(string)
return v
}
func (s *opencodeSession) Alive() bool {
return s.alive.Load()
}
func (s *opencodeSession) Close() error {
s.alive.Store(false)
s.cancel()
done := make(chan struct{})
go func() {
s.wg.Wait()
close(done)
}()
select {
case <-done:
close(s.events)
case <-time.After(8 * time.Second):
slog.Warn("opencodeSession: close timed out, abandoning wg.Wait")
}
return nil
}
func truncate(s string, maxRunes int) string {
if utf8.RuneCountInString(s) <= maxRunes {
return s
}
return string([]rune(s)[:maxRunes]) + "..."
}