1017 lines
25 KiB
Go
1017 lines
25 KiB
Go
package bridge
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/daniel/cursor-adapter/internal/workspace"
|
|
)
|
|
|
|
// Bridge 定義與 Cursor CLI 的整合介面。
|
|
type Bridge interface {
|
|
Execute(ctx context.Context, prompt string, model string, sessionKey string) (<-chan string, <-chan error)
|
|
ExecuteSync(ctx context.Context, prompt string, model string, sessionKey string) (string, error)
|
|
ListModels(ctx context.Context) ([]string, error)
|
|
CheckHealth(ctx context.Context) error
|
|
}
|
|
|
|
// Options bundles the knobs NewBridge needs. CursorPath, ChatOnly, Mode and
|
|
// WorkspaceRoot together decide how each subprocess is sandboxed and what
|
|
// `--mode` flag (if any) is passed.
|
|
type Options struct {
|
|
CursorPath string
|
|
Logger *slog.Logger
|
|
UseACP bool
|
|
ChatOnly bool
|
|
MaxConcurrent int
|
|
Timeout time.Duration
|
|
// Mode is "plan" (the CLI proposes only; caller executes via tool_use
|
|
// translation) or "agent" (the CLI executes natively in WorkspaceRoot).
|
|
// Empty defaults to "plan".
|
|
Mode string
|
|
// WorkspaceRoot, when non-empty, overrides ChatOnly's temp workspace
|
|
// and runs the CLI directly in this absolute directory. Per-request
|
|
// override via context (see WithWorkspaceOverride) takes precedence.
|
|
WorkspaceRoot string
|
|
}
|
|
|
|
// NewBridge 建立 Bridge。
|
|
func NewBridge(opts Options) Bridge {
|
|
if opts.UseACP {
|
|
return NewACPBridge(opts)
|
|
}
|
|
return NewCLIBridge(opts)
|
|
}
|
|
|
|
// --- per-request workspace override via context ---
|
|
|
|
type ctxKey int
|
|
|
|
const workspaceCtxKey ctxKey = 1
|
|
|
|
// WithWorkspaceOverride attaches a per-request absolute workspace path to
|
|
// ctx. Bridges honour it ahead of the Options.WorkspaceRoot.
|
|
func WithWorkspaceOverride(ctx context.Context, workspace string) context.Context {
|
|
if workspace == "" {
|
|
return ctx
|
|
}
|
|
return context.WithValue(ctx, workspaceCtxKey, workspace)
|
|
}
|
|
|
|
func workspaceOverride(ctx context.Context) string {
|
|
v, _ := ctx.Value(workspaceCtxKey).(string)
|
|
return v
|
|
}
|
|
|
|
// --- CLI Bridge ---
|
|
|
|
type CLIBridge struct {
|
|
cursorPath string
|
|
semaphore chan struct{}
|
|
timeout time.Duration
|
|
chatOnly bool
|
|
mode string
|
|
workspaceRoot string
|
|
}
|
|
|
|
func buildCLICommandArgs(prompt, model, workspaceDir, mode string, stream, chatOnly bool) []string {
|
|
args := []string{"--print"}
|
|
// "plan" (default): the CLI proposes plans without executing tools;
|
|
// the proxy translates a brain-side <tool_call> sentinel into real
|
|
// Anthropic tool_use blocks for the caller to execute.
|
|
// "agent": omit --mode to let the CLI run with full filesystem/shell
|
|
// tools — useful when the user wants the CLI itself to be the
|
|
// executor inside a real workspace dir.
|
|
switch mode {
|
|
case "agent":
|
|
// no --mode flag — agent mode is the CLI default
|
|
case "", "plan":
|
|
args = append(args, "--mode", "plan")
|
|
default:
|
|
args = append(args, "--mode", mode)
|
|
}
|
|
// --trust skips interactive permission prompts. We always want this
|
|
// non-interactively: chat-only mode is sandboxed anyway, and agent
|
|
// mode against a real WorkspaceRoot means the operator already
|
|
// opted in to letting the CLI execute there.
|
|
if chatOnly || mode == "agent" {
|
|
args = append(args, "--trust")
|
|
}
|
|
if workspaceDir != "" {
|
|
args = append(args, "--workspace", workspaceDir)
|
|
}
|
|
if model != "" {
|
|
args = append(args, "--model", model)
|
|
}
|
|
if stream {
|
|
args = append(args, "--stream-partial-output", "--output-format", "stream-json")
|
|
} else {
|
|
args = append(args, "--output-format", "text")
|
|
}
|
|
args = append(args, prompt)
|
|
return args
|
|
}
|
|
|
|
// NewCLIBridge constructs a CLIBridge from an Options struct. ChatOnly,
|
|
// Mode and WorkspaceRoot together decide how each subprocess is sandboxed.
|
|
func NewCLIBridge(opts Options) *CLIBridge {
|
|
if opts.MaxConcurrent <= 0 {
|
|
opts.MaxConcurrent = 1
|
|
}
|
|
return &CLIBridge{
|
|
cursorPath: opts.CursorPath,
|
|
semaphore: make(chan struct{}, opts.MaxConcurrent),
|
|
timeout: opts.Timeout,
|
|
chatOnly: opts.ChatOnly,
|
|
mode: opts.Mode,
|
|
workspaceRoot: opts.WorkspaceRoot,
|
|
}
|
|
}
|
|
|
|
// prepareWorkspace returns (workspaceDir, envOverrides, cleanup).
|
|
//
|
|
// Resolution order:
|
|
// 1. ctx override (X-Cursor-Workspace header) if set
|
|
// 2. configured WorkspaceRoot if set
|
|
// 3. chat-only temp dir if enabled
|
|
// 4. adapter's cwd
|
|
//
|
|
// Cases (1) and (2) deliberately return no env overrides — the caller
|
|
// asked for a real host directory, so HOME / CURSOR_CONFIG_DIR stay
|
|
// untouched and the CLI sees the real user profile (auth + tools).
|
|
func (b *CLIBridge) prepareWorkspace(ctx context.Context) (string, map[string]string, func()) {
|
|
if override := workspaceOverride(ctx); override != "" {
|
|
return override, nil, func() {}
|
|
}
|
|
if b.workspaceRoot != "" {
|
|
return b.workspaceRoot, nil, func() {}
|
|
}
|
|
if !b.chatOnly {
|
|
ws, _ := os.Getwd()
|
|
return ws, nil, func() {}
|
|
}
|
|
dir, env, err := workspace.ChatOnly("")
|
|
if err != nil {
|
|
slog.Warn("chat-only workspace setup failed, falling back to cwd", "err", err)
|
|
ws, _ := os.Getwd()
|
|
return ws, nil, func() {}
|
|
}
|
|
return dir, env, func() { _ = os.RemoveAll(dir) }
|
|
}
|
|
|
|
func (b *CLIBridge) Execute(ctx context.Context, prompt string, model string, sessionKey string) (<-chan string, <-chan error) {
|
|
outputChan := make(chan string, 64)
|
|
errChan := make(chan error, 1)
|
|
|
|
go func() {
|
|
defer close(outputChan)
|
|
defer close(errChan)
|
|
|
|
select {
|
|
case b.semaphore <- struct{}{}:
|
|
defer func() { <-b.semaphore }()
|
|
case <-ctx.Done():
|
|
errChan <- ctx.Err()
|
|
return
|
|
}
|
|
|
|
execCtx, cancel := context.WithTimeout(ctx, b.timeout)
|
|
defer cancel()
|
|
|
|
ws, envOverrides, cleanup := b.prepareWorkspace(ctx)
|
|
defer cleanup()
|
|
cmd := exec.CommandContext(execCtx, b.cursorPath, buildCLICommandArgs(prompt, model, ws, b.mode, true, b.chatOnly)...)
|
|
cmd.Dir = ws
|
|
cmd.Env = workspace.MergeEnv(os.Environ(), envOverrides)
|
|
|
|
stdoutPipe, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
errChan <- fmt.Errorf("stdout pipe: %w", err)
|
|
return
|
|
}
|
|
defer stdoutPipe.Close()
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
errChan <- fmt.Errorf("start command: %w", err)
|
|
return
|
|
}
|
|
scanner := bufio.NewScanner(stdoutPipe)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
outputChan <- line
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
errChan <- fmt.Errorf("stdout scanner: %w", err)
|
|
}
|
|
|
|
if err := cmd.Wait(); err != nil {
|
|
errChan <- err
|
|
}
|
|
}()
|
|
|
|
return outputChan, errChan
|
|
}
|
|
|
|
func (b *CLIBridge) ExecuteSync(ctx context.Context, prompt string, model string, sessionKey string) (string, error) {
|
|
select {
|
|
case b.semaphore <- struct{}{}:
|
|
defer func() { <-b.semaphore }()
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
}
|
|
|
|
execCtx, cancel := context.WithTimeout(ctx, b.timeout)
|
|
defer cancel()
|
|
|
|
ws, envOverrides, cleanup := b.prepareWorkspace(ctx)
|
|
defer cleanup()
|
|
cmd := exec.CommandContext(execCtx, b.cursorPath, buildCLICommandArgs(prompt, model, ws, b.mode, false, b.chatOnly)...)
|
|
cmd.Dir = ws
|
|
cmd.Env = workspace.MergeEnv(os.Environ(), envOverrides)
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return "", fmt.Errorf("run command: %w (stderr: %s)", err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
return strings.TrimSpace(stdout.String()), nil
|
|
}
|
|
|
|
func (b *CLIBridge) ListModels(ctx context.Context) ([]string, error) {
|
|
select {
|
|
case b.semaphore <- struct{}{}:
|
|
defer func() { <-b.semaphore }()
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, b.cursorPath, "models")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list models: %w", err)
|
|
}
|
|
return parseModelsOutput(string(output)), nil
|
|
}
|
|
|
|
func (b *CLIBridge) CheckHealth(ctx context.Context) error {
|
|
cmd := exec.CommandContext(ctx, b.cursorPath, "--version")
|
|
_, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("health check: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`)
|
|
|
|
func parseModelsOutput(output string) []string {
|
|
clean := ansiEscapeRe.ReplaceAllString(output, "")
|
|
var models []string
|
|
for _, line := range strings.Split(clean, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if strings.Contains(line, "Loading") || strings.Contains(line, "Available") ||
|
|
strings.Contains(line, "Tip:") || strings.Contains(line, "Error") {
|
|
continue
|
|
}
|
|
id := line
|
|
if idx := strings.Index(line, " - "); idx > 0 {
|
|
id = line[:idx]
|
|
}
|
|
id = strings.TrimSpace(id)
|
|
if id != "" {
|
|
models = append(models, id)
|
|
}
|
|
}
|
|
return models
|
|
}
|
|
|
|
// --- ACP Bridge (per-request 完整流程,參考 cursor-api-proxy) ---
|
|
|
|
type ACPBridge struct {
|
|
cursorPath string
|
|
logger *slog.Logger
|
|
timeout time.Duration
|
|
chatOnly bool
|
|
workspaceRoot string
|
|
workers []*acpWorker
|
|
nextWorker atomic.Uint32
|
|
sessionsMu sync.Mutex
|
|
sessions map[string]acpSessionHandle
|
|
sessionTTL time.Duration
|
|
}
|
|
|
|
type acpSessionHandle struct {
|
|
WorkerIndex int
|
|
SessionID string
|
|
Model string
|
|
Generation uint64
|
|
LastUsedAt time.Time
|
|
}
|
|
|
|
func NewACPBridge(opts Options) *ACPBridge {
|
|
if opts.MaxConcurrent <= 0 {
|
|
opts.MaxConcurrent = 1
|
|
}
|
|
bridge := &ACPBridge{
|
|
cursorPath: opts.CursorPath,
|
|
logger: opts.Logger,
|
|
timeout: opts.Timeout,
|
|
chatOnly: opts.ChatOnly,
|
|
workspaceRoot: opts.WorkspaceRoot,
|
|
sessions: make(map[string]acpSessionHandle),
|
|
sessionTTL: 30 * time.Minute,
|
|
}
|
|
for i := 0; i < opts.MaxConcurrent; i++ {
|
|
bridge.workers = append(bridge.workers, newACPWorker(opts.CursorPath, opts.Logger, opts.ChatOnly, opts.WorkspaceRoot, opts.Timeout))
|
|
}
|
|
return bridge
|
|
}
|
|
|
|
func buildACPCommandArgs(workspace, model string) []string {
|
|
args := []string{"--workspace", workspace}
|
|
if model != "" && model != "auto" && model != "default" {
|
|
args = append(args, "--model", model)
|
|
}
|
|
args = append(args, "acp")
|
|
return args
|
|
}
|
|
|
|
// acpMessage 定義 ACP JSON-RPC message 格式。
|
|
type acpMessage struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID *int `json:"id,omitempty"`
|
|
Method string `json:"method,omitempty"`
|
|
Params json.RawMessage `json:"params,omitempty"`
|
|
Result json.RawMessage `json:"result,omitempty"`
|
|
Error *acpError `json:"error,omitempty"`
|
|
}
|
|
|
|
type acpError struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type acpSession struct {
|
|
SessionID string `json:"sessionId"`
|
|
}
|
|
|
|
type acpResponse struct {
|
|
result json.RawMessage
|
|
err error
|
|
}
|
|
|
|
type acpWorker struct {
|
|
cursorPath string
|
|
logger *slog.Logger
|
|
timeout time.Duration
|
|
chatOnly bool
|
|
workspaceRoot string
|
|
|
|
reqMu sync.Mutex
|
|
|
|
writeMu sync.Mutex
|
|
stateMu sync.Mutex
|
|
|
|
workspace string
|
|
envOverrides map[string]string
|
|
currentModel string
|
|
cmd *exec.Cmd
|
|
stdin io.WriteCloser
|
|
pending map[int]chan acpResponse
|
|
nextID int
|
|
readerErr error
|
|
readerDone chan struct{}
|
|
activeSink func(string)
|
|
generation atomic.Uint64
|
|
}
|
|
|
|
func newACPWorker(cursorPath string, logger *slog.Logger, chatOnly bool, workspaceRoot string, timeout time.Duration) *acpWorker {
|
|
return &acpWorker{
|
|
cursorPath: cursorPath,
|
|
logger: logger,
|
|
timeout: timeout,
|
|
chatOnly: chatOnly,
|
|
workspaceRoot: workspaceRoot,
|
|
}
|
|
}
|
|
|
|
func (b *ACPBridge) Execute(ctx context.Context, prompt string, model string, sessionKey string) (<-chan string, <-chan error) {
|
|
outputChan := make(chan string, 64)
|
|
errChan := make(chan error, 1)
|
|
|
|
go func() {
|
|
defer close(outputChan)
|
|
defer close(errChan)
|
|
|
|
worker, sessionID := b.resolveSession(sessionKey, model)
|
|
finalSessionID, err := worker.run(ctx, prompt, model, sessionID, func(text string) {
|
|
if text == "" {
|
|
return
|
|
}
|
|
select {
|
|
case outputChan <- text:
|
|
case <-ctx.Done():
|
|
}
|
|
})
|
|
if err == nil {
|
|
b.storeSession(sessionKey, model, worker, finalSessionID)
|
|
}
|
|
if err != nil {
|
|
errChan <- err
|
|
}
|
|
}()
|
|
|
|
return outputChan, errChan
|
|
}
|
|
|
|
func (b *ACPBridge) ExecuteSync(ctx context.Context, prompt string, model string, sessionKey string) (string, error) {
|
|
var content strings.Builder
|
|
worker, sessionID := b.resolveSession(sessionKey, model)
|
|
finalSessionID, err := worker.run(ctx, prompt, model, sessionID, func(text string) {
|
|
content.WriteString(text)
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
b.storeSession(sessionKey, model, worker, finalSessionID)
|
|
return strings.TrimSpace(content.String()), nil
|
|
}
|
|
|
|
func (b *ACPBridge) pickWorker() *acpWorker {
|
|
if len(b.workers) == 0 {
|
|
return newACPWorker(b.cursorPath, b.logger, b.chatOnly, b.workspaceRoot, b.timeout)
|
|
}
|
|
idx := int(b.nextWorker.Add(1)-1) % len(b.workers)
|
|
return b.workers[idx]
|
|
}
|
|
|
|
func (b *ACPBridge) resolveSession(sessionKey, model string) (*acpWorker, string) {
|
|
normalizedModel := normalizeModel(model)
|
|
if sessionKey == "" {
|
|
return b.pickWorker(), ""
|
|
}
|
|
|
|
b.sessionsMu.Lock()
|
|
defer b.sessionsMu.Unlock()
|
|
b.cleanupExpiredSessionsLocked()
|
|
|
|
handle, ok := b.sessions[sessionKey]
|
|
if !ok {
|
|
return b.pickWorker(), ""
|
|
}
|
|
if handle.Model != normalizedModel {
|
|
delete(b.sessions, sessionKey)
|
|
return b.workers[handle.WorkerIndex], ""
|
|
}
|
|
if handle.WorkerIndex < 0 || handle.WorkerIndex >= len(b.workers) {
|
|
delete(b.sessions, sessionKey)
|
|
return b.pickWorker(), ""
|
|
}
|
|
worker := b.workers[handle.WorkerIndex]
|
|
if worker.Generation() != handle.Generation {
|
|
delete(b.sessions, sessionKey)
|
|
return worker, ""
|
|
}
|
|
|
|
handle.LastUsedAt = time.Now()
|
|
b.sessions[sessionKey] = handle
|
|
return worker, handle.SessionID
|
|
}
|
|
|
|
func (b *ACPBridge) storeSession(sessionKey, model string, worker *acpWorker, sessionID string) {
|
|
if sessionKey == "" || sessionID == "" {
|
|
return
|
|
}
|
|
workerIndex := -1
|
|
for i, candidate := range b.workers {
|
|
if candidate == worker {
|
|
workerIndex = i
|
|
break
|
|
}
|
|
}
|
|
if workerIndex == -1 {
|
|
return
|
|
}
|
|
|
|
b.sessionsMu.Lock()
|
|
defer b.sessionsMu.Unlock()
|
|
b.cleanupExpiredSessionsLocked()
|
|
b.sessions[sessionKey] = acpSessionHandle{
|
|
WorkerIndex: workerIndex,
|
|
SessionID: sessionID,
|
|
Model: normalizeModel(model),
|
|
Generation: worker.Generation(),
|
|
LastUsedAt: time.Now(),
|
|
}
|
|
}
|
|
|
|
func (b *ACPBridge) cleanupExpiredSessionsLocked() {
|
|
if b.sessionTTL <= 0 {
|
|
return
|
|
}
|
|
cutoff := time.Now().Add(-b.sessionTTL)
|
|
for key, handle := range b.sessions {
|
|
if handle.LastUsedAt.Before(cutoff) {
|
|
delete(b.sessions, key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *ACPBridge) ListModels(ctx context.Context) ([]string, error) {
|
|
cmd := exec.CommandContext(ctx, b.cursorPath, "models")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list models: %w", err)
|
|
}
|
|
return parseModelsOutput(string(output)), nil
|
|
}
|
|
|
|
func (b *ACPBridge) CheckHealth(ctx context.Context) error {
|
|
cmd := exec.CommandContext(ctx, b.cursorPath, "--version")
|
|
_, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("health check: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sendACP 送 JSON-RPC request。
|
|
func sendACP(stdin io.Writer, id int, method string, params interface{}) error {
|
|
msg := acpMessage{
|
|
JSONRPC: "2.0",
|
|
ID: &id,
|
|
Method: method,
|
|
}
|
|
if params != nil {
|
|
data, _ := json.Marshal(params)
|
|
msg.Params = data
|
|
}
|
|
|
|
data, _ := json.Marshal(msg)
|
|
_, err := fmt.Fprintf(stdin, "%s\n", data)
|
|
return err
|
|
}
|
|
|
|
func respondACP(stdin io.Writer, id int, result interface{}) error {
|
|
msg := map[string]interface{}{
|
|
"jsonrpc": "2.0",
|
|
"id": id,
|
|
"result": result,
|
|
}
|
|
data, _ := json.Marshal(msg)
|
|
_, err := fmt.Fprintf(stdin, "%s\n", data)
|
|
return err
|
|
}
|
|
|
|
func extractACPText(params json.RawMessage) string {
|
|
var update map[string]interface{}
|
|
_ = json.Unmarshal(params, &update)
|
|
|
|
updateMap, _ := update["update"].(map[string]interface{})
|
|
sessionUpdate, _ := updateMap["sessionUpdate"].(string)
|
|
if sessionUpdate != "" && !strings.HasPrefix(sessionUpdate, "agent_message") {
|
|
return ""
|
|
}
|
|
content, _ := updateMap["content"].(interface{})
|
|
|
|
switch v := content.(type) {
|
|
case map[string]interface{}:
|
|
if t, ok := v["text"].(string); ok {
|
|
return t
|
|
}
|
|
case []interface{}:
|
|
var parts []string
|
|
for _, item := range v {
|
|
if m, ok := item.(map[string]interface{}); ok {
|
|
if nested, ok := m["content"].(map[string]interface{}); ok {
|
|
if t, ok := nested["text"].(string); ok {
|
|
parts = append(parts, t)
|
|
continue
|
|
}
|
|
}
|
|
if t, ok := m["text"].(string); ok {
|
|
parts = append(parts, t)
|
|
}
|
|
}
|
|
}
|
|
return strings.Join(parts, "")
|
|
case string:
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (w *acpWorker) Generation() uint64 {
|
|
return w.generation.Load()
|
|
}
|
|
|
|
func normalizeModel(m string) string {
|
|
m = strings.TrimSpace(m)
|
|
if m == "" || m == "default" {
|
|
return "auto"
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (w *acpWorker) run(ctx context.Context, prompt string, model string, sessionID string, sink func(string)) (string, error) {
|
|
w.reqMu.Lock()
|
|
defer w.reqMu.Unlock()
|
|
|
|
t0 := time.Now()
|
|
wantModel := normalizeModel(model)
|
|
|
|
if w.cmd != nil && w.currentModel != wantModel {
|
|
slog.Debug("acp: model changed, restarting worker", "from", w.currentModel, "to", wantModel)
|
|
w.resetLocked()
|
|
sessionID = ""
|
|
}
|
|
|
|
if err := w.ensureStartedLocked(ctx, wantModel); err != nil {
|
|
return "", err
|
|
}
|
|
slog.Debug("acp: ensureStarted", "model", wantModel, "elapsed", time.Since(t0))
|
|
|
|
newSession := sessionID == ""
|
|
if newSession {
|
|
var err error
|
|
sessionID, err = w.createSessionLocked(ctx)
|
|
if err != nil {
|
|
w.resetLocked()
|
|
return "", err
|
|
}
|
|
slog.Debug("acp: session created", "elapsed", time.Since(t0))
|
|
}
|
|
|
|
w.setActiveSinkLocked(sink)
|
|
defer w.setActiveSinkLocked(nil)
|
|
|
|
slog.Debug("acp: sending prompt", "newSession", newSession, "elapsed", time.Since(t0))
|
|
if _, err := w.sendRequestLocked(ctx, "session/prompt", map[string]interface{}{
|
|
"sessionId": sessionID,
|
|
"prompt": []interface{}{map[string]interface{}{
|
|
"type": "text",
|
|
"text": prompt,
|
|
}},
|
|
}); err != nil {
|
|
w.resetLocked()
|
|
return "", err
|
|
}
|
|
slog.Debug("acp: prompt complete", "elapsed", time.Since(t0))
|
|
|
|
return sessionID, nil
|
|
}
|
|
|
|
func (w *acpWorker) ensureStartedLocked(ctx context.Context, model string) error {
|
|
if w.cmd != nil {
|
|
return nil
|
|
}
|
|
|
|
if w.workspace == "" {
|
|
var (
|
|
dir string
|
|
env map[string]string
|
|
err error
|
|
)
|
|
switch {
|
|
case w.workspaceRoot != "":
|
|
dir = w.workspaceRoot
|
|
case w.chatOnly:
|
|
dir, env, err = workspace.ChatOnly("")
|
|
if err != nil {
|
|
return fmt.Errorf("chat-only workspace: %w", err)
|
|
}
|
|
default:
|
|
dir, err = os.MkdirTemp("", "cursor-acp-worker-*")
|
|
if err != nil {
|
|
return fmt.Errorf("temp workspace: %w", err)
|
|
}
|
|
}
|
|
w.workspace = dir
|
|
w.envOverrides = env
|
|
}
|
|
|
|
w.currentModel = model
|
|
cmd := exec.Command(w.cursorPath, buildACPCommandArgs(w.workspace, model)...)
|
|
cmd.Dir = w.workspace
|
|
cmd.Env = workspace.MergeEnv(os.Environ(), w.envOverrides)
|
|
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("stdin pipe: %w", err)
|
|
}
|
|
|
|
stdoutPipe, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
_ = stdin.Close()
|
|
return fmt.Errorf("stdout pipe: %w", err)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
_ = stdin.Close()
|
|
_ = stdoutPipe.Close()
|
|
return fmt.Errorf("start acp: %w", err)
|
|
}
|
|
|
|
w.cmd = cmd
|
|
w.stdin = stdin
|
|
w.pending = make(map[int]chan acpResponse)
|
|
w.nextID = 1
|
|
w.readerDone = make(chan struct{})
|
|
w.readerErr = nil
|
|
w.generation.Add(1)
|
|
go w.readLoop(stdoutPipe)
|
|
|
|
if _, err := w.sendRequestLocked(ctx, "initialize", map[string]interface{}{
|
|
"protocolVersion": 1,
|
|
"clientCapabilities": map[string]interface{}{
|
|
"promptCapabilities": map[string]interface{}{"text": true},
|
|
"fs": map[string]interface{}{"readTextFile": false, "writeTextFile": false},
|
|
"terminal": false,
|
|
},
|
|
"clientInfo": map[string]interface{}{
|
|
"name": "cursor-adapter",
|
|
"version": "0.2.0",
|
|
},
|
|
}); err != nil {
|
|
return fmt.Errorf("initialize: %w", err)
|
|
}
|
|
|
|
if _, err := w.sendRequestLocked(ctx, "authenticate", map[string]interface{}{
|
|
"methodId": "cursor_login",
|
|
}); err != nil {
|
|
return fmt.Errorf("authenticate: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (w *acpWorker) createSessionLocked(ctx context.Context) (string, error) {
|
|
resp, err := w.sendRequestLocked(ctx, "session/new", map[string]interface{}{
|
|
"cwd": w.workspace,
|
|
"mcpServers": []interface{}{},
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("session/new: %w", err)
|
|
}
|
|
|
|
var session acpSession
|
|
if err := json.Unmarshal(resp, &session); err != nil || session.SessionID == "" {
|
|
return "", fmt.Errorf("session/new invalid response: %s", string(resp))
|
|
}
|
|
return session.SessionID, nil
|
|
}
|
|
|
|
func (w *acpWorker) setConfigLocked(ctx context.Context, sessionID, configID string, value interface{}) error {
|
|
_, err := w.sendRequestLocked(ctx, "session/set_config_option", map[string]interface{}{
|
|
"sessionId": sessionID,
|
|
"configId": configID,
|
|
"value": value,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("session/set_config_option(%s=%v): %w", configID, value, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (w *acpWorker) sendRequestLocked(ctx context.Context, method string, params interface{}) (json.RawMessage, error) {
|
|
if w.stdin == nil {
|
|
return nil, fmt.Errorf("acp stdin unavailable")
|
|
}
|
|
|
|
id := w.nextID
|
|
w.nextID++
|
|
respCh := make(chan acpResponse, 1)
|
|
|
|
w.stateMu.Lock()
|
|
w.pending[id] = respCh
|
|
readerDone := w.readerDone
|
|
w.stateMu.Unlock()
|
|
|
|
if err := w.writeJSONRPCLocked(id, method, params); err != nil {
|
|
w.removePending(id)
|
|
return nil, err
|
|
}
|
|
|
|
timer := time.NewTimer(w.timeout)
|
|
defer timer.Stop()
|
|
|
|
select {
|
|
case resp := <-respCh:
|
|
return resp.result, resp.err
|
|
case <-ctx.Done():
|
|
w.removePending(id)
|
|
return nil, ctx.Err()
|
|
case <-readerDone:
|
|
return nil, w.getReaderErr()
|
|
case <-timer.C:
|
|
w.removePending(id)
|
|
return nil, fmt.Errorf("acp %s timed out after %s", method, w.timeout)
|
|
}
|
|
}
|
|
|
|
func (w *acpWorker) writeJSONRPCLocked(id int, method string, params interface{}) error {
|
|
w.writeMu.Lock()
|
|
defer w.writeMu.Unlock()
|
|
return sendACP(w.stdin, id, method, params)
|
|
}
|
|
|
|
func (w *acpWorker) readLoop(stdout io.ReadCloser) {
|
|
defer stdout.Close()
|
|
|
|
scanner := bufio.NewScanner(stdout)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var msg acpMessage
|
|
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
|
continue
|
|
}
|
|
|
|
if msg.ID != nil && (msg.Result != nil || msg.Error != nil) {
|
|
w.deliverResponse(*msg.ID, msg)
|
|
continue
|
|
}
|
|
|
|
w.handleACPNotification(msg)
|
|
}
|
|
|
|
err := scanner.Err()
|
|
w.stateMu.Lock()
|
|
w.readerErr = err
|
|
done := w.readerDone
|
|
pending := w.pending
|
|
w.pending = make(map[int]chan acpResponse)
|
|
w.stateMu.Unlock()
|
|
|
|
for _, ch := range pending {
|
|
ch <- acpResponse{err: w.getReaderErr()}
|
|
close(ch)
|
|
}
|
|
|
|
if w.cmd != nil {
|
|
_ = w.cmd.Wait()
|
|
}
|
|
if done != nil {
|
|
close(done)
|
|
}
|
|
}
|
|
|
|
func (w *acpWorker) deliverResponse(id int, msg acpMessage) {
|
|
w.stateMu.Lock()
|
|
ch := w.pending[id]
|
|
delete(w.pending, id)
|
|
w.stateMu.Unlock()
|
|
if ch == nil {
|
|
return
|
|
}
|
|
|
|
resp := acpResponse{result: msg.Result}
|
|
if msg.Error != nil {
|
|
resp.err = fmt.Errorf("%s", msg.Error.Message)
|
|
}
|
|
ch <- resp
|
|
close(ch)
|
|
}
|
|
|
|
func (w *acpWorker) handleACPNotification(msg acpMessage) bool {
|
|
switch msg.Method {
|
|
case "session/update":
|
|
text := extractACPText(msg.Params)
|
|
if text != "" {
|
|
w.stateMu.Lock()
|
|
sink := w.activeSink
|
|
w.stateMu.Unlock()
|
|
if sink != nil {
|
|
sink(text)
|
|
}
|
|
}
|
|
return true
|
|
case "session/request_permission":
|
|
if msg.ID != nil {
|
|
w.writeMu.Lock()
|
|
_ = respondACP(w.stdin, *msg.ID, map[string]interface{}{
|
|
"outcome": map[string]interface{}{
|
|
"outcome": "selected",
|
|
"optionId": "reject-once",
|
|
},
|
|
})
|
|
w.writeMu.Unlock()
|
|
}
|
|
return true
|
|
}
|
|
|
|
if msg.ID != nil && strings.HasPrefix(msg.Method, "cursor/") {
|
|
var params map[string]interface{}
|
|
_ = json.Unmarshal(msg.Params, ¶ms)
|
|
|
|
w.writeMu.Lock()
|
|
defer w.writeMu.Unlock()
|
|
|
|
switch msg.Method {
|
|
case "cursor/ask_question":
|
|
selectedID := ""
|
|
if options, ok := params["options"].([]interface{}); ok && len(options) > 0 {
|
|
if first, ok := options[0].(map[string]interface{}); ok {
|
|
if id, ok := first["id"].(string); ok {
|
|
selectedID = id
|
|
}
|
|
}
|
|
}
|
|
_ = respondACP(w.stdin, *msg.ID, map[string]interface{}{"selectedId": selectedID})
|
|
case "cursor/create_plan":
|
|
_ = respondACP(w.stdin, *msg.ID, map[string]interface{}{"approved": true})
|
|
default:
|
|
_ = respondACP(w.stdin, *msg.ID, map[string]interface{}{})
|
|
}
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (w *acpWorker) setActiveSinkLocked(sink func(string)) {
|
|
w.stateMu.Lock()
|
|
w.activeSink = sink
|
|
w.stateMu.Unlock()
|
|
}
|
|
|
|
func (w *acpWorker) removePending(id int) {
|
|
w.stateMu.Lock()
|
|
delete(w.pending, id)
|
|
w.stateMu.Unlock()
|
|
}
|
|
|
|
func (w *acpWorker) getReaderErr() error {
|
|
w.stateMu.Lock()
|
|
defer w.stateMu.Unlock()
|
|
if w.readerErr != nil {
|
|
return w.readerErr
|
|
}
|
|
return fmt.Errorf("acp process exited")
|
|
}
|
|
|
|
func (w *acpWorker) resetLocked() {
|
|
w.stateMu.Lock()
|
|
cmd := w.cmd
|
|
stdin := w.stdin
|
|
done := w.readerDone
|
|
w.cmd = nil
|
|
w.stdin = nil
|
|
w.pending = make(map[int]chan acpResponse)
|
|
w.nextID = 1
|
|
w.readerDone = nil
|
|
w.readerErr = nil
|
|
w.activeSink = nil
|
|
w.stateMu.Unlock()
|
|
|
|
w.generation.Add(1)
|
|
|
|
if stdin != nil {
|
|
_ = stdin.Close()
|
|
}
|
|
if cmd != nil && cmd.Process != nil {
|
|
_ = cmd.Process.Kill()
|
|
}
|
|
if done != nil {
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
}
|
|
}
|
|
|
|
if w.workspace != "" {
|
|
// Only remove temp / chat-only directories — never delete a
|
|
// configured WorkspaceRoot (that's a real user directory).
|
|
if w.workspace != w.workspaceRoot {
|
|
_ = os.RemoveAll(w.workspace)
|
|
}
|
|
w.workspace = ""
|
|
}
|
|
w.envOverrides = nil
|
|
}
|