mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 09:26:50 +08:00
* fix(agent): supervise Cursor background shells with a liveness guard Cursor's stream-json reports a launched background shell as the `isBackground` boolean at the root of its completed tool-call result, and the adapter acted on none of it: a top-level execution that went quiet after launching one kept holding its runtime slot until the execution deadline (upstream #7833). Bound that shape instead of condemning it. The structural observation now arms a Cursor-private liveness guard: a rolling semantic-progress deadline (10 minutes in production, overridable per backend so tests do not have to wait a minute-scale timeout) while execution continues normally. Only a supervised run that stops progressing and never emits its terminal result is ended, and it fails closed with a dedicated, content-free reason rather than a generic cancellation. Precedence is decided when the cancellation happens, not guessed at finalization: fire() declines to claim a run the execution deadline or the caller had already ended, so `timeout` and `aborted` keep their classifications, while a cancellation the guard provoked is never rewritten into "execution cancelled". A valid terminal Cursor result outranks a latched background observation and still completes. Detection is structural only — no reading of the command string, no substring of captured output, no assistant text — and malformed or non-object result metadata leaves the call foreground, so it cannot arm the guard or fail the rest of the tool parse. Raw result bytes are forwarded unchanged. The guard owns one goroutine and one timer per execution, started on the first background observation and waited for before the reader hands over its last channel; the overwhelmingly common foreground run never arms it and pays nothing. Tests run cross-platform by re-executing the test binary as a fake cursor-agent, so the pathological stream stays alive and only the daemon can end it. Test A is RED on unpatched main, where the same run is bounded solely by the 6s execution deadline with status "timeout". Co-authored-by: multica-agent <[email protected]> * fix(agent): keep the Cursor liveness guard on its own evidence Cross-verification of 15605057e found three places where the guard acted on weaker evidence than the spec allows, plus one helper that did not do what it documented. - cursorSemanticProgressEvent believed every `tool_call` subtype. Execute handles `started` and `completed` and counts anything else as unhandled, so a stream of subtypes it drops could extend the deadline forever. Progress is now limited to the subtypes that are actually handled. - The lifecycle observation read `isBackground` off any completed tool result. The flag the spec names lives on the shell payload, and another tool reporting it is not evidence that Cursor detached a process, so the read is gated on the `shellToolCall` key. An unrecognised payload key now leaves the run unsupervised, which is the pre-existing behaviour, not a new failure. - Precedence between a native terminal result and the guard is now decided when the result is observed. A result that lands after the guard ended the run is late evidence: it no longer rewrites the failure to `completed`, its text is not exposed as output, and the run keeps the guard's own reason. A cause the guard declined to claim — a real deadline or a caller cancellation — still leaves the result authoritative. - cursorBackgroundGuard.Stop closes its channels through sync.Once, matching the idempotency its doc claims, and returns the guard's cause as of that moment, which is what makes the ordering above decidable at all: after fire() the guard's cancellation is otherwise indistinguishable from the caller's. Tests: the guard's own contract (fire-once, progress extension, yielding to real causes, Stop's report, concurrent Stop) is unit-tested directly; the observation rules are pinned by structural table cases and by two subprocess fakes — a stalled non-shell `isBackground` must still end on the execution timeout, not the guard. Co-authored-by: multica-agent <[email protected]> * chore(agent): clarify Cursor fake dispatch comment in TestMain Co-authored-by: multica-agent <[email protected]> * fix(cursor): recheck observed progress before liveness cancellation Record semantic progress under the guard mutex and recheck its deadline when handling timer or progress notifications, so a stale tick cannot cancel an active run. Keep the background-liveness failure content-free. Add a deterministic virtual-time regression for the expiry boundary and strengthen the noisy subprocess regression to check the exact error. Validation: focused regressions fail before the fix and pass after it; Cursor/ParseCursor tests pass with -race; go vet ./pkg/agent passes. Co-authored-by: multica-agent <[email protected]> * fix(cursor): supervise background tools with the daemon tool budget Co-authored-by: multica-agent <[email protected]> * fix(cursor): retain background ownership and refresh watchdog state Co-authored-by: multica-agent <[email protected]> * fix(cursor): validate unreaped Darwin group anchors Co-authored-by: multica-agent <[email protected]> * fix(cursor): distinguish empty Darwin groups from lost ownership Co-authored-by: multica-agent <[email protected]> * test(cursor): isolate reviewed baseline regression checks Co-authored-by: multica-agent <[email protected]> * test(cursor): stop platform checks at the first failure Co-authored-by: multica-agent <[email protected]> * fix(cursor): verify Darwin process identities during cleanup Co-authored-by: multica-agent <[email protected]> * test(cursor): use standard agent limits on macOS Co-authored-by: multica-agent <[email protected]> * test(cursor): keep lifecycle reproductions ahead of full suite Co-authored-by: multica-agent <[email protected]> * test(cursor): keep macOS CI focused on lifecycle Co-authored-by: multica-agent <[email protected]> * chore(ci): drop the PR-specific Cursor regression reproduction steps The two steps gated on `github.event.pull_request.number == 8050` proved the new regression tests were RED on earlier heads of this PR. That is useful while the PR is open and dead weight once it merges: the condition can never hold again on main, and both steps pin commit SHAs that only exist on a closed PR's branch. The permanent coverage stays: the lifecycle job still runs on ubuntu, macOS and Windows, and macOS still repeats the finalization test and checks the cgo-free ownership path. Co-authored-by: multica-agent <[email protected]> * docs(daemon): state what a zero tool budget means for a background shell Keeping a Cursor background shell in flight for the launched process's whole lifetime is what puts a legitimate long background job on the tool budget instead of the shorter idle one. It also changes what MULTICA_AGENT_TOOL_WATCHDOG=0 means in practice: such a run is now bounded only by MULTICA_AGENT_TIMEOUT, which is 0 by default. That follows from the invariant rather than contradicting it — a foreground tool that never returns already behaves this way — but an operator reading "never force-stop during a tool call" would not predict it, so say it where the knob is defined. Co-authored-by: multica-agent <[email protected]> * test(daemon): pin the captured-background zero tool budget outcome TestCursorBackgroundUncapturedDisabledToolWatchdog covers the case where nothing was claimed, and releases the tool so the idle budget still applies. Its mirror was untested: when ownership IS held the process is genuinely in flight, so a zero tool budget deliberately force-stops nothing. Without this test the two cases look like an inconsistency and invite a later "fix" that quietly changes what the knob promises. Co-authored-by: multica-agent <[email protected]> * fix(daemon): let an observed terminal result outrank the idle watchdog A Cursor run could still be reported as a hang after it had already succeeded. On reading its authoritative result the backend called background.Close() synchronously, before Session.Result reached the daemon. That call takes the tracker lock, so it can queue behind a tool-watchdog Interrupt already in progress — and that Interrupt is allowed to return false without moving native accounting or its timestamp. The watchdog's revalidation then saw an unchanged count, an unchanged timestamp and an empty queue, cancelled the run, and executeAndDrain re-tagged the completed result as idle_watchdog. The missing piece was a lifecycle boundary the daemon could see: cleanup succeeding is not the same fact as the outcome being decided, and only the latter should silence a liveness policy. Session.TerminalObserved carries it, the Cursor backend publishes it before any cleanup that can block or fail, and the watchdog checks it both before interrupting and immediately before force-stopping. The re-tag at Session.Result is gated on it too, so even a watchdog that wins the last instant cannot rewrite a decided outcome. Both regression tests drive the exact interleaving through channel handshakes rather than sleeps: the cleanup callback itself publishes the terminal observation, so the terminal result provably lands while cleanup is in progress. They fail on the parent commit with 'fired=true' and 'Status:idle_watchdog' respectively. Co-authored-by: multica-agent <[email protected]> * docs: sync the Cursor background watchdog contract to ja/ko/zh The English page gained three operator-facing contracts that the other three locales still described with the pre-change one-liner: that a Cursor background shell stays in flight until its owned processes exit, that normal finalization stops those processes, and what the per-platform fail-closed limits are. These decide whether an operator sets an overall timeout, sets a tool budget, and expects a background server to survive a run, so they cannot ship in one locale only — the same watchdog settings are already maintained in four languages. Also states explicitly, in all four, that a zero tool budget leaves claimed background work in flight for as long as it lives, bounding the run only by MULTICA_AGENT_TIMEOUT, which is itself 0 by default. Co-authored-by: multica-agent <[email protected]> * fix(daemon): keep a decided outcome through the drain-timeout classifier The terminal boundary was checked in the watchdog and on the Result arm, but not on the one path a losing race actually takes. The watchdog reads terminalObserved and only then writes fired and cancels, so Cursor can publish its terminal result in between. The cancellation that follows does not surface on the Result arm — the backend is still finalizing and cannot deliver Result yet — it surfaces on drainCtx.Done(), which classified purely on the fired flag and returned idle_watchdog with an empty output for a run that had already succeeded. Check the boundary there too and wait, bounded, for the real result. Falling through on expiry keeps the previous behaviour, so the backstop cannot hang a run; the backend bounds its own finalization, which is what makes the wait short in practice. The earlier regression test could not reach this: there the watchdog returned at its final gate, so fired was never set and neither the rewrite guard nor this classifier ran. The new test makes TerminalObserved publish on the read that the watchdog's final gate performs while still answering false to that reader, which is the losing race exactly, and asserts the watchdog really did cancel before checking the outcome. It fails on the parent commit with Status:idle_watchdog and an emptied output. Co-authored-by: multica-agent <[email protected]> * fix(agent): bound the whole of Cursor background cleanup at close Each process termination was bounded, but the number of background shells a run may launch is not, so finalization as a whole was not. That matters only after a terminal result — precisely when the daemon's watchdog has deliberately stopped supervising the run and MULTICA_AGENT_TIMEOUT is 0 by default, leaving nothing else to bound it. Give Close() one budget for the whole pass. Work still unconfirmed when it expires takes the existing unconfirmed-at-close path, so its launch result is still surfaced and its cleanup is still logged as unconfirmed rather than reported as successful. Reap and Interrupt keep their previous behaviour. The test drives 40 tools whose termination never confirms; it takes ~8s on the parent commit and stays inside the budget here. Co-authored-by: multica-agent <[email protected]> * docs(ja): match the repo's existing wording in the Cursor watchdog contract Uses 最終結果 as elsewhere in the docs, makes the two signalling clauses say シグナル送信 explicitly, and names プロセス捕捉時 and the background process rather than the launch. Co-authored-by: multica-agent <[email protected]> * fix(daemon): linearize the post-force-stop verdict on the result hand-off Checking terminalObserved before classifying only moved the window: the backend can publish between that read and the classifier that follows it. Reading a flag and then acting on it cannot be made safe by adding more read sites. Enter the hand-off unconditionally once the watchdog has fired, and decide from what arrives. The result's delivery is then the linearization point, and the backend contract — publish the observation before sending Result — is what makes the check after delivery reliable rather than lucky. The Result arm already read in that order; this brings the drain arm in line. An expired budget still linearizes to the liveness verdict, so the branch stays bounded whatever a backend does. The previous test could not reach this: its backend sent Result immediately on cancellation, so the outer select could satisfy itself on the Result arm and the drain classifier never ran. The new one withholds Result until the daemon has entered the hand-off, using the hand-off's own log record as both the gate and the evidence that the branch executed — otherwise a passing status proves nothing about which arm produced it. On the parent commit it fails with 'the post-force-stop hand-off never ran'. Co-authored-by: multica-agent <[email protected]> * fix(agent): bound every locked background-cleanup pass, not just the closing one Close() took its deadline before the lock, which is correct, but the deadline could not shorten the wait for it. A concurrent Interrupt() still scanned an unbounded number of tools with no deadline of its own, so an unbounded pass held by anyone else was an unbounded Close() one level down — and interrupt-holds-the-lock-while-the-terminal-result-arrives is the exact topology this boundary exists for. Bound every pass that holds the lock by the same budget. If waiting for the lock consumed it, the closing pass takes a fresh one rather than pushing every tool onto the unconfirmed path without trying; two budgets is the honest ceiling, and terminalResultHandoffBudget is derived from exactly that. The existing test had no concurrent holder, so it could not see this. The new one puts an Interrupt() pass on the lock first: it takes ~1.1s on the parent commit against a 100ms budget. Co-authored-by: multica-agent <[email protected]> * fix(daemon): scope the result hand-off to backends that publish a boundary Entering the hand-off on every force stop taxed the case the watchdog exists for. A wedged backend has no result that could outrank the stop, so waiting for one only delays freeing the runtime slot — the three existing idle-watchdog latency tests caught this immediately, each taking the full budget instead of one window. Nil TerminalObserved is the signal and is now kept distinguishable rather than collapsed into a false-returning shim at the point of use. Backends without a boundary keep exactly their previous behaviour. Cursor, which has one, always closes Result, so even a wedged Cursor run ends this wait through the closed channel rather than the budget; the budget remains as the bound for a backend that neither delivers nor closes. The new test pins the fast path so the hand-off cannot quietly become a tax on every force stop again. Co-authored-by: multica-agent <[email protected]> * test(agent): prove the interrupting pass holds the lock before Close asks The gate was closed just before calling Interrupt(), which only says the goroutine was about to try. Close() could win the lock, finish on its own, and the test would pass even against an unbounded Interrupt() — a regression test that can go green without exercising what it guards. Close the gate from inside the first process's terminate() instead: finish() holds mu for the whole pass, so reaching it is proof the pass owns the lock. The ordering is now a fact rather than a scheduling habit. It did not misbehave in practice — with the old gate the test still failed 110/110 against an unbounded Interrupt(), including at GOMAXPROCS=1 and 2, because close() does not yield the P. It is now deterministic by construction: 20/20 at GOMAXPROCS 1, 2 and 8. Also corrects the handoff budget derivation: closing the reaper's stop channel wakes it immediately, so its tick is not a term. What deserves the margin is an already-started termination overshooting its pass's deadline by its own bound. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: worker-opencode <[email protected]> Co-authored-by: multica-agent <[email protected]> Co-authored-by: J <[email protected]>
1055 lines
36 KiB
Go
1055 lines
36 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
|
|
|
|
if err := startOwnedProcessTree(cmd, b.cfg.Logger); err != nil {
|
|
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()
|
|
|
|
// 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
|
|
}
|
|
|
|
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)
|
|
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
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
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)
|
|
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,
|
|
}
|
|
}()
|
|
|
|
return &Session{
|
|
Messages: msgCh,
|
|
Result: resCh,
|
|
ToolActivity: background.Activity,
|
|
InterruptBackgroundTools: background.Interrupt,
|
|
TerminalObserved: background.TerminalObserved,
|
|
}, 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|