Files
chenhg5-cc-connect/agent/opencode/opencode.go
Claude f86c26634b fix(opencode): pass --agent flag when agent config option is set (#1210)
Adds optional 'agent' config option under [projects.agent.options] for
opencode. When set, the value is passed as --agent to every 'opencode run'
invocation, enabling plugin-defined agents (e.g. oh-my-openagent's
'Sisyphus - Ultraworker') to work correctly without falling back to the
default agent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 10:17:34 +08:00

722 lines
18 KiB
Go

package opencode
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/chenhg5/cc-connect/core"
)
func init() {
core.RegisterAgent("opencode", New)
}
// Agent drives the OpenCode CLI in headless mode using `opencode run --format json`.
//
// Modes:
// - "default": standard mode
// - "yolo": auto mode (opencode run is auto by default in non-interactive mode)
type Agent struct {
workDir string
model string
mode string
cmd string // CLI binary name, default "opencode"
agentName string // passed as --agent to opencode (for plugin-defined agents)
providers []core.ProviderConfig
activeIdx int
sessionEnv []string
modelCachePath string
persistentModelCache *opencodePersistentModelCache
refreshingModelCache bool
mu sync.RWMutex
}
type opencodePersistentModelCache struct {
Models []core.ModelOption `json:"models"`
UpdatedAt time.Time `json:"updated_at"`
ProviderKey string `json:"provider_key,omitempty"`
ContextKey string `json:"context_key,omitempty"`
}
type opencodeModelDiscoverySnapshot struct {
cmd string
workDir string
providerEnv []string
providerKey string
cachePath string
}
func New(opts map[string]any) (core.Agent, error) {
workDir, _ := opts["work_dir"].(string)
if workDir == "" {
workDir = "."
}
model, _ := opts["model"].(string)
mode, _ := opts["mode"].(string)
mode = normalizeMode(mode)
cmd, _ := opts["cmd"].(string)
if cmd == "" {
cmd = "opencode"
}
agentName, _ := opts["agent"].(string) // --agent flag for plugin-defined agents (#1210)
ccDataDir, _ := opts["cc_data_dir"].(string)
ccProject, _ := opts["cc_project"].(string)
modelCachePath := opencodeProjectModelCachePath(ccDataDir, ccProject)
persistentModelCache, err := loadOpencodePersistentModelCache(modelCachePath)
if err != nil {
slog.Warn("opencode: load persistent model cache failed", "path", modelCachePath, "err", err)
}
if _, err := exec.LookPath(cmd); err != nil {
return nil, fmt.Errorf("opencode: %q CLI not found in PATH, install from: https://github.com/opencode-ai/opencode", cmd)
}
return &Agent{
workDir: workDir,
model: model,
mode: mode,
cmd: cmd,
agentName: agentName,
activeIdx: -1,
modelCachePath: modelCachePath,
persistentModelCache: persistentModelCache,
}, nil
}
func opencodeProjectModelCachePath(dataDir, project string) string {
if dataDir == "" || project == "" {
return ""
}
prefix := sanitizeProjectCacheComponent(project)
if prefix == "" {
prefix = "project"
}
hash := sha256.Sum256([]byte(project))
fileName := fmt.Sprintf("%s-%s.opencode-models.json", prefix, hex.EncodeToString(hash[:8]))
return filepath.Join(dataDir, "projects", fileName)
}
func sanitizeProjectCacheComponent(project string) string {
project = strings.TrimSpace(strings.ToLower(project))
if project == "" {
return ""
}
var b strings.Builder
b.Grow(len(project))
lastDash := false
for _, r := range project {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastDash = false
continue
}
if !lastDash {
b.WriteByte('-')
lastDash = true
}
}
return strings.Trim(b.String(), "-")
}
func loadOpencodePersistentModelCache(path string) (*opencodePersistentModelCache, error) {
if path == "" {
return nil, nil
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var cache opencodePersistentModelCache
if err := json.Unmarshal(data, &cache); err != nil {
return nil, err
}
cache.Models = normalizeModelOptions(cache.Models)
if len(cache.Models) == 0 {
return nil, nil
}
return &cache, nil
}
func normalizeModelOptions(models []core.ModelOption) []core.ModelOption {
if len(models) == 0 {
return nil
}
seen := make(map[string]struct{}, len(models))
normalized := make([]core.ModelOption, 0, len(models))
for _, model := range models {
model.Name = strings.TrimSpace(model.Name)
if model.Name == "" {
continue
}
if _, dup := seen[model.Name]; dup {
continue
}
seen[model.Name] = struct{}{}
normalized = append(normalized, model)
}
if len(normalized) == 0 {
return nil
}
sort.Slice(normalized, func(i, j int) bool {
return normalized[i].Name < normalized[j].Name
})
return normalized
}
func normalizeMode(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "yolo", "auto", "force", "bypasspermissions":
return "yolo"
default:
return "default"
}
}
func (a *Agent) Name() string { return "opencode" }
func (a *Agent) SetWorkDir(dir string) {
a.mu.Lock()
defer a.mu.Unlock()
a.workDir = dir
slog.Info("opencode: work_dir changed", "work_dir", dir)
}
func (a *Agent) GetWorkDir() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.workDir
}
func (a *Agent) SetModel(model string) {
a.mu.Lock()
defer a.mu.Unlock()
a.model = model
slog.Info("opencode: model changed", "model", model)
}
func (a *Agent) GetModel() string {
a.mu.Lock()
defer a.mu.Unlock()
return core.GetProviderModel(a.providers, a.activeIdx, a.model)
}
func (a *Agent) configuredModels() []core.ModelOption {
a.mu.RLock()
defer a.mu.RUnlock()
return core.GetProviderModels(a.providers, a.activeIdx)
}
func (a *Agent) configuredModelsForSnapshot(snapshot opencodeModelDiscoverySnapshot) []core.ModelOption {
a.mu.RLock()
defer a.mu.RUnlock()
if len(a.providers) == 0 {
return nil
}
for i := range a.providers {
if providerCacheKey(a.providers[i]) != snapshot.providerKey {
continue
}
return core.GetProviderModels(a.providers, i)
}
return nil
}
func (a *Agent) activeProviderKeyLocked() string {
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
return ""
}
return providerCacheKey(a.providers[a.activeIdx])
}
func providerCacheKey(p core.ProviderConfig) string {
h := sha256.New()
mustWriteProviderSignaturePart(h, "name", p.Name)
mustWriteProviderSignaturePart(h, "base_url", p.BaseURL)
mustWriteProviderSignaturePart(h, "model", p.Model)
mustWriteProviderSignaturePart(h, "api_key", p.APIKey)
keys := make([]string, 0, len(p.Env))
for k := range p.Env {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
mustWriteProviderSignaturePart(h, "env:"+k, p.Env[k])
}
return hex.EncodeToString(h.Sum(nil)[:16])
}
func mustWriteProviderSignaturePart(w io.Writer, key, value string) {
if err := writeProviderSignaturePart(w, key, value); err != nil {
panic(fmt.Sprintf("write provider signature: %v", err))
}
}
func writeProviderSignaturePart(w io.Writer, key, value string) error {
if _, err := io.WriteString(w, key); err != nil {
return err
}
if _, err := io.WriteString(w, "\x00"); err != nil {
return err
}
if _, err := io.WriteString(w, value); err != nil {
return err
}
if _, err := io.WriteString(w, "\x00"); err != nil {
return err
}
return nil
}
func (a *Agent) activeProviderKey() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.activeProviderKeyLocked()
}
func (a *Agent) modelDiscoverySnapshot() opencodeModelDiscoverySnapshot {
a.mu.RLock()
defer a.mu.RUnlock()
return opencodeModelDiscoverySnapshot{
cmd: a.cmd,
workDir: a.workDir,
providerEnv: append([]string(nil), a.providerEnvLocked()...),
providerKey: a.activeProviderKeyLocked(),
cachePath: a.modelCachePath,
}
}
func modelDiscoveryContextKey(snapshot opencodeModelDiscoverySnapshot) string {
h := sha256.New()
mustWriteProviderSignaturePart(h, "provider_key", snapshot.providerKey)
mustWriteProviderSignaturePart(h, "work_dir", snapshot.workDir)
return hex.EncodeToString(h.Sum(nil)[:16])
}
func (a *Agent) persistentModelsForSnapshot(snapshot opencodeModelDiscoverySnapshot) []core.ModelOption {
a.mu.RLock()
defer a.mu.RUnlock()
if a.persistentModelCache == nil || len(a.persistentModelCache.Models) == 0 {
return nil
}
if a.persistentModelCache.ProviderKey != snapshot.providerKey {
return nil
}
if a.persistentModelCache.ContextKey != "" && a.persistentModelCache.ContextKey != modelDiscoveryContextKey(snapshot) {
return nil
}
if a.persistentModelCache.ContextKey == "" && snapshot.workDir != "" {
return nil
}
models := make([]core.ModelOption, len(a.persistentModelCache.Models))
copy(models, a.persistentModelCache.Models)
return models
}
func (a *Agent) persistentModels() []core.ModelOption {
return a.persistentModelsForSnapshot(a.modelDiscoverySnapshot())
}
func (a *Agent) startPersistentModelRefresh(snapshot opencodeModelDiscoverySnapshot, allowColdStart bool) {
a.mu.Lock()
hasPersistentModels := a.persistentModelCache != nil && len(a.persistentModelCache.Models) > 0
if (!allowColdStart && !hasPersistentModels) || a.refreshingModelCache {
a.mu.Unlock()
return
}
a.refreshingModelCache = true
a.mu.Unlock()
go func() {
defer func() {
a.mu.Lock()
a.refreshingModelCache = false
a.mu.Unlock()
}()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
models := a.discoverModelsWithSnapshot(ctx, snapshot)
if len(models) == 0 {
return
}
if err := a.storePersistentModelCache(snapshot, models); err != nil {
slog.Warn("opencode: update persistent model cache failed", "path", snapshot.cachePath, "err", err)
}
}()
}
func (a *Agent) StartInitialModelRefresh() {
a.startPersistentModelRefresh(a.modelDiscoverySnapshot(), true)
}
func (a *Agent) storePersistentModelCache(snapshot opencodeModelDiscoverySnapshot, models []core.ModelOption) error {
models = normalizeModelOptions(models)
if len(models) == 0 {
return nil
}
cache := &opencodePersistentModelCache{
Models: models,
UpdatedAt: time.Now(),
ProviderKey: snapshot.providerKey,
ContextKey: modelDiscoveryContextKey(snapshot),
}
if snapshot.cachePath != "" {
if err := os.MkdirAll(filepath.Dir(snapshot.cachePath), 0o755); err != nil {
return err
}
data, err := json.Marshal(cache)
if err != nil {
return err
}
if err := core.AtomicWriteFile(snapshot.cachePath, data, 0o644); err != nil {
return err
}
}
a.mu.Lock()
a.persistentModelCache = cache
a.mu.Unlock()
return nil
}
func (a *Agent) discoverModelsWithSnapshot(ctx context.Context, snapshot opencodeModelDiscoverySnapshot) []core.ModelOption {
c := exec.CommandContext(ctx, snapshot.cmd, "models")
c.Dir = snapshot.workDir
if len(snapshot.providerEnv) > 0 {
c.Env = append(os.Environ(), snapshot.providerEnv...)
}
out, err := c.Output()
if err != nil {
slog.Debug("opencode: discoverModels failed", "err", err)
return nil
}
var models []core.ModelOption
for _, line := range strings.Split(string(out), "\n") {
models = append(models, core.ModelOption{Name: line})
}
models = normalizeModelOptions(models)
if len(models) == 0 {
slog.Debug("opencode: discoverModels: no models in output")
return nil
}
return models
}
func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption {
snapshot := a.modelDiscoverySnapshot()
if models := a.persistentModelsForSnapshot(snapshot); len(models) > 0 {
a.startPersistentModelRefresh(snapshot, false)
return models
}
if models := a.discoverModelsWithSnapshot(ctx, snapshot); len(models) > 0 {
if err := a.storePersistentModelCache(snapshot, models); err != nil {
slog.Warn("opencode: persist discovered model cache failed", "path", a.modelCachePath, "err", err)
}
return models
}
if models := a.configuredModelsForSnapshot(snapshot); len(models) > 0 {
return models
}
return []core.ModelOption{
{Name: "anthropic/claude-sonnet-4-20250514", Desc: "Claude Sonnet 4 (default)"},
{Name: "anthropic/claude-opus-4-20250514", Desc: "Claude Opus 4"},
{Name: "openai/gpt-4o", Desc: "GPT-4o"},
{Name: "openai/o3", Desc: "OpenAI o3"},
}
}
func (a *Agent) SetSessionEnv(env []string) {
a.mu.Lock()
defer a.mu.Unlock()
a.sessionEnv = env
}
func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) {
a.mu.Lock()
model := a.model
mode := a.mode
cmd := a.cmd
workDir := a.workDir
agentName := a.agentName
extraEnv := a.providerEnvLocked()
extraEnv = append(extraEnv, a.sessionEnv...)
if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
if m := a.providers[a.activeIdx].Model; m != "" {
model = m
}
}
a.mu.Unlock()
return newOpencodeSession(ctx, cmd, workDir, model, mode, agentName, sessionID, extraEnv)
}
// ListSessions runs `opencode session list` and parses the JSON output.
func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
a.mu.RLock()
cmd := a.cmd
workDir := a.workDir
a.mu.RUnlock()
return listOpencodeSessions(cmd, workDir)
}
func (a *Agent) Stop() error { return nil }
// DeleteSession implements core.SessionDeleter via `opencode session delete <id>`.
func (a *Agent) DeleteSession(_ context.Context, sessionID string) error {
a.mu.RLock()
cmd := a.cmd
workDir := a.workDir
a.mu.RUnlock()
c := exec.Command(cmd, "session", "delete", sessionID)
c.Dir = workDir
if out, err := c.CombinedOutput(); err != nil {
return fmt.Errorf("opencode: delete session %s: %w: %s", sessionID, err, strings.TrimSpace(string(out)))
}
return nil
}
// -- ModeSwitcher --
func (a *Agent) SetMode(mode string) {
a.mu.Lock()
defer a.mu.Unlock()
a.mode = normalizeMode(mode)
slog.Info("opencode: mode changed", "mode", a.mode)
}
func (a *Agent) GetMode() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.mode
}
func (a *Agent) PermissionModes() []core.PermissionModeInfo {
return []core.PermissionModeInfo{
{Key: "default", Name: "Default", NameZh: "默认", Desc: "Standard mode", DescZh: "标准模式"},
{Key: "yolo", Name: "YOLO", NameZh: "全自动", Desc: "Auto-approve all tool calls", DescZh: "自动批准所有工具调用"},
}
}
// -- ContextCompressor --
func (a *Agent) CompressCommand() string { return "/compact" }
// -- MemoryFileProvider --
func (a *Agent) ProjectMemoryFile() string {
a.mu.RLock()
workDir := a.workDir
a.mu.RUnlock()
absDir, err := filepath.Abs(workDir)
if err != nil {
absDir = workDir
}
return filepath.Join(absDir, "OPENCODE.md")
}
func (a *Agent) GlobalMemoryFile() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(homeDir, ".opencode", "OPENCODE.md")
}
// -- ProviderSwitcher --
func (a *Agent) SetProviders(providers []core.ProviderConfig) {
a.mu.Lock()
defer a.mu.Unlock()
a.providers = providers
}
func (a *Agent) SetActiveProvider(name string) bool {
a.mu.Lock()
defer a.mu.Unlock()
if name == "" {
a.activeIdx = -1
slog.Info("opencode: provider cleared")
return true
}
for i, p := range a.providers {
if p.Name == name {
a.activeIdx = i
slog.Info("opencode: provider switched", "provider", name)
return true
}
}
return false
}
func (a *Agent) GetActiveProvider() *core.ProviderConfig {
a.mu.Lock()
defer a.mu.Unlock()
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
return nil
}
p := a.providers[a.activeIdx]
return &p
}
func (a *Agent) ListProviders() []core.ProviderConfig {
a.mu.Lock()
defer a.mu.Unlock()
result := make([]core.ProviderConfig, len(a.providers))
copy(result, a.providers)
return result
}
func (a *Agent) providerEnvLocked() []string {
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
return nil
}
p := a.providers[a.activeIdx]
var env []string
if p.APIKey != "" {
env = append(env, "ANTHROPIC_API_KEY="+p.APIKey)
}
for k, v := range p.Env {
env = append(env, k+"="+v)
}
return env
}
// -- Session listing --
// opencodeSessionEntry represents a session from `opencode session list` output.
type opencodeSessionEntry struct {
ID string `json:"id"`
Title string `json:"title"`
Updated int64 `json:"updated"` // Unix timestamp in milliseconds
Created int64 `json:"created"`
}
func listOpencodeSessions(cmd, workDir string) ([]core.AgentSessionInfo, error) {
c := exec.Command(cmd, "session", "list", "--format", "json")
c.Dir = workDir
out, err := c.Output()
if err != nil {
return nil, fmt.Errorf("opencode: session list: %w", err)
}
var entries []opencodeSessionEntry
if err := json.Unmarshal(out, &entries); err != nil {
return nil, fmt.Errorf("opencode: parse session list: %w", err)
}
msgCounts := querySessionMessageCounts()
var sessions []core.AgentSessionInfo
for _, e := range entries {
sessions = append(sessions, core.AgentSessionInfo{
ID: e.ID,
Summary: e.Title,
MessageCount: msgCounts[e.ID],
ModifiedAt: time.UnixMilli(e.Updated),
})
}
return sessions, nil
}
// querySessionMessageCounts uses the sqlite3 CLI to read message counts from
// OpenCode's local database. Returns an empty map on any failure.
func querySessionMessageCounts() map[string]int {
dbPath := opencodeDBPath()
if dbPath == "" {
return nil
}
if _, err := os.Stat(dbPath); err != nil {
return nil
}
sqlite3, err := exec.LookPath("sqlite3")
if err != nil {
slog.Warn("opencode: sqlite3 CLI not found, message counts unavailable", "err", err)
return nil
}
out, err := exec.Command(sqlite3, dbPath,
"SELECT session_id, COUNT(*) FROM message GROUP BY session_id").Output()
if err != nil {
slog.Warn("opencode: sqlite3 query failed", "db_path", dbPath, "err", err)
return nil
}
counts := make(map[string]int)
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
parts := strings.SplitN(line, "|", 2)
if len(parts) != 2 {
continue
}
var n int
if _, err := fmt.Sscanf(parts[1], "%d", &n); err == nil {
counts[parts[0]] = n
}
}
return counts
}
func opencodeDBPath() string {
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
return filepath.Join(xdg, "opencode", "opencode.db")
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".local", "share", "opencode", "opencode.db")
}
func (a *Agent) GetSessionTitle(sessionID string) string {
return querySessionTitle(sessionID)
}
func querySessionTitle(sessionID string) string {
dbPath := opencodeDBPath()
if dbPath == "" {
return ""
}
if _, err := os.Stat(dbPath); err != nil {
return ""
}
sqlite3, err := exec.LookPath("sqlite3")
if err != nil {
return ""
}
escaped := strings.ReplaceAll(sessionID, "'", "''")
query := fmt.Sprintf("SELECT title FROM session WHERE id = '%s' LIMIT 1", escaped)
out, err := exec.Command(sqlite3, dbPath, query).Output()
if err != nil {
return ""
}
title := strings.TrimSpace(string(out))
return title
}