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

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
@@ -153,7 +153,10 @@ as prior usage. Stateless Runs otherwise keep the dashboard accounting above.
153
153
  `X-Magpie-Cursor-Continuity` identifies `same-run` or `stateless`, with the fallback
154
154
  reason where available. A missing native batch count can retry stateless once
155
155
  before any output is exposed or native result submitted; progressive output is
156
- 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.
157
160
 
158
161
  The existing request builder and model selection remain in use, including the
159
162
  legacy `thinking=false` mapping for explicit `none`/`off`. Retention adds no
package/index.mjs CHANGED
@@ -117,6 +117,68 @@ function authFile() {
117
117
  return join(process.env.XDG_CONFIG_HOME || join(home, ".config"), "cursor", "auth.json")
118
118
  }
119
119
 
120
+ // cliConfigFile is cursor-agent's cli-config.json, where it keeps the team
121
+ // it signed in to (authInfo.activeTeamId); its directory found as the CLI
122
+ // finds it, on every OS.
123
+ function cliConfigFile() {
124
+ const dir = process.env.CURSOR_CONFIG_DIR?.trim()
125
+ || (process.env.XDG_CONFIG_HOME?.trim() ? join(process.env.XDG_CONFIG_HOME, "cursor") : join(homedir(), ".cursor"))
126
+ return join(dir, "cli-config.json")
127
+ }
128
+
129
+ // tokenSub is a JWT's subject (the auth id, "auth0|user_…"); "" when it
130
+ // doesn't say.
131
+ function tokenSub(tok) {
132
+ const parts = String(tok ?? "").split(".")
133
+ if (parts.length !== 3) return ""
134
+ try {
135
+ const sub = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"))?.sub
136
+ return typeof sub === "string" ? sub : ""
137
+ } catch {
138
+ return ""
139
+ }
140
+ }
141
+
142
+ const teamOf = (v) => (Number.isSafeInteger(v) && v > 0 ? v : undefined)
143
+
144
+ // activeTeamId is the team a request is billed to, as cursor-agent sends it
145
+ // (x-cursor-team-id): cli-config.json's authInfo.activeTeamId. Without it
146
+ // Cursor takes the token's default team, which in an organization may be one
147
+ // with on-demand spend turned off ("Team budget limit reached"). The config's
148
+ // team is only taken for the account cursor-agent signed in to: a token
149
+ // whose subject isn't its authInfo.authId, or a config with none, gets none.
150
+ // Read again after 10 s; cursor-agent rewrites the file in place, so a read
151
+ // that fails to parse keeps the last one, and only a missing file clears it.
152
+ const TEAM_TTL = 10_000
153
+ const teamRead = { at: 0, info: null }
154
+ async function activeTeamId(tok) {
155
+ if (Date.now() - teamRead.at >= TEAM_TTL) {
156
+ try {
157
+ teamRead.info = JSON.parse(await readFile(cliConfigFile(), "utf8"))?.authInfo ?? null
158
+ } catch (e) {
159
+ if (e?.code === "ENOENT") teamRead.info = null
160
+ }
161
+ teamRead.at = Date.now()
162
+ }
163
+ const info = teamRead.info
164
+ if (!info) return undefined
165
+ if (!info.authId || info.authId !== tokenSub(tok)) return undefined
166
+ return teamOf(info.activeTeamId)
167
+ }
168
+
169
+ // teamHeader names that team in a request's headers, and teamBody in
170
+ // GetFilteredUsageEvents' body; neither says anything when there is none.
171
+ // GetHardLimit and GetCurrentPeriodUsage go out with "{}", as cursor-agent
172
+ // sends them: the team rides in the header only.
173
+ async function teamHeader(tok) {
174
+ const id = await activeTeamId(tok)
175
+ return id ? { "x-cursor-team-id": String(id) } : {}
176
+ }
177
+ async function teamBody(tok) {
178
+ const id = await activeTeamId(tok)
179
+ return id ? { teamId: id } : {}
180
+ }
181
+
120
182
  // TOKEN_TTL is how long a token read from the Keychain is taken as it: a
121
183
  // sign-in reads it afresh, one made in cursor-agent is seen within it.
122
184
  // KEYCHAIN_DENIED is how long a read the user may have been asked about,
@@ -218,6 +280,7 @@ async function headersFor(tok) {
218
280
  "x-cursor-client-version": await clientVersion(),
219
281
  "x-cursor-client-type": "cli",
220
282
  "x-ghost-mode": "true",
283
+ ...await teamHeader(tok),
221
284
  }
222
285
  }
223
286
 
