初始化仓库
This commit is contained in:
@@ -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]) + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user