@magpie-community/opencode-cursor-auth 0.2.20 → 0.2.22

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
@@ -109,6 +109,13 @@ A step's calls must all arrive within 30 seconds of each other: Cursor sends
109
109
  each one once the model has written it, so a step of many long calls can take
110
110
  longer than that in all (#155).
111
111
 
112
+ A Run Cursor stops answering ends with a 502 that says so, retained or
113
+ stateless: 60 seconds with no message at all, or 4 minutes with only
114
+ Cursor's heartbeats (yetone/magpie#2672, where a Run sent some thinking and
115
+ then nothing, and the reply waited until the agent gave up after 5 minutes).
116
+ Only time spent waiting on Cursor counts: a retained Run waiting for the
117
+ caller's tool results is bounded by the 120 seconds above instead.
118
+
112
119
  Cursor's count of a step's tool calls includes calls the plugin answers itself:
113
120
  a tool the request doesn't have is refused to the model, and Cursor's own
114
121
  `get_mcp_tools` is answered with the request's tools. The step's calls are the
@@ -146,7 +153,10 @@ as prior usage. Stateless Runs otherwise keep the dashboard accounting above.
146
153
  `X-Magpie-Cursor-Continuity` identifies `same-run` or `stateless`, with the fallback
147
154
  reason where available. A missing native batch count can retry stateless once
148
155
  before any output is exposed or native result submitted; progressive output is
149
- kept and its later errors are surfaced.
156
+ kept and its later errors are surfaced. A step's declared tool batch waits 30
157
+ seconds from Cursor's latest call, count, text or heartbeat, so one long call
158
+ written under heartbeats is not cut off; the failure says how many of the
159
+ declared calls came.
150
160
 
151
161
  The existing request builder and model selection remain in use, including the
152
162
  legacy `thinking=false` mapping for explicit `none`/`off`. Retention adds no
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 } from "./native-session.mjs"
31
+ import { NATIVE_LIMITS, nativeSessions, NativeSessionError, nativeFields, nativeMcpArgs, Quiet } 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"
@@ -2278,7 +2278,8 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
2278
2278
  book.done()
2279
2279
  return { error: failure(o.status, Buffer.concat(chunks).toString("utf8").slice(0, 1 << 20)) }
2280
2280
  }
