Files
cc-connect/agent/opencode/session.go
T
shenghui kevin 5d54487237 feat(agent): add OpenCode support
OpenCode (`opencode run --format json`) streams NDJSON events,
similar to the Gemini agent. This wires it up so users can drive
OpenCode from Telegram / Discord / Slack / etc.

- agent/opencode/opencode.go — agent struct, factory, model/mode/provider switching
- agent/opencode/session.go  — subprocess lifecycle, NDJSON event → core.Event mapping
- cmd/cc-connect/main.go     — register the "opencode" agent
2026-03-03 15:55:57 -08:00

302 lines
6.5 KiB
Go

package opencode
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"sync"
"sync/atomic"
"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", 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)
s.events <- core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)}
}
}
}()
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)
s.events <- core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", err)}
}
}
// 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 != "" {
s.events <- core.Event{
Type: core.EventText,
Content: text,
}
}
}
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)
}
s.events <- core.Event{
Type: core.EventToolResult,
ToolName: toolName,
Content: truncate(output, 500),
}
} 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)
}
}
}
s.events <- core.Event{
Type: core.EventToolUse,
ToolName: toolName,
ToolInput: input,
}
}
}
func (s *opencodeSession) handleReasoning(raw map[string]any) {
part, _ := raw["part"].(map[string]any)
if part == nil {
return
}
text, _ := part["text"].(string)
if text != "" {
s.events <- core.Event{
Type: core.EventThinking,
Content: text,
}
}
}
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()
s.events <- core.Event{
Type: core.EventResult,
SessionID: sid,
Done: true,
}
}
// 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()
s.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]) + "..."
}