@magpie-community/opencode-cursor-auth 0.2.33 → 0.2.35
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 +29 -2
- package/index.mjs +72 -12
- package/native-session.mjs +295 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -123,8 +123,11 @@ caller's tool results is bounded by the 120 seconds above instead.
|
|
|
123
123
|
|
|
124
124
|
Cursor's count of a step's tool calls includes calls the plugin answers itself:
|
|
125
125
|
a tool the request doesn't have is refused to the model, and Cursor's own
|
|
126
|
-
`get_mcp_tools` is answered with the request's tools.
|
|
127
|
-
|
|
126
|
+
`get_mcp_tools` is answered with the request's tools. It also includes calls
|
|
127
|
+
Cursor completes by itself, which it writes and completes in the step but never
|
|
128
|
+
starts on the client or sends to it; those count toward it too. Once the count
|
|
129
|
+
has come, a checkpoint whose state waits on exactly the calls the caller was
|
|
130
|
+
given also ends the step (#550). The step's calls are the rest.
|
|
128
131
|
|
|
129
132
|
These are retention limits. A missing, expired, restarted, busy, incompatible,
|
|
130
133
|
or full owner uses the original history reconstruction and stateless decoder
|
|
@@ -298,6 +301,30 @@ could still pay for them. On-demand and Total are still shown but never
|
|
|
298
301
|
keep an account in routing. Use it when on-demand is a shared or paid pool
|
|
299
302
|
you don't want spent. When off, on-demand works as described above.
|
|
300
303
|
|
|
304
|
+
### Wire trace (optional, for bug reports)
|
|
305
|
+
|
|
306
|
+
In magpie, open the Cursor provider, turn on **Wire trace** under **Plugin
|
|
307
|
+
options**, and save (or set `"pluginOptions": {"cursor": {"wire_trace":
|
|
308
|
+
true}}` in magpie's settings.json). It is off by default and takes effect
|
|
309
|
+
at the next request.
|
|
310
|
+
|
|
311
|
+
When on, each Cursor request that fails (not one you cancel) saves a JSON
|
|
312
|
+
file in the `cursor-traces` folder of magpie's folder: `~/.config/magpie/cursor-traces`
|
|
313
|
+
(on Windows `C:\Users\<you>\.config\magpie\cursor-traces`), or the
|
|
314
|
+
portable data folder. The newest 20 are kept. A file holds the Run's
|
|
315
|
+
frames in order, both ways, as each frame's time (ms from the Run's
|
|
316
|
+
start), size, kind (text, thinking, checkpoint, heartbeat, ...), field
|
|
317
|
+
numbers and sizes, and the field numbers and sizes one level inside them,
|
|
318
|
+
plus the error and the model id. A few of Cursor's numbers are kept too: a
|
|
319
|
+
tool count's value, which kind of call (the field number of Cursor's tool
|
|
320
|
+
call) a call update is about, how many calls a checkpoint waits on, and,
|
|
321
|
+
for a Run that went without the native session, why. No prompt, reply or thinking text, tool
|
|
322
|
+
name or argument, blob or checkpoint content, token, header, account or
|
|
323
|
+
conversation id is written; the error's message has anything shaped like
|
|
324
|
+
an email, id or token taken out. Look the file over before you attach it.
|
|
325
|
+
|
|
326
|
+
Turn it off again when you're done.
|
|
327
|
+
|
|
301
328
|
Sign in to the Cursor account linked in Grok Bot. A linked SuperGrok
|
|
302
329
|
subscription grants Bot access without a paid Cursor plan; see
|
|
303
330
|
[Grok Bot plans and billing](https://cursor.com/help/grok-bot/plans).
|
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, Quiet } from "./native-session.mjs"
|
|
31
|
+
import { NATIVE_LIMITS, nativeSessions, NativeSessionError, nativeFields, nativeMcpArgs, pendingCalls, Quiet, setWireTrace, StepCalls } 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"
|
|
@@ -642,6 +642,11 @@ function credits(plan, grants, ids, resets, now = Date.now()) {
|
|
|
642
642
|
// keeping nothing in routing. Off, on-demand that can still pay keeps it.
|
|
643
643
|
const INCLUDED_ONLY = "included_only"
|
|
644
644
|
|
|
645
|
+
// WIRE_TRACE is the switch (magpie's plugin options, off by default) that
|
|
646
|
+
// saves a failed Run's frames, scrubbed, as a JSON file in cursor-traces in
|
|
647
|
+
// magpie's folder (plugins#550): see WireTrace for what one holds.
|
|
648
|
+
const WIRE_TRACE = "wire_trace"
|
|
649
|
+
|
|
645
650
|
// enterpriseUsage is the allowance GetCurrentPeriodUsage leaves out on an
|
|
646
651
|
// Enterprise account. That call answers with only the billing cycle there;
|
|
647
652
|
// the website's usage-summary has individualUsage.overall, in cents, and
|
|
@@ -2742,7 +2747,7 @@ async function runNative({ tok, base, id, tools, conv, convID, signal, maxMode,
|
|
|
2742
2747
|
|
|
2743
2748
|
// runOnce is one Run of the chat on the agent API at base: an async
|
|
2744
2749
|
// iterator of the answer's pieces, or the error it failed with before any.
|
|
2745
|
-
async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, params }) {
|
|
2750
|
+
async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, params, why }) {
|
|
2746
2751
|
// the Run's conversation_id, a new one when nothing names the session:
|
|
2747
2752
|
// its usage event is found by it
|
|
2748
2753
|
const cid = convID || randomUUID()
|
|
@@ -2772,8 +2777,13 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
|
|
|
2772
2777
|
const { session, req } = o
|
|
2773
2778
|
let closed = false
|
|
2774
2779
|
let heartbeat
|
|
2780
|
+
// a Run Cursor stops answering ends, as a retained one does (#2672)
|
|
2781
|
+
const quiet = new Quiet(NATIVE_LIMITS, () => close(), { path: "stateless", model: id, why })
|
|
2775
2782
|
const send = (msg) => {
|
|
2776
|
-
if (!closed && !req.destroyed && req.writable)
|
|
2783
|
+
if (!closed && !req.destroyed && req.writable) {
|
|
2784
|
+
quiet.trace?.frame("out", msg)
|
|
2785
|
+
req.write(frame(msg))
|
|
2786
|
+
}
|
|
2777
2787
|
}
|
|
2778
2788
|
const close = () => {
|
|
2779
2789
|
if (closed) return
|
|
@@ -2804,8 +2814,7 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
|
|
|
2804
2814
|
book.done()
|
|
2805
2815
|
return { error: failure(o.status, Buffer.concat(chunks).toString("utf8").slice(0, 1 << 20)) }
|
|
2806
2816
|
}
|
|
2807
|
-
|
|
2808
|
-
const it = decode(new Quiet(NATIVE_LIMITS, close).frames(frames(req)), {
|
|
2817
|
+
const it = decode(quiet.frames(frames(req)), {
|
|
2809
2818
|
send,
|
|
2810
2819
|
blobs,
|
|
2811
2820
|
tools,
|
|
@@ -2813,10 +2822,14 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
|
|
|
2813
2822
|
close,
|
|
2814
2823
|
stepUsage: book.own,
|
|
2815
2824
|
owedUsage: book.owed,
|
|
2825
|
+
note: (name) => quiet.trace?.event(name),
|
|
2816
2826
|
})
|
|
2817
2827
|
const wrapped = (async function* () {
|
|
2818
2828
|
try {
|
|
2819
|
-
|
|
2829
|
+
for await (const v of it) {
|
|
2830
|
+
if (v?.error) quiet.failed(v.error)
|
|
2831
|
+
yield v
|
|
2832
|
+
}
|
|
2820
2833
|
} finally {
|
|
2821
2834
|
close()
|
|
2822
2835
|
book.done()
|
|
@@ -2840,7 +2853,7 @@ async function runOnce({ tok, base, id, tools, conv, convID, signal, maxMode, pa
|
|
|
2840
2853
|
|
|
2841
2854
|
// decode follows the server's messages into the answer's pieces until the
|
|
2842
2855
|
// turn ends, or the model has made its tool calls.
|
|
2843
|
-
async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage, owedUsage }) {
|
|
2856
|
+
async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage, owedUsage, note }) {
|
|
2844
2857
|
let calls = 0
|
|
2845
2858
|
// answered: the step's calls answered here (a refusal, the MCP tool
|
|
2846
2859
|
// list), which Cursor's count of the step's calls takes in too (#142)
|
|
@@ -2848,14 +2861,18 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage,
|
|
|
2848
2861
|
// ended: Cursor said what the turn used
|
|
2849
2862
|
let ended = false
|
|
2850
2863
|
let listed = 0
|
|
2864
|
+
// what Cursor said of the step's calls: those it completed by itself
|
|
2865
|
+
// count toward listed too, and never come as an exec (plugins#550)
|
|
2866
|
+
const step = new StepCalls(fields)
|
|
2851
2867
|
// stepDone is whether every call the step listed has come. A step whose
|
|
2852
2868
|
// calls were all answered here goes on on Cursor's side, under a count of
|
|
2853
2869
|
// its own.
|
|
2854
2870
|
const stepDone = () => {
|
|
2855
|
-
if (!listed || calls + answered < listed) return false
|
|
2871
|
+
if (!listed || calls + answered + step.completed < listed) return false
|
|
2856
2872
|
if (calls > 0) return true
|
|
2857
2873
|
listed = 0
|
|
2858
2874
|
answered = 0
|
|
2875
|
+
step.nextStep()
|
|
2859
2876
|
return false
|
|
2860
2877
|
}
|
|
2861
2878
|
let said = 0
|
|
@@ -2972,6 +2989,16 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage,
|
|
|
2972
2989
|
return
|
|
2973
2990
|
}
|
|
2974
2991
|
break
|
|
2992
|
+
case 2: // a call started on the client
|
|
2993
|
+
case 3: // a call completed: by Cursor itself when it never started here
|
|
2994
|
+
case 7: // a call the model is writing
|
|
2995
|
+
// still named in an empty reply's list: only their ids are read
|
|
2996
|
+
count(seen.updates, u.num)
|
|
2997
|
+
if (step.update(u.num, uf) && (note?.("completed by Cursor"), stepDone())) {
|
|
2998
|
+
yield await settled()
|
|
2999
|
+
return
|
|
3000
|
+
}
|
|
3001
|
+
break
|
|
2975
3002
|
default:
|
|
2976
3003
|
count(seen.updates, u.num)
|
|
2977
3004
|
}
|
|
@@ -2979,6 +3006,7 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage,
|
|
|
2979
3006
|
break
|
|
2980
3007
|
case 2: {
|
|
2981
3008
|
// exec_server_message: the server asks the client to run something
|
|
3009
|
+
step.exec(m.data)
|
|
2982
3010
|
const call = exec(m.data, tools, send, closeExec, seen.refused)
|
|
2983
3011
|
if (call) {
|
|
2984
3012
|
said += call.args.length
|
|
@@ -2991,6 +3019,18 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage,
|
|
|
2991
3019
|
}
|
|
2992
3020
|
break
|
|
2993
3021
|
}
|
|
3022
|
+
case 3:
|
|
3023
|
+
// conversation_checkpoint_update: Cursor's state. Once the step
|
|
3024
|
+
// has its count, a state waiting on just the calls handed to the
|
|
3025
|
+
// caller is a step Cursor has finished: what more the count
|
|
3026
|
+
// says will not come (plugins#550)
|
|
3027
|
+
if (listed && calls > 0 && pendingCalls(m.data, fields) === calls) {
|
|
3028
|
+
note?.("waited on")
|
|
3029
|
+
yield await settled()
|
|
3030
|
+
return
|
|
3031
|
+
}
|
|
3032
|
+
count(seen.messages, m.num)
|
|
3033
|
+
break
|
|
2994
3034
|
case 4: {
|
|
2995
3035
|
// kv_server_message: a blob wanted, or one to keep
|
|
2996
3036
|
const kv = fields(m.data)
|
|
@@ -3021,7 +3061,10 @@ async function* decode(stream, { send, blobs, tools, estimate, close, stepUsage,
|
|
|
3021
3061
|
} catch (e) {
|
|
3022
3062
|
// gone quiet is no answer, calls or not: the step's count never came
|
|
3023
3063
|
if (e?.quiet) {
|
|
3024
|
-
|
|
3064
|
+
// with what came of the step's calls, as the native path's batch
|
|
3065
|
+
// error says it (plugins#550)
|
|
3066
|
+
const batch = calls || listed ? `; the step's tool count ${listed || "not sent"}: ${calls} calls came, ${answered} answered here, ${step.completed} completed by Cursor` : ""
|
|
3067
|
+
yield { error: { status: e.status, message: batch ? e.message.replace(/( \(try again\))?$/, batch + "$1") : e.message } }
|
|
3025
3068
|
return
|
|
3026
3069
|
}
|
|
3027
3070
|
if (calls > 0 && /EOF/.test(e?.message ?? "")) {
|
|
@@ -3195,10 +3238,11 @@ async function answerOf(auth, chat, signal, session = "") {
|
|
|
3195
3238
|
// uses the original history builder/decoder without cancelling a live parent.
|
|
3196
3239
|
const native = prepared.fallback ? null : nativeSessions.create(prepared, { id, maxMode, params }, { pb, exec, usageOf, failure })
|
|
3197
3240
|
const args = { tok, base, id, tools, conv, convID, signal, maxMode, params }
|
|
3198
|
-
let response = native ? await runNative({ ...args, native }) : await runOnce(args)
|
|
3241
|
+
let response = native ? await runNative({ ...args, native }) : await runOnce({ ...args, why: prepared.fallback || "retention capacity or reservation unavailable" })
|
|
3199
3242
|
const rebuild = async () => {
|
|
3200
|
-
const
|
|
3201
|
-
fallback
|
|
3243
|
+
const why = response.error?.continuityReason || "missing-batch-count"
|
|
3244
|
+
const fallback = await runOnce({ ...args, why })
|
|
3245
|
+
fallback.diagnostic = "stateless; bootstrap=imported-history; reason=" + why
|
|
3202
3246
|
return fallback
|
|
3203
3247
|
}
|
|
3204
3248
|
// A missing or conflicting native batch count may be retried once,
|
|
@@ -3328,6 +3372,16 @@ export async function CursorAuthPlugin(input) {
|
|
|
3328
3372
|
return false
|
|
3329
3373
|
}
|
|
3330
3374
|
}
|
|
3375
|
+
// the folder a failed Run's trace goes to, read at each Run's start;
|
|
3376
|
+
// "" with Wire trace off, and outside magpie, which has no such option
|
|
3377
|
+
// (OpenCode's directory is the project's, never written to)
|
|
3378
|
+
setWireTrace(() => {
|
|
3379
|
+
try {
|
|
3380
|
+
return input?.magpie?.option?.(ID, WIRE_TRACE) === true && input.directory ? join(input.directory, "cursor-traces") : ""
|
|
3381
|
+
} catch {
|
|
3382
|
+
return ""
|
|
3383
|
+
}
|
|
3384
|
+
})
|
|
3331
3385
|
return {
|
|
3332
3386
|
auth: {
|
|
3333
3387
|
provider: ID,
|
|
@@ -3369,6 +3423,12 @@ export async function CursorAuthPlugin(input) {
|
|
|
3369
3423
|
description:
|
|
3370
3424
|
"Keeps an account in routing only while its included usage lasts: once an included pool (Cursor Models, Other Models, a newer plan's Weekly) is used up, magpie moves its models to the next account until it resets, even if on-demand or free credit could pay.",
|
|
3371
3425
|
},
|
|
3426
|
+
{
|
|
3427
|
+
key: WIRE_TRACE,
|
|
3428
|
+
label: "Wire trace",
|
|
3429
|
+
description:
|
|
3430
|
+
"For a bug report: when a Cursor request fails, saves the shape of what Cursor sent and what was sent back (each frame's kind, field numbers, size and time) to the cursor-traces folder in magpie's folder. No prompt or reply text, tool names or arguments, tokens, headers or account ids are written. The newest 20 are kept.",
|
|
3431
|
+
},
|
|
3372
3432
|
],
|
|
3373
3433
|
// magpie's: how much of the plan's included usage is gone (the plan
|
|
3374
3434
|
// is the one the sign-in read). The built-in's read, clean or not,
|
package/native-session.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto"
|
|
2
|
+
import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { join } from "node:path"
|
|
2
4
|
|
|
3
5
|
// Retention is process-local and optional. A lost owner rebuilds Chat history
|
|
4
6
|
// on a fresh Run; it never resubmits a result to an uncertain native exec.
|
|
@@ -53,7 +55,13 @@ const failureOf = (e) => ({ status: e?.status ?? 502, message: e?.message ?? Str
|
|
|
53
55
|
|
|
54
56
|
// kindOf is what a frame of AgentServerMessage is, for Quiet: a heartbeat
|
|
55
57
|
// alone, or what an error names as the last thing Cursor sent
|
|
56
|
-
|
|
58
|
+
// (names after cursor-agent's agent.v1 InteractionUpdate, 2026.10.01)
|
|
59
|
+
const UPDATES = {
|
|
60
|
+
1: "text", 2: "tool call started", 3: "tool call completed", 4: "thinking", 5: "thinking done",
|
|
61
|
+
6: "user message", 7: "partial tool call", 8: "token count", 9: "summary", 10: "summary started",
|
|
62
|
+
11: "summary done", 12: "shell output", 14: "turn end", 15: "tool call delta", 16: "step started",
|
|
63
|
+
17: "step done", 27: "tool count",
|
|
64
|
+
}
|
|
57
65
|
const MESSAGES = { 2: "tool call", 3: "checkpoint", 4: "blob request" }
|
|
58
66
|
function kindOf(f) {
|
|
59
67
|
if (f.end) return "stream end"
|
|
@@ -75,8 +83,10 @@ function kindOf(f) {
|
|
|
75
83
|
// reply then waited until the agent gave up (305 s, yetone/magpie#2672).
|
|
76
84
|
// Only time spent waiting on Cursor's next frame counts: not this side
|
|
77
85
|
// handling one, not a slow reader of the reply, not a paused Run.
|
|
86
|
+
// meta names the Run for a wire trace (path, model); with the trace off it
|
|
87
|
+
// is unused.
|
|
78
88
|
export class Quiet {
|
|
79
|
-
constructor(limits, stop) {
|
|
89
|
+
constructor(limits, stop, meta = {}) {
|
|
80
90
|
this.limits = limits
|
|
81
91
|
this.stop = stop
|
|
82
92
|
this.heard = 0 // ms waited since Cursor's last frame that wasn't a heartbeat
|
|
@@ -86,6 +96,14 @@ export class Quiet {
|
|
|
86
96
|
this.paused = false
|
|
87
97
|
this.waiting = false
|
|
88
98
|
this.why = ""
|
|
99
|
+
// what the error says of a checkpoint (plugins#550): its number in the
|
|
100
|
+
// Run, which of its step's it was (step is 0 where the path doesn't
|
|
101
|
+
// count steps), and the kinds of the frames before it, run-length
|
|
102
|
+
this.checkpoints = 0
|
|
103
|
+
this.step = 0
|
|
104
|
+
this.stepCheckpoints = 0
|
|
105
|
+
this.trail = []
|
|
106
|
+
this.trace = WireTrace.open(meta)
|
|
89
107
|
}
|
|
90
108
|
arm() {
|
|
91
109
|
clearTimeout(this.timer)
|
|
@@ -102,20 +120,47 @@ export class Quiet {
|
|
|
102
120
|
fire() {
|
|
103
121
|
const gap = Date.now() - this.since
|
|
104
122
|
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.
|
|
123
|
+
this.why = `Cursor went quiet: ${what}${this.last ? ", its last message " + this.lastSaid() : ""} (try again)`
|
|
124
|
+
this.trace?.event("quiet", { heard: this.heard + gap, heartbeats: this.heartbeats })
|
|
106
125
|
this.gone?.()
|
|
107
126
|
this.stop({ status: 502, message: this.why })
|
|
127
|
+
this.failed({ status: 502, message: this.why })
|
|
128
|
+
}
|
|
129
|
+
// lastSaid is the last frame's kind as the error names it; a checkpoint
|
|
130
|
+
// with its number, its place in the step and what came before it
|
|
131
|
+
lastSaid() {
|
|
132
|
+
if (this.last !== "checkpoint") return this.last
|
|
133
|
+
const row = this.trail.at(-1)?.[1] ?? 1
|
|
134
|
+
const before = this.trail.slice(0, -1).map(([k, n]) => n > 1 ? `${k} ×${n}` : k).join(", ")
|
|
135
|
+
const nth = this.stepCheckpoints === 1 ? "the first" : `number ${this.stepCheckpoints}`
|
|
136
|
+
return `checkpoint #${this.checkpoints} of the Run`
|
|
137
|
+
+ (this.step ? `, ${nth} of step ${this.step}` : "")
|
|
138
|
+
+ (row > 1 ? `, ${row} in a row` : "")
|
|
139
|
+
+ `, after ${before || "nothing else"}`
|
|
140
|
+
}
|
|
141
|
+
// nextStep is the Run's next model call (NativeSession counts them)
|
|
142
|
+
nextStep(n) {
|
|
143
|
+
this.step = n
|
|
144
|
+
this.stepCheckpoints = 0
|
|
145
|
+
this.trace?.event("step", { n })
|
|
146
|
+
}
|
|
147
|
+
// failed is how the Run ended, when it failed: a wire trace, if on, is
|
|
148
|
+
// written then (once)
|
|
149
|
+
failed(error) {
|
|
150
|
+
this.trace?.write(error, this)
|
|
108
151
|
}
|
|
109
152
|
// pause stops the clock while the Run waits on the caller's tool results;
|
|
110
153
|
// resume starts it again from nothing, for Cursor's next model call
|
|
111
154
|
pause() {
|
|
112
155
|
this.waited()
|
|
113
156
|
this.paused = true
|
|
157
|
+
this.trace?.event("pause")
|
|
114
158
|
}
|
|
115
159
|
resume() {
|
|
116
160
|
this.paused = false
|
|
117
161
|
this.heard = 0
|
|
118
162
|
this.heartbeats = 0
|
|
163
|
+
this.trace?.event("resume")
|
|
119
164
|
this.arm()
|
|
120
165
|
}
|
|
121
166
|
// frames passes on a Run's frames; once Cursor has gone quiet, the read
|
|
@@ -141,10 +186,25 @@ export class Quiet {
|
|
|
141
186
|
this.waited()
|
|
142
187
|
}
|
|
143
188
|
if (this.why) throw Object.assign(new NativeSessionError(502, this.why), { quiet: true })
|
|
144
|
-
if (r.done)
|
|
189
|
+
if (r.done) {
|
|
190
|
+
this.trace?.event("eof")
|
|
191
|
+
return
|
|
192
|
+
}
|
|
145
193
|
const kind = kindOf(r.value)
|
|
194
|
+
this.trace?.frame("in", r.value, kind)
|
|
146
195
|
if (kind == null) this.heartbeats++
|
|
147
|
-
else
|
|
196
|
+
else {
|
|
197
|
+
[this.heard, this.heartbeats, this.last] = [0, 0, kind]
|
|
198
|
+
if (kind === "checkpoint") {
|
|
199
|
+
this.checkpoints++
|
|
200
|
+
this.stepCheckpoints++
|
|
201
|
+
}
|
|
202
|
+
if (this.trail.at(-1)?.[0] === kind) this.trail.at(-1)[1]++
|
|
203
|
+
else {
|
|
204
|
+
this.trail.push([kind, 1])
|
|
205
|
+
if (this.trail.length > 4) this.trail.shift()
|
|
206
|
+
}
|
|
207
|
+
}
|
|
148
208
|
yield r.value
|
|
149
209
|
}
|
|
150
210
|
} finally {
|
|
@@ -158,6 +218,141 @@ export class Quiet {
|
|
|
158
218
|
}
|
|
159
219
|
}
|
|
160
220
|
|
|
221
|
+
// The wire trace (plugins#550, off by default; the plugin's Wire trace
|
|
222
|
+
// option): a failed Run's frames, in and out, written to a JSON file for a
|
|
223
|
+
// bug report. A frame is kept as its kind, size, top-level field numbers
|
|
224
|
+
// with their wire types and sizes, and those fields' own field numbers and
|
|
225
|
+
// sizes one level down: the shape of the protocol, never its bytes. No
|
|
226
|
+
// text, thinking, tool name or argument, blob or blob id, checkpoint
|
|
227
|
+
// content, token, header, account or conversation id is read into it; the
|
|
228
|
+
// error's message is scrubbed of anything shaped like an email, id or
|
|
229
|
+
// token. traceDir is the folder, or "" while off, asked at each Run's start.
|
|
230
|
+
let traceDir = () => ""
|
|
231
|
+
export function setWireTrace(dir) {
|
|
232
|
+
traceDir = typeof dir === "function" ? dir : () => dir || ""
|
|
233
|
+
}
|
|
234
|
+
const TRACE_HEAD = 2000 // the Run's first frames, kept
|
|
235
|
+
const TRACE_TAIL = 3000 // and its last, the ones in between counted only
|
|
236
|
+
const TRACE_FILES = 20 // the newest traces kept in the folder
|
|
237
|
+
// shape is a message's fields as [number, wire, size] (a varint's size 0),
|
|
238
|
+
// with children: the fields' own fields, for a field that is a message
|
|
239
|
+
function shape(data, children) {
|
|
240
|
+
return nativeFields(data).map((f) => {
|
|
241
|
+
const own = [f.num, f.wire, f.wire === 0 ? 0 : f.data.length]
|
|
242
|
+
if (!children || f.wire !== 2) return own
|
|
243
|
+
try {
|
|
244
|
+
return [...own, nativeFields(f.data).map((c) => [c.num, c.wire, c.wire === 0 ? 0 : c.data.length])]
|
|
245
|
+
} catch {
|
|
246
|
+
return own
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
export function scrub(s) {
|
|
251
|
+
return String(s ?? "")
|
|
252
|
+
.replace(/[\w.+-]+@[\w-]+(\.[\w-]+)+/g, "<email>")
|
|
253
|
+
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<id>")
|
|
254
|
+
.replace(/(auth0|user|google-oauth2|github)\|[\w-]+/gi, "<account>")
|
|
255
|
+
.replace(/[A-Za-z0-9_\-+/=.]{24,}/g, "<redacted>")
|
|
256
|
+
.slice(0, 2000)
|
|
257
|
+
}
|
|
258
|
+
// detail is what a trace keeps of an inbound frame past its shape, all of
|
|
259
|
+
// it numbers of Cursor's protocol, never content (plugins#550): a tool
|
|
260
|
+
// count's number, the kind of call (ToolCall's field) a call update is
|
|
261
|
+
// of, and how many calls a checkpoint's state waits on
|
|
262
|
+
function detail(data) {
|
|
263
|
+
const out = {}
|
|
264
|
+
for (const m of nativeFields(data)) {
|
|
265
|
+
if (m.num === 1) {
|
|
266
|
+
for (const u of nativeFields(m.data)) {
|
|
267
|
+
if (u.num === 27) out.count = num(nativeFields(u.data), 1)
|
|
268
|
+
else if (u.num === 2 || u.num === 3 || u.num === 7) {
|
|
269
|
+
const tc = field(nativeFields(u.data), 2)
|
|
270
|
+
const kind = tc && nativeFields(tc.data)[0]
|
|
271
|
+
if (kind) out.tool = kind.wire === 2 ? [kind.num, nativeFields(kind.data)[0]?.num ?? 0] : [kind.num]
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} else if (m.num === 3) out.pending = pendingCalls(m.data)
|
|
275
|
+
}
|
|
276
|
+
return out
|
|
277
|
+
}
|
|
278
|
+
export class WireTrace {
|
|
279
|
+
static open(meta) {
|
|
280
|
+
let dir = ""
|
|
281
|
+
try { dir = traceDir() } catch {}
|
|
282
|
+
return dir ? new WireTrace(dir, meta) : null
|
|
283
|
+
}
|
|
284
|
+
constructor(dir, meta = {}) {
|
|
285
|
+
this.dir = dir
|
|
286
|
+
this.meta = { path: String(meta.path ?? ""), model: String(meta.model ?? "") }
|
|
287
|
+
// why a Run went stateless: the retention fallback's reason
|
|
288
|
+
if (meta.why) this.meta.why = String(meta.why)
|
|
289
|
+
this.started = Date.now()
|
|
290
|
+
this.head = []
|
|
291
|
+
this.tail = []
|
|
292
|
+
this.dropped = 0
|
|
293
|
+
}
|
|
294
|
+
push(entry) {
|
|
295
|
+
if (this.written) return
|
|
296
|
+
if (this.head.length < TRACE_HEAD) return void this.head.push(entry)
|
|
297
|
+
this.tail.push(entry)
|
|
298
|
+
if (this.tail.length > TRACE_TAIL) {
|
|
299
|
+
this.tail.shift()
|
|
300
|
+
this.dropped++
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// frame is one frame of the Run: "in" from Cursor ({data} or {end, data}),
|
|
304
|
+
// "out" a message this side sent
|
|
305
|
+
frame(dir, f, kind) {
|
|
306
|
+
const t = Date.now() - this.started
|
|
307
|
+
const data = dir === "in" ? f.data : f
|
|
308
|
+
const entry = { t, dir, size: data?.length ?? 0 }
|
|
309
|
+
if (dir === "in") entry.kind = kind ?? "heartbeat"
|
|
310
|
+
if (dir === "in" && f.end) entry.end = true
|
|
311
|
+
else try { entry.fields = shape(data, true) } catch { entry.malformed = true }
|
|
312
|
+
if (dir === "in" && !f.end) try { Object.assign(entry, detail(data)) } catch {}
|
|
313
|
+
this.push(entry)
|
|
314
|
+
}
|
|
315
|
+
event(name, extra = {}) {
|
|
316
|
+
this.push({ t: Date.now() - this.started, event: name, ...extra })
|
|
317
|
+
}
|
|
318
|
+
// write saves the trace once, when the Run failed; a write that fails
|
|
319
|
+
// leaves the Run's error as it was
|
|
320
|
+
write(error, quiet) {
|
|
321
|
+
if (this.written) return
|
|
322
|
+
this.written = true
|
|
323
|
+
try {
|
|
324
|
+
const at = new Date(this.started).toISOString().replace(/[:.]/g, "-")
|
|
325
|
+
const body = {
|
|
326
|
+
trace: 1,
|
|
327
|
+
plugin: "@magpie-community/opencode-cursor-auth",
|
|
328
|
+
version: version(),
|
|
329
|
+
...this.meta,
|
|
330
|
+
started: new Date(this.started).toISOString(),
|
|
331
|
+
ms: Date.now() - this.started,
|
|
332
|
+
error: { status: error?.status ?? 0, message: scrub(error?.message) },
|
|
333
|
+
quiet: quiet ? { last: quiet.last, checkpoints: quiet.checkpoints, step: quiet.step, stepCheckpoints: quiet.stepCheckpoints, heartbeats: quiet.heartbeats, heard: quiet.heard, trail: quiet.trail } : undefined,
|
|
334
|
+
frames: [...this.head, ...(this.dropped ? [{ dropped: this.dropped }] : []), ...this.tail],
|
|
335
|
+
}
|
|
336
|
+
mkdirSync(this.dir, { recursive: true })
|
|
337
|
+
const file = join(this.dir, `${at}-${randomBytes(3).toString("hex")}.json`)
|
|
338
|
+
writeFileSync(file + ".tmp", JSON.stringify(body) + "\n", { mode: 0o600 })
|
|
339
|
+
renameSync(file + ".tmp", file)
|
|
340
|
+
this.file = file
|
|
341
|
+
const old = readdirSync(this.dir).filter((n) => n.endsWith(".json")).sort()
|
|
342
|
+
for (const n of old.slice(0, Math.max(0, old.length - TRACE_FILES))) rmSync(join(this.dir, n), { force: true })
|
|
343
|
+
} catch {}
|
|
344
|
+
this.head = this.tail = []
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// version is the package's, for a trace to say
|
|
348
|
+
function version() {
|
|
349
|
+
try {
|
|
350
|
+
return JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version ?? ""
|
|
351
|
+
} catch {
|
|
352
|
+
return ""
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
161
356
|
// Unlike the compatibility codec, malformed protobuf tails must fail a live
|
|
162
357
|
// continuation. Dropping one could turn a truncated batch into a valid handoff.
|
|
163
358
|
export function nativeFields(input, maxFields = Infinity) {
|
|
@@ -272,6 +467,67 @@ const field = (fs, n) => fs.find((f) => f.num === n)
|
|
|
272
467
|
const str = (fs, n) => field(fs, n)?.data.toString("utf8") ?? ""
|
|
273
468
|
const num = (fs, n) => field(fs, n)?.n ?? 0
|
|
274
469
|
|
|
470
|
+
// StepCalls is what Cursor says of a step's calls besides its execs
|
|
471
|
+
// (plugins#550). InteractionUpdate 2 (tool_call_started), 3
|
|
472
|
+
// (tool_call_completed) and 7 (partial_tool_call) each carry the call's
|
|
473
|
+
// call_id (field 1). A call Cursor completed by itself, never started on
|
|
474
|
+
// the client and never sent as an exec, is in tool_requests_listed's count
|
|
475
|
+
// all the same, and no exec of it will come: 0.2.34 waited for it while
|
|
476
|
+
// Cursor waited for the one call it had sent, 240 s, heartbeats only.
|
|
477
|
+
// parse is the path's field reader.
|
|
478
|
+
export class StepCalls {
|
|
479
|
+
constructor(parse) {
|
|
480
|
+
this.parse = parse
|
|
481
|
+
this.started = new Set()
|
|
482
|
+
this.execs = new Set() // the short strings of each exec's args: its tool_call_id among them
|
|
483
|
+
this.partial = new Set() // calls the model is writing in this step
|
|
484
|
+
this.byCursor = new Set()
|
|
485
|
+
}
|
|
486
|
+
// update takes an InteractionUpdate's own field (num) and its fields; it
|
|
487
|
+
// says whether a call was completed by Cursor: one this step wrote (7)
|
|
488
|
+
// and completed (3) with no start on the client (2) and no exec. A
|
|
489
|
+
// completion of an earlier step's call is never counted again.
|
|
490
|
+
update(n, uf) {
|
|
491
|
+
if (n !== 2 && n !== 3 && n !== 7) return false
|
|
492
|
+
const id = str(uf, 1)
|
|
493
|
+
if (!id) return false
|
|
494
|
+
if (n === 2) this.started.add(id)
|
|
495
|
+
if (n === 7) this.partial.add(id)
|
|
496
|
+
if (n !== 3) return false
|
|
497
|
+
if (!this.partial.has(id) || this.started.has(id) || this.execs.has(id) || this.byCursor.has(id)) return false
|
|
498
|
+
this.byCursor.add(id)
|
|
499
|
+
return true
|
|
500
|
+
}
|
|
501
|
+
// exec notes an exec's args, whatever its kind: each kind keeps its
|
|
502
|
+
// tool_call_id at a field of its own (ShellArgs 4, ReadArgs 2, McpArgs 3)
|
|
503
|
+
exec(data) {
|
|
504
|
+
try {
|
|
505
|
+
for (const e of this.parse(data)) {
|
|
506
|
+
if (e.wire !== 2 || e.num === 15 || e.num === 19) continue
|
|
507
|
+
for (const a of this.parse(e.data)) if (a.wire === 2 && a.data.length && a.data.length <= 512) this.execs.add(a.data.toString("utf8"))
|
|
508
|
+
}
|
|
509
|
+
} catch {}
|
|
510
|
+
}
|
|
511
|
+
get completed() {
|
|
512
|
+
return this.byCursor.size
|
|
513
|
+
}
|
|
514
|
+
// nextStep starts the next step's count
|
|
515
|
+
nextStep() {
|
|
516
|
+
this.partial.clear()
|
|
517
|
+
this.byCursor.clear()
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
// pendingCalls is how many calls a checkpoint (ConversationStateStructure)
|
|
521
|
+
// says Cursor waits on: its pending_tool_calls, field 4; null when it
|
|
522
|
+
// can't be read
|
|
523
|
+
export function pendingCalls(data, parse = nativeFields) {
|
|
524
|
+
try {
|
|
525
|
+
return parse(data).filter((f) => f.num === 4).length
|
|
526
|
+
} catch {
|
|
527
|
+
return null
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
275
531
|
function argsOf(s) {
|
|
276
532
|
try {
|
|
277
533
|
const v = typeof s === "string" ? JSON.parse(s) : s
|
|
@@ -571,6 +827,8 @@ class NativeSession {
|
|
|
571
827
|
this.ac = new AbortController()
|
|
572
828
|
this.execs = new Map()
|
|
573
829
|
this.nativeCalls = new Map()
|
|
830
|
+
// what Cursor said of the step's calls besides its execs (plugins#550)
|
|
831
|
+
this.stepCalls = new StepCalls(nativeFields)
|
|
574
832
|
this.blobs = new Map()
|
|
575
833
|
this.knownHashes = new Set()
|
|
576
834
|
this.blobSize = 0
|
|
@@ -587,7 +845,8 @@ class NativeSession {
|
|
|
587
845
|
this.segment = new Segment(this, prepared.chat)
|
|
588
846
|
this.retain(bytes(prepared.chat))
|
|
589
847
|
this.expireIn(this.limits.lifetime)
|
|
590
|
-
this.quiet = new Quiet(this.limits, (e) => this.fail(e))
|
|
848
|
+
this.quiet = new Quiet(this.limits, (e) => this.fail(e), { path: "native", model: resolution?.id })
|
|
849
|
+
this.quiet.nextStep(this.steps)
|
|
591
850
|
}
|
|
592
851
|
// expireIn sets the Run's absolute deadline ms from now: from its start,
|
|
593
852
|
// the whole lifetime; a test brings it in after the first answer
|
|
@@ -632,6 +891,8 @@ class NativeSession {
|
|
|
632
891
|
if (this.error || this.state === "complete") return
|
|
633
892
|
this.error = error
|
|
634
893
|
this.state = state
|
|
894
|
+
// a cancelled Run is the caller's doing, not a failure to trace
|
|
895
|
+
if (state !== "cancelled") this.quiet.failed(error)
|
|
635
896
|
this.segment.fail(error)
|
|
636
897
|
for (const segment of this.batches ?? []) segment.detach?.()
|
|
637
898
|
this.segment.detach?.()
|
|
@@ -671,6 +932,7 @@ class NativeSession {
|
|
|
671
932
|
}
|
|
672
933
|
send(message) {
|
|
673
934
|
if (this.error || this.transportClosed) invalid("native transport is no longer writable", 410)
|
|
935
|
+
this.quiet.trace?.frame("out", message)
|
|
674
936
|
this.transport.send(message)
|
|
675
937
|
}
|
|
676
938
|
// reply answers one of Cursor's KV requests once the outbound queue has
|
|
@@ -764,6 +1026,8 @@ class NativeSession {
|
|
|
764
1026
|
// caller's results, or after a step whose calls were all answered here.
|
|
765
1027
|
nextStep(estimate) {
|
|
766
1028
|
this.steps++
|
|
1029
|
+
this.stepCalls.nextStep()
|
|
1030
|
+
this.quiet.nextStep(this.steps)
|
|
767
1031
|
this.stepSaid = this.said
|
|
768
1032
|
if (estimate != null) this.stepEstimate = estimate
|
|
769
1033
|
}
|
|
@@ -780,7 +1044,7 @@ class NativeSession {
|
|
|
780
1044
|
this.batchTimer = later(() => {
|
|
781
1045
|
const s = this.segment
|
|
782
1046
|
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` })
|
|
1047
|
+
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${this.stepCalls.completed ? `, ${this.stepCalls.completed} completed by Cursor` : ""}, none since ${Date.now() - since} ms` })
|
|
784
1048
|
}, this.limits.batchWait)
|
|
785
1049
|
}
|
|
786
1050
|
missingCount() {
|
|
@@ -796,6 +1060,23 @@ class NativeSession {
|
|
|
796
1060
|
if (this.batchTimer) this.waitForBatch()
|
|
797
1061
|
this.sealBatch()
|
|
798
1062
|
}
|
|
1063
|
+
// A call of the step Cursor completed by itself (plugins#550): in its
|
|
1064
|
+
// count, and never sent as an exec.
|
|
1065
|
+
completedByCursor() {
|
|
1066
|
+
if (this.terminal || this.state === "awaiting-results") return
|
|
1067
|
+
if (this.batchTimer) this.waitForBatch()
|
|
1068
|
+
this.sealBatch()
|
|
1069
|
+
}
|
|
1070
|
+
// A checkpoint whose state waits on just the calls handed to the caller,
|
|
1071
|
+
// after the step's count, is a step Cursor has finished: what more the
|
|
1072
|
+
// count says will not come (plugins#550).
|
|
1073
|
+
waitedOn(data) {
|
|
1074
|
+
const s = this.segment
|
|
1075
|
+
if (this.terminal || this.state === "awaiting-results" || s.closed || s.expected == null || !s.calls.length) return
|
|
1076
|
+
if (pendingCalls(data) !== s.calls.length) return
|
|
1077
|
+
s.waitedOn = true
|
|
1078
|
+
this.sealBatch()
|
|
1079
|
+
}
|
|
799
1080
|
listed(count) {
|
|
800
1081
|
if (!Number.isSafeInteger(count) || count < 1) invalid("invalid native tool count", 502)
|
|
801
1082
|
if (this.segment.expected != null && this.segment.expected !== count) {
|
|
@@ -815,7 +1096,8 @@ class NativeSession {
|
|
|
815
1096
|
if (segment.expected == null) return
|
|
816
1097
|
const made = segment.calls.length + segment.answered
|
|
817
1098
|
if (made > segment.expected) invalid("native tool batch exceeded its declared count", 502)
|
|
818
|
-
|
|
1099
|
+
// the calls Cursor completed by itself make up the rest (plugins#550)
|
|
1100
|
+
if ((made + this.stepCalls.completed < segment.expected && !segment.waitedOn) || segment.closed) return
|
|
819
1101
|
if (!segment.calls.length) {
|
|
820
1102
|
// Every call of the step was answered here: Cursor goes on with the
|
|
821
1103
|
// turn by itself, and its next step lists a count of its own.
|
|
@@ -1012,10 +1294,14 @@ class NativeSession {
|
|
|
1012
1294
|
if (this.error) return
|
|
1013
1295
|
} else if (u.num === 13) {
|
|
1014
1296
|
if (this.batchTimer && !this.terminal && this.state !== "awaiting-results") this.waitForBatch()
|
|
1297
|
+
} else if ((u.num === 2 || u.num === 3 || u.num === 7) && this.stepCalls.update(u.num, uf)) {
|
|
1298
|
+
this.completedByCursor()
|
|
1299
|
+
if (this.error) return
|
|
1015
1300
|
}
|
|
1016
1301
|
}
|
|
1017
1302
|
break
|
|
1018
1303
|
case 2: {
|
|
1304
|
+
this.stepCalls.exec(m.data)
|
|
1019
1305
|
const c = exec(m.data, this.chatTools, (b) => this.send(b), (id) => this.execClose(id), null, true)
|
|
1020
1306
|
if (c) this.acceptCall(c)
|
|
1021
1307
|
// request_context is Cursor asking for the environment, not a call
|
|
@@ -1024,6 +1310,7 @@ class NativeSession {
|
|
|
1024
1310
|
}
|
|
1025
1311
|
case 3:
|
|
1026
1312
|
this.checkpointReceived(m.data)
|
|
1313
|
+
this.waitedOn(m.data)
|
|
1027
1314
|
break
|
|
1028
1315
|
case 4: {
|
|
1029
1316
|
const kv = nativeFields(m.data)
|
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.35",
|
|
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",
|