@magpie-community/opencode-cursor-auth 0.2.34 → 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
@@ -312,7 +315,10 @@ portable data folder. The newest 20 are kept. A file holds the Run's
312
315
  frames in order, both ways, as each frame's time (ms from the Run's
313
316
  start), size, kind (text, thinking, checkpoint, heartbeat, ...), field
314
317
  numbers and sizes, and the field numbers and sizes one level inside them,
315
- plus the error and the model id. No prompt, reply or thinking text, tool
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
316
322
  name or argument, blob or checkpoint content, token, header, account or
317
323
  conversation id is written; the error's message has anything shaped like
318
324
  an email, id or token taken out. Look the file over before you attach it.
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, setWireTrace } 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"
@@ -2747,7 +2747,7 @@ async function runNative({ tok, base, id, tools, conv, convID, signal, maxMode,
2747
2747
 
2748
2748
  // runOnce is one Run of the chat on the agent API at base: an async
2749
2749
  // iterator of the answer's pieces, or the error it failed with before any.
2750
- 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 }) {
2751
2751
  // the Run's conversation_id, a new one when nothing names the session:
2752
2752
  // its usage event is found by it
2753
2753
  const cid = convID || randomUUID()
@@ -2778,7 +2778,7 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
2778
2778
  let closed = false
2779
2779
  let heartbeat
2780
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 })
2781
+ const quiet = new Quiet(NATIVE_LIMITS, () => close(), { path: "stateless", model: id, why })
2782
2782
  const send = (msg) => {
2783
2783
  if (!closed && !req.destroyed && req.writable) {
2784
2784
  quiet.trace?.frame("out", msg)
@@ -2822,6 +2822,7 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
2822
2822
  close,
2823
2823
  stepUsage: book.own,
2824
2824
  owedUsage: book.owed,
2825
+ note: (name) => quiet.trace?.event(name),
2825
2826
  })
2826
2827
  const wrapped = (async function* () {
2827
2828
  try {
@@ -2852,7 +2853,7 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
2852
2853
 
2853
2854
  // decode follows the server's messages into the answer's pieces until the
2854
2855
  // turn ends, or the model has made its tool calls.
2855
- async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage, owedUsage }) {
2856
+ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage, owedUsage, note }) {
2856
2857
  let calls = 0
2857
2858
  // answered: the step's calls answered here (a refusal, the MCP tool
2858
2859
  // list), which Cursor's count of the step's calls takes in too (#142)
@@ -2860,14 +2861,18 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage,
2860
2861
  // ended: Cursor said what the turn used
2861
2862
  let ended = false
2862
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)
2863
2867
  // stepDone is whether every call the step listed has come. A step whose
2864
2868
  // calls were all answered here goes on on Cursor's side, under a count of
2865
2869
  // its own.
2866
2870
  const stepDone = () => {
2867
- if (!listed || calls + answered < listed) return false
2871
+ if (!listed || calls + answered + step.completed < listed) return false
2868
2872
  if (calls > 0) return true
2869
2873
  listed = 0
2870
2874
  answered = 0
2875
+ step.nextStep()
2871
2876
  return false
2872
2877
  }
2873
2878
  let said = 0
@@ -2984,6 +2989,16 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage,
2984
2989
  return
2985
2990
  }
2986
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
2987
3002
  default:
2988
3003
  count(seen.updates, u.num)
2989
3004
  }
@@ -2991,6 +3006,7 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage,
2991
3006
  break
2992
3007
  case 2: {
2993
3008
  // exec_server_message: the server asks the client to run something
3009
+ step.exec(m.data)
2994
3010
  const call = exec(m.data, tools, send, closeExec, seen.refused)
2995
3011
  if (call) {
2996
3012
  said += call.args.length
@@ -3003,6 +3019,18 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage,
3003
3019
  }
3004
3020
  break
3005
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
3006
3034
  case 4: {
3007
3035
  // kv_server_message: a blob wanted, or one to keep
3008
3036
  const kv = fields(m.data)
@@ -3033,7 +3061,10 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage,
3033
3061
  } catch (e) {
3034
3062
  // gone quiet is no answer, calls or not: the step's count never came
3035
3063
  if (e?.quiet) {
3036
- 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 } }
3037
3068
  return
3038
3069
  }
