479 lines
11 KiB
Go
479 lines
11 KiB
Go
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]) + "..."
|
|
}
|