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) error { 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, 1024*1024), 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]) + "..." }