初始化仓库

This commit is contained in:
2026-06-02 23:14:41 +08:00
commit 0bc3f02670
520 changed files with 191097 additions and 0 deletions
+568
View File
@@ -0,0 +1,568 @@
package cursor
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"unicode/utf8"
"github.com/chenhg5/cc-connect/core"
)
func init() {
core.RegisterAgent("cursor", New)
}
// Agent drives the Cursor Agent CLI (`agent`) using --print --output-format stream-json.
//
// Modes (maps to Cursor agent CLI flags):
// - "default": default permissions (ask permission for tools)
// - "force": --force (auto-approve tools unless explicitly denied)
// - "plan": --mode plan (read-only analysis)
// - "ask": --mode ask (Q&A style, read-only)
type Agent struct {
workDir string
model string
mode string
cmd string // CLI binary name, default "agent"
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 = "agent"
}
if _, err := exec.LookPath(cmd); err != nil {
return nil, fmt.Errorf("cursor: %q CLI not found in PATH, install with: npm i -g @anthropic-ai/cursor-agent (or from Cursor IDE settings)", cmd)
}
return &Agent{
workDir: workDir,
model: model,
mode: mode,
cmd: cmd,
activeIdx: -1,
}, nil
}
func normalizeMode(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "force", "yolo", "auto":
return "force"
case "plan":
return "plan"
case "ask":
return "ask"
default:
return "default"
}
}
func (a *Agent) Name() string { return "cursor" }
func (a *Agent) CLIBinaryName() string { return "agent" }
func (a *Agent) CLIDisplayName() string { return "Cursor Agent" }
func (a *Agent) SetWorkDir(dir string) {
a.mu.Lock()
defer a.mu.Unlock()
a.workDir = dir
slog.Info("cursor: work_dir changed", "work_dir", dir)
}
func (a *Agent) GetWorkDir() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.workDir
}
func (a *Agent) SetModel(model string) {
a.mu.Lock()
defer a.mu.Unlock()
a.model = model
slog.Info("cursor: model changed", "model", model)
}
func (a *Agent) GetModel() string {
a.mu.RLock()
defer a.mu.RUnlock()
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
}
a.mu.RLock()
cmd := a.cmd
extraEnv := a.providerEnvLocked()
extraEnv = append(extraEnv, a.sessionEnv...)
a.mu.RUnlock()
if models := fetchModelsFromAgentCLI(ctx, cmd, extraEnv); len(models) > 0 {
return models
}
return cursorFallbackModels()
}
// fetchModelsFromAgentCLI runs `agent models` and parses the output.
// Output format: "model-id - Display Name (current)" or "model-id - Display Name"
func fetchModelsFromAgentCLI(ctx context.Context, cmd string, extraEnv []string) []core.ModelOption {
c := exec.CommandContext(ctx, cmd, "models")
c.Env = append(os.Environ(), extraEnv...)
out, err := c.Output()
if err != nil {
slog.Debug("cursor: agent models failed", "error", err)
return nil
}
var models []core.ModelOption
seen := make(map[string]struct{})
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if line == "" || line == "Available models" || strings.HasPrefix(line, "Tip:") {
continue
}
idx := strings.Index(line, " - ")
if idx < 0 {
continue
}
name := strings.TrimSpace(line[:idx])
desc := strings.TrimSpace(line[idx+3:])
if name == "" {
continue
}
// Remove trailing markers like "(current)", "(default)"
desc = strings.TrimSuffix(desc, " (current)")
desc = strings.TrimSuffix(desc, " (default)")
desc = strings.TrimSpace(desc)
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
models = append(models, core.ModelOption{Name: name, Desc: desc})
}
sort.Slice(models, func(i, j int) bool { return models[i].Name < models[j].Name })
return models
}
func cursorFallbackModels() []core.ModelOption {
return []core.ModelOption{
{Name: "claude-sonnet-4-20250514", Desc: "Claude Sonnet 4"},
{Name: "claude-opus-4-20250514", Desc: "Claude Opus 4"},
{Name: "gpt-4o", Desc: "GPT-4o"},
{Name: "gemini-2.5-pro", Desc: "Gemini 2.5 Pro"},
{Name: "cursor-small", Desc: "Cursor Small (fast)"},
}
}
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.RLock()
model := a.model
mode := a.mode
cmd := a.cmd
workDir := a.workDir
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.RUnlock()
return newCursorSession(ctx, cmd, workDir, model, mode, sessionID, extraEnv)
}
// ListSessions reads sessions from ~/.cursor/chats/<workspace_hash>/.
func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
workDir := a.GetWorkDir()
return listCursorSessions(workDir)
}
func (a *Agent) DeleteSession(_ context.Context, sessionID string) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("cursor: cannot determine home dir: %w", err)
}
workDir := a.GetWorkDir()
hash := workspaceHash(workDir)
dir := filepath.Join(homeDir, ".cursor", "chats", hash, sessionID)
if _, err := os.Stat(dir); os.IsNotExist(err) {
return fmt.Errorf("session not found: %s", sessionID)
}
return os.RemoveAll(dir)
}
func (a *Agent) Stop() error { return nil }
// ── SkillProvider implementation ──────────────────────────────
func (a *Agent) SkillDirs() []string {
workDir := a.GetWorkDir()
absDir, err := filepath.Abs(workDir)
if err != nil {
absDir = workDir
}
dirs := []string{filepath.Join(absDir, ".claude", "skills")}
if home, err := os.UserHomeDir(); err == nil {
dirs = append(dirs, filepath.Join(home, ".claude", "skills"))
}
return dirs
}
// ── ContextCompressor implementation ──────────────────────────
func (a *Agent) CompressCommand() string { return "" }
// ── ModeSwitcher ────────────────────────────────────────────────
func (a *Agent) SetMode(mode string) {
a.mu.Lock()
defer a.mu.Unlock()
a.mode = normalizeMode(mode)
slog.Info("cursor: mode changed", "mode", a.mode)
}
func (a *Agent) GetMode() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.mode
}
func (a *Agent) PermissionModes() []core.PermissionModeInfo {
return []core.PermissionModeInfo{
{Key: "default", Name: "Default", NameZh: "默认", Desc: "Trust workspace, ask before tool use", DescZh: "信任工作区,工具调用前询问"},
{Key: "force", Name: "Force (YOLO)", NameZh: "强制执行", Desc: "Auto-approve all tool calls", DescZh: "自动批准所有工具调用"},
{Key: "plan", Name: "Plan", NameZh: "规划模式", Desc: "Read-only analysis, no edits", DescZh: "只读分析,不做修改"},
{Key: "ask", Name: "Ask", NameZh: "问答模式", Desc: "Q&A style, read-only", DescZh: "问答风格,只读"},
}
}
// ── 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("cursor: provider cleared")
return true
}
for i, p := range a.providers {
if p.Name == name {
a.activeIdx = i
slog.Info("cursor: provider switched", "provider", name)
return true
}
}
return false
}
func (a *Agent) GetActiveProvider() *core.ProviderConfig {
a.mu.RLock()
defer a.mu.RUnlock()
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.RLock()
defer a.mu.RUnlock()
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, "CURSOR_API_KEY="+p.APIKey)
}
for k, v := range p.Env {
env = append(env, k+"="+v)
}
return env
}
// ── Session listing ─────────────────────────────────────────────
// workspaceHash returns the MD5 hash that Cursor uses to organize chats by workspace.
func workspaceHash(workDir string) string {
abs, err := filepath.Abs(workDir)
if err != nil {
abs = workDir
}
h := md5.Sum([]byte(abs))
return hex.EncodeToString(h[:])
}
func listCursorSessions(workDir string) ([]core.AgentSessionInfo, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("cursor: cannot determine home dir: %w", err)
}
hash := workspaceHash(workDir)
chatsDir := filepath.Join(homeDir, ".cursor", "chats", hash)
entries, err := os.ReadDir(chatsDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("cursor: read chats dir: %w", err)
}
var sessions []core.AgentSessionInfo
for _, entry := range entries {
if !entry.IsDir() {
continue
}
sessionID := entry.Name()
dbPath := filepath.Join(chatsDir, sessionID, "store.db")
if _, err := os.Stat(dbPath); err != nil {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
meta := readSessionMeta(dbPath)
msgCount, firstUserMsg := countSessionMessages(dbPath, meta.RootBlobID)
summary := meta.Name
if summary == "" || summary == "New Agent" {
if firstUserMsg != "" {
summary = firstUserMsg
} else {
summary = sessionID[:12] + "..."
}
}
if utf8.RuneCountInString(summary) > 60 {
summary = string([]rune(summary)[:60]) + "..."
}
sessions = append(sessions, core.AgentSessionInfo{
ID: sessionID,
Summary: summary,
MessageCount: msgCount,
ModifiedAt: info.ModTime(),
})
}
sort.Slice(sessions, func(i, j int) bool {
return sessions[i].ModifiedAt.After(sessions[j].ModifiedAt)
})
return sessions, nil
}
// sessionMeta holds metadata extracted from a Cursor chat store.db.
type sessionMeta struct {
AgentID string
Name string
Mode string
RootBlobID string
}
// readSessionMeta reads the meta table from store.db without importing database/sql.
// The meta value at key "0" is already a hex-encoded JSON string in the TEXT column,
// so we read it directly (no extra hex() wrapping) and decode once.
func readSessionMeta(dbPath string) sessionMeta {
sqliteBin, err := exec.LookPath("sqlite3")
if err != nil {
return sessionMeta{}
}
out, err := exec.Command(sqliteBin, dbPath,
"SELECT value FROM meta WHERE key='0' LIMIT 1;",
).Output()
if err != nil {
return sessionMeta{}
}
hexStr := strings.TrimSpace(string(out))
if hexStr == "" {
return sessionMeta{}
}
decoded, err := hex.DecodeString(hexStr)
if err != nil {
// Fallback: value might be raw JSON (not hex-encoded) in some versions
decoded = []byte(hexStr)
}
var m struct {
AgentID string `json:"agentId"`
Name string `json:"name"`
Mode string `json:"mode"`
RootBlobID string `json:"latestRootBlobId"`
}
if json.Unmarshal(decoded, &m) != nil {
return sessionMeta{}
}
return sessionMeta{AgentID: m.AgentID, Name: m.Name, Mode: m.Mode, RootBlobID: m.RootBlobID}
}
// countSessionMessages reads the root blob from store.db and counts conversation
// messages. It also returns the first user message text as a summary fallback.
// The root blob uses a protobuf-like encoding where field 1 (tag 0x0a, length 0x20)
// entries are 32-byte SHA-256 references to child message blobs.
func countSessionMessages(dbPath, rootBlobID string) (int, string) {
if rootBlobID == "" {
return 0, ""
}
sqliteBin, err := exec.LookPath("sqlite3")
if err != nil {
return 0, ""
}
// Read root blob header (first ~8KB is enough for counting refs)
out, err := exec.Command(sqliteBin, dbPath,
fmt.Sprintf("SELECT hex(substr(data,1,8192)) FROM blobs WHERE id='%s' LIMIT 1;", rootBlobID),
).Output()
if err != nil {
return 0, ""
}
rootHex := strings.TrimSpace(string(out))
rootBytes, err := hex.DecodeString(rootHex)
if err != nil || len(rootBytes) == 0 {
return 0, ""
}
// Count field-1 entries (0x0a 0x20 + 32-byte hash)
var childIDs []string
i := 0
for i+33 < len(rootBytes) && rootBytes[i] == 0x0a && rootBytes[i+1] == 0x20 {
childIDs = append(childIDs, hex.EncodeToString(rootBytes[i+2:i+34]))
i += 34
}
if len(childIDs) == 0 {
return 0, ""
}
// Read the first few children to find the first real user message for summary,
// and count roles to determine message count (excluding system).
msgCount := 0
var firstUserMsg string
limit := len(childIDs)
if limit > 80 {
limit = 80
}
// Build a single query to read multiple children
var ids []string
for _, cid := range childIDs[:limit] {
ids = append(ids, "'"+cid+"'")
}
query := fmt.Sprintf(
"SELECT id, data FROM blobs WHERE id IN (%s);",
strings.Join(ids, ","),
)
blobOut, err := exec.Command(sqliteBin, "-separator", "|", dbPath, query).Output()
if err != nil {
// Fallback: estimate from child count minus 1 (system message)
if len(childIDs) > 1 {
return len(childIDs) - 1, ""
}
return 0, ""
}
roleCount := make(map[string]int)
blobMap := make(map[string][]byte)
for _, line := range strings.Split(string(blobOut), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, "|", 2)
if len(parts) != 2 {
continue
}
blobMap[parts[0]] = []byte(parts[1])
}
for _, cid := range childIDs[:limit] {
raw, ok := blobMap[cid]
if !ok || len(raw) == 0 {
continue
}
var msg struct {
Role string `json:"role"`
Content any `json:"content"`
}
if json.Unmarshal(raw, &msg) != nil {
continue
}
roleCount[msg.Role]++
if msg.Role == "user" && firstUserMsg == "" {
if s, ok := msg.Content.(string); ok {
s = strings.TrimSpace(s)
// Skip injected context (XML tags, conversation summaries, etc.)
if len(s) > 0 && !strings.HasPrefix(s, "<") && !strings.HasPrefix(s, "[") && !strings.HasPrefix(s, "{") {
if utf8.RuneCountInString(s) > 50 {
s = string([]rune(s)[:50]) + "..."
}
firstUserMsg = s
}
}
}
}
msgCount = roleCount["user"] + roleCount["assistant"]
if limit < len(childIDs) {
// Extrapolate for remaining children
total := len(childIDs)
msgCount = msgCount * total / limit
}
return msgCount, firstUserMsg
}
+40
View File
@@ -0,0 +1,40 @@
package cursor
import (
"context"
"fmt"
"sync"
"testing"
)
// TestAgent_StartSessionWorkDirRace exercises concurrent SetWorkDir + StartSession.
// Without the fix, StartSession reads a.workDir without holding a.mu while
// SetWorkDir writes it under the lock, which Go's -race detector flags as a
// data race. With the fix, the field is captured inside the existing critical
// section and no race is reported.
//
// newCursorSession only initialises the session struct; it does not spawn the
// Cursor agent CLI until Send() is called, so this test runs without requiring
// the binary on PATH.
func TestAgent_StartSessionWorkDirRace(t *testing.T) {
a := &Agent{cmd: "agent", workDir: "/initial"}
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(2)
go func(i int) {
defer wg.Done()
a.SetWorkDir(fmt.Sprintf("/path-%d", i))
}(i)
go func() {
defer wg.Done()
sess, err := a.StartSession(context.Background(), "")
if err != nil {
t.Errorf("StartSession: %v", err)
return
}
_ = sess.Close()
}()
}
wg.Wait()
}
+123
View File
@@ -0,0 +1,123 @@
package cursor
import (
"context"
"os"
"os/exec"
"strings"
"testing"
"time"
)
func shortTestContext(t *testing.T) (context.Context, context.CancelFunc) {
t.Helper()
timeout := 30 * time.Second
if deadline, ok := t.Deadline(); ok {
remaining := time.Until(deadline)
if remaining <= 0 {
timeout = 100 * time.Millisecond
} else if remaining < timeout {
timeout = remaining
}
}
return context.WithTimeout(context.Background(), timeout)
}
func requireWorkingAgentCLI(t *testing.T) {
t.Helper()
if os.Getenv("CI") != "" {
t.Skip("skipping agent CLI test in CI (no real cursor agent available)")
}
if os.Getenv("SKIP_REAL_AGENT_CLI") != "" {
t.Skip("skipping real agent CLI test (SKIP_REAL_AGENT_CLI is set)")
}
if _, err := exec.LookPath("agent"); err != nil {
t.Skip("agent CLI not in PATH")
}
ctx, cancel := shortTestContext(t)
defer cancel()
out, err := exec.CommandContext(ctx, "agent", "models").CombinedOutput()
if err != nil {
t.Skipf("agent CLI is not runnable in this environment: %v (%s)", err, strings.TrimSpace(string(out)))
}
}
func TestFetchModelsFromAgentCLI(t *testing.T) {
ctx, cancel := shortTestContext(t)
defer cancel()
requireWorkingAgentCLI(t)
models := fetchModelsFromAgentCLI(ctx, "agent", nil)
if len(models) == 0 {
t.Fatal("expected models from agent models, got none")
}
// Verify format: each model has non-empty Name
for i, m := range models {
if m.Name == "" {
t.Errorf("models[%d].Name is empty", i)
}
}
// 运行 go test -v 时可见
t.Logf("fetched %d models:", len(models))
for i, m := range models {
t.Logf(" %2d. %s - %s", i+1, m.Name, m.Desc)
}
}
func TestFetchModelsFromAgentCLI_FailsGracefully(t *testing.T) {
ctx, cancel := shortTestContext(t)
defer cancel()
models := fetchModelsFromAgentCLI(ctx, "nonexistent-agent-xyz", nil)
if len(models) != 0 {
t.Errorf("expected empty when command fails, got %d models", len(models))
}
}
func TestAvailableModels_Fallback(t *testing.T) {
// When agent models fails, should fall back to hardcoded list
ctx, cancel := shortTestContext(t)
defer cancel()
a := &Agent{cmd: "nonexistent-cmd-that-will-fail"}
models := a.AvailableModels(ctx)
fallback := cursorFallbackModels()
if len(models) != len(fallback) {
t.Fatalf("fallback models length = %d, want %d", len(models), len(fallback))
}
for i := range models {
if models[i].Name != fallback[i].Name {
t.Errorf("models[%d].Name = %q, want %q", i, models[i].Name, fallback[i].Name)
}
}
}
func TestAvailableModels_FetchFromAgent(t *testing.T) {
requireWorkingAgentCLI(t)
ctx, cancel := shortTestContext(t)
defer cancel()
a := &Agent{cmd: "agent"}
models := a.AvailableModels(ctx)
if len(models) == 0 {
t.Fatal("expected models from agent models, got none")
}
t.Logf("AvailableModels returned %d models:", len(models))
for i, m := range models {
t.Logf(" %2d. %s - %s", i+1, m.Name, m.Desc)
}
// Should have real models like gpt-5.3-codex, opus-4.6-thinking, etc.
hasCodex := false
for _, m := range models {
if m.Name == "gpt-5.3-codex" || m.Name == "opus-4.6-thinking" || m.Name == "auto" {
hasCodex = true
break
}
}
if !hasCodex {
t.Logf("models: %v", models)
t.Log("agent models returned models but none of the expected ones (gpt-5.3-codex, opus-4.6-thinking, auto) - may be OK if CLI output format changed")
}
}
+478
View File
@@ -0,0 +1,478 @@
package cursor
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/chenhg5/cc-connect/core"
)
// cursorSession manages multi-turn conversations with the Cursor Agent CLI.
// Each Send() launches a new `agent --print` process with --resume for continuity.
type cursorSession struct {
cmd string // CLI binary name
workDir string
model string
mode string
extraEnv []string
events chan core.Event
chatID atomic.Value // stores string — Cursor chat/session ID
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
alive atomic.Bool
thinkingBuf strings.Builder // accumulate thinking deltas
}
func newCursorSession(ctx context.Context, cmd, workDir, model, mode, resumeID string, extraEnv []string) (*cursorSession, error) {
sessionCtx, cancel := context.WithCancel(ctx)
cs := &cursorSession{
cmd: cmd,
workDir: workDir,
model: model,
mode: mode,
extraEnv: extraEnv,
events: make(chan core.Event, 64),
ctx: sessionCtx,
cancel: cancel,
}
cs.alive.Store(true)
if resumeID != "" && resumeID != core.ContinueSession {
cs.chatID.Store(resumeID)
}
return cs, nil
}
func (cs *cursorSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error {
if len(images) > 0 {
slog.Warn("cursorSession: images not yet supported in CLI mode, ignoring")
}
if len(files) > 0 {
filePaths := core.SaveFilesToDisk(cs.workDir, files)
prompt = core.AppendFileRefs(prompt, filePaths)
}
if !cs.alive.Load() {
return fmt.Errorf("session is closed")
}
chatID := cs.CurrentSessionID()
isResume := chatID != ""
args := []string{
"--print",
"--output-format", "stream-json",
}
switch cs.mode {
case "force":
args = append(args, "--force")
case "plan":
args = append(args, "--mode", "plan")
case "ask":
args = append(args, "--mode", "ask")
}
if isResume {
args = append(args, "--resume", chatID)
}
if cs.model != "" {
args = append(args, "--model", cs.model)
}
args = append(args, "--workspace", cs.workDir, "--", prompt)
slog.Debug("cursorSession: launching", "resume", isResume, "args", core.RedactArgs(args))
cmd := exec.CommandContext(cs.ctx, cs.cmd, args...)
cmd.Dir = cs.workDir
env := os.Environ()
if len(cs.extraEnv) > 0 {
env = core.MergeEnv(env, cs.extraEnv)
}
cmd.Env = env
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("cursorSession: stdout pipe: %w", err)
}
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
return fmt.Errorf("cursorSession: start: %w", err)
}
cs.wg.Add(1)
go cs.readLoop(cmd, stdout, &stderrBuf)
return nil
}
func (cs *cursorSession) readLoop(cmd *exec.Cmd, stdout io.ReadCloser, stderrBuf *bytes.Buffer) {
defer cs.wg.Done()
defer func() {
if err := cmd.Wait(); err != nil {
stderrMsg := strings.TrimSpace(stderrBuf.String())
if stderrMsg != "" {
slog.Error("cursorSession: process failed", "error", err, "stderr", stderrMsg)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
}
}()
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
slog.Debug("cursorSession: raw", "line", truncateStr(line, 500))
var raw map[string]any
if err := json.Unmarshal([]byte(line), &raw); err != nil {
slog.Debug("cursorSession: non-JSON line", "line", line)
continue
}
cs.handleEvent(raw)
}
if err := scanner.Err(); err != nil {
slog.Error("cursorSession: scanner error", "error", err)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", err)}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
}
func (cs *cursorSession) handleEvent(raw map[string]any) {
eventType, _ := raw["type"].(string)
switch eventType {
case "system":
cs.handleSystem(raw)
case "user":
// User echo — nothing to do
case "thinking":
cs.handleThinking(raw)
case "assistant":
cs.handleAssistant(raw)
case "tool_call":
cs.handleToolCall(raw)
case "interaction_query":
cs.handleInteractionQuery(raw)
case "result":
cs.handleResult(raw)
default:
slog.Debug("cursorSession: unhandled event", "type", eventType)
}
}
func (cs *cursorSession) handleSystem(raw map[string]any) {
if sid, ok := raw["session_id"].(string); ok && sid != "" {
cs.chatID.Store(sid)
slog.Debug("cursorSession: session init", "session_id", sid)
model, _ := raw["model"].(string)
evt := core.Event{Type: core.EventText, SessionID: sid, Content: "", ToolName: model}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
}
func (cs *cursorSession) handleThinking(raw map[string]any) {
subtype, _ := raw["subtype"].(string)
switch subtype {
case "delta":
if text, _ := raw["text"].(string); text != "" {
cs.thinkingBuf.WriteString(text)
}
default:
text := cs.thinkingBuf.String()
cs.thinkingBuf.Reset()
if text != "" {
evt := core.Event{Type: core.EventThinking, Content: text}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
}
}
func (cs *cursorSession) handleAssistant(raw map[string]any) {
msg, ok := raw["message"].(map[string]any)
if !ok {
return
}
contentArr, ok := msg["content"].([]any)
if !ok {
return
}
for _, contentItem := range contentArr {
item, ok := contentItem.(map[string]any)
if !ok {
continue
}
contentType, _ := item["type"].(string)
if contentType == "text" {
if text, ok := item["text"].(string); ok && text != "" {
evt := core.Event{Type: core.EventText, Content: text}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
}
}
}
func (cs *cursorSession) handleToolCall(raw map[string]any) {
subtype, _ := raw["subtype"].(string)
tc, _ := raw["tool_call"].(map[string]any)
if tc == nil {
return
}
if subtype == "started" {
name, input := extractToolInfo(tc)
if name != "" {
evt := core.Event{Type: core.EventToolUse, ToolName: name, ToolInput: input}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
}
// "completed" tool_call events contain results; we log but don't emit to chat
if subtype == "completed" {
name, _ := extractToolInfo(tc)
slog.Debug("cursorSession: tool completed", "tool", name)
}
}
func (cs *cursorSession) handleInteractionQuery(raw map[string]any) {
subtype, _ := raw["subtype"].(string)
if subtype != "request" {
return
}
queryType, _ := raw["query_type"].(string)
query, _ := raw["query"].(map[string]any)
if query == nil {
return
}
toolName, input := extractInteractionQueryInfo(queryType, query)
if toolName == "" {
return
}
evt := core.Event{Type: core.EventToolUse, ToolName: toolName, ToolInput: input}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
func extractInteractionQueryInfo(queryType string, query map[string]any) (string, string) {
switch queryType {
case "webFetchRequestQuery":
if inner, ok := query["webFetchRequestQuery"].(map[string]any); ok {
if args, ok := inner["args"].(map[string]any); ok {
url, _ := args["url"].(string)
return "WebFetch", url
}
}
case "shellRequestQuery":
if inner, ok := query["shellRequestQuery"].(map[string]any); ok {
if args, ok := inner["args"].(map[string]any); ok {
cmd, _ := args["command"].(string)
return "Bash", cmd
}
}
}
name := strings.TrimSuffix(queryType, "RequestQuery")
name = strings.TrimSuffix(name, "Query")
if name == "" {
name = queryType
}
return name, ""
}
// extractToolInfo parses the nested tool_call structure from Cursor's stream-json.
// Tool calls can be shellToolCall, readToolCall, editToolCall, etc.
func extractToolInfo(tc map[string]any) (name string, input string) {
toolTypes := []struct {
key string
toolName string
}{
{"shellToolCall", "Bash"},
{"readToolCall", "Read"},
{"editToolCall", "Edit"},
{"writeToolCall", "Write"},
{"listToolCall", "List"},
{"searchToolCall", "Search"},
{"grepToolCall", "Grep"},
{"globToolCall", "Glob"},
{"webFetchToolCall", "WebFetch"},
}
for _, tt := range toolTypes {
if call, ok := tc[tt.key].(map[string]any); ok {
name = tt.toolName
input = extractToolInput(name, call)
return
}
}
// Generic: try "description" field at top level
if desc, ok := tc["description"].(string); ok && desc != "" {
return "Tool", truncateStr(desc, 200)
}
return "", ""
}
func extractToolInput(toolName string, call map[string]any) string {
args, _ := call["args"].(map[string]any)
if args == nil {
if desc, ok := call["description"].(string); ok {
return desc
}
return ""
}
switch toolName {
case "Bash":
if cmd, ok := args["command"].(string); ok {
return cmd
}
case "Read":
if p, ok := args["path"].(string); ok {
return p
}
case "Edit", "Write":
if p, ok := args["path"].(string); ok {
return p
}
if p, ok := args["filePath"].(string); ok {
return p
}
case "Grep":
if p, ok := args["pattern"].(string); ok {
return p
}
case "Glob":
if p, ok := args["pattern"].(string); ok {
return p
}
}
if desc, ok := call["description"].(string); ok && desc != "" {
return desc
}
b, _ := json.Marshal(args)
return string(b)
}
func (cs *cursorSession) handleResult(raw map[string]any) {
var content string
if result, ok := raw["result"].(string); ok {
content = result
}
if sid, ok := raw["session_id"].(string); ok && sid != "" {
cs.chatID.Store(sid)
}
evt := core.Event{Type: core.EventResult, Content: content, SessionID: cs.CurrentSessionID(), Done: true}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
// RespondPermission is a no-op — Cursor Agent permissions are handled via CLI default behavior or --force flag.
func (cs *cursorSession) RespondPermission(_ string, _ core.PermissionResult) error {
return nil
}
func (cs *cursorSession) Events() <-chan core.Event {
return cs.events
}
func (cs *cursorSession) CurrentSessionID() string {
v, _ := cs.chatID.Load().(string)
return v
}
func (cs *cursorSession) Alive() bool {
return cs.alive.Load()
}
func (cs *cursorSession) Close() error {
cs.alive.Store(false)
cs.cancel()
done := make(chan struct{})
go func() {
cs.wg.Wait()
close(done)
}()
select {
case <-done:
close(cs.events)
case <-time.After(8 * time.Second):
slog.Warn("cursorSession: close timed out, abandoning wg.Wait")
}
return nil
}
func truncateStr(s string, maxRunes int) string {
if utf8.RuneCountInString(s) <= maxRunes {
return s
}
return string([]rune(s)[:maxRunes]) + "..."
}