@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 +11 -1
- package/index.mjs +8 -2
- package/native-session.mjs +133 -6
- package/package.json +1 -1
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
|
-
|
|
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
|
package/native-session.mjs
CHANGED
|
@@ -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
|
|
654
|
-
// has written its arguments, so a step of many long
|
|
655
|
-
// longer than batchWait while never pausing that long
|
|
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
|
-
|
|
660
|
-
|
|
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.
|
|
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",
|