@magpie-community/opencode-cursor-auth 0.2.33 → 0.2.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -123,8 +123,11 @@ caller's tool results is bounded by the 120 seconds above instead.
123
123
 
124
124
  Cursor's count of a step's tool calls includes calls the plugin answers itself:
125
125
  a tool the request doesn't have is refused to the model, and Cursor's own
126
- `get_mcp_tools` is answered with the request's tools. The step's calls are the
127
- rest.
126
+ `get_mcp_tools` is answered with the request's tools. It also includes calls
127
+ Cursor completes by itself, which it writes and completes in the step but never
128
+ starts on the client or sends to it; those count toward it too. Once the count
129
+ has come, a checkpoint whose state waits on exactly the calls the caller was
130
+ given also ends the step (#550). The step's calls are the rest.
128
131
 
129
132
  These are retention limits. A missing, expired, restarted, busy, incompatible,
130
133
  or full owner uses the original history reconstruction and stateless decoder
@@ -298,6 +301,30 @@ could still pay for them. On-demand and Total are still shown but never
298
301
  keep an account in routing. Use it when on-demand is a shared or paid pool
299
302
  you don't want spent. When off, on-demand works as described above.
300
303
 
304
+ ### Wire trace (optional, for bug reports)
305
+
306
+ In magpie, open the Cursor provider, turn on **Wire trace** under **Plugin
307
+ options**, and save (or set `"pluginOptions": {"cursor": {"wire_trace":
308
+ true}}` in magpie's settings.json). It is off by default and takes effect
309
+ at the next request.
310
+
311
+ When on, each Cursor request that fails (not one you cancel) saves a JSON
312
+ file in the `cursor-traces` folder of magpie's folder: `~/.config/magpie/cursor-traces`
313
+ (on Windows `C:\Users\<you>\.config\magpie\cursor-traces`), or the
314
+ portable data folder. The newest 20 are kept. A file holds the Run's
315
+ frames in order, both ways, as each frame's time (ms from the Run's
316
+ start), size, kind (text, thinking, checkpoint, heartbeat, ...), field
317
+ numbers and sizes, and the field numbers and sizes one level inside them,
318
+ plus the error and the model id. A few of Cursor's numbers are kept too: a
319
+ tool count's value, which kind of call (the field number of Cursor's tool
320
+ call) a call update is about, how many calls a checkpoint waits on, and,
321
+ for a Run that went without the native session, why. No prompt, reply or thinking text, tool
322
+ name or argument, blob or checkpoint content, token, header, account or
323
+ conversation id is written; the error's message has anything shaped like
324
+ an email, id or token taken out. Look the file over before you attach it.
325
+
326
+ Turn it off again when you're done.
327
+
301
328
  Sign in to the Cursor account linked in Grok Bot. A linked SuperGrok
302
329
  subscription grants Bot access without a paid Cursor plan; see
