mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 09:26:50 +08:00
* MUL-8070: feat(cursor): support live-run steering via Cursor hooks Cursor's headless --print mode has no mid-turn input channel, but its postToolUse/postToolUseFailure hooks can return additional_context that reaches the model in the same turn. A steerable Cursor run now writes a managed .cursor/hooks.json whose command is the multica binary in a hidden relay mode; the relay forwards the hook event to a token-protected loopback endpoint owned by the run, which hands back pending guidance tagged with a per-run nonce. The prompt declares that tag so the model treats the context as genuine user guidance instead of a prompt injection. Steering is offered only for daemon-managed workdirs on Unix with cursor-agent 2026.10.14+, and the server allowlists cursor for capability negotiation, supplement creation and composer binding. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * MUL-8070: fix(cursor): confirm steering only on Cursor's receipt and gate on installable hooks - Count hook-delivered guidance as delivered only when the tool call's completed stream event echoes it in hookAdditionalContexts. Guidance Cursor dropped for the same call is retried at the next tool boundary; guidance without a receipt fails when the turn ends. - Offer steering only when the workdir can carry the managed hooks.json, and fail supplements immediately if installation still fails after negotiation. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]>
1101 lines
38 KiB
Go
1101 lines
38 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os/exec"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"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) {
|
|
execName := b.cfg.ExecutablePath
|
|
if execName == "" {
|
|
execName = "cursor-agent"
|
|
}
|
|
lookedUp, err := exec.LookPath(execName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cursor-agent executable not found at %q: %w", execName, err)
|
|
}
|
|
|
|
timeout := opts.Timeout
|
|
runCtx, cancel := runContext(ctx, timeout)
|
|
|
|
args := buildCursorArgs(opts, b.cfg.Logger)
|
|
cmd, _, _ := b.cfg.commandAt(execName).execVia(runCtx, chooseCursorInvocation, lookedUp, args, b.cfg.Logger)
|
|
hideAgentWindow(cmd)
|
|
b.cfg.logAgentCommand(cmd, newAgentCommandLogArgs(args))
|
|
cmd.WaitDelay = 500 * time.Millisecond
|
|
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)
|
|
}
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("cursor stdin pipe: %w", err)
|
|
}
|
|
var closeStdinOnce sync.Once
|
|
closeStdin := func() { closeStdinOnce.Do(func() { _ = stdin.Close() }) }
|
|
stderrBuf := newStderrTail(newLogWriter(b.cfg.Logger, "[cursor:stderr] "), agentStderrTailBytes)
|
|
cmd.Stderr = stderrBuf
|
|
|
|
var supplements *cursorSupplementSession
|
|
var supplementErr error
|
|
if opts.EnableTaskSupplement {
|
|
supplements, supplementErr = startCursorSupplementSession(runCtx, opts.Cwd, opts.CursorHookCommand, b.cfg.Logger)
|
|
if supplementErr != nil {
|
|
b.cfg.Logger.Warn("cursor additional messages unavailable for this run", "error", supplementErr)
|
|
supplements = nil
|
|
} else {
|
|
cmd.Env = append(cmd.Env, supplements.env()...)
|
|
prompt += supplements.promptNote()
|
|
}
|
|
}
|
|
|
|
if err := startOwnedProcessTree(cmd, b.cfg.Logger); err != nil {
|
|
supplements.close()
|
|
closeStdin()
|
|
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)
|
|
background := newCursorBackgroundTools(runCtx, cmd, msgCh, b.cfg.Logger)
|
|
|
|
// The prompt is delivered on stdin (see buildCursorArgs). Write it from its
|
|
// own goroutine so it cannot deadlock against the stdout reader below: a
|
|
// prompt larger than the OS pipe buffer (~64 KiB) blocks mid-write until the
|
|
// child drains it, and the child cannot drain while we are not reading its
|
|
// stdout. Closing stdin is what signals end-of-prompt — cursor-agent reads
|
|
// to EOF — so we always close, on both the success and error paths.
|
|
writeErrCh := make(chan error, 1)
|
|
go func() {
|
|
_, err := io.WriteString(stdin, prompt)
|
|
closeStdin()
|
|
writeErrCh <- err
|
|
}()
|
|
|
|
go func() {
|
|
defer cancel()
|
|
defer close(msgCh)
|
|
defer close(resCh)
|
|
defer background.Close()
|
|
defer supplements.close()
|
|
|
|
// Close stdout when the context is cancelled so scanner.Scan() unblocks.
|
|
// Closing stdin too releases a prompt write still blocked on a full pipe
|
|
// (e.g. the child died before draining it), so that goroutine cannot leak.
|
|
go func() {
|
|
<-runCtx.Done()
|
|
background.Close()
|
|
closeStdin()
|
|
_ = stdout.Close()
|
|
}()
|
|
|
|
startTime := time.Now()
|
|
configuredModel := strings.TrimSpace(opts.Model)
|
|
var output strings.Builder
|
|
var sessionID string
|
|
finalStatus := "completed"
|
|
var finalError string
|
|
var protocolError string
|
|
resultSeen := false
|
|
resultIsError := false
|
|
resultBytes := 0
|
|
eventCount := 0
|
|
invalidEventCount := 0
|
|
assistantEventCount := 0
|
|
toolUseCount := 0
|
|
// unhandledSubtypeCount tracks thinking/tool_call events whose subtype we
|
|
// don't recognize. They are ignored (never synthesized into a message)
|
|
// and surfaced once as a bounded, content-free diagnostic so an upstream
|
|
// protocol addition is visible instead of silent.
|
|
unhandledSubtypeCount := 0
|
|
// unhandledTypes tracks top-level event types the switch below does not
|
|
// handle. See cursorUnhandledTypeTally: it makes a dropped event
|
|
// observable, and documents what the count does and does not establish
|
|
// (MUL-5434).
|
|
var unhandledTypes cursorUnhandledTypeTally
|
|
lastEventType := "none"
|
|
// assistantBytes counts only model-authored streamed text. It is kept
|
|
// separate from `output` because the result event also writes into
|
|
// `output`, which made the reported last_assistant_bytes equal to
|
|
// result_bytes on a run where the assistant streamed nothing at all —
|
|
// hiding the exact signal needed to tell those two cases apart.
|
|
assistantBytes := 0
|
|
// 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
|
|
var thinking cursorThinkingStream
|
|
|
|
scanner := newAgentStreamScanner(stdout)
|
|
|
|
scanLoop:
|
|
for scanner.Scan() {
|
|
raw := scanner.Text()
|
|
line := normalizeCursorStreamLine(raw)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var evt cursorStreamEvent
|
|
if err := json.Unmarshal([]byte(line), &evt); err != nil {
|
|
invalidEventCount++
|
|
continue
|
|
}
|
|
eventCount++
|
|
lastEventType = observedCursorEventType(evt.Type)
|
|
|
|
if sid := evt.readSessionID(); sid != "" {
|
|
sessionID = sid
|
|
supplements.activate(sid)
|
|
}
|
|
|
|
switch evt.Type {
|
|
case "system":
|
|
if evt.Subtype == "task_notification" {
|
|
background.Reap()
|
|
}
|
|
if evt.Subtype == "init" {
|
|
trySend(msgCh, Message{Type: MessageStatus, Status: "running"})
|
|
}
|
|
if evt.Subtype == "error" {
|
|
errMsg := cursorErrorText(&evt)
|
|
if errMsg != "" {
|
|
protocolError = errMsg
|
|
trySend(msgCh, Message{Type: MessageError, Content: errMsg})
|
|
}
|
|
}
|
|
|
|
case "assistant":
|
|
assistantEventCount++
|
|
assistantBytes += b.handleCursorAssistant(&evt, background.Send, &output)
|
|
|
|
case "thinking":
|
|
// Reasoning is a top-level event streamed as deltas, not a
|
|
// content block inside assistant messages. Match subtypes
|
|
// explicitly: only `delta` carries reasoning content (forwarded
|
|
// as it lands so the daemon's 500ms flush shows it mid-run),
|
|
// `completed` closes the block. An unknown subtype is NOT folded
|
|
// into reasoning — silently absorbing upstream additions is the
|
|
// exact failure mode this fix exists to prevent (MUL-5231).
|
|
switch evt.Subtype {
|
|
case "delta":
|
|
if content := thinking.delta(evt.Text); content != "" {
|
|
trySend(msgCh, Message{Type: MessageThinking, Content: content})
|
|
}
|
|
case "completed":
|
|
thinking.complete()
|
|
default:
|
|
unhandledSubtypeCount++
|
|
}
|
|
|
|
case "tool_call":
|
|
// Only the two subtypes that define a call's boundaries drive the
|
|
// transcript: `started` opens it, `completed` closes it. A
|
|
// non-terminal (e.g. a future `progress`) or missing subtype must
|
|
// NOT synthesize a result — that would decrement the daemon's
|
|
// in-flight tool count early and drop a still-running long tool
|
|
// from the tool watchdog onto the shorter idle watchdog, which
|
|
// can force-stop it as falsely stuck (MUL-5231 review).
|
|
switch evt.Subtype {
|
|
case "started":
|
|
call := parseCursorToolCall(&evt)
|
|
toolUseCount++
|
|
background.Send(Message{
|
|
Type: MessageToolUse,
|
|
Tool: call.Name,
|
|
CallID: call.CallID,
|
|
Input: call.Input,
|
|
})
|
|
case "completed":
|
|
call := parseCursorToolCall(&evt)
|
|
supplements.receipt(call.CallID, call.HookContexts)
|
|
if call.Background {
|
|
background.Add(call)
|
|
} else {
|
|
background.SendResult(call)
|
|
}
|
|
default:
|
|
unhandledSubtypeCount++
|
|
}
|
|
|
|
case "tool_use":
|
|
toolUseCount++
|
|
var params map[string]any
|
|
if evt.Parameters != nil {
|
|
_ = json.Unmarshal(evt.Parameters, ¶ms)
|
|
}
|
|
background.Send(Message{
|
|
Type: MessageToolUse,
|
|
Tool: evt.ToolName,
|
|
CallID: evt.ToolID,
|
|
Input: params,
|
|
})
|
|
|
|
case "tool_result":
|
|
background.Send(Message{
|
|
Type: MessageToolResult,
|
|
CallID: evt.ToolID,
|
|
Output: evt.Output,
|
|
})
|
|
|
|
case "result":
|
|
resultSeen = true
|
|
supplements.end()
|
|
// Publish the decided outcome BEFORE cleanup. Close() can block
|
|
// on the tracker lock behind a tool watchdog Interrupt already in
|
|
// progress, and that Interrupt can return false without moving
|
|
// native accounting — which would leave the watchdog free to
|
|
// cancel and re-tag this completed run as idle_watchdog.
|
|
background.ObserveTerminal()
|
|
background.Close()
|
|
if evt.IsError || evt.Subtype == "error" {
|
|
resultIsError = true
|
|
finalStatus = "failed"
|
|
finalError = cursorErrorText(&evt)
|
|
}
|
|
resultBytes = len(evt.ResultText)
|
|
if evt.ResultText != "" && output.Len() == 0 {
|
|
output.WriteString(evt.ResultText)
|
|
trySend(msgCh, Message{Type: MessageText, Content: evt.ResultText})
|
|
}
|
|
b.accumulateResultUsage(resultUsage, &evt, configuredModel)
|
|
if evt.hasResultUsage() {
|
|
hasResultUsage = true
|
|
}
|
|
// Current Cursor Agent versions can emit the terminal result
|
|
// event but keep a worker process alive. Treat result as the
|
|
// protocol boundary so the daemon can report completion.
|
|
cancel()
|
|
break scanLoop
|
|
|
|
case "error":
|
|
errMsg := cursorErrorText(&evt)
|
|
if errMsg != "" {
|
|
protocolError = 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)
|
|
assistantBytes += len(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 := cursorUsageModel(evt.Model, configuredModel)
|
|
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
|
|
}
|
|
|
|
default:
|
|
// A top-level type this parser does not handle. Falling through
|
|
// here used to be completely silent, and that silence is the
|
|
// bug: if the CLI renames `tool_call` or `thinking`, every tool
|
|
// and reasoning row vanishes while the run still reports
|
|
// success and tool_use_count=0, with no diagnostic to
|
|
// distinguish that from a tool-free run (MUL-5434).
|
|
//
|
|
// Counting makes the drop observable. It does not identify the
|
|
// cause on its own — see cursorUnhandledTypeTally for what a
|
|
// non-zero and a zero tally each do and do not establish.
|
|
//
|
|
// Counting is also all we do. An unrecognized event is never
|
|
// coerced into a tool or reasoning message: guessing at
|
|
// upstream additions is the failure mode MUL-5231 already
|
|
// fixed once.
|
|
if !cursorNonTranscriptEventType(evt.Type) {
|
|
unhandledTypes.observe(evt.Type)
|
|
}
|
|
}
|
|
}
|
|
supplements.end()
|
|
scanErr := scanner.Err()
|
|
if scanErr != nil {
|
|
// Scanner stopped consuming stdout. Close the pipe before Wait so a
|
|
// child writing a malformed or oversized event cannot deadlock on a
|
|
// full OS pipe; the scanner error remains the primary failure.
|
|
_ = stdout.Close()
|
|
}
|
|
|
|
// Use result usage if available (session totals); otherwise fall back
|
|
// to accumulated step_finish usage.
|
|
if !hasResultUsage {
|
|
resultUsage = stepUsage
|
|
}
|
|
|
|
background.Close()
|
|
exitErr := cmd.Wait()
|
|
releaseProcessGroup(cmd)
|
|
supplements.close()
|
|
duration := time.Since(startTime)
|
|
|
|
// Wait has already closed the stdin pipe, so a prompt write still blocked
|
|
// on a full pipe has returned by now; the writer sends exactly once.
|
|
writeErr := <-writeErrCh
|
|
|
|
if resultSeen {
|
|
// A parsed result is the protocol boundary. Ignore the cancellation and
|
|
// exit error caused by stopping a Cursor worker that lingers afterward.
|
|
if finalStatus == "failed" && finalError == "" {
|
|
finalError = "cursor-agent returned an error result without details"
|
|
}
|
|
} else {
|
|
switch {
|
|
case runCtx.Err() == context.DeadlineExceeded:
|
|
finalStatus = "timeout"
|
|
finalError = fmt.Sprintf("cursor-agent timed out after %s", timeout)
|
|
case runCtx.Err() == context.Canceled:
|
|
finalStatus = "aborted"
|
|
finalError = "execution cancelled"
|
|
case scanErr != nil:
|
|
finalStatus = "failed"
|
|
finalError = fmt.Sprintf("cursor-agent stdout read error: %v", scanErr)
|
|
case protocolError != "":
|
|
finalStatus = "failed"
|
|
finalError = protocolError
|
|
case writeErr != nil:
|
|
// Ranked below explicit agent errors because a child that exits
|
|
// early for its own reason (bad auth, bad flag) makes our write
|
|
// fail with EPIPE as a side effect. The stderr tail is appended
|
|
// to every !resultSeen failure below, so the real cause still
|
|
// surfaces either way.
|
|
finalStatus = "failed"
|
|
finalError = fmt.Sprintf("cursor-agent prompt write failed: %v", writeErr)
|
|
case exitErr != nil:
|
|
finalStatus = "failed"
|
|
finalError = fmt.Sprintf("cursor-agent exited with error: %v", exitErr)
|
|
default:
|
|
finalStatus = "failed"
|
|
finalError = "cursor-agent stream ended without terminal result"
|
|
}
|
|
}
|
|
|
|
if finalError != "" {
|
|
finalError = sanitizeAgentDiagnostic(finalError)
|
|
}
|
|
if finalStatus == "failed" && !resultSeen {
|
|
finalError = cursorFailureDiagnostic(
|
|
finalError,
|
|
exitErr,
|
|
scanErr,
|
|
eventCount,
|
|
invalidEventCount,
|
|
lastEventType,
|
|
)
|
|
finalError = withAgentStderr(finalError, "cursor", sanitizeAgentDiagnostic(stderrBuf.Tail()))
|
|
}
|
|
|
|
logStreamProtocolObservation(b.cfg.Logger, streamProtocolObservation{
|
|
provider: "cursor-agent",
|
|
cliVersion: b.cfg.CLIVersion,
|
|
model: opts.Model,
|
|
exitCode: streamProcessExitCode(exitErr),
|
|
eventCount: eventCount,
|
|
invalidEventCount: invalidEventCount,
|
|
assistantEventCount: assistantEventCount,
|
|
toolUseCount: toolUseCount,
|
|
sawResult: resultSeen,
|
|
resultIsError: resultIsError,
|
|
resultBytes: resultBytes,
|
|
lastAssistantBytes: assistantBytes,
|
|
scannerError: scanErr != nil && !resultSeen,
|
|
lastEventType: lastEventType,
|
|
unhandledEventTypeCount: unhandledTypes.total,
|
|
unhandledEventTypes: unhandledTypes.summary(),
|
|
unhandledSubtypeCount: unhandledSubtypeCount,
|
|
})
|
|
|
|
if unhandledSubtypeCount > 0 {
|
|
// Content-free and emitted once per run: signals that the CLI sent a
|
|
// thinking/tool_call subtype we chose to ignore, so a protocol
|
|
// addition is diagnosable without the parser having guessed at it.
|
|
b.cfg.Logger.Warn("cursor-agent ignored unhandled event subtypes", "count", unhandledSubtypeCount)
|
|
}
|
|
|
|
if unhandledTypes.total > 0 {
|
|
// Same contract one level up, and the broader signal of the two: an
|
|
// unhandled top-level type means whole events never reached the
|
|
// transcript. What was in them is not decided here — the type names
|
|
// are the starting point for that, not the conclusion.
|
|
b.cfg.Logger.Warn("cursor-agent ignored unhandled event types",
|
|
"count", unhandledTypes.total,
|
|
"types", unhandledTypes.summary(),
|
|
)
|
|
}
|
|
|
|
b.cfg.Logger.Info("cursor-agent finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
|
|
|
|
finalOutput := output.String()
|
|
if finalStatus != "completed" {
|
|
// A partial transcript is not a final answer. Keep it in Messages for
|
|
// observability, but never expose it as Result.Output on failure.
|
|
finalOutput = ""
|
|
}
|
|
|
|
resCh <- Result{
|
|
Status: finalStatus,
|
|
Output: finalOutput,
|
|
Error: finalError,
|
|
DurationMs: duration.Milliseconds(),
|
|
SessionID: sessionID,
|
|
Usage: resultUsage,
|
|
}
|
|
}()
|
|
|
|
session := &Session{
|
|
Messages: msgCh,
|
|
Result: resCh,
|
|
ToolActivity: background.Activity,
|
|
InterruptBackgroundTools: background.Interrupt,
|
|
TerminalObserved: background.TerminalObserved,
|
|
}
|
|
switch {
|
|
case supplements != nil:
|
|
session.Supplement = supplements.supplement
|
|
session.SupplementReady = supplements.ready
|
|
case supplementErr != nil:
|
|
// The daemon already offered steering for this run and there is no way
|
|
// to withdraw it, so settle each message as failed right away instead of
|
|
// leaving it pending until the turn ends.
|
|
session.Supplement = func(context.Context, string) error {
|
|
return fmt.Errorf("cursor hooks unavailable: %w", supplementErr)
|
|
}
|
|
session.SupplementReady = func() bool { return runCtx.Err() == nil }
|
|
}
|
|
return session, nil
|
|
}
|
|
|
|
const cursorIncompleteFinalizationWarning = "actions completed before finalization may already have taken effect"
|
|
|
|
func cursorFailureDiagnostic(message string, exitErr, scanErr error, eventCount, invalidEventCount int, lastEventType string) string {
|
|
return fmt.Sprintf(
|
|
"%s (result_seen=false, exit_code=%d, scanner_error=%t, event_count=%d, invalid_event_count=%d, last_event_type=%s); %s",
|
|
message,
|
|
streamProcessExitCode(exitErr),
|
|
scanErr != nil,
|
|
eventCount,
|
|
invalidEventCount,
|
|
lastEventType,
|
|
cursorIncompleteFinalizationWarning,
|
|
)
|
|
}
|
|
|
|
// observedCursorEventType keeps protocol diagnostics bounded and content-free.
|
|
// Event types are identifiers; arbitrary values are collapsed instead of being
|
|
// copied into daemon logs or task errors.
|
|
func observedCursorEventType(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return "unknown"
|
|
}
|
|
if len(value) > 64 {
|
|
return "invalid"
|
|
}
|
|
for _, r := range value {
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
|
continue
|
|
}
|
|
return "invalid"
|
|
}
|
|
return value
|
|
}
|
|
|
|
// cursorNonTranscriptEventTypes are top-level events that are known to exist,
|
|
// carry nothing the transcript needs, and are therefore NOT reported as
|
|
// protocol drift. Dropping them is the behaviour that already shipped; listing
|
|
// them here only keeps them out of the diagnostic, because a warning that fires
|
|
// on every task is a warning nobody reads.
|
|
//
|
|
// Provenance differs per entry and matters when revisiting this list:
|
|
// - `user` — the CLI echoing our own prompt back. Confirmed present in the
|
|
// recorded 2026.07.20 stream, i.e. in every real run.
|
|
// - `connection`, `retry` — transport/control frames reported on newer CLI
|
|
// builds (MUL-5434 review). Not reproduced locally, so they are listed
|
|
// defensively: if a build does not emit them the entry is inert, and if it
|
|
// does we must not call a known control frame an unhandled protocol event.
|
|
//
|
|
// An entry here is a claim that the type carries no transcript content. Do not
|
|
// add a type merely to silence the warning.
|
|
var cursorNonTranscriptEventTypes = map[string]struct{}{
|
|
"user": {},
|
|
"connection": {},
|
|
"retry": {},
|
|
}
|
|
|
|
func cursorNonTranscriptEventType(eventType string) bool {
|
|
_, ok := cursorNonTranscriptEventTypes[strings.TrimSpace(eventType)]
|
|
return ok
|
|
}
|
|
|
|
// cursorUnhandledTypeCardinalityCap bounds how many distinct type names the
|
|
// tally retains. A stream emitting novel type names (or garbage that still
|
|
// parses as JSON) must not grow the map without limit inside a long-running
|
|
// daemon; names past the cap collapse into one overflow bucket.
|
|
const cursorUnhandledTypeCardinalityCap = 16
|
|
|
|
// cursorUnhandledTypeOverflowKey holds the counts that exceeded the cardinality
|
|
// cap. The parentheses cannot collide with a real type name: observedCursorEventType
|
|
// only ever returns identifier characters, "unknown", or "invalid".
|
|
const cursorUnhandledTypeOverflowKey = "(overflow)"
|
|
|
|
// cursorUnhandledTypeTally counts top-level event types this parser does not
|
|
// handle, so that dropping them stops being silent. Before it existed a renamed
|
|
// `tool_call` fell through the type switch and the run reported success with
|
|
// tool_use_count=0 and no diagnostic whatsoever (MUL-5434).
|
|
//
|
|
// Read it as evidence, not as a verdict — in both directions:
|
|
//
|
|
// - A non-zero tally shows the stream carried top-level events we do not
|
|
// handle. Which events, and whether they are what the transcript lost,
|
|
// still has to be established from the type names, invalid_event_count and
|
|
// ultimately a captured stream.
|
|
// - A zero tally shows only that no unhandled top-level type was observed. It
|
|
// does NOT establish that the model used no tools: the CLI may have executed
|
|
// tools without handing the updates to its stream serializer at all, a new
|
|
// shape may be nested inside an event type we already recognize (e.g. an
|
|
// assistant content block), or the events may have been lost to invalid
|
|
// framing or a scanner boundary. Those branches are open on #6071.
|
|
//
|
|
// Only normalized identifiers and counts are retained, never payload content,
|
|
// so the tally is safe to log beside the rest of the protocol summary.
|
|
type cursorUnhandledTypeTally struct {
|
|
total int
|
|
counts map[string]int
|
|
}
|
|
|
|
func (t *cursorUnhandledTypeTally) observe(eventType string) {
|
|
t.total++
|
|
if t.counts == nil {
|
|
t.counts = make(map[string]int, 4)
|
|
}
|
|
key := observedCursorEventType(eventType)
|
|
if _, seen := t.counts[key]; !seen && len(t.counts) >= cursorUnhandledTypeCardinalityCap {
|
|
key = cursorUnhandledTypeOverflowKey
|
|
}
|
|
t.counts[key]++
|
|
}
|
|
|
|
// summary renders the tally as a stable, sorted "name=count" list (e.g.
|
|
// "reasoning=9,tool_calls=6") so it can be grepped and diffed across runs.
|
|
func (t *cursorUnhandledTypeTally) summary() string {
|
|
if len(t.counts) == 0 {
|
|
return ""
|
|
}
|
|
keys := make([]string, 0, len(t.counts))
|
|
for key := range t.counts {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
parts := make([]string, 0, len(keys))
|
|
for _, key := range keys {
|
|
parts = append(parts, fmt.Sprintf("%s=%d", key, t.counts[key]))
|
|
}
|
|
return strings.Join(parts, ",")
|
|
}
|
|
|
|
// handleCursorAssistant forwards one assistant event's content blocks and
|
|
// returns how many bytes of model-authored text it appended to output. The
|
|
// caller tracks that separately from output.Len(), which also absorbs the
|
|
// terminal result text.
|
|
func (b *cursorBackend) handleCursorAssistant(evt *cursorStreamEvent, send func(Message), output *strings.Builder) int {
|
|
if evt.Message == nil {
|
|
return 0
|
|
}
|
|
|
|
var content cursorAssistantMessage
|
|
if err := json.Unmarshal(evt.Message, &content); err != nil {
|
|
return 0
|
|
}
|
|
|
|
// Note: per-message usage in assistant events is intentionally ignored.
|
|
// Token usage is taken exclusively from "result" events (session totals)
|
|
// to avoid double-counting.
|
|
|
|
written := 0
|
|
for _, block := range content.Content {
|
|
switch block.Type {
|
|
case "output_text", "text":
|
|
if block.Text != "" {
|
|
output.WriteString(block.Text)
|
|
written += len(block.Text)
|
|
send(Message{Type: MessageText, Content: block.Text})
|
|
}
|
|
case "thinking":
|
|
if block.Text != "" {
|
|
send(Message{Type: MessageThinking, Content: block.Text})
|
|
}
|
|
case "tool_use":
|
|
var input map[string]any
|
|
if block.Input != nil {
|
|
_ = json.Unmarshal(block.Input, &input)
|
|
}
|
|
send(Message{
|
|
Type: MessageToolUse,
|
|
Tool: block.Name,
|
|
CallID: block.ID,
|
|
Input: input,
|
|
})
|
|
}
|
|
}
|
|
return written
|
|
}
|
|
|
|
// cursorThinkingStream turns the CLI's `thinking` event sequence into the
|
|
// content we forward as MessageThinking. Cursor streams a reasoning block as
|
|
// `subtype:"delta"` events carrying a text fragment each, terminated by a
|
|
// `subtype:"completed"` event that carries no text of its own.
|
|
//
|
|
// Deltas are forwarded immediately (the daemon concatenates and flushes them on
|
|
// a ticker, so reasoning shows up while the task runs) and blocks are separated
|
|
// by a blank line, because consecutive blocks would otherwise be glued together
|
|
// into one paragraph by that same concatenation.
|
|
type cursorThinkingStream struct {
|
|
blockOpen bool
|
|
anySent bool
|
|
}
|
|
|
|
// delta returns the content to forward for one `delta` event, or "" when the
|
|
// fragment is empty. The first fragment of a new block is prefixed with a blank
|
|
// line so the daemon's concatenation keeps blocks visually separated.
|
|
func (t *cursorThinkingStream) delta(text string) string {
|
|
if text == "" {
|
|
return ""
|
|
}
|
|
if !t.blockOpen && t.anySent {
|
|
text = "\n\n" + text
|
|
}
|
|
t.blockOpen = true
|
|
t.anySent = true
|
|
return text
|
|
}
|
|
|
|
// complete closes the current reasoning block. The terminal event carries no
|
|
// content of its own in the observed protocol, so nothing is forwarded; the
|
|
// next block's first delta gets the separating blank line.
|
|
func (t *cursorThinkingStream) complete() {
|
|
t.blockOpen = false
|
|
}
|
|
|
|
// cursorToolCall is a top-level `tool_call` event projected onto the fields the
|
|
// daemon transcript needs.
|
|
type cursorToolCall struct {
|
|
Name string
|
|
CallID string
|
|
Input map[string]any
|
|
Result string
|
|
|
|
Background bool
|
|
PID int
|
|
|
|
// HookContexts are the additional_context values Cursor attached to the
|
|
// tool result, the receipt for hook-delivered guidance.
|
|
HookContexts []string
|
|
}
|
|
|
|
// cursorToolCallKeySuffix is how Cursor names the per-tool payload: the tool is
|
|
// the key of a nested object rather than a `name` field, so `readToolCall`
|
|
// identifies a read and holds that call's `args` and (once completed) `result`.
|
|
const cursorToolCallKeySuffix = "ToolCall"
|
|
|
|
// parseCursorToolCall extracts tool identity, arguments and result from a
|
|
// `tool_call` event:
|
|
//
|
|
// {"type":"tool_call","subtype":"started","call_id":"call-…",
|
|
// "tool_call":{"readToolCall":{"args":{"path":"/x/a.txt"}},"toolCallId":"call-…"}}
|
|
//
|
|
// A payload we cannot decode still yields the call ID, so started/completed
|
|
// events stay paired and the transcript keeps a row for the call.
|
|
func parseCursorToolCall(evt *cursorStreamEvent) cursorToolCall {
|
|
call := cursorToolCall{CallID: cursorCallID(evt.CallID)}
|
|
|
|
var envelope map[string]json.RawMessage
|
|
if len(evt.ToolCall) == 0 || json.Unmarshal(evt.ToolCall, &envelope) != nil {
|
|
return call
|
|
}
|
|
if call.CallID == "" {
|
|
var nestedID string
|
|
if err := json.Unmarshal(envelope["toolCallId"], &nestedID); err == nil {
|
|
call.CallID = cursorCallID(nestedID)
|
|
}
|
|
}
|
|
var hookContexts []struct {
|
|
Content string `json:"content"`
|
|
}
|
|
if raw, ok := envelope["hookAdditionalContexts"]; ok && json.Unmarshal(raw, &hookContexts) == nil {
|
|
for _, c := range hookContexts {
|
|
call.HookContexts = append(call.HookContexts, c.Content)
|
|
}
|
|
}
|
|
|
|
key := cursorToolPayloadKey(envelope)
|
|
if key == "" {
|
|
return call
|
|
}
|
|
call.Name = strings.TrimSuffix(key, cursorToolCallKeySuffix)
|
|
|
|
var payload struct {
|
|
Args map[string]any `json:"args"`
|
|
Result json.RawMessage `json:"result"`
|
|
}
|
|
if err := json.Unmarshal(envelope[key], &payload); err != nil {
|
|
return call
|
|
}
|
|
call.Input = payload.Args
|
|
if len(payload.Result) > 0 {
|
|
call.Result = string(payload.Result)
|
|
if call.Name == "shell" {
|
|
call.Background = cursorResultReportsBackground(payload.Result)
|
|
var metadata struct {
|
|
Success struct {
|
|
PID int `json:"pid"`
|
|
} `json:"success"`
|
|
}
|
|
if json.Unmarshal(payload.Result, &metadata) == nil && call.Background {
|
|
call.PID = metadata.Success.PID
|
|
}
|
|
}
|
|
}
|
|
return call
|
|
}
|
|
|
|
// cursorResultReportsBackground reads only the root boolean, never output text.
|
|
func cursorResultReportsBackground(raw json.RawMessage) bool {
|
|
var resultMeta struct {
|
|
IsBackground bool `json:"isBackground"`
|
|
}
|
|
if err := json.Unmarshal(raw, &resultMeta); err != nil {
|
|
return false
|
|
}
|
|
return resultMeta.IsBackground
|
|
}
|
|
|
|
// cursorToolPayloadKey picks the `<name>ToolCall` key of a tool_call envelope.
|
|
// Observed payloads carry exactly one; the sort keeps the choice deterministic
|
|
// if a future CLI ever emits more than one.
|
|
func cursorToolPayloadKey(envelope map[string]json.RawMessage) string {
|
|
var keys []string
|
|
for key := range envelope {
|
|
if len(key) > len(cursorToolCallKeySuffix) && strings.HasSuffix(key, cursorToolCallKeySuffix) {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
if len(keys) == 0 {
|
|
return ""
|
|
}
|
|
sort.Strings(keys)
|
|
return keys[0]
|
|
}
|
|
|
|
// cursorCallID normalizes the CLI's call identifier. Cursor packs two ids into
|
|
// one newline-separated string ("call-…\nfc_…"); the first line alone is
|
|
// unique, and keeping IDs single-line stops the embedded newline from breaking
|
|
// up daemon log lines.
|
|
func cursorCallID(raw string) string {
|
|
id := strings.TrimSpace(raw)
|
|
if idx := strings.IndexByte(id, '\n'); idx >= 0 {
|
|
id = id[:idx]
|
|
}
|
|
return strings.TrimSpace(id)
|
|
}
|
|
|
|
func cursorUsageModel(evtModel, configuredModel string) string {
|
|
if model := strings.TrimSpace(evtModel); model != "" {
|
|
return model
|
|
}
|
|
if model := strings.TrimSpace(configuredModel); model != "" {
|
|
return model
|
|
}
|
|
return "cursor"
|
|
}
|
|
|
|
func (b *cursorBackend) accumulateResultUsage(usage map[string]TokenUsage, evt *cursorStreamEvent, configuredModel string) {
|
|
model := cursorUsageModel(evt.Model, configuredModel)
|
|
u := usage[model]
|
|
|
|
// Cursor agent has emitted token usage in multiple shapes: top-level
|
|
// camelCase fields, nested camelCase usage, and nested legacy snake_case
|
|
// usage. Prefer top-level result totals when present, otherwise use the
|
|
// nested usage object.
|
|
if evt.InputTokens != 0 || evt.OutputTokens != 0 || evt.CacheReadTokens != 0 || evt.CacheWriteTokens != 0 {
|
|
u.InputTokens += evt.InputTokens
|
|
u.OutputTokens += evt.OutputTokens
|
|
u.CacheReadTokens += evt.CacheReadTokens
|
|
u.CacheWriteTokens += evt.CacheWriteTokens
|
|
} else if evt.Usage != nil {
|
|
u.InputTokens += evt.Usage.InputTokens
|
|
u.OutputTokens += evt.Usage.OutputTokens
|
|
u.CacheReadTokens += evt.Usage.CacheReadInputTokens
|
|
u.CacheWriteTokens += evt.Usage.CacheWriteInputTokens
|
|
} else {
|
|
return
|
|
}
|
|
|
|
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"`
|
|
|
|
// thinking fields
|
|
Text string `json:"text,omitempty"`
|
|
|
|
// tool_call fields (current CLI): the tool is a nested key under tool_call
|
|
ToolCall json.RawMessage `json:"tool_call,omitempty"`
|
|
CallID string `json:"call_id,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.
|
|
//
|
|
// The result event reports token counts only; cursor-agent's stream-json
|
|
// carries no per-turn cost (no `total_cost_usd`, no per-step `cost`). This
|
|
// was verified against the real CLI (2026.07.20, both stream-json and json
|
|
// output) and Cursor's CLI docs. So — unlike Grok, whose turn cost xAI
|
|
// reports authoritatively — Cursor spend is estimated downstream from the
|
|
// static rate table, never carried through here. Add a cost field only
|
|
// when a real payload proves the CLI emits one.
|
|
ResultText string `json:"result,omitempty"`
|
|
IsError bool `json:"is_error,omitempty"`
|
|
InputTokens int64 `json:"inputTokens,omitempty"`
|
|
OutputTokens int64 `json:"outputTokens,omitempty"`
|
|
CacheReadTokens int64 `json:"cacheReadTokens,omitempty"`
|
|
CacheWriteTokens int64 `json:"cacheWriteTokens,omitempty"`
|
|
Usage *cursorUsage `json:"usage,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 ""
|
|
}
|
|
|
|
func (evt *cursorStreamEvent) hasResultUsage() bool {
|
|
return evt.Usage != nil || evt.InputTokens != 0 || evt.OutputTokens != 0 || evt.CacheReadTokens != 0 || evt.CacheWriteTokens != 0
|
|
}
|
|
|
|
type cursorUsage struct {
|
|
InputTokens int64 `json:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens"`
|
|
CacheReadInputTokens int64 `json:"cached_input_tokens"`
|
|
CacheWriteInputTokens int64
|
|
}
|
|
|
|
func (u *cursorUsage) UnmarshalJSON(data []byte) error {
|
|
var raw struct {
|
|
InputTokensSnake int64 `json:"input_tokens"`
|
|
InputTokensCamel int64 `json:"inputTokens"`
|
|
OutputTokensSnake int64 `json:"output_tokens"`
|
|
OutputTokensCamel int64 `json:"outputTokens"`
|
|
CachedInputTokensSnake int64 `json:"cached_input_tokens"`
|
|
CachedInputTokensCamel int64 `json:"cachedInputTokens"`
|
|
CacheReadTokensCamel int64 `json:"cacheReadTokens"`
|
|
CacheReadInputTokensSnake int64 `json:"cache_read_input_tokens"`
|
|
CacheReadInputTokensCamel int64 `json:"cacheReadInputTokens"`
|
|
CacheWriteTokensCamel int64 `json:"cacheWriteTokens"`
|
|
CacheCreationInputTokensSnake int64 `json:"cache_creation_input_tokens"`
|
|
CacheCreationInputTokensCamel int64 `json:"cacheCreationInputTokens"`
|
|
}
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return err
|
|
}
|
|
u.InputTokens = firstNonZeroInt64(raw.InputTokensSnake, raw.InputTokensCamel)
|
|
u.OutputTokens = firstNonZeroInt64(raw.OutputTokensSnake, raw.OutputTokensCamel)
|
|
u.CacheReadInputTokens = firstNonZeroInt64(
|
|
raw.CachedInputTokensSnake,
|
|
raw.CachedInputTokensCamel,
|
|
raw.CacheReadTokensCamel,
|
|
raw.CacheReadInputTokensSnake,
|
|
raw.CacheReadInputTokensCamel,
|
|
)
|
|
u.CacheWriteInputTokens = firstNonZeroInt64(
|
|
raw.CacheWriteTokensCamel,
|
|
raw.CacheCreationInputTokensSnake,
|
|
raw.CacheCreationInputTokensCamel,
|
|
)
|
|
return nil
|
|
}
|
|
|
|
func firstNonZeroInt64(values ...int64) int64 {
|
|
for _, v := range values {
|
|
if v != 0 {
|
|
return v
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
// cursorStepFinishPart carries per-step token counts. cursor-agent does not
|
|
// report a per-step cost (see cursorStreamEvent's result-fields note), so only
|
|
// tokens are parsed here.
|
|
type cursorStepFinishPart struct {
|
|
Tokens struct {
|
|
Input int `json:"input"`
|
|
Output int `json:"output"`
|
|
Cache struct {
|
|
Read int `json:"read"`
|
|
} `json:"cache"`
|
|
} `json:"tokens"`
|
|
}
|
|
|
|
// ── 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 -p --output-format stream-json
|
|
//
|
|
// --workspace <cwd> --yolo [--model <m>] [--resume <id>]
|
|
//
|
|
// The prompt is deliberately NOT part of argv. cursor-agent's -p is a boolean
|
|
// print-mode switch and the prompt is a positional argument; when no positional
|
|
// prompt is present and stdin is not a TTY, the CLI reads stdin to EOF and uses
|
|
// that as the prompt. We rely on that path because putting user-controlled text
|
|
// on the command line is not safe on Windows: the official cursor-agent.cmd/.ps1
|
|
// launcher ends in `& node.exe index.js $args`, and PowerShell re-serialises
|
|
// $args onto node's command line. Under Windows PowerShell 5.1 (and pwsh
|
|
// <= 7.2, which default to Legacy native argument passing) an argument holding
|
|
// embedded double quotes is not re-escaped, so a prompt containing e.g.
|
|
// `go build -ldflags "-X main.version=foo"` gets re-tokenised and `-X` reaches
|
|
// commander.js as a standalone flag: "error: unknown option '-X'" (#5649).
|
|
// Routing the prompt through stdin keeps it off every command line, so no shell
|
|
// or launcher on any platform can re-tokenise it. Only fixed, content-free
|
|
// flags remain in argv.
|
|
func buildCursorArgs(opts ExecOptions, logger *slog.Logger) []string {
|
|
args := []string{
|
|
"-p",
|
|
"--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
|
|
}
|