3039
3070
  if (calls > 0 && /EOF/.test(e?.message ?? "")) {
@@ -3207,10 +3238,11 @@ async function answerOf(auth, chat, signal, session = "") {
3207
3238
  // uses the original history builder/decoder without cancelling a live parent.
3208
3239
  const native = prepared.fallback ? null : nativeSessions.create(prepared, { id, maxMode, params }, { pb, exec, usageOf, failure })
3209
3240
  const args = { tok, base, id, tools, conv, convID, signal, maxMode, params }
3210
- 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" })
3211
3242
  const rebuild = async () => {
3212
- const fallback = await runOnce(args)
3213
- 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
3214
3246
  return fallback
3215
3247
  }
3216
3248
  // A missing or conflicting native batch count may be retried once,
@@ -55,7 +55,13 @@ const failureOf = (e) => ({ status: e?.status ?? 502, message: e?.message ?? Str
55
55
 
56
56
  // kindOf is what a frame of AgentServerMessage is, for Quiet: a heartbeat
57
57
  // alone, or what an error names as the last thing Cursor sent
58
- 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
+ }
59
65
  const MESSAGES = { 2: "tool call", 3: "checkpoint", 4: "blob request" }
60
66
  function kindOf(f) {
61
67
  if (f.end) return "stream end"
@@ -249,6 +255,26 @@ export function scrub(s) {
249
255
  .replace(/[A-Za-z0-9_\-+/=.]{24,}/g, "<redacted>")
250
256
  .slice(0, 2000)
251
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
+ }
252
278
  export class WireTrace {
253
279
  static open(meta) {
254
280
  let dir = ""
@@ -258,6 +284,8 @@ export class WireTrace {
258
284
  constructor(dir, meta = {}) {
259
285
  this.dir = dir
260
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)
261
289
  this.started = Date.now()
262
290
  this.head = []
263
291
  this.tail = []
@@ -281,6 +309,7 @@ export class WireTrace {
281
309
  if (dir === "in") entry.kind = kind ?? "heartbeat"
282
310
  if (dir === "in" && f.end) entry.end = true
283
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 {}
284
313
  this.push(entry)
285
314
  }
286
315
  event(name, extra = {}) {
@@ -438,6 +467,67 @@ const field = (fs, n) => fs.find((f) => f.num === n)
438
467
  const str = (fs, n) => field(fs, n)?.data.toString("utf8") ?? ""
439
468
  const num = (fs, n) => field(fs, n)?.n ?? 0
440
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
+
441
531
  function argsOf(s) {
442
532
  try {
443
533
  const v = typeof s === "string" ? JSON.parse(s) : s
@@ -737,6 +827,8 @@ class NativeSession {
737
827
  this.ac = new AbortController()
738
828
  this.execs = new Map()
739
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)
740
832
  this.blobs = new Map()
741
833
  this.knownHashes = new Set()
742
834
  this.blobSize = 0
@@ -934,6 +1026,7 @@ class NativeSession {
934
1026
  // caller's results, or after a step whose calls were all answered here.
935
1027
  nextStep(estimate) {
936
1028
  this.steps++
1029
+ this.stepCalls.nextStep()
937
1030
  this.quiet.nextStep(this.steps)
938
1031
  this.stepSaid = this.said
939
1032
  if (estimate != null) this.stepEstimate = estimate
@@ -951,7 +1044,7 @@ class NativeSession {
951
1044
  this.batchTimer = later(() => {
952
1045
  const s = this.segment
953
1046
  if (s.expected == null && s.calls.length && !this.resultSubmitted) this.missingCount()
954
- 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` })
955
1048
  }, this.limits.batchWait)
956
1049
  }
957
1050
  missingCount() {
@@ -967,6 +1060,23 @@ class NativeSession {
967
1060
  if (this.batchTimer) this.waitForBatch()
968
1061
  this.sealBatch()
969
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
+ }
970
1080
  listed(count) {
971
1081
  if (!Number.isSafeInteger(count) || count < 1) invalid("invalid native tool count", 502)
972
1082
  if (this.segment.expected != null && this.segment.expected !== count) {
@@ -986,7 +1096,8 @@ class NativeSession {
986
1096
  if (segment.expected == null) return
987
1097
  const made = segment.calls.length + segment.answered
988
1098
  if (made > segment.expected) invalid("native tool batch exceeded its declared count", 502)
989
- 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
990
1101
  if (!segment.calls.length) {
991
1102
  // Every call of the step was answered here: Cursor goes on with the
992
1103
  // turn by itself, and its next step lists a count of its own.
@@ -1183,10 +1294,14 @@ class NativeSession {
1183
1294
  if (this.error) return
1184
1295
  } else if (u.num === 13) {
1185
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
1186
1300
  }
1187
1301
  }
1188
1302
  break
1189
1303
  case 2: {
1304
+ this.stepCalls.exec(m.data)
1190
1305
  const c = exec(m.data, this.chatTools, (b) => this.send(b), (id) => this.execClose(id), null, true)
1191
1306
  if (c) this.acceptCall(c)
1192
1307
  // request_context is Cursor asking for the environment, not a call
@@ -1195,6 +1310,7 @@ class NativeSession {
1195
1310
  }
1196
1311
  case 3:
1197
1312
  this.checkpointReceived(m.data)
1313
+ this.waitedOn(m.data)
1198
1314
  break
1199
1315
  case 4: {
1200
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.34",
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",