Six agent session adapters (codex, cursor, gemini, iflow, opencode, qoder) only close the events channel when wg.Wait completes within the 8-second timeout. If the timeout fires, the channel is left open, which can cause consumers to block indefinitely waiting for channel closure and prevents the engine from detecting that the session has ended. Move close(events) after the select block so the channel is always closed regardless of whether the timeout fires, matching the pattern already used by piSession.
409 lines
9.6 KiB
Go
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:
|
|
case <-time.After(8 * time.Second):
|
|
slog.Warn("opencodeSession: close timed out, abandoning wg.Wait")
|
|
}
|
|
close(s.events)
|
|
return nil
|
|
}
|
|
|
|
func truncate(s string, maxRunes int) string {
|
|
if utf8.RuneCountInString(s) <= maxRunes {
|
|
return s
|
|
}
|
|
return string([]rune(s)[:maxRunes]) + "..."
|
|
}
|