@magpie-community/opencode-cursor-auth 0.2.19 → 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"
@@ -1179,6 +1179,19 @@ function toolsOf(chat) {
1179
1179
  })
1180
1180
  }
1181
1181
 
1182
+ // NS is the MCP server the caller's tools are served as, the way Cursor's
1183
+ // own client names a user's server by its mcp.json label (providerIdentifier,
1184
+ // with "<label>-<tool>" as a call's name). It goes to Cursor in the prompt,
1185
+ // each call and each tool definition, so it names nothing of the plugin or
1186
+ // the app it runs in. Calls are read back under NAMESPACES: before 0.2.20
1187
+ // the server was named "magpie", and a session begun then can still call it.
1188
+ const NS = "tools"
1189
+ const NAMESPACES = [NS, "magpie"]
1190
+ const bareName = (s) => {
1191
+ for (const ns of NAMESPACES) if (s.startsWith(ns + "-")) return s.slice(ns.length + 1)
1192
+ return s
1193
+ }
1194
+
1182
1195
  // catalog lists the caller's tools for the model. It sees MCP tools only
1183
1196
  // through Cursor's GetDynamicTools and CallDynamicTool, and Cursor names
1184
1197
  // them in a prompt of its own, which this conversation goes without:
@@ -1188,8 +1201,8 @@ function toolsOf(chat) {
1188
1201
  // those calls went nowhere (#11).
1189
1202
  function catalog(tools) {
1190
1203
  if (!tools.length) return ""
1191
- let s = `\n\n<dynamic_tool_catalog>\nThe MCP namespace "magpie" has the tools below. They are reached only through \`${CALL}\`, never as tools of their own. Each entry is the exact call to make; its arguments must match the schema given. The schemas are complete, so there is no need to call \`GetDynamicTools\` first.\n`
1192
- for (const t of tools) s += `<call>${CALL}({"namespace":"magpie","toolName":"${t.name}","arguments":{...}})\n${t.description}\narguments schema: ${t.schemaText}\n</call>\n`
1204
+ let s = `\n\n<dynamic_tool_catalog>\nThe MCP namespace "${NS}" has the tools below. They are reached only through \`${CALL}\`, never as tools of their own. Each entry is the exact call to make; its arguments must match the schema given. The schemas are complete, so there is no need to call \`GetDynamicTools\` first.\n`
1205
+ for (const t of tools) s += `<call>${CALL}({"namespace":"${NS}","toolName":"${t.name}","arguments":{...}})\n${t.description}\narguments schema: ${t.schemaText}\n</call>\n`
1193
1206
  return s + "</dynamic_tool_catalog>"
1194
1207
  }
1195
1208
 
@@ -1263,7 +1276,7 @@ function conversation(chat, tools) {
1263
1276
  if (c?.type && c.type !== "function") continue
1264
1277
  const args = parseArgs(c.function?.arguments)
1265
1278
  size += (c.function?.arguments ?? "").length + (c.function?.name ?? "").length
1266
- content.push({ type: "tool-call", toolCallId: callID(c.id), toolName: CALL, args: { namespace: "magpie", toolName: c.function?.name, arguments: args } })
1279
+ content.push({ type: "tool-call", toolCallId: callID(c.id), toolName: CALL, args: { namespace: NS, toolName: c.function?.name, arguments: args } })
1267
1280
  pending.push(c.id)
1268
1281
  }
1269
1282
  if (content.length) add({ role: "assistant", content })
@@ -1303,7 +1316,7 @@ function toolDef(t) {
1303
1316
  const d = pb().str(1, t.name)
1304
1317
  if (t.description) d.str(2, t.description)
1305
1318
  d.bytes(3, pbValue(t.schema))
1306
- return d.str(4, "magpie").str(5, t.name).str(6, t.schemaText).done()
1319
+ return d.str(4, NS).str(5, t.name).str(6, t.schemaText).done()
1307
1320
  }
1308
1321
 
1309
1322
  const env = () => pb().str(1, GOOS).str(2, tmpdir()).str(10, "UTC")
@@ -2265,7 +2278,8 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
2265
2278
  book.done()
2266
2279
  return { error: failure(o.status, Buffer.concat(chunks).toString("utf8").slice(0, 1 << 20)) }
2267
2280
  }
