Files
multica/server/pkg/agent/cursor.go
devv-eve c0b4e7e8b8 feat(agent): add Cursor Agent CLI runtime support (#1057)
* feat(agent): add Cursor Agent CLI runtime support

Add cursor-agent as a new agent backend, following the same pattern as
existing providers. The implementation spawns cursor-agent CLI with
stream-json output, parses JSONL events into the unified Message type,
and supports session resume, usage tracking, and auto-approval (--yolo).

Changes:
- server/pkg/agent/cursor.go: cursorBackend implementation
- server/pkg/agent/cursor_test.go: unit tests for args, parsing, errors
- server/pkg/agent/agent.go: register "cursor" in New() factory
- server/internal/daemon/config.go: probe cursor-agent in PATH
- server/internal/daemon/execenv/context.go: cursor skill discovery path
- server/internal/daemon/execenv/runtime_config.go: AGENTS.md injection
- packages/views/.../provider-logo.tsx: cursor logo in UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(agent): address PR review for cursor backend

1. Fix token usage double-counting: usage is now taken exclusively from
   "result" events (session totals). Per-message usage in "assistant"
   events is intentionally ignored. "step_finish" usage is only used as
   fallback when no "result" usage is available.

2. Remove dead code: isCursorUnknownSessionError() and its regex were
   defined but never called. Removed along with corresponding test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(agent): add missing CustomArgs, SystemPrompt, MaxTurns, and debug logging to cursor backend

- Add cursorBlockedArgs and filterCustomArgs support for safe custom arg passthrough
- Add --system-prompt and --max-turns flag support to buildCursorArgs
- Add debug logging of command args before execution (consistent with all other backends)
- Move stdout-close goroutine inside main goroutine (consistent with claude.go pattern)
- Add tests for SystemPrompt/MaxTurns and CustomArgs filtering

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore: make daemon uses local profile & update Cursor logo to official brand

- Makefile: make daemon now runs 'daemon start --profile local' for local dev
- Replace Cursor runtime logo with official brand SVG (removed background rect)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(agent): remove unsupported --system-prompt and --max-turns from cursor-agent

cursor-agent CLI does not support these flags. Instructions are already
injected via AGENTS.md and .cursor/skills/ files.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(agent): prevent step_finish + result usage double-counting in cursor

Split usage accumulation into separate stepUsage and resultUsage maps.
After stream ends, use resultUsage if available (session totals from
result event), otherwise fall back to stepUsage (sum of step_finish).
This prevents 2x counting when result.usage already includes totals.

Added table-driven test covering: result-only, step_finish-only,
step_finish+result (no double count), and multi-model scenarios.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(agent): fix misleading comment on cursor -p flag

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: yushen <ldnvnbl@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 15:54:21 +08:00

420 lines
11 KiB
Go

package agent
import (
"bufio"
"context"
"encoding/json"
"fmt"
"log/slog"
"os/exec"
"regexp"
"strings"
"time"
)
// cursorBackend implements Backend by spawning the Cursor Agent CLI
// (cursor-agent) with --output-format stream-json and parsing the JSONL
// event stream. The protocol is similar to Claude Code's stream-json
// format: events are newline-delimited JSON objects with a "type" field.
type cursorBackend struct {
cfg Config
}
func (b *cursorBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
execPath := b.cfg.ExecutablePath
if execPath == "" {
execPath = "cursor-agent"
}
if _, err := exec.LookPath(execPath); err != nil {
return nil, fmt.Errorf("cursor-agent executable not found at %q: %w", execPath, err)
}
timeout := opts.Timeout
if timeout == 0 {
timeout = 20 * time.Minute
}
runCtx, cancel := context.WithTimeout(ctx, timeout)
args := buildCursorArgs(prompt, opts, b.cfg.Logger)
cmd := exec.CommandContext(runCtx, execPath, args...)
b.cfg.Logger.Debug("agent command", "exec", execPath, "args", args)
cmd.WaitDelay = 20 * time.Second
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
cmd.Env = buildEnv(b.cfg.Env)
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("cursor stdout pipe: %w", err)
}
cmd.Stderr = newLogWriter(b.cfg.Logger, "[cursor:stderr] ")
if err := cmd.Start(); err != nil {
cancel()
return nil, fmt.Errorf("start cursor-agent: %w", err)
}
b.cfg.Logger.Info("cursor-agent started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
msgCh := make(chan Message, 256)
resCh := make(chan Result, 1)
go func() {
defer cancel()
defer close(msgCh)
defer close(resCh)
// Close stdout when the context is cancelled so scanner.Scan() unblocks.
go func() {
<-runCtx.Done()
_ = stdout.Close()
}()
startTime := time.Now()
var output strings.Builder
var sessionID string
finalStatus := "completed"
var finalError string
// stepUsage accumulates per-step token counts from "step_finish" events.
// resultUsage holds authoritative session totals from "result" events.
// If the result event includes usage, we use resultUsage exclusively;
// otherwise we fall back to stepUsage.
stepUsage := make(map[string]TokenUsage)
resultUsage := make(map[string]TokenUsage)
hasResultUsage := false
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
for scanner.Scan() {
raw := scanner.Text()
line := normalizeCursorStreamLine(raw)
if line == "" {
continue
}
var evt cursorStreamEvent
if err := json.Unmarshal([]byte(line), &evt); err != nil {
continue
}
if sid := evt.readSessionID(); sid != "" {
sessionID = sid
}
switch evt.Type {
case "system":
if evt.Subtype == "init" {
trySend(msgCh, Message{Type: MessageStatus, Status: "running"})
}
if evt.Subtype == "error" {
errMsg := cursorErrorText(&evt)
if errMsg != "" {
trySend(msgCh, Message{Type: MessageError, Content: errMsg})
}
}
case "assistant":
b.handleCursorAssistant(&evt, msgCh, &output)
case "tool_use":
var params map[string]any
if evt.Parameters != nil {
_ = json.Unmarshal(evt.Parameters, &params)
}
trySend(msgCh, Message{
Type: MessageToolUse,
Tool: evt.ToolName,
CallID: evt.ToolID,
Input: params,
})
case "tool_result":
trySend(msgCh, Message{
Type: MessageToolResult,
CallID: evt.ToolID,
Output: evt.Output,
})
case "result":
if evt.IsError || evt.Subtype == "error" {
finalStatus = "failed"
finalError = cursorErrorText(&evt)
}
if evt.ResultText != "" && output.Len() == 0 {
output.WriteString(evt.ResultText)
}
b.accumulateResultUsage(resultUsage, &evt)
if evt.Usage != nil {
hasResultUsage = true
}
case "error":
errMsg := cursorErrorText(&evt)
if errMsg != "" {
finalError = errMsg
}
trySend(msgCh, Message{Type: MessageError, Content: errMsg})
case "text":
if evt.Part != nil {
var part cursorTextPart
_ = json.Unmarshal(evt.Part, &part)
if part.Text != "" {
output.WriteString(part.Text)
trySend(msgCh, Message{Type: MessageText, Content: part.Text})
}
}
case "step_finish":
if evt.Part != nil {
var part cursorStepFinishPart
_ = json.Unmarshal(evt.Part, &part)
model := evt.Model
if model == "" {
model = "cursor"
}
u := stepUsage[model]
u.InputTokens += int64(part.Tokens.Input)
u.OutputTokens += int64(part.Tokens.Output)
u.CacheReadTokens += int64(part.Tokens.Cache.Read)
stepUsage[model] = u
}
}
}
// Use result usage if available (session totals); otherwise fall back
// to accumulated step_finish usage.
if !hasResultUsage {
resultUsage = stepUsage
}
exitErr := cmd.Wait()
duration := time.Since(startTime)
if runCtx.Err() == context.DeadlineExceeded {
finalStatus = "timeout"
finalError = fmt.Sprintf("cursor-agent timed out after %s", timeout)
} else if runCtx.Err() == context.Canceled {
finalStatus = "aborted"
finalError = "execution cancelled"
} else if exitErr != nil && finalStatus == "completed" {
finalStatus = "failed"
finalError = fmt.Sprintf("cursor-agent exited with error: %v", exitErr)
}
b.cfg.Logger.Info("cursor-agent finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
resCh <- Result{
Status: finalStatus,
Output: output.String(),
Error: finalError,
DurationMs: duration.Milliseconds(),
SessionID: sessionID,
Usage: resultUsage,
}
}()
return &Session{Messages: msgCh, Result: resCh}, nil
}
func (b *cursorBackend) handleCursorAssistant(evt *cursorStreamEvent, ch chan<- Message, output *strings.Builder) {
if evt.Message == nil {
return
}
var content cursorAssistantMessage
if err := json.Unmarshal(evt.Message, &content); err != nil {
return
}
// Note: per-message usage in assistant events is intentionally ignored.
// Token usage is taken exclusively from "result" events (session totals)
// to avoid double-counting.
for _, block := range content.Content {
switch block.Type {
case "output_text", "text":
if block.Text != "" {
output.WriteString(block.Text)
trySend(ch, Message{Type: MessageText, Content: block.Text})
}
case "thinking":
if block.Text != "" {
trySend(ch, Message{Type: MessageThinking, Content: block.Text})
}
case "tool_use":
var input map[string]any
if block.Input != nil {
_ = json.Unmarshal(block.Input, &input)
}
trySend(ch, Message{
Type: MessageToolUse,
Tool: block.Name,
CallID: block.ID,
Input: input,
})
}
}
}
func (b *cursorBackend) accumulateResultUsage(usage map[string]TokenUsage, evt *cursorStreamEvent) {
if evt.Usage == nil {
return
}
model := evt.Model
if model == "" {
model = "cursor"
}
u := usage[model]
u.InputTokens += evt.Usage.InputTokens
u.OutputTokens += evt.Usage.OutputTokens
u.CacheReadTokens += evt.Usage.CacheReadInputTokens
usage[model] = u
}
// ── Cursor stream-json types ──
type cursorStreamEvent struct {
Type string `json:"type"`
Subtype string `json:"subtype,omitempty"`
SessionID string `json:"session_id,omitempty"`
Model string `json:"model,omitempty"`
// assistant fields
Message json.RawMessage `json:"message,omitempty"`
// tool_use fields
ToolName string `json:"tool_name,omitempty"`
ToolID string `json:"tool_id,omitempty"`
Parameters json.RawMessage `json:"parameters,omitempty"`
// tool_result fields
Output string `json:"output,omitempty"`
// result fields
ResultText string `json:"result,omitempty"`
IsError bool `json:"is_error,omitempty"`
Usage *cursorUsage `json:"usage,omitempty"`
TotalCost float64 `json:"total_cost_usd,omitempty"`
// error fields
ErrorMsg string `json:"error,omitempty"`
Detail string `json:"detail,omitempty"`
// legacy compat
Part json.RawMessage `json:"part,omitempty"`
}
func (evt *cursorStreamEvent) readSessionID() string {
if s := strings.TrimSpace(evt.SessionID); s != "" {
return s
}
return ""
}
type cursorUsage struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheReadInputTokens int64 `json:"cached_input_tokens"`
}
type cursorAssistantMessage struct {
Model string `json:"model"`
Content []cursorContentBlock `json:"content"`
Usage *cursorUsage `json:"usage,omitempty"`
}
type cursorContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
}
type cursorTextPart struct {
Text string `json:"text"`
}
type cursorStepFinishPart struct {
Tokens struct {
Input int `json:"input"`
Output int `json:"output"`
Cache struct {
Read int `json:"read"`
} `json:"cache"`
} `json:"tokens"`
Cost float64 `json:"cost"`
}
// ── Helpers ──
// normalizeCursorStreamLine handles the stdout:/stderr: prefix that Cursor
// CLI may emit in stream-json mode. Returns the trimmed JSON line.
func normalizeCursorStreamLine(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
// Cursor CLI may prefix lines with "stdout:" or "stderr:" — strip it.
if idx := cursorStreamPrefixRe.FindStringIndex(trimmed); idx != nil {
return strings.TrimSpace(trimmed[idx[1]:])
}
return trimmed
}
var cursorStreamPrefixRe = regexp.MustCompile(`^(?i)(stdout|stderr)\s*[:=]?\s*`)
func cursorErrorText(evt *cursorStreamEvent) string {
if evt.ErrorMsg != "" {
return evt.ErrorMsg
}
if evt.Detail != "" {
return evt.Detail
}
if evt.ResultText != "" {
return evt.ResultText
}
return ""
}
// cursorBlockedArgs are flags hardcoded by the daemon that must not be
// overridden by user-configured custom_args. Overriding these would break
// the daemon↔cursor-agent communication protocol.
var cursorBlockedArgs = map[string]blockedArgMode{
"-p": blockedStandalone, // non-interactive print mode
"--output-format": blockedWithValue, // stream-json protocol
"--yolo": blockedStandalone, // auto-approval for autonomous operation
}
// buildCursorArgs assembles the argv for a one-shot cursor-agent invocation.
//
// Usage: cursor-agent chat -p <prompt> --output-format stream-json
//
// --workspace <cwd> --yolo [--model <m>] [--resume <id>]
func buildCursorArgs(prompt string, opts ExecOptions, logger *slog.Logger) []string {
args := []string{
"chat",
"-p", prompt,
"--output-format", "stream-json",
"--yolo",
}
if opts.Cwd != "" {
args = append(args, "--workspace", opts.Cwd)
}
if opts.Model != "" {
args = append(args, "--model", opts.Model)
}
// NOTE: cursor-agent CLI does not support --system-prompt or --max-turns.
// Instructions are injected via AGENTS.md and .cursor/skills/ files instead.
if opts.ResumeSessionID != "" {
args = append(args, "--resume", opts.ResumeSessionID)
}
args = append(args, filterCustomArgs(opts.CustomArgs, cursorBlockedArgs, logger)...)
return args
}