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

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
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` })
@@ -706,6 +824,8 @@ class NativeSession {
706
824
  clearTimeout(this.batchTimer)
707
825
  this.batchTimer = null
708
826
  this.state = "awaiting-results"
827
+ // Cursor waits on the caller now: resultWait bounds the wait
828
+ this.quiet.pause()
709
829
  this.batches ??= []
710
830
  this.batches.push(segment)
711
831
  for (const c of segment.calls) this.pool.handles.set(c.id, { owner: this, batch: segment })
@@ -855,7 +975,7 @@ class NativeSession {
855
975
  async pump(stream) {
856
976
  const { pb, exec, failure } = this.wire
857
977
  try {
858
- for await (const f of stream) {
978
+ for await (const f of this.quiet.frames(stream)) {
859
979
  if (this.error || this.state === "complete") return
860
980
  if (f.end) {
861
981
  const e = failure(200, f.data)
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.21",
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",