@@ -456,7 +519,7 @@ async function hardLimit(tok) {
456
519
  try {
457
520
  const res = await fetch(API + "/aiserver.v1.DashboardService/GetHardLimit", {
458
521
  method: "POST",
459
- headers: { Authorization: `Bearer ${tok}`, "Content-Type": "application/json", "Connect-Protocol-Version": "1" },
522
+ headers: { Authorization: `Bearer ${tok}`, "Content-Type": "application/json", "Connect-Protocol-Version": "1", ...await teamHeader(tok) },
460
523
  body: "{}",
461
524
  signal: AbortSignal.timeout(15_000),
462
525
  })
@@ -481,7 +544,7 @@ async function usage(tok, ids) {
481
544
  const [res, hard] = await Promise.all([
482
545
  fetch(API + "/aiserver.v1.DashboardService/GetCurrentPeriodUsage", {
483
546
  method: "POST",
484
- headers: { Authorization: `Bearer ${tok}`, "Content-Type": "application/json", "Connect-Protocol-Version": "1" },
547
+ headers: { Authorization: `Bearer ${tok}`, "Content-Type": "application/json", "Connect-Protocol-Version": "1", ...await teamHeader(tok) },
485
548
  body: "{}",
486
549
  signal: AbortSignal.timeout(15_000),
487
550
  }),
@@ -1515,7 +1578,7 @@ async function usageEvents(tok, conv, since, want, { poll = false, first = STEP_
1515
1578
  const res = await fetch(API + "/aiserver.v1.DashboardService/GetFilteredUsageEvents", {
1516
1579
  method: "POST",
1517
1580
  headers: await headersFor(tok),
1518
- body: JSON.stringify({ teamId: 0, startDate: String(since - 60_000), endDate: String(Date.now() + 60_000), page: 1, pageSize: 50 }),
1581
+ body: JSON.stringify({ ...await teamBody(tok), startDate: String(since - 60_000), endDate: String(Date.now() + 60_000), page: 1, pageSize: 50 }),
1519
1582
  signal: abortFetch && signal ? AbortSignal.any([signal, AbortSignal.timeout(STEP_WAIT.ask)]) : AbortSignal.timeout(STEP_WAIT.ask),
1520
1583
  })
1521
1584
  if (res.status >= 400 && res.status < 500 && res.status !== 429) {
@@ -2149,6 +2212,7 @@ async function runNative({ tok, base, id, tools, conv, convID, signal, maxMode,
2149
2212
  "x-cursor-client-type": "cli", // else Cursor adds a prompt of its own
2150
2213
  "x-ghost-mode": "true", // privacy mode: nothing kept for training
2151
2214
  "x-request-id": randomUUID(),
2215
+ ...await teamHeader(tok),
2152
2216
  // none of Cursor's own tools, only the caller's
2153
2217
  "x-cursor-agent-allowed-tools": "mcp_tool_call,get_mcp_tools_tool_call",
2154
2218
  },
@@ -2237,6 +2301,7 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
2237
2301
  "x-cursor-client-type": "cli", // else Cursor adds a prompt of its own
2238
2302
  "x-ghost-mode": "true", // privacy mode: nothing kept for training
2239
2303
  "x-request-id": randomUUID(),
2304
+ ...await teamHeader(tok),
2240
2305
  // none of Cursor's own tools, only the caller's
2241
2306
  "x-cursor-agent-allowed-tools": "mcp_tool_call,get_mcp_tools_tool_call",
2242
2307
  },
@@ -2887,4 +2952,4 @@ export async function CursorAuthPlugin() {
2887
2952
  }
2888
2953
 
2889
2954
  // for tests
2890
- export const _internal = { keychain, keychainToken, nativeTransport, onDemandOf,resetH1: () => (h1Until = 0), onH1: () => Date.now() < h1Until, 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 }
2955
+ export const _internal = { 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 }
@@ -768,14 +768,19 @@ class NativeSession {
768
768
  if (estimate != null) this.stepEstimate = estimate
769
769
  }
770
770
  // waitForBatch gives the step batchWait from its latest call, answered
771
- // call or count, not from its first. Cursor sends each call once the model
772
- // has written its arguments, so a step of many long calls arrives over
773
- // 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.
774
777
  waitForBatch() {
775
778
  clearTimeout(this.batchTimer)
779
+ const since = Date.now()
776
780
  this.batchTimer = later(() => {
777
- if (this.segment.expected == null && this.segment.calls.length && !this.resultSubmitted) this.missingCount()
778
- 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` })
779
784
  }, this.limits.batchWait)
780
785
  }
781
786
  missingCount() {
@@ -1005,6 +1010,8 @@ class NativeSession {
1005
1010
  } else if (u.num === 27) {
1006
1011
  this.listed(num(uf, 1))
1007
1012
  if (this.error) return
1013
+ } else if (u.num === 13) {
1014
+ if (this.batchTimer && !this.terminal && this.state !== "awaiting-results") this.waitForBatch()
1008
1015
  }
1009
1016
  }
1010
1017
  break
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-cursor-auth",
3
- "version": "0.2.21",
3
+ "version": "0.2.23",
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",