diff --git a/Makefile b/Makefile
index 7ff4e34b4..3cad49be5 100644
--- a/Makefile
+++ b/Makefile
@@ -182,7 +182,7 @@ server:
cd server && go run ./cmd/server
daemon:
- @$(MAKE) multica MULTICA_ARGS="daemon"
+ @$(MAKE) multica MULTICA_ARGS="daemon start --profile local"
cli:
@$(MAKE) multica MULTICA_ARGS="$(MULTICA_ARGS)"
diff --git a/packages/views/runtimes/components/provider-logo.tsx b/packages/views/runtimes/components/provider-logo.tsx
index 809b67c32..ea8d5f519 100644
--- a/packages/views/runtimes/components/provider-logo.tsx
+++ b/packages/views/runtimes/components/provider-logo.tsx
@@ -88,6 +88,20 @@ function PiLogo({ className }: { className: string }) {
);
}
+// Cursor — official brand logo from Cursor brand assets
+function CursorLogo({ className }: { className: string }) {
+ return (
+
+ );
+}
+
export function ProviderLogo({
provider,
className = "h-4 w-4",
@@ -108,6 +122,8 @@ export function ProviderLogo({
return ;
case "pi":
return ;
+ case "cursor":
+ return ;
default:
return ;
}
diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go
index cd1e5e948..034ab570f 100644
--- a/server/internal/daemon/config.go
+++ b/server/internal/daemon/config.go
@@ -127,8 +127,15 @@ func LoadConfig(overrides Overrides) (Config, error) {
Model: strings.TrimSpace(os.Getenv("MULTICA_PI_MODEL")),
}
}
+ cursorPath := envOrDefault("MULTICA_CURSOR_PATH", "cursor-agent")
+ if _, err := exec.LookPath(cursorPath); err == nil {
+ agents["cursor"] = AgentEntry{
+ Path: cursorPath,
+ Model: strings.TrimSpace(os.Getenv("MULTICA_CURSOR_MODEL")),
+ }
+ }
if len(agents) == 0 {
- return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, opencode, openclaw, hermes, gemini, or pi and ensure it is on PATH")
+ return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, opencode, openclaw, hermes, gemini, pi, or cursor-agent and ensure it is on PATH")
}
// Host info
diff --git a/server/internal/daemon/execenv/context.go b/server/internal/daemon/execenv/context.go
index 5a9cf91c7..071707a46 100644
--- a/server/internal/daemon/execenv/context.go
+++ b/server/internal/daemon/execenv/context.go
@@ -15,6 +15,7 @@ import (
// Codex: skills → handled separately in Prepare via codex-home
// OpenCode: skills → {workDir}/.config/opencode/skills/{name}/SKILL.md (native discovery)
// Pi: skills → {workDir}/.pi/agent/skills/{name}/SKILL.md (native discovery)
+// Cursor: skills → {workDir}/.cursor/skills/{name}/SKILL.md (native discovery)
// Default: skills → {workDir}/.agent_context/skills/{name}/SKILL.md
func writeContextFiles(workDir, provider string, ctx TaskContextForEnv) error {
contextDir := filepath.Join(workDir, ".agent_context")
@@ -58,6 +59,9 @@ func resolveSkillsDir(workDir, provider string) (string, error) {
case "pi":
// Pi natively discovers skills from .pi/agent/skills/ in the workdir.
skillsDir = filepath.Join(workDir, ".pi", "agent", "skills")
+ case "cursor":
+ // Cursor natively discovers skills from .cursor/skills/ in the workdir.
+ skillsDir = filepath.Join(workDir, ".cursor", "skills")
default:
// Fallback: write to .agent_context/skills/ (referenced by meta config).
skillsDir = filepath.Join(workDir, ".agent_context", "skills")
diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go
index c8a5542ff..fa79e75c4 100644
--- a/server/internal/daemon/execenv/runtime_config.go
+++ b/server/internal/daemon/execenv/runtime_config.go
@@ -16,13 +16,14 @@ import (
// For OpenClaw: writes {workDir}/AGENTS.md (skills discovered natively from .openclaw/skills/)
// For Gemini: writes {workDir}/GEMINI.md (discovered natively by the Gemini CLI)
// For Pi: writes {workDir}/AGENTS.md (skills discovered natively from ~/.pi/agent/skills/)
+// For Cursor: writes {workDir}/AGENTS.md (skills discovered natively from .cursor/skills/)
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error {
content := buildMetaSkillContent(provider, ctx)
switch provider {
case "claude":
return os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(content), 0o644)
- case "codex", "opencode", "openclaw", "pi":
+ case "codex", "opencode", "openclaw", "pi", "cursor":
return os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644)
case "gemini":
return os.WriteFile(filepath.Join(workDir, "GEMINI.md"), []byte(content), 0o644)
@@ -141,8 +142,8 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
case "claude":
// Claude discovers skills natively from .claude/skills/ — just list names.
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
- case "codex", "opencode", "openclaw", "pi":
- // Codex, OpenCode, OpenClaw, and Pi discover skills natively from their respective paths — just list names.
+ case "codex", "opencode", "openclaw", "pi", "cursor":
+ // Codex, OpenCode, OpenClaw, Pi, and Cursor discover skills natively from their respective paths — just list names.
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
case "gemini":
// Gemini reads GEMINI.md directly; point it at the fallback skills dir.
diff --git a/server/pkg/agent/agent.go b/server/pkg/agent/agent.go
index 99a9d1f7e..6520afd56 100644
--- a/server/pkg/agent/agent.go
+++ b/server/pkg/agent/agent.go
@@ -89,7 +89,7 @@ type Config struct {
}
// New creates a Backend for the given agent type.
-// Supported types: "claude", "codex", "opencode", "openclaw", "hermes", "gemini", "pi".
+// Supported types: "claude", "codex", "opencode", "openclaw", "hermes", "gemini", "pi", "cursor".
func New(agentType string, cfg Config) (Backend, error) {
if cfg.Logger == nil {
cfg.Logger = slog.Default()
@@ -110,8 +110,10 @@ func New(agentType string, cfg Config) (Backend, error) {
return &geminiBackend{cfg: cfg}, nil
case "pi":
return &piBackend{cfg: cfg}, nil
+ case "cursor":
+ return &cursorBackend{cfg: cfg}, nil
default:
- return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, opencode, openclaw, hermes, gemini, pi)", agentType)
+ return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, opencode, openclaw, hermes, gemini, pi, cursor)", agentType)
}
}
diff --git a/server/pkg/agent/cursor.go b/server/pkg/agent/cursor.go
new file mode 100644
index 000000000..3df917bdc
--- /dev/null
+++ b/server/pkg/agent/cursor.go
@@ -0,0 +1,419 @@
+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, ¶ms)
+ }
+ 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 --output-format stream-json
+//
+// --workspace --yolo [--model ] [--resume ]
+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
+}
diff --git a/server/pkg/agent/cursor_test.go b/server/pkg/agent/cursor_test.go
new file mode 100644
index 000000000..9390885c4
--- /dev/null
+++ b/server/pkg/agent/cursor_test.go
@@ -0,0 +1,435 @@
+package agent
+
+import (
+ "encoding/json"
+ "log/slog"
+ "strings"
+ "testing"
+)
+
+func TestNewReturnsCursorBackend(t *testing.T) {
+ t.Parallel()
+ b, err := New("cursor", Config{ExecutablePath: "/nonexistent/cursor-agent"})
+ if err != nil {
+ t.Fatalf("New(cursor) error: %v", err)
+ }
+ if _, ok := b.(*cursorBackend); !ok {
+ t.Fatalf("expected *cursorBackend, got %T", b)
+ }
+}
+
+func TestBuildCursorArgs(t *testing.T) {
+ t.Parallel()
+
+ args := buildCursorArgs("do something", ExecOptions{
+ Cwd: "/tmp/work",
+ Model: "composer-1.5",
+ }, slog.Default())
+
+ expected := []string{
+ "chat",
+ "-p", "do something",
+ "--output-format", "stream-json",
+ "--yolo",
+ "--workspace", "/tmp/work",
+ "--model", "composer-1.5",
+ }
+
+ if len(args) != len(expected) {
+ t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args)
+ }
+ for i, want := range expected {
+ if args[i] != want {
+ t.Errorf("args[%d] = %q, want %q", i, args[i], want)
+ }
+ }
+}
+
+func TestBuildCursorArgsWithResume(t *testing.T) {
+ t.Parallel()
+
+ args := buildCursorArgs("continue", ExecOptions{
+ ResumeSessionID: "sess-123",
+ }, slog.Default())
+
+ hasResume := false
+ for i, a := range args {
+ if a == "--resume" && i+1 < len(args) && args[i+1] == "sess-123" {
+ hasResume = true
+ }
+ }
+ if !hasResume {
+ t.Fatalf("expected --resume sess-123, got %v", args)
+ }
+}
+
+func TestBuildCursorArgsMinimal(t *testing.T) {
+ t.Parallel()
+
+ args := buildCursorArgs("hello", ExecOptions{}, slog.Default())
+ expected := []string{"chat", "-p", "hello", "--output-format", "stream-json", "--yolo"}
+
+ if len(args) != len(expected) {
+ t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args)
+ }
+}
+
+func TestBuildCursorArgsIgnoresSystemPromptAndMaxTurns(t *testing.T) {
+ t.Parallel()
+
+ // cursor-agent CLI does not support --system-prompt or --max-turns;
+ // verify they are NOT emitted even when set in ExecOptions.
+ args := buildCursorArgs("task", ExecOptions{
+ SystemPrompt: "You are helpful",
+ MaxTurns: 5,
+ }, slog.Default())
+
+ for _, a := range args {
+ if a == "--system-prompt" {
+ t.Fatalf("unexpected --system-prompt in args: %v", args)
+ }
+ if a == "--max-turns" {
+ t.Fatalf("unexpected --max-turns in args: %v", args)
+ }
+ }
+}
+
+func TestBuildCursorArgsCustomArgs(t *testing.T) {
+ t.Parallel()
+
+ args := buildCursorArgs("task", ExecOptions{
+ CustomArgs: []string{"--extra", "val", "--yolo", "--output-format", "text"},
+ }, slog.Default())
+
+ // --extra val should be present; --yolo and --output-format should be filtered out
+ hasExtra := false
+ hasBlockedYolo := false
+ hasBlockedFormat := false
+ for i, a := range args {
+ if a == "--extra" && i+1 < len(args) && args[i+1] == "val" {
+ hasExtra = true
+ }
+ }
+ // Count occurrences of --yolo (should be exactly 1 — the hardcoded one)
+ yoloCount := 0
+ for _, a := range args {
+ if a == "--yolo" {
+ yoloCount++
+ }
+ if a == "text" {
+ hasBlockedFormat = true
+ }
+ }
+ if yoloCount > 1 {
+ hasBlockedYolo = true
+ }
+ if !hasExtra {
+ t.Fatalf("expected --extra val in args, got %v", args)
+ }
+ if hasBlockedYolo {
+ t.Fatalf("--yolo from custom args should be filtered, got %v", args)
+ }
+ if hasBlockedFormat {
+ t.Fatalf("--output-format from custom args should be filtered, got %v", args)
+ }
+}
+
+func TestNormalizeCursorStreamLine(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ input string
+ want string
+ }{
+ {`stdout: {"type":"init"}`, `{"type":"init"}`},
+ {`stderr: {"type":"error"}`, `{"type":"error"}`},
+ {`stdout:{"type":"init"}`, `{"type":"init"}`},
+ {` {"type":"assistant"} `, `{"type":"assistant"}`},
+ {``, ``},
+ {` `, ``},
+ {`plain text`, `plain text`},
+ }
+
+ for _, tc := range tests {
+ got := normalizeCursorStreamLine(tc.input)
+ if got != tc.want {
+ t.Errorf("normalizeCursorStreamLine(%q) = %q, want %q", tc.input, got, tc.want)
+ }
+ }
+}
+
+func TestCursorHandleAssistantText(t *testing.T) {
+ t.Parallel()
+
+ b := &cursorBackend{cfg: Config{Logger: slog.Default()}}
+ ch := make(chan Message, 10)
+ var output strings.Builder
+
+ evt := &cursorStreamEvent{
+ Type: "assistant",
+ Message: mustMarshal(t, cursorAssistantMessage{
+ Model: "composer-1.5",
+ Content: []cursorContentBlock{
+ {Type: "output_text", Text: "Hello from Cursor"},
+ },
+ Usage: &cursorUsage{
+ InputTokens: 100,
+ OutputTokens: 50,
+ },
+ }),
+ }
+
+ b.handleCursorAssistant(evt, ch, &output)
+
+ if output.String() != "Hello from Cursor" {
+ t.Fatalf("expected output 'Hello from Cursor', got %q", output.String())
+ }
+
+ select {
+ case m := <-ch:
+ if m.Type != MessageText || m.Content != "Hello from Cursor" {
+ t.Fatalf("unexpected message: %+v", m)
+ }
+ default:
+ t.Fatal("expected message on channel")
+ }
+}
+
+func TestCursorHandleAssistantToolUse(t *testing.T) {
+ t.Parallel()
+
+ b := &cursorBackend{cfg: Config{Logger: slog.Default()}}
+ ch := make(chan Message, 10)
+ var output strings.Builder
+
+ evt := &cursorStreamEvent{
+ Type: "assistant",
+ Message: mustMarshal(t, cursorAssistantMessage{
+ Content: []cursorContentBlock{
+ {
+ Type: "tool_use",
+ ID: "call-42",
+ Name: "file_edit",
+ Input: mustMarshal(t, map[string]any{"path": "/tmp/foo.go"}),
+ },
+ },
+ }),
+ }
+
+ b.handleCursorAssistant(evt, ch, &output)
+
+ select {
+ case m := <-ch:
+ if m.Type != MessageToolUse || m.Tool != "file_edit" || m.CallID != "call-42" {
+ t.Fatalf("unexpected message: %+v", m)
+ }
+ default:
+ t.Fatal("expected message on channel")
+ }
+}
+
+func TestCursorErrorText(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ evt cursorStreamEvent
+ want string
+ }{
+ {"error field", cursorStreamEvent{ErrorMsg: "bad request"}, "bad request"},
+ {"detail field", cursorStreamEvent{Detail: "not found"}, "not found"},
+ {"result field", cursorStreamEvent{ResultText: "failed"}, "failed"},
+ {"empty", cursorStreamEvent{}, ""},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := cursorErrorText(&tc.evt)
+ if got != tc.want {
+ t.Errorf("cursorErrorText = %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestCursorAccumulateResultUsage(t *testing.T) {
+ t.Parallel()
+
+ b := &cursorBackend{cfg: Config{Logger: slog.Default()}}
+ usage := make(map[string]TokenUsage)
+
+ evt := &cursorStreamEvent{
+ Model: "gpt-5.3",
+ Usage: &cursorUsage{
+ InputTokens: 200,
+ OutputTokens: 100,
+ CacheReadInputTokens: 50,
+ },
+ }
+
+ b.accumulateResultUsage(usage, evt)
+
+ u := usage["gpt-5.3"]
+ if u.InputTokens != 200 || u.OutputTokens != 100 || u.CacheReadTokens != 50 {
+ t.Fatalf("unexpected usage: %+v", u)
+ }
+}
+
+func TestCursorUsageOnlyFromResult(t *testing.T) {
+ t.Parallel()
+
+ b := &cursorBackend{cfg: Config{Logger: slog.Default()}}
+ ch := make(chan Message, 10)
+ var output strings.Builder
+
+ evt := &cursorStreamEvent{
+ Type: "assistant",
+ Message: mustMarshal(t, cursorAssistantMessage{
+ Model: "gpt-5",
+ Content: []cursorContentBlock{
+ {Type: "text", Text: "hello"},
+ },
+ Usage: &cursorUsage{
+ InputTokens: 999,
+ OutputTokens: 888,
+ },
+ }),
+ }
+
+ b.handleCursorAssistant(evt, ch, &output)
+
+ if output.String() != "hello" {
+ t.Fatalf("expected 'hello', got %q", output.String())
+ }
+
+ // handleCursorAssistant should NOT have accumulated usage anywhere —
+ // usage is only taken from result events to avoid double-counting.
+ // (no usage map to check; this test documents the intent)
+}
+
+func TestCursorStepFinishParsing(t *testing.T) {
+ t.Parallel()
+
+ part := cursorStepFinishPart{}
+ data := `{"tokens":{"input":500,"output":200,"cache":{"read":100}},"cost":0.01}`
+ if err := json.Unmarshal([]byte(data), &part); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if part.Tokens.Input != 500 || part.Tokens.Output != 200 || part.Tokens.Cache.Read != 100 {
+ t.Fatalf("unexpected part: %+v", part)
+ }
+}
+
+// TestCursorUsageNoDoubleCount verifies that step_finish and result usage
+// are never double-counted. When a result event includes usage (session
+// totals), step_finish values must be discarded entirely.
+func TestCursorUsageNoDoubleCount(t *testing.T) {
+ t.Parallel()
+
+ type jsonlEvent struct {
+ raw string
+ }
+
+ tests := []struct {
+ name string
+ lines []string
+ want map[string]TokenUsage
+ }{
+ {
+ name: "result_only — use result usage",
+ lines: []string{
+ `{"type":"result","model":"gpt-5","usage":{"input_tokens":1000,"output_tokens":500,"cached_input_tokens":200}}`,
+ },
+ want: map[string]TokenUsage{
+ "gpt-5": {InputTokens: 1000, OutputTokens: 500, CacheReadTokens: 200},
+ },
+ },
+ {
+ name: "step_finish_only — fallback to step usage",
+ lines: []string{
+ `{"type":"step_finish","model":"gpt-5","part":{"tokens":{"input":300,"output":100,"cache":{"read":50}}}}`,
+ `{"type":"step_finish","model":"gpt-5","part":{"tokens":{"input":200,"output":80,"cache":{"read":30}}}}`,
+ `{"type":"result","model":"gpt-5"}`,
+ },
+ want: map[string]TokenUsage{
+ "gpt-5": {InputTokens: 500, OutputTokens: 180, CacheReadTokens: 80},
+ },
+ },
+ {
+ name: "step_finish_then_result — result wins, no double count",
+ lines: []string{
+ `{"type":"step_finish","model":"gpt-5","part":{"tokens":{"input":300,"output":100,"cache":{"read":50}}}}`,
+ `{"type":"step_finish","model":"gpt-5","part":{"tokens":{"input":200,"output":80,"cache":{"read":30}}}}`,
+ `{"type":"result","model":"gpt-5","usage":{"input_tokens":500,"output_tokens":180,"cached_input_tokens":80}}`,
+ },
+ want: map[string]TokenUsage{
+ "gpt-5": {InputTokens: 500, OutputTokens: 180, CacheReadTokens: 80},
+ },
+ },
+ {
+ name: "multi_model — each model tracked independently",
+ lines: []string{
+ `{"type":"step_finish","model":"gpt-5","part":{"tokens":{"input":100,"output":50,"cache":{"read":10}}}}`,
+ `{"type":"step_finish","model":"sonnet-4","part":{"tokens":{"input":200,"output":80,"cache":{"read":20}}}}`,
+ `{"type":"result","model":"gpt-5","usage":{"input_tokens":100,"output_tokens":50,"cached_input_tokens":10}}`,
+ },
+ want: map[string]TokenUsage{
+ // result had usage → use result only, discard all step_finish
+ "gpt-5": {InputTokens: 100, OutputTokens: 50, CacheReadTokens: 10},
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ stepUsage := make(map[string]TokenUsage)
+ resultUsage := make(map[string]TokenUsage)
+ hasResultUsage := false
+
+ b := &cursorBackend{cfg: Config{Logger: slog.Default()}}
+
+ for _, line := range tc.lines {
+ var evt cursorStreamEvent
+ if err := json.Unmarshal([]byte(line), &evt); err != nil {
+ t.Fatalf("unmarshal %q: %v", line, err)
+ }
+
+ switch evt.Type {
+ case "result":
+ b.accumulateResultUsage(resultUsage, &evt)
+ if evt.Usage != nil {
+ hasResultUsage = true
+ }
+ 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
+ }
+ }
+ }
+
+ if !hasResultUsage {
+ resultUsage = stepUsage
+ }
+
+ if len(resultUsage) != len(tc.want) {
+ t.Fatalf("got %d models, want %d: %+v", len(resultUsage), len(tc.want), resultUsage)
+ }
+ for model, want := range tc.want {
+ got := resultUsage[model]
+ if got != want {
+ t.Errorf("model %q: got %+v, want %+v", model, got, want)
+ }
+ }
+ })
+ }
+}