2281
- const it = decode(frames(req), {
2281
+ // a Run Cursor stops answering ends, as a retained one does (#2672)
2282
+ const it = decode(new Quiet(NATIVE_LIMITS, close).frames(frames(req)), {
2282
2283
  send,
2283
2284
  blobs,
2284
2285
  tools,
@@ -2492,6 +2493,11 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage,
2492
2493
  }
2493
2494
  yield { error: { status: 502, message: "the reply broke off: EOF" } }
2494
2495
  } catch (e) {
2496
+ // gone quiet is no answer, calls or not: the step's count never came
2497
+ if (e?.quiet) {
2498
+ yield { error: { status: e.status, message: e.message } }
2499
+ return
2500
+ }
2495
2501
  if (calls > 0 && /EOF/.test(e?.message ?? "")) {
2496
2502
  yield await settled()
2497
2503
  return
@@ -19,6 +19,14 @@ export const NATIVE_LIMITS = {
19
19
  // How long a KV reply waits for room in the outbound queue (Cursor's own
20
20
  // flow control taking the writes before it) before the Run fails.
21
21
  writeWait: 30_000,
22
+ // How long a Run waits on Cursor before it ends as gone quiet
23
+ // (yetone/magpie#2672): idle, with no message at all, as @cursor/sdk's
24
+ // stall detector ends one (30 s there; Cursor's client retries, this one
25
+ // can't once output went out); quiet, with heartbeats and nothing else,
26
+ // under the 5 minutes magpie keeps a silent reply alive. Only time spent
27
+ // waiting on Cursor counts, never a Run parked for the caller's results.
28
+ idle: 60_000,
29
+ quiet: 240_000,
22
30
  perAccount: 8,
23
31
  perProcess: 32,
24
32
  }
@@ -43,6 +51,113 @@ export class NativeSessionError extends Error {
43
51
  const invalid = (message, status = 409) => { throw new NativeSessionError(status, message) }
44
52
  const failureOf = (e) => ({ status: e?.status ?? 502, message: e?.message ?? String(e) })
45
53
 
54
+ // kindOf is what a frame of AgentServerMessage is, for Quiet: a heartbeat
55
+ // 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" }
57
+ const MESSAGES = { 2: "tool call", 3: "checkpoint", 4: "blob request" }
58
+ function kindOf(f) {
59
+ if (f.end) return "stream end"
60
+ try {
61
+ const fs = nativeFields(f.data)
62
+ const updates = fs.filter((m) => m.num === 1).flatMap((m) => nativeFields(m.data))
63
+ if (fs.length && fs.every((m) => m.num === 1) && updates.length && updates.every((u) => u.num === 13)) return null
64
+ const u = updates.find((u) => u.num !== 13)
65
+ if (u) return UPDATES[u.num] ?? `update #${u.num}`
66
+ return MESSAGES[fs[0]?.num] ?? `message #${fs[0]?.num}`
67
+ } catch {
68
+ return "malformed message"
69
+ }
70
+ }
71
+
72
+ // Quiet ends a Run Cursor has stopped answering. Cursor can leave a Run's
73
+ // stream open after some thinking with nothing more to come, no text, call,
74
+ // count or TurnEnded, while the plugin's heartbeats keep it alive: the
75
+ // reply then waited until the agent gave up (305 s, yetone/magpie#2672).
76
+ // Only time spent waiting on Cursor's next frame counts: not this side
77
+ // handling one, not a slow reader of the reply, not a paused Run.
78
+ export class Quiet {
79
+ constructor(limits, stop) {
80
+ this.limits = limits
81
+ this.stop = stop
82
+ this.heard = 0 // ms waited since Cursor's last frame that wasn't a heartbeat
83
+ this.heartbeats = 0
84
+ this.last = ""
85
+ this.since = null
86
+ this.paused = false
87
+ this.waiting = false
88
+ this.why = ""
89
+ }
90
+ arm() {
91
+ clearTimeout(this.timer)
92
+ if (this.paused || !this.waiting || this.why || this.done) return
93
+ this.since = Date.now()
94
+ this.timer = later(() => this.fire(), Math.max(0, Math.min(this.limits.idle, this.limits.quiet - this.heard)))
95
+ }
96
+ // waited stops the clock: Cursor answered, or the Run paused
97
+ waited() {
98
+ clearTimeout(this.timer)
99
+ if (this.since != null) this.heard += Date.now() - this.since
100
+ this.since = null
101
+ }
102
+ fire() {
103
+ const gap = Date.now() - this.since
104
+ 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)`
106
+ this.gone?.()
107
+ this.stop({ status: 502, message: this.why })
108
+ }
109
+ // pause stops the clock while the Run waits on the caller's tool results;
110
+ // resume starts it again from nothing, for Cursor's next model call
111
+ pause() {
112
+ this.waited()
113
+ this.paused = true
114
+ }
115
+ resume() {
116
+ this.paused = false
117
+ this.heard = 0
118
+ this.heartbeats = 0
119
+ this.arm()
120
+ }
121
+ // frames passes on a Run's frames; once Cursor has gone quiet, the read
122
+ // that was waiting ends in its error, whether or not the stream does
123
+ async *frames(stream) {
124
+ const it = (stream[Symbol.asyncIterator] ?? stream[Symbol.iterator]).call(stream)
125
+ try {
126
+ for (;;) {
127
+ this.waiting = true
128
+ this.arm()
129
+ let r
130
+ try {
131
+ const next = Promise.resolve(it.next())
132
+ next.catch(() => {}) // a read left behind by gone fails unheard
133
+ // a wake of this read's own: one that lived as long as the stream
134
+ // would hold every race's result, each frame, until it ended
135
+ r = await Promise.race([next, new Promise((wake) => { this.gone = wake })])
136
+ } catch (e) {
137
+ if (!this.why) throw e
138
+ } finally {
139
+ this.gone = null
140
+ this.waiting = false
141
+ this.waited()
142
+ }
143
+ if (this.why) throw Object.assign(new NativeSessionError(502, this.why), { quiet: true })
144
+ if (r.done) return
145
+ const kind = kindOf(r.value)
146
+ if (kind == null) this.heartbeats++
147
+ else [this.heard, this.heartbeats, this.last] = [0, 0, kind]
148
+ yield r.value
149
+ }
150
+ } finally {
151
+ this.done = true
152
+ clearTimeout(this.timer)
153
+ // not waited for: a read may still be pending on a stream that didn't
154
+ // end, and the reader's error handling (a failed protocol check failing
155
+ // the Run before a sealed batch is read) must not wait behind it
156
+ Promise.resolve(it.return?.()).catch(() => {})
157
+ }
158
+ }
159
+ }
160
+
46
161
  // Unlike the compatibility codec, malformed protobuf tails must fail a live
47
162
  // continuation. Dropping one could turn a truncated batch into a valid handoff.
48
163
  export function nativeFields(input, maxFields = Infinity) {
@@ -472,6 +587,7 @@ class NativeSession {
472
587
  this.segment = new Segment(this, prepared.chat)
473
588
  this.retain(bytes(prepared.chat))
474
589
  this.expireIn(this.limits.lifetime)
590
+ this.quiet = new Quiet(this.limits, (e) => this.fail(e))
475
591
  }
476
592
  // expireIn sets the Run's absolute deadline ms from now: from its start,
477
593
  // the whole lifetime; a test brings it in after the first answer
@@ -508,6 +624,7 @@ class NativeSession {
508
624
  clearTimeout(this.resultTimer)
509
625
  clearTimeout(this.drainTimer)
510
626
  clearTimeout(this.lifetimeTimer)
627
+ this.quiet.pause()
511
628
  this.ac.abort()
512
629
  this.transport?.close()
513
630
  }
@@ -605,6 +722,7 @@ class NativeSession {
605
722
  this.execClose(call.requestID)
606
723
  }
607
724
  this.state = "running"
725
+ this.quiet.resume()
608
726
  } catch (e) {
609
727
  const error = failureOf(e)
610
728
  this.fail({ status: error.status, message: `${error.message}; native result submission failed; its outcome is uncertain and cannot be replayed` })
@@ -650,14 +768,19 @@ class NativeSession {
650
768
  if (estimate != null) this.stepEstimate = estimate
651
769
  }
652
770
  // waitForBatch gives the step batchWait from its latest call, answered
653
- // call or count, not from its first. Cursor sends each call once the model
654
- // has written its arguments, so a step of many long calls arrives over
655
- // longer than batchWait while never pausing that long (#155).
771
+ // call, count, text or heartbeat, not from its first. Cursor sends each
772
+ // call once the model has written its arguments, so a step of many long
773
+ // calls arrives over longer than batchWait while never pausing that long
774
+ // (#155), and one long call is written with only heartbeats meanwhile
775
+ // (#377); a heartbeat-only stretch is bounded by quiet. The failure says
776
+ // how much of the batch came, so a stall reads apart from a slow call.
656
777
  waitForBatch() {
657
778
  clearTimeout(this.batchTimer)
779
+ const since = Date.now()
658
780
  this.batchTimer = later(() => {
659
- if (this.segment.expected == null && this.segment.calls.length && !this.resultSubmitted) this.missingCount()
660
- else this.fail({ status: 502, message: "Cursor did not complete its declared native tool batch" })
781
+ const s = this.segment
782
+ 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` })
661
784
  }, this.limits.batchWait)
662
785
  }
663
786
  missingCount() {
@@ -706,6 +829,8 @@ class NativeSession {
706
829
  clearTimeout(this.batchTimer)
707
830
  this.batchTimer = null
708
831
  this.state = "awaiting-results"
832
+ // Cursor waits on the caller now: resultWait bounds the wait
833
+ this.quiet.pause()
709
834
  this.batches ??= []
710
835
  this.batches.push(segment)
711
836
  for (const c of segment.calls) this.pool.handles.set(c.id, { owner: this, batch: segment })
@@ -855,7 +980,7 @@ class NativeSession {
855
980
  async pump(stream) {
856
981
  const { pb, exec, failure } = this.wire
857
982
  try {
858
- for await (const f of stream) {
983
+ for await (const f of this.quiet.frames(stream)) {
859
984
  if (this.error || this.state === "complete") return
860
985
  if (f.end) {
861
986
  const e = failure(200, f.data)
@@ -885,6 +1010,8 @@ class NativeSession {
885
1010
  } else if (u.num === 27) {
886
1011
  this.listed(num(uf, 1))
887
1012
  if (this.error) return
1013
+ } else if (u.num === 13) {
1014
+ if (this.batchTimer && !this.terminal && this.state !== "awaiting-results") this.waitForBatch()
888
1015
  }
889
1016
  }
890
1017
  break
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-cursor-auth",
3
- "version": "0.2.20",
3
+ "version": "0.2.22",
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",