2268
- 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)), {
2269
2283
  send,
2270
2284
  blobs,
2271
2285
  tools,
@@ -2479,6 +2493,11 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage,
2479
2493
  }
2480
2494
  yield { error: { status: 502, message: "the reply broke off: EOF" } }
2481
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
+ }
2482
2501
  if (calls > 0 && /EOF/.test(e?.message ?? "")) {
2483
2502
  yield await settled()
2484
2503
  return
@@ -2527,13 +2546,13 @@ function exec(msg, tools, send, closeExec, refused, native = false) {
2527
2546
  if (native) for (const e of es) if (e.num === 11) {
2528
2547
  if (e.wire !== 2) throw new NativeSessionError(502, "invalid native MCP argument envelope")
2529
2548
  const { fields: a, args } = nativeMcpArgs(e.data)
2530
- const name = pbStr(a, 5) || pbStr(a, 1).replace(/^magpie-/, "")
2549
+ const name = pbStr(a, 5) || bareName(pbStr(a, 1))
2531
2550
  const provider = pbStr(a, 4)
2532
2551
  const server = pbStr(a, 9)
2533
- if (provider && provider !== "magpie" || server && server !== "magpie") nativeUnavailable = true
2552
+ if (provider && !NAMESPACES.includes(provider) || server && !NAMESPACES.includes(server)) nativeUnavailable = true
2534
2553
  if (pbNum(a, 7) || field(a, 6)) nativeUnavailable = true
2535
2554
  if (!tools.some((t) => t.name === name)) nativeUnavailable = true
2536
- if (!pbStr(a, 5) && pbStr(a, 1) !== "magpie-" + name) nativeUnavailable = true
2555
+ if (!pbStr(a, 5) && !NAMESPACES.some((ns) => pbStr(a, 1) === ns + "-" + name)) nativeUnavailable = true
2537
2556
  const call = { requestID: id, execID, nativeID: pbStr(a, 3), name, args }
2538
2557
  if (nativeCall && !isDeepStrictEqual(nativeCall, call)) throw new NativeSessionError(502, "ambiguous repeated native MCP argument envelopes")
2539
2558
  nativeCall ??= call
@@ -2565,7 +2584,7 @@ function exec(msg, tools, send, closeExec, refused, native = false) {
2565
2584
  const v = field(ef, 2)
2566
2585
  args[pbStr(ef, 1)] = v ? pbAny(v.data) : null
2567
2586
  }
2568
- const name = pbStr(a, 5) || pbStr(a, 1).replace(/^magpie-/, "")
2587
+ const name = pbStr(a, 5) || bareName(pbStr(a, 1))
2569
2588
  // an OpenAI model's id is its call's and its item's, a line apart,
2570
2589
  // which no caller would take as an id: the line goes as __
2571
2590
  const cid = pbStr(a, 3).replaceAll("\n", "__") || "call_" + randomBytes(12).toString("hex")
@@ -2573,7 +2592,7 @@ function exec(msg, tools, send, closeExec, refused, native = false) {
2573
2592
  }
2574
2593
  case 36: {
2575
2594
  // mcp_state_exec_args: the tools there are
2576
- const srv = pb().str(1, "magpie").str(2, "magpie").str(7, "connected")
2595
+ const srv = pb().str(1, NS).str(2, NS).str(7, "connected")
2577
2596
  for (const t of tools) srv.bytes(5, toolDef(t))
2578
2597
  answer(36, pb().bytes(1, pb().bytes(1, srv)))
2579
2598
  return null
@@ -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.19",
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",