Extract SaveFilesToDisk/AppendFileRefs helpers into core package and wire up file handling for Cursor, Codex, OpenCode, Qoder, and iFlow. Files are saved to .cc-connect/attachments/ and paths are appended to the prompt so each CLI can read them with its built-in tools. Also refactors Claude Code to reuse the shared helper. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
333 lines
7.3 KiB
Go
333 lines
7.3 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
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
default:
|
|
slog.Debug("opencodeSession: unhandled event", "type", eventType)
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
// Check state for status
|
|
state, _ := part["state"].(map[string]any)
|
|
status := ""
|
|
if state != nil {
|
|
status, _ = state["status"].(string)
|
|
}
|
|
|
|
if status == "completed" {
|
|
// Tool result
|
|
output := ""
|
|
if state != nil {
|
|
output, _ = state["output"].(string)
|
|
}
|
|
evt := core.Event{Type: core.EventToolResult, ToolName: toolName, Content: truncate(output, 500)}
|
|
select {
|
|
case s.events <- evt:
|
|
case <-s.ctx.Done():
|
|
return
|
|
}
|
|
} else {
|
|
// Tool use (running or starting)
|
|
input := ""
|
|
if state != nil {
|
|
inputVal, _ := state["input"].(string)
|
|
if inputVal != "" {
|
|
input = inputVal
|
|
} else {
|
|
// Try to marshal input as JSON summary
|
|
if inputMap, ok := state["input"].(map[string]any); ok {
|
|
b, _ := json.Marshal(inputMap)
|
|
input = truncate(string(b), 200)
|
|
}
|
|
}
|
|
}
|
|
evt := core.Event{Type: core.EventToolUse, ToolName: toolName, ToolInput: input}
|
|
select {
|
|
case s.events <- evt:
|
|
case <-s.ctx.Done():
|
|
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) 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(_ map[string]any) {
|
|
sid := s.CurrentSessionID()
|
|
evt := core.Event{Type: core.EventResult, SessionID: sid, Done: true}
|
|
select {
|
|
case s.events <- evt:
|
|
case <-s.ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
|
|
// 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]) + "..."
|
|
}
|