303
330
  [Grok Bot plans and billing](https://cursor.com/help/grok-bot/plans).
package/index.mjs CHANGED
@@ -28,7 +28,7 @@
28
28
  //
29
29
  // OpenCode speaks chat completions to it (@ai-sdk/openai-compatible); the
30
30
  // fetch here answers them.
31
- import { NATIVE_LIMITS, nativeSessions, NativeSessionError, nativeFields, nativeMcpArgs, Quiet } from "./native-session.mjs"
31
+ import { NATIVE_LIMITS, nativeSessions, NativeSessionError, nativeFields, nativeMcpArgs, pendingCalls, Quiet, setWireTrace, StepCalls } from "./native-session.mjs"
32
32
  import { isDeepStrictEqual } from "node:util"
33
33
  import http2 from "node:http2"
34
34
  import { STATUS_CODES } from "node:http"
@@ -642,6 +642,11 @@ function credits(plan, grants, ids, resets, now = Date.now()) {
642
642
  // keeping nothing in routing. Off, on-demand that can still pay keeps it.
643
643
  const INCLUDED_ONLY = "included_only"
644
644
 
645
+ // WIRE_TRACE is the switch (magpie's plugin options, off by default) that
646
+ // saves a failed Run's frames, scrubbed, as a JSON file in cursor-traces in
647
+ // magpie's folder (plugins#550): see WireTrace for what one holds.
648
+ const WIRE_TRACE = "wire_trace"
649
+
645
650
  // enterpriseUsage is the allowance GetCurrentPeriodUsage leaves out on an
646
651
  // Enterprise account. That call answers with only the billing cycle there;
647
652
  // the website's usage-summary has individualUsage.overall, in cents, and
@@ -2742,7 +2747,7 @@ async function runNative({ tok, base, id, tools, conv, convID, signal, maxMode,
2742
2747
 
2743
2748
  // runOnce is one Run of the chat on the agent API at base: an async
2744
2749
  // iterator of the answer's pieces, or the error it failed with before any.
2745
- async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, params }) {
2750
+ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, params, why }) {
2746
2751
  // the Run's conversation_id, a new one when nothing names the session:
2747
2752
  // its usage event is found by it
2748
2753
  const cid = convID || randomUUID()
@@ -2772,8 +2777,13 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
2772
2777
  const { session, req } = o
2773
2778
  let closed = false
2774
2779
  let heartbeat
2780
+ // a Run Cursor stops answering ends, as a retained one does (#2672)
2781
+ const quiet = new Quiet(NATIVE_LIMITS, () => close(), { path: "stateless", model: id, why })
2775
2782
  const send = (msg) => {
2776
- if (!closed && !req.destroyed && req.writable) req.write(frame(msg))
2783
+ if (!closed && !req.destroyed && req.writable) {
2784
+ quiet.trace?.frame("out", msg)
2785
+ req.write(frame(msg))
2786
+ }
2777
2787
  }
2778
2788
  const close = () => {
2779
2789
  if (closed) return
@@ -2804,8 +2814,7 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
2804
2814
  book.done()
2805
2815
  return { error: failure(o.status, Buffer.concat(chunks).toString("utf8").slice(0, 1 << 20)) }
2806
2816
  }
2807
- // a Run Cursor stops answering ends, as a retained one does (#2672)
2808
- const it = decode(new Quiet(NATIVE_LIMITS, close).frames(frames(req)), {
2817
+ const it = decode(quiet.frames(frames(req)), {
2809
2818
  send,
2810
2819
  blobs,
2811
2820
  tools,
@@ -2813,10 +2822,14 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
2813
2822
  close,
2814
2823
  stepUsage: book.own,
2815
2824
  owedUsage: book.owed,
2825
+ note: (name) => quiet.trace?.event(name),
2816
2826
  })
2817
2827
  const wrapped = (async function* () {
2818
2828
  try {
2819
- yield* it
2829
+ for await (const v of it) {
2830
+ if (v?.error) quiet.failed(v.error)
2831
+ yield v
2832
+ }
2820
2833
  } finally {
2821
2834
  close()
2822
2835
  book.done()
@@ -2840,7 +2853,7 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
2840
2853
 
2841
2854
  // decode follows the server's messages into the answer's pieces until the
2842
2855
  // turn ends, or the model has made its tool calls.
2843
- async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage, owedUsage }) {
2856
+ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage, owedUsage, note }) {
2844
2857
  let calls = 0
2845
2858
  // answered: the step's calls answered here (a refusal, the MCP tool
2846
2859
  // list), which Cursor's count of the step's calls takes in too (#142)
@@ -2848,14 +2861,18 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage,
2848
2861
  // ended: Cursor said what the turn used
2849
2862
  let ended = false
2850
2863
  let listed = 0
2864
+ // what Cursor said of the step's calls: those it completed by itself
2865
+ // count toward listed too, and never come as an exec (plugins#550)
2866
+ const step = new StepCalls(fields)
2851
2867
  // stepDone is whether every call the step listed has come. A step whose
2852
2868
  // calls were all answered here goes on on Cursor's side, under a count of
2853
2869
  // its own.
2854
2870
  const stepDone = () => {
2855
- if (!listed || calls + answered < listed) return false
2871
+ if (!listed || calls + answered + step.completed < listed) return false
2856
2872
  if (calls > 0) return true
2857
2873
  listed = 0
2858
2874
  answered = 0
2875
+ step.nextStep()
2859
2876
  return false
2860
2877
  }
2861
2878
  let said = 0
@@ -2972,6 +2989,16 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage,
2972
2989
  return
2973
2990
  }
2974
2991
  break
2992
+ case 2: // a call started on the client
2993
+ case 3: // a call completed: by Cursor itself when it never started here
2994
+ case 7: // a call the model is writing
2995
+ // still named in an empty reply's list: only their ids are read
2996
+ count(seen.updates, u.num)
2997
+ if (step.update(u.num, uf) && (note?.("completed by Cursor"), stepDone())) {
2998
+ yield await settled()
2999
+ return
3000
+ }
3001
+ break
2975
3002
  default:
2976
3003
  count(seen.updates, u.num)
2977
3004
  }
@@ -2979,6 +3006,7 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage,
2979
3006
  break
2980
3007
  case 2: {
2981
3008
  // exec_server_message: the server asks the client to run something
3009
+ step.exec(m.data)
2982
3010
  const call = exec(m.data, tools, send, closeExec, seen.refused)
2983
3011
  if (call) {
2984
3012
  said += call.args.length
@@ -2991,6 +3019,18 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage,
2991
3019
  }
2992
3020
  break
2993
3021
  }
3022
+ case 3:
3023
+ // conversation_checkpoint_update: Cursor's state. Once the step
3024
+ // has its count, a state waiting on just the calls handed to the
3025
+ // caller is a step Cursor has finished: what more the count
3026
+ // says will not come (plugins#550)
3027
+ if (listed && calls > 0 && pendingCalls(m.data, fields) === calls) {
3028
+ note?.("waited on")
3029
+ yield await settled()
3030
+ return
3031
+ }
3032
+ count(seen.messages, m.num)
3033
+ break
2994
3034
  case 4: {
2995
3035
  // kv_server_message: a blob wanted, or one to keep
2996
3036
  const kv = fields(m.data)
@@ -3021,7 +3061,10 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage,
3021
3061
  } catch (e) {
3022
3062
  // gone quiet is no answer, calls or not: the step's count never came
3023
3063
  if (e?.quiet) {
3024
- yield { error: { status: e.status, message: e.message } }
3064
+ // with what came of the step's calls, as the native path's batch
3065
+ // error says it (plugins#550)
3066
+ const batch = calls || listed ? `; the step's tool count ${listed || "not sent"}: ${calls} calls came, ${answered} answered here, ${step.completed} completed by Cursor` : ""
3067
+ yield { error: { status: e.status, message: batch ? e.message.replace(/( \(try again\))?$/, batch + "$1") : e.message } }
3025
3068
  return
3026
3069
  }
3027
3070
  if (calls > 0 && /EOF/.test(e?.message ?? "")) {
@@ -3195,10 +3238,11 @@ async function answerOf(auth, chat, signal, session = "") {
3195
3238
  // uses the original history builder/decoder without cancelling a live parent.
3196
3239
  const native = prepared.fallback ? null : nativeSessions.create(prepared, { id, maxMode, params }, { pb, exec, usageOf, failure })
3197
3240
  const args = { tok, base, id, tools, conv, convID, signal, maxMode, params }
3198
- let response = native ? await runNative({ ...args, native }) : await runOnce(args)
3241
+ let response = native ? await runNative({ ...args, native }) : await runOnce({ ...args, why: prepared.fallback || "retention capacity or reservation unavailable" })
3199
3242
  const rebuild = async () => {
3200
- const fallback = await runOnce(args)
3201
- fallback.diagnostic = "stateless; bootstrap=imported-history; reason=" + (response.error?.continuityReason || "missing-batch-count")
3243
+ const why = response.error?.continuityReason || "missing-batch-count"
3244
+ const fallback = await runOnce({ ...args, why })
3245
+ fallback.diagnostic = "stateless; bootstrap=imported-history; reason=" + why
3202
3246
  return fallback
3203
3247
  }
3204
3248
  // A missing or conflicting native batch count may be retried once,
@@ -3328,6 +3372,16 @@ export async function CursorAuthPlugin(input) {
3328
3372
  return false
3329
3373
  }
3330
3374
  }
3375
+ // the folder a failed Run's trace goes to, read at each Run's start;
3376
+ // "" with Wire trace off, and outside magpie, which has no such option
3377
+ // (OpenCode's directory is the project's, never written to)
3378
+ setWireTrace(() => {
3379
+ try {
3380
+ return input?.magpie?.option?.(ID, WIRE_TRACE) === true && input.directory ? join(input.directory, "cursor-traces") : ""
3381
+ } catch {
3382
+ return ""
3383
+ }
3384
+ })
3331
3385
  return {
3332
3386
  auth: {
3333
3387
  provider: ID,
@@ -3369,6 +3423,12 @@ export async function CursorAuthPlugin(input) {
3369
3423
  description:
3370
3424
  "Keeps an account in routing only while its included usage lasts: once an included pool (Cursor Models, Other Models, a newer plan's Weekly) is used up, magpie moves its models to the next account until it resets, even if on-demand or free credit could pay.",
3371
3425
  },
3426
+ {
3427
+ key: WIRE_TRACE,
3428
+ label: "Wire trace",
3429
+ description:
3430
+ "For a bug report: when a Cursor request fails, saves the shape of what Cursor sent and what was sent back (each frame's kind, field numbers, size and time) to the cursor-traces folder in magpie's folder. No prompt or reply text, tool names or arguments, tokens, headers or account ids are written. The newest 20 are kept.",
3431
+ },
3372
3432
  ],
3373
3433
  // magpie's: how much of the plan's included usage is gone (the plan
3374
3434
  // is the one the sign-in read). The built-in's read, clean or not,
@@ -1,4 +1,6 @@
1
1
  import { createHash, randomBytes } from "node:crypto"
2
+ import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"
3
+ import { join } from "node:path"
2
4
 
3
5
  // Retention is process-local and optional. A lost owner rebuilds Chat history
4
6
  // on a fresh Run; it never resubmits a result to an uncertain native exec.
@@ -53,7 +55,13 @@ const failureOf = (e) => ({ status: e?.status ?? 502, message: e?.message ?? Str
53
55
 
54
56
  // kindOf is what a frame of AgentServerMessage is, for Quiet: a heartbeat
55
57
  // alone, or what an error names as the last thing Cursor sent
56
- const UPDATES = { 1: "text", 4: "thinking", 14: "turn end", 27: "tool count" }
58
+ // (names after cursor-agent's agent.v1 InteractionUpdate, 2026.10.01)
59
+ const UPDATES = {
60
+ 1: "text", 2: "tool call started", 3: "tool call completed", 4: "thinking", 5: "thinking done",
61
+ 6: "user message", 7: "partial tool call", 8: "token count", 9: "summary", 10: "summary started",
62
+ 11: "summary done", 12: "shell output", 14: "turn end", 15: "tool call delta", 16: "step started",
63
+ 17: "step done", 27: "tool count",
64
+ }
57
65
  const MESSAGES = { 2: "tool call", 3: "checkpoint", 4: "blob request" }
58
66
  function kindOf(f) {
59
67
  if (f.end) return "stream end"
@@ -75,8 +83,10 @@ function kindOf(f) {
75
83
  // reply then waited until the agent gave up (305 s, yetone/magpie#2672).
76
84
  // Only time spent waiting on Cursor's next frame counts: not this side
77
85
  // handling one, not a slow reader of the reply, not a paused Run.
86
+ // meta names the Run for a wire trace (path, model); with the trace off it
87
+ // is unused.
78
88
  export class Quiet {
79
- constructor(limits, stop) {
89
+ constructor(limits, stop, meta = {}) {
80
90
  this.limits = limits
81
91
  this.stop = stop
82
92
  this.heard = 0 // ms waited since Cursor's last frame that wasn't a heartbeat
@@ -86,6 +96,14 @@ export class Quiet {
86
96
  this.paused = false
87
97
  this.waiting = false
88
98
  this.why = ""
99
+ // what the error says of a checkpoint (plugins#550): its number in the
100
+ // Run, which of its step's it was (step is 0 where the path doesn't
101
+ // count steps), and the kinds of the frames before it, run-length
102
+ this.checkpoints = 0
103
+ this.step = 0
104
+ this.stepCheckpoints = 0
105
+ this.trail = []
106
+ this.trace = WireTrace.open(meta)
89
107
  }
90
108
  arm() {
91
109
  clearTimeout(this.timer)
@@ -102,20 +120,47 @@ export class Quiet {
102
120
  fire() {
103
121
  const gap = Date.now() - this.since
104
122
  const what = this.heard + gap >= this.limits.quiet ? `${this.heartbeats} heartbeats and nothing else for ${Math.round((this.heard + gap) / 1000)} s` : `no message for ${Math.round(gap / 1000)} s`
105
- this.why = `Cursor went quiet: ${what}${this.last ? ", its last message " + this.last : ""} (try again)`
123
+ this.why = `Cursor went quiet: ${what}${this.last ? ", its last message " + this.lastSaid() : ""} (try again)`
124
+ this.trace?.event("quiet", { heard: this.heard + gap, heartbeats: this.heartbeats })
106
125
  this.gone?.()
107
126
  this.stop({ status: 502, message: this.why })
127
+ this.failed({ status: 502, message: this.why })
128
+ }
129
+ // lastSaid is the last frame's kind as the error names it; a checkpoint
130
+ // with its number, its place in the step and what came before it
131
+ lastSaid() {
132
+ if (this.last !== "checkpoint") return this.last
133
+ const row = this.trail.at(-1)?.[1] ?? 1
134
+ const before = this.trail.slice(0, -1).map(([k, n]) => n > 1 ? `${k} ×${n}` : k).join(", ")
135
+ const nth = this.stepCheckpoints === 1 ? "the first" : `number ${this.stepCheckpoints}`
136
+ return `checkpoint #${this.checkpoints} of the Run`
137
+ + (this.step ? `, ${nth} of step ${this.step}` : "")
138
+ + (row > 1 ? `, ${row} in a row` : "")
139
+ + `, after ${before || "nothing else"}`
140
+ }
141
+ // nextStep is the Run's next model call (NativeSession counts them)
142
+ nextStep(n) {
143
+ this.step = n
144
+ this.stepCheckpoints = 0
145
+ this.trace?.event("step", { n })
146
+ }
147
+ // failed is how the Run ended, when it failed: a wire trace, if on, is
148
+ // written then (once)
149
+ failed(error) {
150
+ this.trace?.write(error, this)
108
151
  }
109
152
  // pause stops the clock while the Run waits on the caller's tool results;
110
153
  // resume starts it again from nothing, for Cursor's next model call
111
154
  pause() {
112
155
  this.waited()
113
156
  this.paused = true
157
+ this.trace?.event("pause")
114
158
  }
115
159
  resume() {
116
160
  this.paused = false
117
161
  this.heard = 0
118
162
  this.heartbeats = 0
163
+ this.trace?.event("resume")
119
164
  this.arm()
120
165
  }
121
166
  // frames passes on a Run's frames; once Cursor has gone quiet, the read
@@ -141,10 +186,25 @@ export class Quiet {
141
186
  this.waited()
142
187
  }
143
188
  if (this.why) throw Object.assign(new NativeSessionError(502, this.why), { quiet: true })
144
- if (r.done) return
189
+ if (r.done) {
190
+ this.trace?.event("eof")
191
+ return
192
+ }
145
193
  const kind = kindOf(r.value)
194
+ this.trace?.frame("in", r.value, kind)
146
195
  if (kind == null) this.heartbeats++
147
- else [this.heard, this.heartbeats, this.last] = [0, 0, kind]
196
+ else {
197
+ [this.heard, this.heartbeats, this.last] = [0, 0, kind]
198
+ if (kind === "checkpoint") {
199
+ this.checkpoints++
200
+ this.stepCheckpoints++
201
+ }
202
+ if (this.trail.at(-1)?.[0] === kind) this.trail.at(-1)[1]++
203
+ else {
204
+ this.trail.push([kind, 1])
205
+ if (this.trail.length > 4) this.trail.shift()
206
+ }
207
+ }
148
208
  yield r.value
149
209
  }
150
210
  } finally {
@@ -158,6 +218,141 @@ export class Quiet {
158
218
  }
159
219
  }
160
220
 
221
+ // The wire trace (plugins#550, off by default; the plugin's Wire trace
222
+ // option): a failed Run's frames, in and out, written to a JSON file for a
223
+ // bug report. A frame is kept as its kind, size, top-level field numbers
224
+ // with their wire types and sizes, and those fields' own field numbers and
225
+ // sizes one level down: the shape of the protocol, never its bytes. No
226
+ // text, thinking, tool name or argument, blob or blob id, checkpoint
227
+ // content, token, header, account or conversation id is read into it; the
228
+ // error's message is scrubbed of anything shaped like an email, id or
229
+ // token. traceDir is the folder, or "" while off, asked at each Run's start.
230
+ let traceDir = () => ""
231
+ export function setWireTrace(dir) {
232
+ traceDir = typeof dir === "function" ? dir : () => dir || ""
233
+ }
234
+ const TRACE_HEAD = 2000 // the Run's first frames, kept
235
+ const TRACE_TAIL = 3000 // and its last, the ones in between counted only
236
+ const TRACE_FILES = 20 // the newest traces kept in the folder
237
+ // shape is a message's fields as [number, wire, size] (a varint's size 0),
238
+ // with children: the fields' own fields, for a field that is a message
239
+ function shape(data, children) {
240
+ return nativeFields(data).map((f) => {
241
+ const own = [f.num, f.wire, f.wire === 0 ? 0 : f.data.length]
242
+ if (!children || f.wire !== 2) return own
243
+ try {
244
+ return [...own, nativeFields(f.data).map((c) => [c.num, c.wire, c.wire === 0 ? 0 : c.data.length])]
245
+ } catch {
246
+ return own
247
+ }
248
+ })
249
+ }
250
+ export function scrub(s) {
251
+ return String(s ?? "")
252
+ .replace(/[\w.+-]+@[\w-]+(\.[\w-]+)+/g, "<email>")
253
+ .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<id>")
254
+ .replace(/(auth0|user|google-oauth2|github)\|[\w-]+/gi, "<account>")
255
+ .replace(/[A-Za-z0-9_\-+/=.]{24,}/g, "<redacted>")
256
+ .slice(0, 2000)
257
+ }
258
+ // detail is what a trace keeps of an inbound frame past its shape, all of
259
+ // it numbers of Cursor's protocol, never content (plugins#550): a tool
260
+ // count's number, the kind of call (ToolCall's field) a call update is
261
+ // of, and how many calls a checkpoint's state waits on
262
+ function detail(data) {
263
+ const out = {}
264
+ for (const m of nativeFields(data)) {
265
+ if (m.num === 1) {
266
+ for (const u of nativeFields(m.data)) {
267
+ if (u.num === 27) out.count = num(nativeFields(u.data), 1)
268
+ else if (u.num === 2 || u.num === 3 || u.num === 7) {
269
+ const tc = field(nativeFields(u.data), 2)
270
+ const kind = tc && nativeFields(tc.data)[0]
271
+ if (kind) out.tool = kind.wire === 2 ? [kind.num, nativeFields(kind.data)[0]?.num ?? 0] : [kind.num]
272
+ }
273
+ }
274
+ } else if (m.num === 3) out.pending = pendingCalls(m.data)
275
+ }
276
+ return out
277
+ }
278
+ export class WireTrace {
279
+ static open(meta) {
280
+ let dir = ""
281
+ try { dir = traceDir() } catch {}
282
+ return dir ? new WireTrace(dir, meta) : null
283
+ }
284
+ constructor(dir, meta = {}) {
285
+ this.dir = dir
286
+ this.meta = { path: String(meta.path ?? ""), model: String(meta.model ?? "") }
287
+ // why a Run went stateless: the retention fallback's reason
288
+ if (meta.why) this.meta.why = String(meta.why)
289
+ this.started = Date.now()
290
+ this.head = []
291
+ this.tail = []
292
+ this.dropped = 0
293
+ }
294
+ push(entry) {
295
+ if (this.written) return
296
+ if (this.head.length < TRACE_HEAD) return void this.head.push(entry)
297
+ this.tail.push(entry)
298
+ if (this.tail.length > TRACE_TAIL) {
299
+ this.tail.shift()
300
+ this.dropped++
301
+ }
302
+ }
303
+ // frame is one frame of the Run: "in" from Cursor ({data} or {end, data}),
304
+ // "out" a message this side sent
305
+ frame(dir, f, kind) {
306
+ const t = Date.now() - this.started
307
+ const data = dir === "in" ? f.data : f
308
+ const entry = { t, dir, size: data?.length ?? 0 }
309
+ if (dir === "in") entry.kind = kind ?? "heartbeat"
310
+ if (dir === "in" && f.end) entry.end = true
311
+ else try { entry.fields = shape(data, true) } catch { entry.malformed = true }
312
+ if (dir === "in" && !f.end) try { Object.assign(entry, detail(data)) } catch {}
313
+ this.push(entry)
314
+ }
315
+ event(name, extra = {}) {
316
+ this.push({ t: Date.now() - this.started, event: name, ...extra })
317
+ }
318
+ // write saves the trace once, when the Run failed; a write that fails
319
+ // leaves the Run's error as it was
320
+ write(error, quiet) {
321
+ if (this.written) return
322
+ this.written = true
323
+ try {
324
+ const at = new Date(this.started).toISOString().replace(/[:.]/g, "-")
325
+ const body = {
326
+ trace: 1,
327
+ plugin: "@magpie-community/opencode-cursor-auth",
328
+ version: version(),
329
+ ...this.meta,
330
+ started: new Date(this.started).toISOString(),
331
+ ms: Date.now() - this.started,
332
+ error: { status: error?.status ?? 0, message: scrub(error?.message) },
333
+ quiet: quiet ? { last: quiet.last, checkpoints: quiet.checkpoints, step: quiet.step, stepCheckpoints: quiet.stepCheckpoints, heartbeats: quiet.heartbeats, heard: quiet.heard, trail: quiet.trail } : undefined,
334
+ frames: [...this.head, ...(this.dropped ? [{ dropped: this.dropped }] : []), ...this.tail],
335
+ }
336
+ mkdirSync(this.dir, { recursive: true })
337
+ const file = join(this.dir, `${at}-${randomBytes(3).toString("hex")}.json`)
338
+ writeFileSync(file + ".tmp", JSON.stringify(body) + "\n", { mode: 0o600 })
339
+ renameSync(file + ".tmp", file)
340
+ this.file = file
341
+ const old = readdirSync(this.dir).filter((n) => n.endsWith(".json")).sort()
342
+ for (const n of old.slice(0, Math.max(0, old.length - TRACE_FILES))) rmSync(join(this.dir, n), { force: true })
343
+ } catch {}
344
+ this.head = this.tail = []
345
+ }
346
+ }
347
+ // version is the package's, for a trace to say
348
+ function version() {
349
+ try {
350
+ return JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version ?? ""
351
+ } catch {
352
+ return ""
353
+ }
354
+ }
355
+
161
356
  // Unlike the compatibility codec, malformed protobuf tails must fail a live
162
357
  // continuation. Dropping one could turn a truncated batch into a valid handoff.
163
358
  export function nativeFields(input, maxFields = Infinity) {
@@ -272,6 +467,67 @@ const field = (fs, n) => fs.find((f) => f.num === n)
272
467
  const str = (fs, n) => field(fs, n)?.data.toString("utf8") ?? ""
273
468
  const num = (fs, n) => field(fs, n)?.n ?? 0
274
469
 
470
+ // StepCalls is what Cursor says of a step's calls besides its execs
471
+ // (plugins#550). InteractionUpdate 2 (tool_call_started), 3
472
+ // (tool_call_completed) and 7 (partial_tool_call) each carry the call's
473
+ // call_id (field 1). A call Cursor completed by itself, never started on
474
+ // the client and never sent as an exec, is in tool_requests_listed's count
475
+ // all the same, and no exec of it will come: 0.2.34 waited for it while
476
+ // Cursor waited for the one call it had sent, 240 s, heartbeats only.
477
+ // parse is the path's field reader.
478
+ export class StepCalls {
479
+ constructor(parse) {
480
+ this.parse = parse
481
+ this.started = new Set()
482
+ this.execs = new Set() // the short strings of each exec's args: its tool_call_id among them
483
+ this.partial = new Set() // calls the model is writing in this step
484
+ this.byCursor = new Set()
485
+ }
486
+ // update takes an InteractionUpdate's own field (num) and its fields; it
487
+ // says whether a call was completed by Cursor: one this step wrote (7)
488
+ // and completed (3) with no start on the client (2) and no exec. A
489
+ // completion of an earlier step's call is never counted again.
490
+ update(n, uf) {
491
+ if (n !== 2 && n !== 3 && n !== 7) return false
492
+ const id = str(uf, 1)
493
+ if (!id) return false
494
+ if (n === 2) this.started.add(id)
495
+ if (n === 7) this.partial.add(id)
496
+ if (n !== 3) return false
497
+ if (!this.partial.has(id) || this.started.has(id) || this.execs.has(id) || this.byCursor.has(id)) return false
498
+ this.byCursor.add(id)
499
+ return true
500
+ }
501
+ // exec notes an exec's args, whatever its kind: each kind keeps its
502
+ // tool_call_id at a field of its own (ShellArgs 4, ReadArgs 2, McpArgs 3)
503
+ exec(data) {
504
+ try {
505
+ for (const e of this.parse(data)) {
506
+ if (e.wire !== 2 || e.num === 15 || e.num === 19) continue
507
+ for (const a of this.parse(e.data)) if (a.wire === 2 && a.data.length && a.data.length <= 512) this.execs.add(a.data.toString("utf8"))
508
+ }
509
+ } catch {}
510
+ }
511
+ get completed() {
512
+ return this.byCursor.size
513
+ }
514
+ // nextStep starts the next step's count
515
+ nextStep() {
516
+ this.partial.clear()
517
+ this.byCursor.clear()
518
+ }
519
+ }
520
+ // pendingCalls is how many calls a checkpoint (ConversationStateStructure)
521
+ // says Cursor waits on: its pending_tool_calls, field 4; null when it
522
+ // can't be read
523
+ export function pendingCalls(data, parse = nativeFields) {
524
+ try {
525
+ return parse(data).filter((f) => f.num === 4).length
526
+ } catch {
527
+ return null
528
+ }
529
+ }
530
+
275
531
  function argsOf(s) {
276
532
  try {
277
533
  const v = typeof s === "string" ? JSON.parse(s) : s
@@ -571,6 +827,8 @@ class NativeSession {
571
827
  this.ac = new AbortController()
572
828
  this.execs = new Map()
573
829
  this.nativeCalls = new Map()
830
+ // what Cursor said of the step's calls besides its execs (plugins#550)
831
+ this.stepCalls = new StepCalls(nativeFields)
574
832
  this.blobs = new Map()
575
833
  this.knownHashes = new Set()
576
834
  this.blobSize = 0
@@ -587,7 +845,8 @@ class NativeSession {
587
845
  this.segment = new Segment(this, prepared.chat)
588
846
  this.retain(bytes(prepared.chat))
589
847
  this.expireIn(this.limits.lifetime)
590
- this.quiet = new Quiet(this.limits, (e) => this.fail(e))
848
+ this.quiet = new Quiet(this.limits, (e) => this.fail(e), { path: "native", model: resolution?.id })
849
+ this.quiet.nextStep(this.steps)
591
850
  }
592
851
  // expireIn sets the Run's absolute deadline ms from now: from its start,
593
852
  // the whole lifetime; a test brings it in after the first answer
@@ -632,6 +891,8 @@ class NativeSession {
632
891
  if (this.error || this.state === "complete") return
633
892
  this.error = error
634
893
  this.state = state
894
+ // a cancelled Run is the caller's doing, not a failure to trace
895
+ if (state !== "cancelled") this.quiet.failed(error)
635
896
  this.segment.fail(error)
636
897
  for (const segment of this.batches ?? []) segment.detach?.()
637
898
  this.segment.detach?.()
@@ -671,6 +932,7 @@ class NativeSession {
671
932
  }
672
933
  send(message) {
673
934
  if (this.error || this.transportClosed) invalid("native transport is no longer writable", 410)
935
+ this.quiet.trace?.frame("out", message)
674
936
  this.transport.send(message)
675
937
  }
676
938
  // reply answers one of Cursor's KV requests once the outbound queue has
@@ -764,6 +1026,8 @@ class NativeSession {
764
1026
  // caller's results, or after a step whose calls were all answered here.
765
1027
  nextStep(estimate) {
766
1028
  this.steps++
1029
+ this.stepCalls.nextStep()
1030
+ this.quiet.nextStep(this.steps)
767
1031
  this.stepSaid = this.said
768
1032
  if (estimate != null) this.stepEstimate = estimate
769
1033
  }
@@ -780,7 +1044,7 @@ class NativeSession {
780
1044
  this.batchTimer = later(() => {
781
1045
  const s = this.segment
782
1046
  if (s.expected == null && s.calls.length && !this.resultSubmitted) this.missingCount()
783
- else this.fail({ status: 502, message: `Cursor did not complete its declared native tool batch: ${s.calls.length} of ${s.expected ?? "?"} calls came, ${s.answered} answered here, none since ${Date.now() - since} ms` })
1047
+ else this.fail({ status: 502, message: `Cursor did not complete its declared native tool batch: ${s.calls.length} of ${s.expected ?? "?"} calls came, ${s.answered} answered here${this.stepCalls.completed ? `, ${this.stepCalls.completed} completed by Cursor` : ""}, none since ${Date.now() - since} ms` })
784
1048
  }, this.limits.batchWait)
785
1049
  }
786
1050
  missingCount() {
@@ -796,6 +1060,23 @@ class NativeSession {
796
1060
  if (this.batchTimer) this.waitForBatch()
797
1061
  this.sealBatch()
798
1062
  }
1063
+ // A call of the step Cursor completed by itself (plugins#550): in its
1064
+ // count, and never sent as an exec.
1065
+ completedByCursor() {
1066
+ if (this.terminal || this.state === "awaiting-results") return
1067
+ if (this.batchTimer) this.waitForBatch()
1068
+ this.sealBatch()
1069
+ }
1070
+ // A checkpoint whose state waits on just the calls handed to the caller,
1071
+ // after the step's count, is a step Cursor has finished: what more the
1072
+ // count says will not come (plugins#550).
1073
+ waitedOn(data) {
1074
+ const s = this.segment
1075
+ if (this.terminal || this.state === "awaiting-results" || s.closed || s.expected == null || !s.calls.length) return
1076
+ if (pendingCalls(data) !== s.calls.length) return
1077
+ s.waitedOn = true
1078
+ this.sealBatch()
1079
+ }
799
1080
  listed(count) {
800
1081
  if (!Number.isSafeInteger(count) || count < 1) invalid("invalid native tool count", 502)
801
1082
  if (this.segment.expected != null && this.segment.expected !== count) {
@@ -815,7 +1096,8 @@ class NativeSession {
815
1096
  if (segment.expected == null) return
816
1097
  const made = segment.calls.length + segment.answered
817
1098
  if (made > segment.expected) invalid("native tool batch exceeded its declared count", 502)
818
- if (made !== segment.expected || segment.closed) return
1099
+ // the calls Cursor completed by itself make up the rest (plugins#550)
1100
+ if ((made + this.stepCalls.completed < segment.expected && !segment.waitedOn) || segment.closed) return
819
1101
  if (!segment.calls.length) {
820
1102
  // Every call of the step was answered here: Cursor goes on with the
821
1103
  // turn by itself, and its next step lists a count of its own.
@@ -1012,10 +1294,14 @@ class NativeSession {
1012
1294
  if (this.error) return
1013
1295
  } else if (u.num === 13) {
1014
1296
  if (this.batchTimer && !this.terminal && this.state !== "awaiting-results") this.waitForBatch()
1297
+ } else if ((u.num === 2 || u.num === 3 || u.num === 7) && this.stepCalls.update(u.num, uf)) {
1298
+ this.completedByCursor()
1299
+ if (this.error) return
1015
1300
  }
1016
1301
  }
1017
1302
  break
1018
1303
  case 2: {
1304
+ this.stepCalls.exec(m.data)
1019
1305
  const c = exec(m.data, this.chatTools, (b) => this.send(b), (id) => this.execClose(id), null, true)
1020
1306
  if (c) this.acceptCall(c)
1021
1307
  // request_context is Cursor asking for the environment, not a call
@@ -1024,6 +1310,7 @@ class NativeSession {
1024
1310
  }
1025
1311
  case 3:
1026
1312
  this.checkpointReceived(m.data)
1313
+ this.waitedOn(m.data)
1027
1314
  break
1028
1315
  case 4: {
1029
1316
  const kv = nativeFields(m.data)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-cursor-auth",
3
- "version": "0.2.33",
3
+ "version": "0.2.35",
4
4
  "description": "Cursor subscriptions (Pro, Pro+, Ultra, Teams) in OpenCode and magpie, through the API cursor-agent talks to",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",