初始化仓库
This commit is contained in:
@@ -0,0 +1,524 @@
|
||||
package opencode
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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
|
||||
expectingContinue atomic.Bool // true when compaction_continue received, waiting for next step
|
||||
resultSent atomic.Bool // true when EventResult has been sent for this turn
|
||||
}
|
||||
|
||||
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 != "" && resumeID != core.ContinueSession {
|
||||
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)
|
||||
}
|
||||
prompt, imagePaths, err := s.stageImages(prompt, images)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !s.alive.Load() {
|
||||
return fmt.Errorf("session is closed")
|
||||
}
|
||||
|
||||
s.resultSent.Store(false)
|
||||
s.expectingContinue.Store(false)
|
||||
|
||||
chatID := s.CurrentSessionID()
|
||||
isResume := chatID != ""
|
||||
|
||||
args := s.buildRunArgs(prompt, imagePaths, chatID)
|
||||
|
||||
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
|
||||
cmd.Stdin = strings.NewReader(prompt)
|
||||
|
||||
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) stageImages(prompt string, images []core.ImageAttachment) (string, []string, error) {
|
||||
if len(images) == 0 {
|
||||
return prompt, nil, nil
|
||||
}
|
||||
|
||||
imgDir := filepath.Join(s.workDir, ".cc-connect", "images")
|
||||
if err := os.MkdirAll(imgDir, 0o755); err != nil {
|
||||
return "", nil, fmt.Errorf("opencodeSession: create image dir: %w", err)
|
||||
}
|
||||
|
||||
imagePaths := make([]string, 0, len(images))
|
||||
for i, img := range images {
|
||||
ext := opencodeImageExt(img.MimeType)
|
||||
fname := fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext)
|
||||
fpath := filepath.Join(imgDir, fname)
|
||||
if err := os.WriteFile(fpath, img.Data, 0o644); err != nil {
|
||||
return "", nil, fmt.Errorf("opencodeSession: save image: %w", err)
|
||||
}
|
||||
imagePaths = append(imagePaths, fpath)
|
||||
}
|
||||
|
||||
if prompt == "" {
|
||||
prompt = "Please analyze the attached image(s)."
|
||||
}
|
||||
|
||||
return prompt, imagePaths, nil
|
||||
}
|
||||
|
||||
func opencodeImageExt(mimeType string) string {
|
||||
switch mimeType {
|
||||
case "image/jpeg":
|
||||
return ".jpg"
|
||||
case "image/gif":
|
||||
return ".gif"
|
||||
case "image/webp":
|
||||
return ".webp"
|
||||
default:
|
||||
return ".png"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *opencodeSession) buildRunArgs(prompt string, imagePaths []string, chatID string) []string {
|
||||
args := []string{"run", "--format", "json"}
|
||||
|
||||
if chatID != "" {
|
||||
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")
|
||||
|
||||
// In yolo/auto mode, skip permission prompts entirely so headless
|
||||
// runs don't get stuck with auto-rejected external-directory ops.
|
||||
if s.mode == "yolo" {
|
||||
args = append(args, "--dangerously-skip-permissions")
|
||||
}
|
||||
|
||||
for _, imagePath := range imagePaths {
|
||||
if imagePath == "" {
|
||||
continue
|
||||
}
|
||||
args = append(args, "--file", imagePath)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
func (s *opencodeSession) readLoop(cmd *exec.Cmd, stdout io.ReadCloser, stderrBuf *bytes.Buffer) {
|
||||
defer s.wg.Done()
|
||||
defer func() { _ = cmd.Wait() }()
|
||||
|
||||
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
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
stderrMsg := stderrBuf.String()
|
||||
if stderrMsg != "" {
|
||||
slog.Error("opencodeSession: process error", "stderr", truncate(stderrMsg, 500))
|
||||
if strings.Contains(stderrMsg, "Session not found") {
|
||||
s.chatID.Store("")
|
||||
slog.Warn("opencodeSession: cleared stale session ID")
|
||||
}
|
||||
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)}
|
||||
select {
|
||||
case s.events <- evt:
|
||||
case <-s.ctx.Done():
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we received compaction_continue before readLoop ended.
|
||||
// If so, OpenCode will continue with a new turn - do NOT send EventResult.
|
||||
// The subsequent process will send its own EventResult when it finishes.
|
||||
if s.expectingContinue.Load() {
|
||||
slog.Info("opencodeSession: readLoop ended after compaction_continue, skipping EventResult", "session_id", s.CurrentSessionID())
|
||||
s.expectingContinue.Store(false)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("opencodeSession: readLoop complete, sending fallback EventResult", "session_id", s.CurrentSessionID())
|
||||
s.sendEventResult()
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// Extract metadata and synthetic flags to identify compaction_continue
|
||||
metadata, _ := part["metadata"].(map[string]any)
|
||||
synthetic, _ := part["synthetic"].(bool)
|
||||
|
||||
// Check for compaction_continue: this is OpenCode's auto-continuation signal.
|
||||
// When received, we should NOT send EventText to engine, but mark that we expect
|
||||
// a continuation (next step_start will start a new turn without EventResult).
|
||||
if synthetic && metadata != nil {
|
||||
if cc, ok := metadata["compaction_continue"].(bool); ok && cc {
|
||||
slog.Info("opencodeSession: compaction_continue detected, marking expectingContinue", "session_id", s.CurrentSessionID())
|
||||
s.expectingContinue.Store(true)
|
||||
// Do NOT send EventText - this is internal continuation signal
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if text != "" {
|
||||
evt := core.Event{Type: core.EventText, Content: text, Metadata: metadata, Synthetic: synthetic}
|
||||
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) {
|
||||
sessionID, _ := raw["sessionID"].(string)
|
||||
if sessionID == "" {
|
||||
part, _ := raw["part"].(map[string]any)
|
||||
if part != nil {
|
||||
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 := ""
|
||||
if part != nil {
|
||||
reason, _ = part["reason"].(string)
|
||||
}
|
||||
slog.Debug("opencodeSession: step finished", "reason", reason, "session_id", s.CurrentSessionID())
|
||||
|
||||
if reason == "stop" {
|
||||
s.sendEventResult()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *opencodeSession) sendEventResult() {
|
||||
if s.resultSent.Load() {
|
||||
slog.Debug("opencodeSession: EventResult already sent, skipping", "session_id", s.CurrentSessionID())
|
||||
return
|
||||
}
|
||||
s.resultSent.Store(true)
|
||||
sid := s.CurrentSessionID()
|
||||
evt := core.Event{Type: core.EventResult, SessionID: sid, Done: true}
|
||||
select {
|
||||
case s.events <- evt:
|
||||
case <-s.ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
// 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]) + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user