@magpie-community/opencode-cursor-auth 0.2.28 → 0.2.30

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.
Files changed (3) hide show
  1. package/README.md +15 -2
  2. package/index.mjs +118 -21
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -51,8 +51,10 @@ plugin's `fetch` answers them on Cursor's agent API:
51
51
  (refused, no network) or a stream the server refused leaves the next Run
52
52
  to try HTTP/2 again. A region error over HTTP/1.1 sends the Run back to
53
53
  HTTP/2 at the region's agent host. BidiAppends go up numbered in order,
54
- up to 16 at a time as cursor-agent sends them (one at a time for a
55
- native session's Run, whose tool results must be certain). A BidiAppend
54
+ up to 16 at a time as cursor-agent sends them. A native session's Run
55
+ sends only its KV replies (the history's blobs) that way; a tool result
56
+ or anything else waits for the appends before it, goes alone, and is
57
+ written once Cursor answered it, so it is never uncertain. A BidiAppend
56
58
  with no answer in 60 s (more for a large one) ends the Run, as
57
59
  cursor-agent does.
58
60
  - A new Run imports the whole conversation as AI SDK messages kept as blobs
@@ -252,6 +254,17 @@ own spends the team's pool: On-demand shows the pool's spend and limit,
252
254
  with the member's own spend beside it, and the account is used up when
253
255
  the pool is.
254
256
 
257
+ An Enterprise account's period has no Cursor Models, Other Models or Total
258
+ in `GetCurrentPeriodUsage`: Cursor reports its overall allowance through
259
+ the website's `usage-summary` instead. Magpie shows that as Enterprise,
260
+ for example `49.45 / 800 USD · 6.2% used`, or
261
+ `750.55 / 800 USD · 93.8% left` with Magpie's Used / Left choice, reset at
262
+ the billing cycle's end. When the summary says team on-demand is enabled
263
+ and unlimited or still has room, the Enterprise window is informational:
264
+ reaching its included allowance doesn't count the account as used up.
265
+ On-demand also shows the team's spend when known, with its limit when it
266
+ has one. A reached on-demand limit counts the account as used up.
267
+
255
268
  Sign in to the Cursor account linked in Grok Bot. A linked SuperGrok
256
269
  subscription grants Bot access without a paid Cursor plan; see
257
270
  [Grok Bot plans and billing](https://cursor.com/help/grok-bot/plans).
package/index.mjs CHANGED
@@ -532,14 +532,79 @@ async function hardLimit(tok) {
532
532
  // dollars, thousands grouped: a team's pool runs to six figures ($451,200.00)
533
533
  const dollars = (n) => "$" + n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
534
534
 
535
- // usage is the account's windows this billing period; an enterprise plan
536
- // reports spend instead, and gets none. ids are the models it can be
537
- // asked for, each counted by the pool Cursor bills it to. An account that
538
- // may spend on-demand once its included usage is gone (fottencity on
535
+ // enterpriseUsage is the allowance GetCurrentPeriodUsage leaves out on an
536
+ // Enterprise account. That call answers with only the billing cycle there;
537
+ // the website's usage-summary has individualUsage.overall, in cents, and
538
+ // the account's membership. The CLI JWT is also the website's dashboard
539
+ // session, as botUsage builds it. undefined when the summary says nothing
540
+ // this can show, so a failed read stays an empty card, as before.
541
+ async function enterpriseUsage(tok) {
542
+ try {
543
+ const subject = JSON.parse(Buffer.from(tok.split(".")[1], "base64url").toString()).sub
544
+ if (typeof subject !== "string") return undefined
545
+ const user = subject.split("|").filter(Boolean).at(-1)
546
+ if (!user || !/^[a-zA-Z0-9._-]+$/.test(user)) return undefined
547
+ const res = await fetch(WEBSITE + "/api/usage-summary", {
548
+ method: "GET",
549
+ headers: {
550
+ Accept: "application/json",
551
+ Origin: WEBSITE,
552
+ Cookie: "WorkosCursorSessionToken=" + encodeURIComponent(user + "::" + tok),
553
+ },
554
+ redirect: "error",
555
+ signal: AbortSignal.timeout(15_000),
556
+ })
557
+ if (!res.ok) return undefined
558
+ const data = await res.json()
559
+ if (String(data?.membershipType).toLowerCase() !== "enterprise") return undefined
560
+ const overall = data?.individualUsage?.overall
561
+ if (overall?.enabled !== true) return undefined
562
+ const { used, limit } = overall
563
+ if (typeof used !== "number" || !Number.isFinite(used) || used < 0) return undefined
564
+ if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0) return undefined
565
+ const end = Date.parse(data.billingCycleEnd ?? "")
566
+ const resets = Number.isFinite(end) ? { resetsAt: new Date(end).toISOString() } : {}
567
+ const od = data.teamUsage?.onDemand
568
+ const spent = typeof od?.used === "number" && Number.isFinite(od.used) && od.used >= 0
569
+ const unlimited = od?.enabled === true && od.limit === null
570
+ const fixed = od?.enabled === true && typeof od.limit === "number" && Number.isFinite(od.limit) && od.limit > 0 && spent
571
+ // Only an explicitly unlimited budget or a measured budget with room
572
+ // lets a spent included allowance stay available to magpie's routing.
573
+ const aside = unlimited || (fixed && od.used < od.limit)
574
+ const windows = [{
575
+ name: "Enterprise",
576
+ used: Math.max(0, Math.min(100, (100 * used) / limit)),
577
+ display: `${dollars(used / 100)} / ${dollars(limit / 100)}`,
578
+ amount: used / 100,
579
+ limit: limit / 100,
580
+ unit: "USD",
581
+ ...resets,
582
+ ...(aside ? { aside: true } : {}),
583
+ }]
584
+ if (fixed) windows.push({
585
+ name: "On-demand",
586
+ used: Math.min(100, (100 * od.used) / od.limit),
587
+ display: `${dollars(od.used / 100)} / ${dollars(od.limit / 100)}`,
588
+ amount: od.used / 100,
589
+ limit: od.limit / 100,
590
+ unit: "USD",
591
+ ...resets,
592
+ })
593
+ else if (unlimited && spent) windows.push({ name: "On-demand", used: 0, display: dollars(od.used / 100), ...resets, aside: true })
594
+ return windows
595
+ } catch {
596
+ return undefined
597
+ }
598
+ }
599
+
600
+ // usage is the account's windows this billing period. ids are the models it
601
+ // can be asked for, each counted by the pool Cursor bills it to. An account
602
+ // that may spend on-demand once its included usage is gone (fottencity on
539
603
  // Discord: a team that pays for more) is still served when the pools are
540
604
  // spent, so they are shown aside, and its On-demand spend is what can run
541
605
  // out: at its limit (a team member with none of their own: the team's
542
- // pool), or never when it has none.
606
+ // pool), or never when it has none. An Enterprise account's period has no
607
+ // planUsage; its allowance comes from usage-summary instead.
543
608
  async function usage(tok, ids) {
544
609
  const [res, hard] = await Promise.all([
545
610
  fetch(API + "/aiserver.v1.DashboardService/GetCurrentPeriodUsage", {
@@ -553,7 +618,11 @@ async function usage(tok, ids) {
553
618
  if (!res.ok) return { error: statusText(res.status), windows: [] }
554
619
  const data = await res.json()
555
620
  const u = data?.planUsage
556
- if (!u) return { windows: [] }
621
+ if (!u) {
622
+ // Prefer measured Enterprise usage before any reset-date-only fallback.
623
+ const windows = await enterpriseUsage(tok)
624
+ return windows ? { windows } : { windows: [] }
625
+ }
557
626
  const ms = /^[+-]?\d+$/.test(data.billingCycleEnd ?? "") ? Number(data.billingCycleEnd) : 0
558
627
  const resets = ms > 0 ? { resetsAt: new Date(ms).toISOString() } : {}
559
628
  const bucket = new Set((data.autoBucketModels ?? []).map(poolBase))
@@ -2038,10 +2107,11 @@ async function agentURL(tok, fresh) {
2038
2107
  // H2.retryAfter, or until a region error sends one back (answerOf); a
2039
2108
  // connection that failed (refused, no network) or a stream the server
2040
2109
  // refused leaves the next Run to try HTTP/2 again. atOnce is how many
2041
- // BidiAppends openH1 may have on their way at once.
2110
+ // BidiAppends openH1 may have on their way at once, overlaps which messages
2111
+ // may be among them (openH1).
2042
2112
  let h1Until = 0
2043
2113
  const cancelledOpen = () => ({ error: { status: 410, message: "the request was cancelled" }, h2Failed: false })
2044
- async function open(base, headers, signal, atOnce = H1.atOnce) {
2114
+ async function open(base, headers, signal, atOnce = H1.atOnce, overlaps) {
2045
2115
  if (signal?.aborted) return cancelledOpen()
2046
2116
  if (Date.now() >= h1Until) {
2047
2117
  const o = await openH2(base, headers, signal)
@@ -2055,7 +2125,7 @@ async function open(base, headers, signal, atOnce = H1.atOnce) {
2055
2125
  if (o.blocked) h1Until = Date.now() + H2.retryAfter
2056
2126
  }
2057
2127
  if (signal?.aborted) return cancelledOpen()
2058
- return openH1(headers, signal, atOnce)
2128
+ return openH1(headers, signal, atOnce, overlaps)
2059
2129
  }
2060
2130
 
2061
2131
  const H2 = {
@@ -2128,10 +2198,19 @@ const H1 = {
2128
2198
  // keeps them: one at a time, a new Run's history (a blob a message,
2129
2199
  // hundreds for a long conversation, all asked for at once) took a round
2130
2200
  // trip to Cursor each before the model could start. A native session's
2131
- // Run keeps them one at a time (runNative).
2201
+ // Run sends only its KV replies that way (kvReply); each of its other
2202
+ // messages waits for every append before it, and goes alone (runNative).
2132
2203
  atOnce: 16,
2133
2204
  }
2134
2205
 
2206
+ // kvReply is whether a client message (an AgentClientMessage, unframed) is
2207
+ // a KV reply, kv_client_message: a blob Cursor asked for, which nothing
2208
+ // else waits on. Cursor asks for a new Run's whole history at once.
2209
+ function kvReply(msg) {
2210
+ const b = Buffer.isBuffer(msg) ? msg : Buffer.from(msg ?? EMPTY)
2211
+ return b.length > 0 && b[0] === 0x1a // field 3, length-delimited
2212
+ }
2213
+
2135
2214
  // connectCode is Connect's code for an HTTP status that came with no
2136
2215
  // Connect error of its own
2137
2216
  function connectCode(status) {
@@ -2144,8 +2223,11 @@ function connectCode(status) {
2144
2223
  // the stream it reads, Connect's frames as the HTTP/2 Run's. Its response
2145
2224
  // is a 200 at once; an error Cursor says later, or a call that fails,
2146
2225
  // ends the stream with Connect's end frame, which runOnce reads as the
2147
- // Run's own.
2148
- function openH1(headers, signal, atOnce = H1.atOnce) {
2226
+ // Run's own. overlaps says of a message whether it may go up while appends
2227
+ // before it are on their way (up to atOnce of them); one that may not waits
2228
+ // for all of them, goes alone, and its write's callback is its own append
2229
+ // answered.
2230
+ function openH1(headers, signal, atOnce = H1.atOnce, overlaps = () => true) {
2149
2231
  if (signal?.aborted) return Promise.resolve(cancelledOpen())
2150
2232
  const ac = new AbortController()
2151
2233
  const id = headers["x-request-id"]
@@ -2159,11 +2241,20 @@ function openH1(headers, signal, atOnce = H1.atOnce) {
2159
2241
  write(chunk, _enc, cb) {
2160
2242
  // runOnce writes one whole frame each time
2161
2243
  const msg = Buffer.from(chunk).subarray(5)
2162
- const n = seq++
2163
- const p = append(msg, n)
2164
- going.add(p)
2165
- const gone = () => going.delete(p)
2166
- p.then(gone, gone)
2244
+ const go = () => {
2245
+ const p = append(msg, seq++)
2246
+ going.add(p)
2247
+ const gone = () => going.delete(p)
2248
+ p.then(gone, gone)
2249
+ return p
2250
+ }
2251
+ if (!overlaps(msg)) {
2252
+ // alone: after every append before it is answered, and answered
2253
+ // itself before the next write
2254
+ Promise.allSettled([...going]).then(go).then(() => cb(), (e) => cb(e))
2255
+ return
2256
+ }
2257
+ go()
2167
2258
  // Up to atOnce appends go at once; past that the write stays
2168
2259
  // buffered until one finishes, so callbacks and drain reflect the
2169
2260
  // pending upload rather than an unbounded promise chain. At one,
@@ -2432,9 +2523,15 @@ async function runNative({ tok, base, id, tools, conv, convID, signal, maxMode,
2432
2523
  "x-cursor-agent-allowed-tools": "mcp_tool_call,get_mcp_tools_tool_call",
2433
2524
  },
2434
2525
  native.ac.signal,
2435
- // one at a time: a native session counts a write as done only when
2436
- // Cursor answered its own append, so a tool result is never uncertain
2437
- 1,
2526
+ // Over HTTP/1.1 the history's blobs go up H1.atOnce at a time, as
2527
+ // cursor-agent sends them; one at a time, a Run with a long history
2528
+ // waited a round trip to api2 a blob before its first token (ysm119 on
2529
+ // Discord: 135 blobs, 37 s against 3 s over HTTP/2). Everything else
2530
+ // goes alone, after the appends before it were answered, and counts as
2531
+ // written only once Cursor answered it, so a tool result is never
2532
+ // uncertain.
2533
+ H1.atOnce,
2534
+ kvReply,
2438
2535
  )
2439
2536
  if (o.error) {
2440
2537
  native.fail(o.error)
@@ -3167,4 +3264,4 @@ export async function CursorAuthPlugin() {
3167
3264
  }
3168
3265
 
3169
3266
  // for tests
3170
- export const _internal = { listFor, lists, keychain, keychainToken, nativeTransport, onDemandOf,resetH1: () => (h1Until = 0), onH1: () => Date.now() < h1Until, resetTeam: () => { teamRead.at = 0; teamRead.info = null }, expireTeam: () => (teamRead.at = 0),H2, H1, h2Blocked, open, openH1, openH2, STEP_WAIT, stepUsage, usageKey, ledger, owed, flights, misses, unreadable, catalog, usable, request, modelsOf, tokensOf, maxRequired, conversationID, usageOf, sessionOf, SESSION, errorResponse, kept, poolBase, conversation, buildRun, decode, exec, fields, pb, pbValue, pbAny, frame, frames, failure, toolsOf, expiry }
3267
+ export const _internal = { kvReply, listFor, lists, keychain, keychainToken, nativeTransport, onDemandOf,resetH1: () => (h1Until = 0), onH1: () => Date.now() < h1Until, resetTeam: () => { teamRead.at = 0; teamRead.info = null }, expireTeam: () => (teamRead.at = 0),H2, H1, h2Blocked, open, openH1, openH2, STEP_WAIT, stepUsage, usageKey, ledger, owed, flights, misses, unreadable, catalog, usable, request, modelsOf, tokensOf, maxRequired, conversationID, usageOf, sessionOf, SESSION, errorResponse, kept, poolBase, conversation, buildRun, decode, exec, fields, pb, pbValue, pbAny, frame, frames, failure, toolsOf, expiry }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-cursor-auth",
3
- "version": "0.2.28",
3
+ "version": "0.2.30",
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",