Files
cc-connect/agent/opencode/session.go
T
chenhg5andClaude e8e39413e8 refactor(session): add context-aware event sending and secure arg logging
Add context-aware select statements when sending events to prevent blocking on closed channels. Implement secure argument logging using RedactArgs to mask sensitive flags. Add atomic file writes and locks for config safety. Improve session handling with mutex protection and deduplication checks.

generated by llmgit

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-04 12:38:37 +08:00

319 lines
6.9 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", 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()
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]) + "..."
}