Files
2026-06-02 23:14:41 +08:00

567 lines
13 KiB
Go

package antigravity
import (
"bufio"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/chenhg5/cc-connect/core"
)
func init() {
core.RegisterAgent("antigravity", New)
}
// Agent drives the Antigravity CLI (agy) in headless mode.
//
// Modes (maps to agy approval and sandbox flags):
// - "default": standard approval mode (prompt for each tool use)
// - "yolo": auto-approve all tools (--dangerously-skip-permissions)
// - "plan": read-only plan mode with terminal sandbox constraints (--sandbox)
type Agent struct {
workDir string
model string
mode string
cmd string // CLI binary name, default "agy"
timeout time.Duration
providers []core.ProviderConfig
activeIdx int
sessionEnv []string
mu sync.RWMutex
}
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 = "agy"
}
var timeoutMins int64
switch v := opts["timeout_mins"].(type) {
case int64:
timeoutMins = v
case int:
timeoutMins = int64(v)
case float64:
timeoutMins = int64(v)
default:
if v != nil {
slog.Debug("antigravity: timeout_mins has unexpected type", "type", fmt.Sprintf("%T", v))
}
}
var timeout time.Duration
if timeoutMins > 0 {
timeout = time.Duration(timeoutMins) * time.Minute
}
if _, err := exec.LookPath(cmd); err != nil {
return nil, fmt.Errorf("antigravity: %q CLI not found in PATH, install from: https://antigravity.google/docs/cli-overview", cmd)
}
return &Agent{
workDir: workDir,
model: model,
mode: mode,
cmd: cmd,
timeout: timeout,
activeIdx: -1,
}, nil
}
func normalizeMode(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "yolo", "auto", "force", "bypasspermissions":
return "yolo"
case "plan", "sandbox":
return "plan"
default:
return "default"
}
}
func (a *Agent) Name() string { return "antigravity" }
func (a *Agent) SetWorkDir(dir string) {
a.mu.Lock()
defer a.mu.Unlock()
a.workDir = dir
slog.Info("antigravity: 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("antigravity: 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) AvailableModels(ctx context.Context) []core.ModelOption {
if models := a.configuredModels(); len(models) > 0 {
return models
}
if models := a.fetchModelsFromAPI(ctx); len(models) > 0 {
return models
}
return []core.ModelOption{
{Name: "gemini-3.1-pro-preview", Desc: "Gemini 3.1 Pro Preview"},
{Name: "gemini-3-flash-preview", Desc: "Gemini 3 Flash Preview"},
{Name: "gemini-2.5-pro", Desc: "Gemini 2.5 Pro"},
{Name: "gemini-2.5-flash", Desc: "Gemini 2.5 Flash"},
}
}
func (a *Agent) fetchModelsFromAPI(ctx context.Context) []core.ModelOption {
apiKey := os.Getenv("GEMINI_API_KEY")
if apiKey == "" {
apiKey = os.Getenv("GOOGLE_API_KEY")
}
if apiKey == "" {
return nil
}
url := "https://generativelanguage.googleapis.com/v1beta/models?key=" + apiKey
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
slog.Debug("antigravity: failed to fetch models", "error", err)
return nil
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return nil
}
var result struct {
Models []struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
} `json:"models"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil
}
var models []core.ModelOption
for _, m := range result.Models {
id := strings.TrimPrefix(m.Name, "models/")
if !strings.HasPrefix(id, "gemini-") {
continue
}
models = append(models, core.ModelOption{Name: id, Desc: m.DisplayName})
}
sort.Slice(models, func(i, j int) bool { return models[i].Name > models[j].Name })
return models
}
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
timeout := a.timeout
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 newAntigravitySession(ctx, cmd, workDir, model, mode, sessionID, extraEnv, timeout)
}
func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
return listAntigravitySessions(a.workDir)
}
func (a *Agent) DeleteSession(_ context.Context, sessionID string) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("antigravity: cannot determine home dir: %w", err)
}
chatsDir := filepath.Join(homeDir, ".gemini", "tmp", antigravityProjectSlug(a.workDir), "chats")
entries, err := os.ReadDir(chatsDir)
if err != nil {
return fmt.Errorf("session file not found: %s", sessionID)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
continue
}
fpath := filepath.Join(chatsDir, entry.Name())
file, err := os.Open(fpath)
if err != nil {
continue
}
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
if scanner.Scan() {
var sf struct {
SessionID string `json:"sessionId"`
}
if json.Unmarshal([]byte(scanner.Text()), &sf) == nil && sf.SessionID == sessionID {
_ = file.Close()
return os.Remove(fpath)
}
}
if err := scanner.Err(); err != nil {
_ = file.Close()
continue
}
_ = file.Close()
}
return fmt.Errorf("session file not found: %s", sessionID)
}
func (a *Agent) Stop() error { return nil }
func (a *Agent) SetMode(mode string) {
a.mu.Lock()
defer a.mu.Unlock()
a.mode = normalizeMode(mode)
slog.Info("antigravity: 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: "Prompt for approval on each tool use", DescZh: "每次工具调用都需要确认"},
{Key: "yolo", Name: "YOLO", NameZh: "全自动", Desc: "Auto-approve all tool calls", DescZh: "自动批准所有工具调用"},
{Key: "plan", Name: "Plan", NameZh: "规划模式", Desc: "Read-only plan mode in sandbox", DescZh: "只读沙箱规划模式"},
}
}
func (a *Agent) CommandDirs() []string {
absDir, err := filepath.Abs(a.workDir)
if err != nil {
absDir = a.workDir
}
dirs := []string{filepath.Join(absDir, ".gemini", "commands")}
if home, err := os.UserHomeDir(); err == nil {
dirs = append(dirs, filepath.Join(home, ".gemini", "commands"))
}
return dirs
}
func (a *Agent) SkillDirs() []string {
absDir, err := filepath.Abs(a.workDir)
if err != nil {
absDir = a.workDir
}
dirs := []string{filepath.Join(absDir, ".gemini", "skills")}
if home, err := os.UserHomeDir(); err == nil {
dirs = append(dirs, filepath.Join(home, ".gemini", "skills"))
}
return dirs
}
func (a *Agent) CompressCommand() string { return "" }
func (a *Agent) ProjectMemoryFile() string {
absDir, err := filepath.Abs(a.workDir)
if err != nil {
absDir = a.workDir
}
return filepath.Join(absDir, "GEMINI.md")
}
func (a *Agent) GlobalMemoryFile() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(homeDir, ".gemini", "GEMINI.md")
}
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("antigravity: provider cleared")
return true
}
for i, p := range a.providers {
if p.Name == name {
a.activeIdx = i
slog.Info("antigravity: 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, "GEMINI_API_KEY="+p.APIKey)
}
for k, v := range p.Env {
env = append(env, k+"="+v)
}
return env
}
func antigravityProjectSlug(workDir string) string {
abs, err := filepath.Abs(workDir)
if err != nil {
abs = workDir
}
homeDir, err := os.UserHomeDir()
if err != nil {
return slugify(filepath.Base(abs))
}
data, err := os.ReadFile(filepath.Join(homeDir, ".gemini", "projects.json"))
if err == nil {
var registry struct {
Projects map[string]string `json:"projects"`
}
if json.Unmarshal(data, &registry) == nil {
normalized := filepath.Clean(abs)
if slug, ok := registry.Projects[normalized]; ok {
return slug
}
}
}
return slugify(filepath.Base(abs))
}
func slugify(s string) string {
s = strings.ToLower(s)
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
} else {
b.WriteByte('-')
}
}
result := b.String()
for strings.Contains(result, "--") {
result = strings.ReplaceAll(result, "--", "-")
}
result = strings.Trim(result, "-")
if result == "" {
result = "project"
}
return result
}
type sessionFile struct {
SessionID string `json:"sessionId"`
ProjectHash string `json:"projectHash"`
StartTime time.Time `json:"startTime"`
LastUpdated time.Time `json:"lastUpdated"`
Kind string `json:"kind"`
}
type sessionMessagePart struct {
Text string `json:"text"`
}
type sessionLine struct {
Type string `json:"type"` // "user", "assistant"
Content []sessionMessagePart `json:"content"`
Set *struct {
LastUpdated time.Time `json:"lastUpdated"`
} `json:"$set"`
}
func listAntigravitySessions(workDir string) ([]core.AgentSessionInfo, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("antigravity: cannot determine home dir: %w", err)
}
slug := antigravityProjectSlug(workDir)
chatsDir := filepath.Join(homeDir, ".gemini", "tmp", slug, "chats")
entries, err := os.ReadDir(chatsDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("antigravity: read chats dir: %w", err)
}
var sessions []core.AgentSessionInfo
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
continue
}
fpath := filepath.Join(chatsDir, entry.Name())
file, err := os.Open(fpath)
if err != nil {
continue
}
var sf sessionFile
var summary string
msgCount := 0
hasUserMsg := false
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
lineNum := 0
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
if lineNum == 0 {
if err := json.Unmarshal([]byte(line), &sf); err != nil || sf.SessionID == "" {
break
}
lineNum++
continue
}
var sl sessionLine
if json.Unmarshal([]byte(line), &sl) == nil {
if sl.Set != nil && !sl.Set.LastUpdated.IsZero() {
sf.LastUpdated = sl.Set.LastUpdated
} else if sl.Type != "" {
msgCount++
if sl.Type == "user" {
hasUserMsg = true
if summary == "" && len(sl.Content) > 0 {
text := strings.TrimSpace(sl.Content[0].Text)
for _, chunk := range strings.Split(text, "\n") {
chunk = strings.TrimSpace(chunk)
if chunk != "" {
summary = chunk
break
}
}
}
}
}
}
lineNum++
}
if err := scanner.Err(); err != nil {
_ = file.Close()
continue
}
_ = file.Close()
if sf.SessionID == "" || sf.Kind == "subagent" || !hasUserMsg {
continue
}
if summary == "" {
summary = sf.SessionID
}
if utf8.RuneCountInString(summary) > 60 {
summary = string([]rune(summary)[:60]) + "..."
}
modTime := sf.LastUpdated
if modTime.IsZero() {
modTime = sf.StartTime
}
sessions = append(sessions, core.AgentSessionInfo{
ID: sf.SessionID,
Summary: summary,
MessageCount: msgCount,
ModifiedAt: modTime,
})
}
sort.Slice(sessions, func(i, j int) bool {
return sessions[i].ModifiedAt.After(sessions[j].ModifiedAt)
})
return sessions, nil
}