@magpie-community/opencode-cursor-auth 0.2.14 → 0.2.16

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
@@ -95,7 +95,19 @@ uses the same session/cache key and first user message as `conversation_id`, so
95
95
  Task subagents sharing their parent's session can run independently. Retention
96
96
  waits at most 120 seconds for a result batch and 15 minutes for a Run's total
97
97
  lifetime. The pool holds at most 8 owners per account and 32 per process, with
98
- bounded history, blobs, arguments, and pending writes.
98
+ bounded history, blobs, arguments, and pending writes. When pending writes
99
+ reach their bound (a long history asks for its blobs at once, and a slow link
100
+ drains them slowly), the Run waits for the link to drain, and fails only if it
101
+ doesn't for 30 seconds.
102
+
103
+ A step's calls must all arrive within 30 seconds of each other: Cursor sends
104
+ each one once the model has written it, so a step of many long calls can take
105
+ longer than that in all (#155).
106
+
107
+ Cursor's count of a step's tool calls includes calls the plugin answers itself:
108
+ a tool the request doesn't have is refused to the model, and Cursor's own
109
+ `get_mcp_tools` is answered with the request's tools. The step's calls are the
110
+ rest.
99
111
 
100
112
  These are retention limits. A missing, expired, restarted, busy, incompatible,
101
113
  or full owner uses the original history reconstruction and stateless decoder
@@ -107,7 +119,11 @@ request can rebuild from its supplied history without submitting the result to
107
119
  the old native exec again.
108
120
 
109
121
  A native tool handoff reports zero usage with `magpie_native.accounting=pending`;
110
- the final native answer reports the turn's totals. An identical completed retry
122
+ the final native answer bills the turn's totals. Its own `prompt_tokens` and
123
+ `completion_tokens` are its last model call's share (the larger of that
124
+ call's prompt estimate and the turn's mean per call), since a client reads
125
+ them as how full its context is; the earlier calls' share is
126
+ `usage.magpie_deferred` (#156). An identical completed retry
111
127
  replays the saved response with zero usage. A native final also collects earlier
112
128
  stateless debt as `usage.magpie_deferred`, separately from its own prompt total.
113
129
  If a started native Run is abandoned after a pending handoff, its dashboard
package/index.mjs CHANGED
@@ -1939,6 +1939,15 @@ function nativeTransport(req, close, fail, limits = NATIVE_LIMITS) {
1939
1939
  const outbound = { bytes: 0, messages: 0 }
1940
1940
  const queue = []
1941
1941
  let closed = false, flushing = false, rejectWrite
1942
+ // waiters for room (KV replies), woken as writes complete. An eighth of
1943
+ // each bound stays free for heartbeats and exec answers, which never wait.
1944
+ const waiters = new Set()
1945
+ const spare = (n) => Math.floor(n / 8)
1946
+ const fits = (size) => outbound.messages === 0 || (outbound.messages + 1 + spare(limits.outboundMessages) <= limits.outboundMessages
1947
+ && outbound.bytes + size + spare(limits.outboundBytes) <= limits.outboundBytes)
1948
+ const wake = () => {
1949
+ for (const w of waiters) if (closed || fits(w.size)) w.done()
1950
+ }
1942
1951
  const flush = async () => {
1943
1952
  if (closed || flushing) return
1944
1953
  flushing = true
@@ -1975,7 +1984,7 @@ function nativeTransport(req, close, fail, limits = NATIVE_LIMITS) {
1975
1984
  check()
1976
1985
  } catch (e) { finish(e) }
1977
1986
  })
1978
- if (!closed) { outbound.bytes -= packet.length; outbound.messages-- }
1987
+ if (!closed) { outbound.bytes -= packet.length; outbound.messages--; wake() }
1979
1988
  }
