mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-03 20:37:04 +08:00
* refactor: centralize cmd/env option parsing into core - Add core.ParseCmdOpts() to unify cmd/cli_path/command option parsing across all agents, with deprecation warnings for old keys - Add core.ParseConfigEnv() to parse [projects.agent.options.env] from config into []string KEY=VALUE format - Rename cliBin/command struct fields to cmd consistently - Add cliExtraArgs support so cmd="binary arg1 arg2" works - Add configEnv field for static env that persists across SetSessionEnv - Fix env merge order: configEnv < providerEnv < sessionEnv (was inconsistent in copilot and opencode agents) - Update all tests for renamed fields * fix(claudecode): add backward compat for deprecated cli_args_flag Users with cli_args_flag in their config.toml will see a deprecation warning directing them to the new cmd_args_flag key. * docs(config): update config examples to use cmd instead of deprecated keys - config.example.toml: cli_path → cmd, command(S) → S(6 occurrences) - claudecode/claudecode.go: comment cli_path → cmd - copilot/copilot_test.go: comment cliBin/cli_path → cmd * test(core): add direct unit tests for ParseCmdOpts and ParseConfigEnv Address review feedback on PR #1297 (P2). The two helper functions in core/cmdopts.go are depended on by 13 agent packages; previously their behavior was only covered indirectly via agent-level New() tests. This commit adds direct unit tests covering: - ParseCmdOpts: four-tier priority (cmd / cli_path / command / default), empty/whitespace boundaries, tokenization of extra args, no-warning for canonical cmd field, and capture of deprecation warnings. - ParseConfigEnv: nil / missing key, map[string]string, map[string]any (TOML parser output), non-string value filtering, unsupported types, and input non-mutation. Also adds structured slog attrs (deprecated_key, new_key) to all three deprecation warnings (cli_path, command, cli_args_flag) so that future log aggregation can scan deprecated-key usage without code changes (review feedback P3). Ref: PR #1297 review. * fix(codex): use local cmd var (not stale cliBin name) in struct init Post-rebase fixup: the struct field was renamed cliBin -> cmd in PR #1297, and the local variable returned by core.ParseCmdOpts is also named cmd. The rebased struct initializer still referenced the old name 'cliBin', which no longer exists in scope. Assign the local cmd variable to the cmd struct field.
728 lines
19 KiB
Go
728 lines
19 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"
|
|
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 <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
|
|
}
|