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" cliExtraArgs []string // extra args from cmd after the binary name configEnv []string // env vars from [projects.agent.options.env] agentName string // passed as --agent to opencode (for plugin-defined agents) providers []core.ProviderConfig activeIdx int sessionEnv []string modelCachePath string persistentModelCache *opencodePersistentModelCache refreshingModelCache bool refreshWg sync.WaitGroup // tracks in-flight background model-cache refresh goroutines 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, extraArgs := core.ParseCmdOpts(opts, "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, cliExtraArgs: extraArgs, configEnv: core.ParseConfigEnv(opts), 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.refreshWg.Add(1) a.mu.Unlock() go func() { defer a.refreshWg.Done() 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 extraArgs := append([]string{}, a.cliExtraArgs...) workDir := a.workDir agentName := a.agentName extraEnv := append([]string(nil), a.configEnv...) extraEnv = append(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, extraArgs, 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 `. 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 }