1980
1989
  } catch (e) {
1981
1990
  if (!closed) fail(e)
@@ -1993,12 +2002,35 @@ function nativeTransport(req, close, fail, limits = NATIVE_LIMITS) {
1993
2002
  queue.push(frame(message))
1994
2003
  void flush()
1995
2004
  },
2005
+ // room resolves once a message of size fits the queue with the spare
2006
+ // kept, and fails the Run when the queue doesn't drain for writeWait.
2007
+ room(size) {
2008
+ if (closed || fits(size)) return Promise.resolve()
2009
+ return new Promise((resolve, reject) => {
2010
+ const w = {
2011
+ size,
2012
+ done() {
2013
+ waiters.delete(w)
2014
+ clearTimeout(w.timer)
2015
+ resolve()
2016
+ },
2017
+ }
2018
+ w.timer = setTimeout(() => {
2019
+ waiters.delete(w)
2020
+ const which = outbound.bytes + size + spare(limits.outboundBytes) > limits.outboundBytes ? "bytes" : "messages"
2021
+ reject(new NativeSessionError(503, `native outbound ${which} limit reached: the queue did not drain for ${Math.round(limits.writeWait / 1000)}s`))
2022
+ }, limits.writeWait)
2023
+ w.timer.unref?.()
2024
+ waiters.add(w)
2025
+ })
2026
+ },
1996
2027
  close() {
1997
2028
  if (closed) return
1998
2029
  closed = true
1999
2030
  queue.length = 0
2000
2031
  outbound.bytes = 0
2001
2032
  outbound.messages = 0
2033
+ wake()
2002
2034
  rejectWrite?.(new NativeSessionError(410, "native transport was cancelled"))
2003
2035
  close()
2004
2036
  },
@@ -2232,9 +2264,22 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
2232
2264
  // turn ends, or the model has made its tool calls.
2233
2265
  async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage, owedUsage }) {
2234
2266
  let calls = 0
2267
+ // answered: the step's calls answered here (a refusal, the MCP tool
2268
+ // list), which Cursor's count of the step's calls takes in too (#142)
2269
+ let answered = 0
2235
2270
  // ended: Cursor said what the turn used
2236
2271
  let ended = false
2237
2272
  let listed = 0
2273
+ // stepDone is whether every call the step listed has come. A step whose
2274
+ // calls were all answered here goes on on Cursor's side, under a count of
2275
+ // its own.
2276
+ const stepDone = () => {
2277
+ if (!listed || calls + answered < listed) return false
2278
+ if (calls > 0) return true
2279
+ listed = 0
2280
+ answered = 0
2281
+ return false
2282
+ }
2238
2283
  let said = 0
2239
2284
  // wrote: the reply's text alone; a turn of thinking only says nothing
2240
2285
  let wrote = 0
@@ -2342,9 +2387,9 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage,
2342
2387
  return
2343
2388
  }
2344
2389
  break
2345
- case 27: // how many tool calls the step makes, sent before them
2390
+ case 27: // how many tool calls the step makes, sent before or after them
2346
2391
  listed = pbNum(uf, 1)
2347
- if (listed > 0 && calls >= listed) {
2392
+ if (stepDone()) {
2348
2393
  yield await settled()
2349
2394
  return
2350
2395
  }
@@ -2360,8 +2405,8 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage,
2360
2405
  if (call) {
2361
2406
  said += call.args.length
2362
2407
  yield { tool: { index: calls++, ...call } }
2363
- }
2364
- if (listed > 0 && calls >= listed) {
2408
+ } else if (!fields(m.data).some((e) => e.num === 10)) answered++
2409
+ if (stepDone()) {
2365
2410
  // every call made: the caller runs them
2366
2411
  yield await settled()
2367
2412
  return
@@ -2547,7 +2592,8 @@ async function answerOf(auth, chat, signal, session = "") {
2547
2592
  const convID = conversationID(chat, session)
2548
2593
  const prepared = nativeSessions.prepare(tok, chat, convID, tools)
2549
2594
  let r
2550
- if (prepared.owner) r = prepared.owner.resume(prepared, signal)
2595
+ // the continuation's own prompt, which its final step reports (#156)
2596
+ if (prepared.owner) r = prepared.owner.resume({ ...prepared, estimate: conversation(chat, tools).estimate }, signal)
2551
2597
  const model = String(chat.model ?? "")
2552
2598
  if (!r) {
2553
2599
  let raw = []
@@ -16,6 +16,9 @@ export const NATIVE_LIMITS = {
16
16
  argumentDepth: 64,
17
17
  outboundBytes: 16 << 20,
18
18
  outboundMessages: 256,
19
+ // How long a KV reply waits for room in the outbound queue (Cursor's own
20
+ // flow control taking the writes before it) before the Run fails.
21
+ writeWait: 30_000,
19
22
  perAccount: 8,
20
23
  perProcess: 32,
21
24
  }
@@ -270,6 +273,9 @@ class Segment {
270
273
  this.prefixDigest = historyDigest(chat.messages)
271
274
  this.events = []
272
275
  this.calls = []
276
+ // answered: the step's execs the bridge answered itself (a refusal, or
277
+ // the MCP tool list). Cursor's tool_requests_listed counts them too.
278
+ this.answered = 0
273
279
  this.content = ""
274
280
  this.reasoning = ""
275
281
  this.expected = null
@@ -448,6 +454,10 @@ class NativeSession {
448
454
  this.retainedBytes = 0
449
455
  this.sequence = 0
450
456
  this.segments = 0
457
+ // the model calls of the turn so far, and what was said before the
458
+ // latest one began: the last step's own share of the turn (#156)
459
+ this.steps = 1
460
+ this.stepSaid = 0
451
461
  this.said = 0
452
462
  this.wrote = 0
453
463
  this.terminal = false
@@ -509,6 +519,7 @@ class NativeSession {
509
519
  }
510
520
  this.transport = transport
511
521
  this.estimate = estimate
522
+ this.stepEstimate = estimate
512
523
  this.blobs = blobs
513
524
  this.knownHashes = new Set(blobs.keys())
514
525
  this.blobSize = [...blobs.values()].reduce((n, b) => n + b.length, 0)
@@ -530,6 +541,14 @@ class NativeSession {
530
541
  if (this.error || this.transportClosed) invalid("native transport is no longer writable", 410)
531
542
  this.transport.send(message)
532
543
  }
544
+ // reply answers one of Cursor's KV requests once the outbound queue has
545
+ // room. Cursor asks for every history blob at once, hundreds for a long
546
+ // conversation, faster than a slow link takes the replies: the decoder
547
+ // waits for the queue rather than failing the Run (#142).
548
+ async reply(message) {
549
+ await this.transport.room?.(message.length + 5)
550
+ this.send(message)
551
+ }
533
552
  execClose(id) {
534
553
  this.send(this.wire.pb().bytes(5, this.wire.pb().bytes(1, this.wire.pb().varint(1, id))).done())
535
554
  }
@@ -557,6 +576,8 @@ class NativeSession {
557
576
  this.state = "submitting"
558
577
  clearTimeout(this.resultTimer)
559
578
  clearTimeout(this.batchTimer)
579
+ this.batchTimer = null
580
+ this.nextStep(prepared.estimate)
560
581
  try {
561
582
  for (let i = 0; i < prepared.results.length; i++) {
562
583
  const call = batch.calls.find((c) => c.id === prepared.results[i].tool_call_id)
@@ -606,8 +627,20 @@ class NativeSession {
606
627
  this.waitForBatch()
607
628
  this.sealBatch()
608
629
  }
630
+ // nextStep is Cursor's next model call in the same turn: after the
631
+ // caller's results, or after a step whose calls were all answered here.
632
+ nextStep(estimate) {
633
+ this.steps++
634
+ this.stepSaid = this.said
635
+ if (estimate != null) this.stepEstimate = estimate
636
+ }
637
+ // waitForBatch gives the step batchWait from its latest call, answered
638
+ // call or count, not from its first. Cursor sends each call once the model
639
+ // has written its arguments, so a step of many long calls arrives over
640
+ // longer than batchWait while never pausing that long (#155).
609
641
  waitForBatch() {
610
- this.batchTimer ??= later(() => {
642
+ clearTimeout(this.batchTimer)
643
+ this.batchTimer = later(() => {
611
644
  if (this.segment.expected == null && this.segment.calls.length && !this.resultSubmitted) this.missingCount()
612
645
  else this.fail({ status: 502, message: "Cursor did not complete its declared native tool batch" })
613
646
  }, this.limits.batchWait)
@@ -615,6 +648,16 @@ class NativeSession {
615
648
  missingCount() {
616
649
  this.fail({ status: 502, message: "Cursor did not declare its native tool batch", retentionIneligible: !this.resultSubmitted })
617
650
  }
651
+ // A call of the model's step the bridge answered itself: a refused tool (a
652
+ // name it doesn't serve, Cursor's own tools) or get_mcp_tools. Cursor's
653
+ // count is of every call the step made, so it counts toward the batch;
654
+ // never handed to the caller (#142).
655
+ answeredCall() {
656
+ if (this.terminal || this.state === "awaiting-results") return
657
+ this.segment.answered++
658
+ if (this.batchTimer) this.waitForBatch()
659
+ this.sealBatch()
660
+ }
618
661
  listed(count) {
619
662
  if (!Number.isSafeInteger(count) || count < 1) invalid("invalid native tool count", 502)
620
663
  if (this.segment.expected != null && this.segment.expected !== count) invalid("inconsistent native tool count", 502)
@@ -626,8 +669,19 @@ class NativeSession {
626
669
  sealBatch() {
627
670
  const segment = this.segment
628
671
  if (segment.expected == null) return
629
- if (segment.calls.length > segment.expected) invalid("native tool batch exceeded its declared count", 502)
630
- if (segment.calls.length !== segment.expected || segment.closed) return
672
+ const made = segment.calls.length + segment.answered
673
+ if (made > segment.expected) invalid("native tool batch exceeded its declared count", 502)
674
+ if (made !== segment.expected || segment.closed) return
675
+ if (!segment.calls.length) {
676
+ // Every call of the step was answered here: Cursor goes on with the
677
+ // turn by itself, and its next step lists a count of its own.
678
+ segment.expected = null
679
+ segment.answered = 0
680
+ clearTimeout(this.batchTimer)
681
+ this.batchTimer = null
682
+ this.nextStep()
683
+ return
684
+ }
631
685
  clearTimeout(this.batchTimer)
632
686
  this.batchTimer = null
633
687
  this.state = "awaiting-results"
@@ -729,6 +783,24 @@ class NativeSession {
729
783
  const u = usageOf(this.usageFields ?? [])
730
784
  if (!measuredInput) u.input = this.estimate
731
785
  if (!measuredOutput) u.output = Math.floor((this.said + 3) / 4)
786
+ // A turn of several model calls: TurnEndedUpdate counts them all, but a
787
+ // client reads the last response's prompt as how full its context is
788
+ // (Codex compacted a 61K context at 1.1M, #156). The last call keeps its
789
+ // own share, the larger of its prompt estimate and the turn's mean per
790
+ // call; the rest is billed as magpie_deferred.
791
+ let earlier = null
792
+ if (this.steps > 1) {
793
+ const whole = u.input + u.cacheRead + u.cacheWrite
794
+ const share = (total, own) => total > 0 ? Math.min(1, Math.max(own, total / this.steps) / total) : 0
795
+ const fi = share(whole, measuredInput ? this.stepEstimate ?? 0 : whole)
796
+ const fo = share(u.output, Math.floor((this.said - this.stepSaid + 3) / 4))
797
+ const own = {
798
+ input: Math.round(u.input * fi), cacheRead: Math.round(u.cacheRead * fi), cacheWrite: Math.round(u.cacheWrite * fi),
799
+ output: Math.round(u.output * fo), reasoning: Math.round(u.reasoning * fo),
800
+ }
801
+ earlier = { input: u.input - own.input, cacheRead: u.cacheRead - own.cacheRead, cacheWrite: u.cacheWrite - own.cacheWrite, output: u.output - own.output }
802
+ Object.assign(u, own)
803
+ }
732
804
  const prompt = u.input + u.cacheRead + u.cacheWrite
733
805
  this.finalUsage = {
734
806
  prompt_tokens: prompt, completion_tokens: u.output, total_tokens: prompt + u.output,
@@ -738,8 +810,9 @@ class NativeSession {
738
810
  }
739
811
  this.state = "finalizing"
740
812
  this.closeTransport()
741
- const finish = (more) => {
813
+ const finish = (settled) => {
742
814
  if (this.error) return
815
+ const more = earlier && settled ? Object.fromEntries(Object.keys(earlier).map((k) => [k, earlier[k] + settled[k]])) : earlier ?? settled
743
816
  const deferred = more && more.input + more.output + more.cacheRead + more.cacheWrite > 0
744
817
  this.segment.finish({ stop: "stop", usage: {
745
818
  ...this.finalUsage,
@@ -783,6 +856,7 @@ class NativeSession {
783
856
  this.said += text.length
784
857
  if (u.num === 1) this.wrote += text.length
785
858
  this.segment.emit(u.num === 1 ? { text } : { reasoning: text })
859
+ if (this.batchTimer) this.waitForBatch()
786
860
  }
787
861
  } else if (u.num === 14) {
788
862
  if (!this.segment.content && !this.segment.calls.length) invalid("an empty reply (Cursor ended the turn without text or a tool call)", 502)
@@ -793,6 +867,8 @@ class NativeSession {
793
867
  case 2: {
794
868
  const c = exec(m.data, this.chatTools, (b) => this.send(b), (id) => this.execClose(id), null, true)
795
869
  if (c) this.acceptCall(c)
870
+ // request_context is Cursor asking for the environment, not a call
871
+ else if (!nativeFields(m.data).some((e) => e.num === 10)) this.answeredCall()
796
872
  break
797
873
  }
798
874
  case 3:
@@ -806,7 +882,7 @@ class NativeSession {
806
882
  const want = field(nativeFields(k.data), 1)?.data.toString("hex") ?? ""
807
883
  const b = this.blobs.get(want)
808
884
  const res = b ? pb().bytes(1, b) : pb().bytes(2, pb().str(1, "blob not found"))
809
- this.send(pb().bytes(3, pb().varint(1, id).bytes(2, res)).done())
885
+ await this.reply(pb().bytes(3, pb().varint(1, id).bytes(2, res)).done())
810
886
  } else if (k.num === 3) {
811
887
  try {
812
888
  const set = nativeFields(k.data)
@@ -815,7 +891,7 @@ class NativeSession {
815
891
  this.send(pb().bytes(3, pb().varint(1, id).bytes(3, pb().bytes(1, pb().str(1, "native blob could not be retained")))).done())
816
892
  throw e
817
893
  }
818
- this.send(pb().bytes(3, pb().varint(1, id).bytes(3, Buffer.alloc(0))).done())
894
+ await this.reply(pb().bytes(3, pb().varint(1, id).bytes(3, Buffer.alloc(0))).done())
819
895
  }
820
896
  }
821
897
  break
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-cursor-auth",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
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",