@vidit19sharma/opencode-cursor-auth 0.1.0 → 0.2.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,32 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
4
+
5
+ ## 0.2.0 — 2026-10-16
6
+
7
+ ### Added
8
+
9
+ - Model variants: every variant Cursor publishes (reasoning effort, fast, 1M context) is a selectable id, e.g. `cursor/gpt-5.5-low-fast`, `cursor/claude-opus-5-5-1m-max`. Ids are the base id plus non-default param values and map back to Cursor `ModelSelection` params; unknown ids still pass through to Cursor's own 400.
10
+ - Model context limits: each model gets `limit.context` from Cursor's `context` parameter (default 200k) and `limit.output` 32k. Without a limit opencode 1.19 sets context to 0 and silently disables auto-compaction and the context indicator.
11
+ - SDK key check on `/chat/completions` (60s cache, skipped for pasted/env keys): a missing or expired key returns 401 telling the user to run `opencode auth login`; one-time stderr warning within 7 days of expiry.
12
+ - Concurrency cap: a FIFO semaphore limits concurrently active Cursor runs (default 4, `OPENCODE_CURSOR_MAX_ACTIVE` to override). Extra requests queue; tool-result continuations never take a slot.
13
+ - Retry: `Agent.create` / `Agent.resume` / `send` are attempted up to 3 times with jittered backoff on 429, 5xx, and network errors; never after the first byte.
14
+ - Usage reports cache-read tokens (`prompt_tokens_details.cached_tokens`) and reasoning tokens (`completion_tokens_details.reasoning_tokens`).
15
+
16
+ ### Changed
17
+
18
+ - Models cache bumped to v3 `{name, context, params}`; older cache files are refetched once. Catalog code moved to `src/models.ts`.
19
+ - The reply is opened only on content deltas (text/thinking), so run errors before any content become real JSON error responses instead of a 200 stream with an error frame.
20
+
21
+ ### Fixed
22
+
23
+ - Real HTTP error statuses: failures before the first byte return a JSON error with a real status (400 bad model/argument, 401 auth, 403 blocked, 429 rate limit, 502 otherwise); mid-stream failures emit an OpenAI error frame and close without `[DONE]`.
24
+ - Admin-blocked models: "Model Blocked" / `permission_denied` map to 403 `permission_error` and are never retried. A 403 marks the model and its variants blocked in the models cache; they are hidden from the model list until the next 24h catalog refresh and still resolve if used explicitly.
25
+ - Abort propagation: the request AbortSignal and stream `cancel()` cancel the Cursor run, close the agent, and release registries (no more zombie runs burning usage).
26
+ - agentId race: the held run is registered before `send()`, so a tool call firing immediately can never miss its continuation lookup.
27
+ - Session resume is scoped by opencode session id (via the `chat.headers` hook), so byte-identical histories from different sessions (parallel sub-agents) never resume each other's Cursor agent. Unscoped callers and existing disk entries hash exactly as before.
28
+ - Per-session temp workspaces are removed once no session, disk, or held entry references them.
29
+
30
+ ## 0.1.0 — 2026-10-15
31
+
32
+ - Initial release: in-process Cursor SDK bridge, auth, tool loop, session resume, AGENTS.md system prompt, dynamic model list.
package/README.md CHANGED
@@ -10,9 +10,14 @@ The plugin bridges opencode's OpenAI-style chat requests to the official [`@curs
10
10
  - **In-process bridge** — no local HTTP proxy, no port to collide on, no extra daemon
11
11
  - **Full tool loop** — opencode's native tools (read, edit, bash, MCP, …) work end-to-end via a held-run bridge: one Cursor run spans the whole tool exchange
12
12
  - **Thinking blocks** — Cursor reasoning streams into opencode as proper thinking blocks (`reasoning_content`)
13
- - **Session resume** — conversations map to native Cursor agents (`Agent.resume`), in-memory and persisted to disk for 7 days, so multi-turn chats don't replay history
13
+ - **Session resume** — conversations map to native Cursor agents (`Agent.resume`), in-memory and persisted to disk for 7 days, scoped per opencode session so parallel sub-agents never share an agent
14
14
  - **System prompts** — delivered via `AGENTS.md` in a per-session workspace (the `--system-prompt` flag is server-gated by Cursor)
15
- - **Dynamic model list** — fetched live from `Cursor.models.list()`, cached 24h
15
+ - **Dynamic model list with variants** — fetched live from `Cursor.models.list()`, cached 24h; every reasoning-effort / fast / 1M-context variant is its own model id
16
+ - **Context limits** — each model publishes its Cursor context window, so opencode's auto-compaction and context indicator work
17
+ - **Real error statuses** — 400 / 401 / 403 / 429 / 502 instead of a 200 stream carrying an error
18
+ - **Cancellation** — Esc or a sub-agent timeout cancels the Cursor run and closes the agent
19
+ - **Concurrency cap and retry** — at most 4 Cursor runs at once (configurable); transient failures retried before the first byte
20
+ - **Usage** — prompt, completion, cached-read, and reasoning tokens reported to opencode
16
21
  - **Zero cost display** — models are marked cost-free since you're on subscription billing
17
22
 
18
23
  ## Requirements
@@ -46,6 +51,8 @@ Pick **Cursor**, then either:
46
51
 
47
52
  Alternatively set `CURSOR_API_KEY` in your environment.
48
53
 
54
+ When the browser-login key is used, the plugin checks it before each request (cached 60s): a missing or expired key returns a 401 telling you to run `opencode auth login`, and a one-time warning is printed to stderr within 7 days of expiry. The check is skipped for pasted keys and `CURSOR_API_KEY`.
55
+
49
56
  ## Use
50
57
 
51
58
  ```sh
@@ -56,6 +63,35 @@ opencode run -m cursor/auto "hello"
56
63
 
57
64
  Or pick any `cursor/*` model from the opencode model selector.
58
65
 
66
+ ## Models
67
+
68
+ ```sh
69
+ opencode models | grep cursor/
70
+ ```
71
+
72
+ - **Base ids** (`cursor/gpt-5.5`) send no parameters, so Cursor applies its own defaults. Cursor's `default` model is exposed as `cursor/auto`.
73
+ - **Variant ids** are the base id plus every non-default parameter value, in Cursor's parameter order. Boolean parameters use the parameter name. Examples: `cursor/gpt-5.5-low`, `cursor/gpt-5.5-high-fast`, `cursor/gpt-5.5-1m-extra-high`, `cursor/claude-opus-5-5-1m-max`.
74
+ - The full catalog is currently about 485 ids from 47 models.
75
+ - **Limits**: `limit.context` is the variant's context value, else the model's first listed context value, else 200k. `limit.output` is a fixed 32k accounting reserve (Cursor publishes no output cap and the bridge sends no `max_tokens`). Limits you set yourself in opencode config are kept.
76
+ - **Unknown ids** are passed through to Cursor, which answers with its own 400.
77
+ - **Blocked models**: Cursor's catalog has no per-account entitlement info. If Cursor rejects a model as "Model Blocked" (e.g. a team admin disabled it), the request fails with 403 `permission_error` carrying Cursor's message, and that model and all its variants are hidden from the list. Using a hidden id explicitly still sends it to Cursor. Blocked flags persist across catalog refreshes; delete `~/.cache/opencode-cursor-auth/models.json` to re-check.
78
+ - **Offline / logged out**: the stale cache is used if present, otherwise only `cursor/auto`.
79
+
80
+ The catalog is cached at `~/.cache/opencode-cursor-auth/models.json`; delete it to force a refresh.
81
+
82
+ ## Configuration
83
+
84
+ | Setting | Default | Purpose |
85
+ | --- | --- | --- |
86
+ | `OPENCODE_CURSOR_MAX_ACTIVE` | `4` | Max Cursor runs active at once; extra requests queue FIFO |
87
+ | `CURSOR_API_KEY` | unset | Use this key instead of the stored SDK key |
88
+
89
+ | Path | Contents |
90
+ | --- | --- |
91
+ | `~/.cursor/sdk/auth.json` | 90-day SDK key from browser login (written by `@cursor/sdk`) |
92
+ | `~/.cache/opencode-cursor-auth/models.json` | Model catalog, 24h TTL, blocked-model flags |
93
+ | `~/.cache/opencode-cursor-auth/sessions.json` | Conversation → Cursor agent map, 7-day TTL |
94
+
59
95
  ## How it works
60
96
 
61
97
  ```
@@ -65,25 +101,43 @@ opencode ──(OpenAI chat JSON)──> plugin fetch() ──> @cursor/sdk Agen
65
101
 
66
102
  - opencode calls the provider's `fetch` — the plugin intercepts it in-process; `http://cursor.local/v1` is a dummy URL that never resolves.
67
103
  - Each opencode tool is registered as a Cursor **custom tool** whose `execute()` parks a promise, emits an OpenAI `tool_calls` chunk, and resumes the *same* Cursor run when opencode sends the tool result back.
68
- - Conversation prefixes are hashed → mapped to Cursor agent IDs, so follow-up turns use `Agent.resume` instead of replaying history. The map persists at `~/.cache/opencode-cursor-auth/sessions.json`.
69
- - System messages are written to `AGENTS.md` in a per-session temp workspace and loaded via Cursor's project rules (`settingSources: ["project"]`).
104
+ - Conversation prefixes, plus the opencode session id (sent via the `chat.headers` hook), are hashed → mapped to Cursor agent IDs, so follow-up turns use `Agent.resume` instead of replaying history. The map persists at `~/.cache/opencode-cursor-auth/sessions.json`.
105
+ - System messages are written to `AGENTS.md` in a per-session temp workspace and loaded via Cursor's project rules (`settingSources: ["project"]`). Workspaces are removed once no session references them.
106
+ - Variant ids are mapped back to Cursor `ModelSelection` params (`{ id, params }`).
107
+ - New turns take a slot from a FIFO semaphore (`OPENCODE_CURSOR_MAX_ACTIVE`); tool-result continuations reuse their run's slot. `Agent.create` / `Agent.resume` / `send` are attempted up to 3 times with jittered exponential backoff on 429, 5xx, and network errors, never after the first byte.
108
+ - The HTTP reply opens on the first text or thinking delta, so failures before any content become real HTTP errors.
109
+ - The request's abort signal and stream cancel cancel the Cursor run, close the agent, and release its slot.
110
+
111
+ ### Errors
112
+
113
+ | Status | When |
114
+ | --- | --- |
115
+ | 400 | Unknown model or bad argument (Cursor's message); also an expired tool-call continuation |
116
+ | 401 | SDK key missing/expired, or Cursor rejects the key |
117
+ | 403 | Model blocked for your account (`permission_error`) |
118
+ | 429 | Cursor rate limit |
119
+ | 502 | Anything else |
120
+
121
+ These are JSON error responses when the failure happens before the first byte; mid-stream failures emit an SSE `error` frame and close the stream without `[DONE]`.
70
122
 
71
123
  ## Development
72
124
 
73
125
  ```sh
74
126
  bun install
75
127
  bun run build # tsc → dist/
76
- bun run test # end-to-end bridge tests (requires Cursor login)
128
+ bun run test # end-to-end bridge tests (requires Cursor login, uses real Cursor usage)
77
129
  bun run spike # raw @cursor/sdk validation
78
130
  ```
79
131
 
80
- The test suite exercises: plain streaming, a full tool loop on one held run, in-memory session resume, AGENTS.md system-prompt delivery, and disk-persisted resume.
132
+ The test suite runs 13 scenarios: plain streaming, a full tool loop on one held run, in-memory and disk-persisted session resume, AGENTS.md system-prompt delivery, mid-stream abort, error statuses, session scoping, context limits and key checks, model variants, the concurrency cap, retry semantics, and blocked models.
81
133
 
82
134
  ## Known limitations
83
135
 
84
- - `systemPrompt` as a true harness replacement is feature-gated server-side by Cursor; system text is applied as a project rule (merged under Cursor's harness prompt).
136
+ - `systemPrompt` as a true harness replacement is feature-gated server-side by Cursor; system text is applied via `AGENTS.md` as a project rule (merged under Cursor's harness prompt).
85
137
  - The SDK spawns a `cursor-agent` worker per turn (~1–2 s startup on resume).
86
- - Usage/rate limits are whatever your Cursor plan enforces — the plugin adds none.
138
+ - The model list is Cursor's full catalog (~485 ids). It has no entitlement info, so models your plan or team blocks only disappear after the first 403.
139
+ - A run parked waiting for a tool result keeps its slot. Cursor-model sessions that spawn Cursor-model sub-agents nested deeper than `OPENCODE_CURSOR_MAX_ACTIVE` will stall; raise the limit if you fan out.
140
+ - Usage/rate limits are whatever your Cursor plan enforces — the plugin adds none beyond the concurrency cap.
87
141
 
88
142
  ## License
89
143
 
package/dist/bridge.d.ts CHANGED
@@ -28,6 +28,18 @@ type OaBody = {
28
28
  tools?: OaTool[];
29
29
  stream?: boolean;
30
30
  };
31
+ export declare function isTransient(err: unknown): boolean;
32
+ export declare function withRetry<T>(fn: () => Promise<T>, signal?: AbortSignal, baseDelayMs?: number, retryable?: typeof isTransient): Promise<T>;
33
+ export declare function _setMaxActive(n: number): void;
34
+ export declare function errorResponse(err: unknown, status?: number): Response;
31
35
  export declare function _resetForTest(): void;
32
- export declare function handleChatCompletion(body: OaBody, apiKey?: string): Promise<Response>;
36
+ export declare function _stats(): {
37
+ held: number;
38
+ sessions: number;
39
+ disk: number;
40
+ agentIds: string[];
41
+ active: number;
42
+ waiting: number;
43
+ };
44
+ export declare function handleChatCompletion(body: OaBody, apiKey?: string, signal?: AbortSignal, scope?: string): Promise<Response>;
33
45
  export {};
package/dist/bridge.js CHANGED
@@ -8,12 +8,13 @@
8
8
  * next HTTP request (role:"tool" messages) resolves the parked promises and
9
9
  * the SAME run continues — one metered Cursor run per opencode tool loop.
10
10
  */
11
- import { Agent } from "@cursor/sdk";
11
+ import { Agent, AuthenticationError, ConfigurationError, NetworkError, RateLimitError } from "@cursor/sdk";
12
12
  import { createHash, randomUUID } from "node:crypto";
13
13
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
14
14
  import { mkdtemp, readFile, writeFile } from "node:fs/promises";
15
15
  import { homedir, tmpdir } from "node:os";
16
16
  import { join } from "node:path";
17
+ import { markBlocked, resolveModel } from "./models.js";
17
18
  // @cursor/sdk's internal logger writes "HH:MM:SS.mmm INFO ..." lines via
18
19
  // console.log unconditionally (no config/env to silence it, verified against
19
20
  // the esm bundle). Filter exactly those lines, pass everything else through.
@@ -31,6 +32,111 @@ function textOf(content) {
31
32
  return "";
32
33
  return content.map((p) => p.text ?? "").join("");
33
34
  }
35
+ function statusFor(err) {
36
+ const e = (typeof err === "object" && err !== null ? err : {});
37
+ const msg = String(e.message ?? err ?? "");
38
+ const code = typeof e.code === "string" ? e.code : "";
39
+ if (err instanceof AuthenticationError || e.status === 401 || code === "unauthenticated" || /invalid user api key/i.test(msg))
40
+ return 401;
41
+ if (err instanceof RateLimitError || e.status === 429 || code === "resource_exhausted" || /rate limit/i.test(msg))
42
+ return 429;
43
+ if (code === "permission_denied" || /model blocked|permission denied|forbidden/i.test(msg))
44
+ return 403;
45
+ if (err instanceof ConfigurationError || code === "invalid_argument" || code === "not_found" || /cannot use this model|unknown model/i.test(msg))
46
+ return 400;
47
+ return 502;
48
+ }
49
+ const ERROR_TYPE = {
50
+ 400: "invalid_request_error",
51
+ 401: "authentication_error",
52
+ 403: "permission_error",
53
+ 429: "rate_limit_error",
54
+ };
55
+ function errorBody(err, status = statusFor(err)) {
56
+ const e = (typeof err === "object" && err !== null ? err : {});
57
+ return {
58
+ error: {
59
+ message: String(e.message ?? err ?? "unknown error"),
60
+ type: ERROR_TYPE[status] ?? "api_error",
61
+ ...(typeof e.code === "string" ? { code: e.code } : {}),
62
+ },
63
+ };
64
+ }
65
+ const abortError = () => new DOMException("aborted", "AbortError");
66
+ export function isTransient(err) {
67
+ const e = (typeof err === "object" && err !== null ? err : {});
68
+ if (e.name === "AbortError")
69
+ return false;
70
+ const status = statusFor(err);
71
+ if (status === 429)
72
+ return true;
73
+ if (status === 400 || status === 401 || status === 403 || e.status === 404)
74
+ return false;
75
+ if (err instanceof NetworkError || (typeof e.status === "number" && e.status >= 500))
76
+ return true;
77
+ return /unavailable|deadline_exceeded|ECONNRESET|ETIMEDOUT|fetch failed/i.test(`${e.code ?? ""} ${e.message ?? ""}`);
78
+ }
79
+ export async function withRetry(fn, signal, baseDelayMs = 1000, retryable = isTransient) {
80
+ for (let attempt = 0;; attempt++) {
81
+ if (signal?.aborted)
82
+ throw abortError();
83
+ try {
84
+ return await fn();
85
+ }
86
+ catch (err) {
87
+ if (attempt >= 2 || signal?.aborted || !retryable(err))
88
+ throw err;
89
+ const delay = Math.min(baseDelayMs * 2 ** attempt * (0.75 + Math.random() * 0.5), 30_000);
90
+ await new Promise((r) => {
91
+ const t = setTimeout(r, delay);
92
+ signal?.addEventListener("abort", () => (clearTimeout(t), r(undefined)), { once: true });
93
+ });
94
+ }
95
+ }
96
+ }
97
+ // ponytail: global semaphore, slot held across parked tool loops; per-session fairness if it ever matters
98
+ let maxActive = Number(process.env.OPENCODE_CURSOR_MAX_ACTIVE) || 4;
99
+ let active = 0;
100
+ const waiters = [];
101
+ function pump() {
102
+ while (active < maxActive && waiters.length) {
103
+ active++;
104
+ waiters.shift()();
105
+ }
106
+ }
107
+ function acquireSlot(signal) {
108
+ if (signal?.aborted)
109
+ return Promise.reject(abortError());
110
+ if (active < maxActive && !waiters.length) {
111
+ active++;
112
+ return Promise.resolve();
113
+ }
114
+ return new Promise((resolve, reject) => {
115
+ const onAbort = () => {
116
+ const i = waiters.indexOf(go);
117
+ if (i >= 0)
118
+ waiters.splice(i, 1);
119
+ reject(abortError());
120
+ };
121
+ const go = () => {
122
+ signal?.removeEventListener("abort", onAbort);
123
+ resolve();
124
+ };
125
+ signal?.addEventListener("abort", onAbort, { once: true });
126
+ waiters.push(go);
127
+ });
128
+ }
129
+ function releaseSlot() {
130
+ active--;
131
+ pump();
132
+ }
133
+ export function _setMaxActive(n) {
134
+ maxActive = n;
135
+ pump();
136
+ }
137
+ export function errorResponse(err, status = statusFor(err)) {
138
+ return Response.json(errorBody(err, status), { status });
139
+ }
34
140
  function chunk(id, model, delta, finish, usage) {
35
141
  return {
36
142
  id,
@@ -41,7 +147,7 @@ function chunk(id, model, delta, finish, usage) {
41
147
  ...(usage ? { usage } : {}),
42
148
  };
43
149
  }
44
- function sseResponse(model) {
150
+ function sseResponse(model, onCancel) {
45
151
  const id = `chatcmpl-${randomUUID()}`;
46
152
  const enc = new TextEncoder();
47
153
  let controller;
@@ -53,19 +159,31 @@ function sseResponse(model) {
53
159
  },
54
160
  cancel() {
55
161
  closed = true;
162
+ onCancel();
56
163
  },
57
164
  });
165
+ function frame(payload) {
166
+ if (closed)
167
+ return;
168
+ controller.enqueue(enc.encode(`data: ${JSON.stringify(payload)}\n\n`));
169
+ }
58
170
  function send(delta, finish = null, usage) {
171
+ frame(chunk(id, model, delta, finish, usage));
172
+ }
173
+ function close() {
59
174
  if (closed)
60
175
  return;
61
- controller.enqueue(enc.encode(`data: ${JSON.stringify(chunk(id, model, delta, finish, usage))}\n\n`));
176
+ closed = true;
177
+ try {
178
+ controller.close();
179
+ }
180
+ catch { }
62
181
  }
63
182
  function end() {
64
183
  if (closed)
65
184
  return;
66
185
  controller.enqueue(enc.encode("data: [DONE]\n\n"));
67
- controller.close();
68
- closed = true;
186
+ close();
69
187
  }
70
188
  const sink = {
71
189
  text: (t) => send({ content: t }),
@@ -83,10 +201,10 @@ function sseResponse(model) {
83
201
  end();
84
202
  },
85
203
  fail: (err) => {
86
- send({ content: `\n\n[cursor error] ${err instanceof Error ? err.message : String(err)}` });
87
- send({}, "stop");
88
- end();
204
+ frame(errorBody(err));
205
+ close();
89
206
  },
207
+ close,
90
208
  };
91
209
  return {
92
210
  response: new Response(stream, {
@@ -96,13 +214,11 @@ function sseResponse(model) {
96
214
  sink,
97
215
  };
98
216
  }
99
- function jsonCollector(model) {
217
+ function jsonCollector(model, resolve) {
100
218
  const id = `chatcmpl-${randomUUID()}`;
101
219
  let content = "";
102
220
  let calls = [];
103
- let resolve;
104
- const promise = new Promise((r) => (resolve = r));
105
- const sink = {
221
+ return {
106
222
  text: (t) => (content += t),
107
223
  reasoning: () => { },
108
224
  toolCalls: (c) => (calls = calls.concat(c)),
@@ -132,9 +248,18 @@ function jsonCollector(model) {
132
248
  ],
133
249
  usage: usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
134
250
  })),
135
- fail: (err) => resolve(Response.json({ error: { message: err instanceof Error ? err.message : String(err), type: "cursor_error" } }, { status: 500 })),
251
+ fail: (err) => resolve(errorResponse(err)),
252
+ close: () => { },
136
253
  };
137
- return { promise, sink };
254
+ }
255
+ function deferred() {
256
+ let resolve;
257
+ let reject;
258
+ const promise = new Promise((res, rej) => {
259
+ resolve = res;
260
+ reject = rej;
261
+ });
262
+ return { promise, resolve, reject };
138
263
  }
139
264
  const BATCH_SETTLE_MS = 300;
140
265
  const TOOL_RESULT_TIMEOUT_MS = 10 * 60 * 1000;
@@ -144,13 +269,33 @@ const DISK_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
144
269
  const MAX_SESSIONS = 8;
145
270
  const SESSION_STORE_DIR = join(homedir(), ".cache", "opencode-cursor-auth");
146
271
  const SESSION_STORE_PATH = join(SESSION_STORE_DIR, "sessions.json");
272
+ const TMP_CWD_PREFIX = join(tmpdir(), "opencode-cursor-");
147
273
  const sessions = new Map();
148
274
  const heldByAgent = new Map();
149
275
  const agentByToolCallId = new Map();
150
276
  let diskSessions = new Map();
151
277
  let sessionsLoaded = false;
152
- function hashMessages(messages) {
278
+ // ponytail: no orphan sweep, other opencode instances may own those dirs
279
+ function releaseCwd(cwd) {
280
+ if (!cwd.startsWith(TMP_CWD_PREFIX))
281
+ return;
282
+ for (const v of sessions.values())
283
+ if (v.cwd === cwd)
284
+ return;
285
+ for (const v of diskSessions.values())
286
+ if (v.cwd === cwd)
287
+ return;
288
+ for (const h of heldByAgent.values())
289
+ if (h.cwd === cwd)
290
+ return;
291
+ rmSync(cwd, { recursive: true, force: true });
292
+ }
293
+ // scope (opencode session id) is folded in so identical histories from
294
+ // different sessions (e.g. parallel sub-agents) never share a Cursor agent
295
+ function hashMessages(messages, scope) {
153
296
  const norm = messages.map((m) => [m.role, textOf(m.content), m.tool_call_id ?? "", JSON.stringify(m.tool_calls ?? null)]);
297
+ if (scope)
298
+ norm.unshift(["scope", scope]);
154
299
  return createHash("sha256").update(JSON.stringify(norm)).digest("hex");
155
300
  }
156
301
  function collectDiskEntries() {
@@ -176,6 +321,9 @@ async function loadSessions() {
176
321
  if (now - v.lastUsedAt <= DISK_SESSION_TTL_MS)
177
322
  diskSessions.set(k, v);
178
323
  }
324
+ for (const v of Object.values(raw.entries))
325
+ if (now - v.lastUsedAt > DISK_SESSION_TTL_MS)
326
+ releaseCwd(v.cwd);
179
327
  }
180
328
  catch { }
181
329
  }
@@ -185,9 +333,17 @@ function saveSessions() {
185
333
  }
186
334
  function sweepSessions(save = true) {
187
335
  const now = Date.now();
336
+ const evicted = [];
337
+ for (const [k, v] of diskSessions)
338
+ if (now - v.lastUsedAt > DISK_SESSION_TTL_MS) {
339
+ diskSessions.delete(k);
340
+ evicted.push(v.cwd);
341
+ }
188
342
  for (const [k, v] of sessions)
189
- if (now - v.lastUsedAt > SESSION_TTL_MS)
343
+ if (now - v.lastUsedAt > SESSION_TTL_MS) {
190
344
  sessions.delete(k);
345
+ evicted.push(v.cwd);
346
+ }
191
347
  while (sessions.size > MAX_SESSIONS) {
192
348
  let oldestKey = "";
193
349
  let oldest = Infinity;
@@ -196,8 +352,11 @@ function sweepSessions(save = true) {
196
352
  oldest = v.lastUsedAt;
197
353
  oldestKey = k;
198
354
  }
355
+ evicted.push(sessions.get(oldestKey).cwd);
199
356
  sessions.delete(oldestKey);
200
357
  }
358
+ for (const cwd of evicted)
359
+ releaseCwd(cwd);
201
360
  if (save)
202
361
  saveSessions();
203
362
  }
@@ -214,15 +373,18 @@ function rememberSession(held, toolCalls) {
214
373
  ? { tool_calls: toolCalls.map((c) => ({ id: c.id, function: { name: c.name, arguments: c.argumentsJson } })) }
215
374
  : {}),
216
375
  };
217
- const key = hashMessages(held.messages.concat(assistant));
376
+ const key = hashMessages(held.messages.concat(assistant), held.scope);
218
377
  const entry = {
219
378
  agentId: held.agent.agentId,
220
379
  cwd: held.cwd,
221
380
  lastUsedAt: Date.now(),
222
381
  };
382
+ const displaced = sessions.get(key) ?? diskSessions.get(key);
223
383
  sessions.set(key, entry);
224
384
  diskSessions.set(key, entry);
225
385
  sweepSessions();
386
+ if (displaced && displaced.cwd !== entry.cwd)
387
+ releaseCwd(displaced.cwd);
226
388
  }
227
389
  async function syncAgentsMd(cwd, messages) {
228
390
  mkdirSync(cwd, { recursive: true });
@@ -242,7 +404,12 @@ function mapUsage(u) {
242
404
  return undefined;
243
405
  const prompt = u.inputTokens ?? 0;
244
406
  const completion = u.outputTokens ?? 0;
245
- return { prompt_tokens: prompt, completion_tokens: completion, total_tokens: prompt + completion };
407
+ const usage = { prompt_tokens: prompt, completion_tokens: completion, total_tokens: prompt + completion };
408
+ if (u.cacheReadTokens)
409
+ usage.prompt_tokens_details = { cached_tokens: u.cacheReadTokens };
410
+ if (u.reasoningTokens)
411
+ usage.completion_tokens_details = { reasoning_tokens: u.reasoningTokens };
412
+ return usage;
246
413
  }
247
414
  // deltas arriving between tool flush and the continuation request get buffered
248
415
  function emit(held, kind, t) {
@@ -251,73 +418,124 @@ function emit(held, kind, t) {
251
418
  else
252
419
  held.buffer.push({ kind, t });
253
420
  }
421
+ function openReply(held) {
422
+ if (held.opened)
423
+ return;
424
+ held.opened = true;
425
+ if (held.response)
426
+ held.reply.resolve(held.response);
427
+ }
254
428
  function attachSink(held, sink) {
255
429
  held.sink = sink;
430
+ if (held.buffer.length)
431
+ openReply(held);
256
432
  for (const b of held.buffer)
257
433
  (b.kind === "text" ? sink.text : sink.reasoning)(b.t);
258
434
  held.buffer = [];
259
435
  }
260
- function teardownHeld(agentId, held) {
436
+ function attachSignal(held, signal) {
437
+ held.detachSignal?.();
438
+ held.detachSignal = null;
439
+ if (!signal)
440
+ return;
441
+ const onAbort = () => abortHeld(held);
442
+ signal.addEventListener("abort", onAbort, { once: true });
443
+ held.detachSignal = () => signal.removeEventListener("abort", onAbort);
444
+ }
445
+ function closeAgent(held) {
446
+ try {
447
+ held.agent.close();
448
+ }
449
+ catch { }
450
+ }
451
+ function teardownHeld(held) {
261
452
  held.done = true;
453
+ if (held.slot) {
454
+ held.slot = false;
455
+ releaseSlot();
456
+ }
457
+ held.detachSignal?.();
458
+ held.detachSignal = null;
262
459
  if (held.flushTimer)
263
460
  clearTimeout(held.flushTimer);
264
461
  if (held.idleTimer)
265
462
  clearTimeout(held.idleTimer);
266
463
  for (const id of held.parked.keys())
267
464
  agentByToolCallId.delete(id);
268
- heldByAgent.delete(agentId);
465
+ heldByAgent.delete(held.agent.agentId);
466
+ }
467
+ function failHeld(held, err) {
468
+ teardownHeld(held);
469
+ if (statusFor(err) === 403)
470
+ markBlocked(held.model);
471
+ if (held.response && !held.opened) {
472
+ held.opened = true;
473
+ held.reply.resolve(errorResponse(err));
474
+ held.sink?.close();
475
+ }
476
+ else {
477
+ held.sink?.fail(err);
478
+ }
479
+ closeAgent(held);
480
+ releaseCwd(held.cwd);
481
+ }
482
+ function abortHeld(held) {
483
+ if (held.done)
484
+ return;
485
+ teardownHeld(held);
486
+ held.run?.cancel().catch(() => { });
487
+ closeAgent(held);
488
+ held.sink?.close();
489
+ held.reply.reject(new DOMException("aborted", "AbortError"));
490
+ releaseCwd(held.cwd);
269
491
  }
270
492
  /** Model requested tools: emit tool_calls, end this HTTP segment, park the run. */
271
- function flushToolCalls(agentId, held) {
493
+ function flushToolCalls(held) {
272
494
  held.flushTimer = null;
273
495
  if (held.done || !held.pendingCalls.length)
274
496
  return;
275
497
  const calls = held.pendingCalls;
276
498
  held.pendingCalls = [];
277
499
  for (const c of calls)
278
- agentByToolCallId.set(c.id, agentId);
500
+ agentByToolCallId.set(c.id, held.agent.agentId);
279
501
  const sink = held.sink;
280
502
  held.sink = null;
503
+ // the parked run outlives this HTTP segment, so its abort signal must not
504
+ // reach it; the continuation request attaches its own
505
+ held.detachSignal?.();
506
+ held.detachSignal = null;
507
+ openReply(held);
281
508
  sink?.toolCalls(calls);
282
509
  sink?.finish("tool_calls");
283
510
  rememberSession(held, calls);
284
- held.idleTimer = setTimeout(() => {
285
- teardownHeld(agentId, held);
286
- held.run.cancel().catch(() => { });
287
- try {
288
- held.agent.close();
289
- }
290
- catch { }
291
- }, TOOL_RESULT_TIMEOUT_MS);
511
+ held.idleTimer = setTimeout(() => abortHeld(held), TOOL_RESULT_TIMEOUT_MS);
292
512
  held.idleTimer.unref?.();
293
513
  }
294
- async function wireRunCompletion(agentId, held) {
295
- if (held.wired)
514
+ async function wireRunCompletion(held) {
515
+ if (held.wired || !held.run)
296
516
  return;
297
517
  held.wired = true;
518
+ let result;
298
519
  try {
299
- const result = await held.run.wait();
300
- if (held.done)
301
- return;
302
- const usage = mapUsage(result.usage) ?? held.usage;
303
- teardownHeld(agentId, held);
304
- rememberSession(held, []);
305
- held.sink?.finish("stop", usage);
306
- try {
307
- held.agent.close();
308
- }
309
- catch { }
520
+ result = await held.run.wait();
310
521
  }
311
522
  catch (err) {
312
- if (held.done)
313
- return;
314
- teardownHeld(agentId, held);
315
- held.sink?.fail(err);
316
- try {
317
- held.agent.close();
318
- }
319
- catch { }
523
+ if (!held.done)
524
+ failHeld(held, err);
525
+ return;
320
526
  }
527
+ if (held.done)
528
+ return;
529
+ if (result.status === "cancelled")
530
+ return abortHeld(held);
531
+ if (result.status === "error")
532
+ return failHeld(held, result.error ?? new Error("cursor run failed"));
533
+ const usage = mapUsage(result.usage) ?? held.usage;
534
+ teardownHeld(held);
535
+ rememberSession(held, []);
536
+ openReply(held);
537
+ held.sink?.finish("stop", usage);
538
+ closeAgent(held);
321
539
  }
322
540
  export function _resetForTest() {
323
541
  sessions.clear();
@@ -326,79 +544,106 @@ export function _resetForTest() {
326
544
  agentByToolCallId.clear();
327
545
  sessionsLoaded = false;
328
546
  }
329
- export async function handleChatCompletion(body, apiKey) {
547
+ export function _stats() {
548
+ return {
549
+ held: heldByAgent.size,
550
+ sessions: sessions.size,
551
+ disk: diskSessions.size,
552
+ agentIds: [...sessions.values()].map((v) => v.agentId).sort(),
553
+ active,
554
+ waiting: waiters.length,
555
+ };
556
+ }
557
+ export async function handleChatCompletion(body, apiKey, signal, scope) {
558
+ if (signal?.aborted)
559
+ throw new DOMException("aborted", "AbortError");
330
560
  await loadSessions();
331
561
  const streaming = body.stream !== false;
332
- const sse = streaming ? sseResponse(body.model) : undefined;
333
- const collected = streaming ? undefined : jsonCollector(body.model);
334
- const sink = (sse?.sink ?? collected?.sink);
335
- const reply = () => sse?.response ?? collected.promise;
562
+ const reply = deferred();
563
+ let held;
564
+ const sse = streaming ? sseResponse(body.model, () => held && abortHeld(held)) : undefined;
565
+ const sink = sse?.sink ?? jsonCollector(body.model, reply.resolve);
336
566
  // continuation: trailing tool results for a parked run
337
567
  const lastMsg = body.messages[body.messages.length - 1];
338
- if (lastMsg?.role === "tool" && lastMsg.tool_call_id && agentByToolCallId.has(lastMsg.tool_call_id)) {
568
+ if (lastMsg?.role === "tool" && lastMsg.tool_call_id) {
339
569
  const agentId = agentByToolCallId.get(lastMsg.tool_call_id);
340
- const held = heldByAgent.get(agentId);
341
- if (held && !held.done) {
342
- if (held.idleTimer)
343
- clearTimeout(held.idleTimer);
344
- held.messages = body.messages;
345
- held.textAcc = "";
346
- attachSink(held, sink);
347
- for (const m of body.messages) {
348
- if (m.role !== "tool" || !m.tool_call_id)
349
- continue;
350
- const resolve = held.parked.get(m.tool_call_id);
351
- if (resolve) {
352
- held.parked.delete(m.tool_call_id);
353
- agentByToolCallId.delete(m.tool_call_id);
354
- resolve(textOf(m.content));
355
- }
570
+ held = agentId ? heldByAgent.get(agentId) : undefined;
571
+ if (!held || held.done) {
572
+ return Response.json({ error: { message: "cursor run for this tool call expired — please retry", type: "invalid_request_error", code: "tool_call_expired" } }, { status: 400 });
573
+ }
574
+ if (held.idleTimer)
575
+ clearTimeout(held.idleTimer);
576
+ held.messages = body.messages;
577
+ held.textAcc = "";
578
+ held.reply = reply;
579
+ held.response = sse?.response ?? null;
580
+ held.opened = false;
581
+ attachSignal(held, signal);
582
+ attachSink(held, sink);
583
+ for (const m of body.messages) {
584
+ if (m.role !== "tool" || !m.tool_call_id)
585
+ continue;
586
+ const resolve = held.parked.get(m.tool_call_id);
587
+ if (resolve) {
588
+ held.parked.delete(m.tool_call_id);
589
+ agentByToolCallId.delete(m.tool_call_id);
590
+ resolve(textOf(m.content));
356
591
  }
357
- return reply();
358
592
  }
359
- sink.fail(new Error("cursor run for this tool call expired — please retry"));
360
- return reply();
593
+ return reply.promise;
361
594
  }
362
595
  // new turn: reuse the agent whose last-seen conversation matches our prefix
363
596
  const prefix = body.messages.slice(0, -1);
364
- const key = hashMessages(prefix);
597
+ const key = hashMessages(prefix, scope);
598
+ await acquireSlot(signal);
365
599
  const entry = sessions.get(key) ?? diskSessions.get(key);
366
600
  let agent;
367
601
  let cwd;
368
602
  let newSuffix = body.messages;
603
+ const agentOptions = (cwd) => ({
604
+ model: resolveModel(body.model) ?? { id: body.model },
605
+ tools: ["mcp"],
606
+ ...(apiKey ? { apiKey } : {}),
607
+ local: { cwd, settingSources: ["project"], enableAgentRetries: true },
608
+ });
369
609
  if (entry) {
370
610
  sessions.delete(key);
611
+ diskSessions.delete(key);
371
612
  cwd = entry.cwd;
372
- agent = await Agent.resume(entry.agentId, {
373
- model: { id: body.model },
374
- tools: ["mcp"],
375
- ...(apiKey ? { apiKey } : {}),
376
- local: { cwd, settingSources: ["project"], enableAgentRetries: true },
377
- }).catch(() => {
378
- sessions.delete(key);
379
- diskSessions.delete(key);
613
+ agent = await withRetry(() => Agent.resume(entry.agentId, agentOptions(cwd)), signal).catch(() => {
380
614
  saveSessions();
615
+ releaseCwd(entry.cwd);
381
616
  return undefined;
382
617
  });
383
618
  if (agent)
384
619
  newSuffix = body.messages.slice(-1);
385
620
  }
386
621
  if (!agent) {
387
- cwd = await mkdtemp(join(tmpdir(), "opencode-cursor-"));
388
- agent = await Agent.create({
389
- model: { id: body.model },
390
- tools: ["mcp"],
391
- ...(apiKey ? { apiKey } : {}),
392
- local: { cwd, settingSources: ["project"], enableAgentRetries: true },
393
- });
622
+ cwd = await mkdtemp(TMP_CWD_PREFIX);
623
+ try {
624
+ agent = await withRetry(() => Agent.create(agentOptions(cwd)), signal);
625
+ }
626
+ catch (err) {
627
+ releaseSlot();
628
+ releaseCwd(cwd);
629
+ if (signal?.aborted)
630
+ throw abortError();
631
+ return errorResponse(err);
632
+ }
394
633
  }
395
- const held = {
634
+ held = {
396
635
  agent,
397
636
  cwd: cwd,
398
- run: undefined,
637
+ model: body.model,
638
+ run: null,
399
639
  messages: body.messages,
640
+ scope,
400
641
  textAcc: "",
401
642
  sink: null,
643
+ reply,
644
+ response: sse?.response ?? null,
645
+ opened: false,
646
+ detachSignal: null,
402
647
  buffer: [],
403
648
  pendingCalls: [],
404
649
  parked: new Map(),
@@ -406,9 +651,12 @@ export async function handleChatCompletion(body, apiKey) {
406
651
  idleTimer: null,
407
652
  wired: false,
408
653
  done: false,
654
+ slot: true,
409
655
  };
656
+ const self = held;
657
+ heldByAgent.set(agent.agentId, held);
658
+ attachSignal(held, signal);
410
659
  attachSink(held, sink);
411
- const agentIdRef = { id: "" };
412
660
  const customTools = {};
413
661
  for (const t of body.tools ?? []) {
414
662
  customTools[t.function.name] = {
@@ -416,11 +664,11 @@ export async function handleChatCompletion(body, apiKey) {
416
664
  inputSchema: t.function.parameters ?? { type: "object", properties: {} },
417
665
  execute: (args, ctx) => {
418
666
  const id = (ctx.toolCallId ?? `call_${randomUUID()}`).replace(/\s+/g, "_");
419
- held.pendingCalls.push({ id, name: t.function.name, argumentsJson: JSON.stringify(args ?? {}) });
420
- if (held.flushTimer)
421
- clearTimeout(held.flushTimer);
422
- held.flushTimer = setTimeout(() => flushToolCalls(agentIdRef.id, held), BATCH_SETTLE_MS);
423
- return new Promise((resolve) => held.parked.set(id, resolve));
667
+ self.pendingCalls.push({ id, name: t.function.name, argumentsJson: JSON.stringify(args ?? {}) });
668
+ if (self.flushTimer)
669
+ clearTimeout(self.flushTimer);
670
+ self.flushTimer = setTimeout(() => flushToolCalls(self), BATCH_SETTLE_MS);
671
+ return new Promise((resolve) => self.parked.set(id, resolve));
424
672
  },
425
673
  };
426
674
  }
@@ -430,29 +678,40 @@ export async function handleChatCompletion(body, apiKey) {
430
678
  .map((m) => (m.role === "user" ? textOf(m.content) : `${m.role.toUpperCase()}: ${textOf(m.content)}`))
431
679
  .filter(Boolean)
432
680
  .join("\n\n") || "(empty message)";
681
+ let run;
433
682
  try {
434
- held.run = await held.agent.send(prompt, {
683
+ run = await withRetry(() => agent.send(prompt, {
435
684
  onDelta: ({ update }) => {
685
+ if (self.done)
686
+ return;
436
687
  if (update.type === "text-delta" && update.text) {
437
- held.textAcc += update.text;
438
- emit(held, "text", update.text);
688
+ openReply(self);
689
+ self.textAcc += update.text;
690
+ emit(self, "text", update.text);
439
691
  }
440
692
  else if (update.type === "thinking-delta" && update.text) {
441
- emit(held, "reasoning", update.text);
693
+ openReply(self);
694
+ emit(self, "reasoning", update.text);
442
695
  }
443
696
  else if (update.type === "turn-ended") {
444
- held.usage = mapUsage(update.usage);
697
+ self.usage = mapUsage(update.usage);
445
698
  }
446
699
  },
447
700
  local: { customTools: customTools },
448
- });
701
+ }), signal, 1000, (err) => !self.opened && !self.done && isTransient(err));
449
702
  }
450
703
  catch (err) {
451
- sink.fail(err);
452
- return reply();
704
+ if (!self.done)
705
+ failHeld(self, err);
706
+ return reply.promise;
707
+ }
708
+ if (self.done) {
709
+ run.cancel().catch(() => { });
710
+ return reply.promise;
453
711
  }
454
- agentIdRef.id = held.run.agentId;
455
- heldByAgent.set(held.run.agentId, held);
456
- void wireRunCompletion(held.run.agentId, held);
457
- return reply();
712
+ if (run.agentId !== agent.agentId)
713
+ console.error(`[opencode-cursor-auth] run.agentId ${run.agentId} != agent.agentId ${agent.agentId}`);
714
+ self.run = run;
715
+ void wireRunCompletion(self);
716
+ return reply.promise;
458
717
  }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,6 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin";
2
+ import { type SdkAuthStatus } from "@cursor/sdk";
3
+ export { parseContext } from "./models.js";
4
+ export declare function keyProblem(status: SdkAuthStatus, nowMs: number): string | undefined;
2
5
  export declare const CursorAuthPlugin: Plugin;
3
6
  export default CursorAuthPlugin;
package/dist/index.js CHANGED
@@ -1,41 +1,39 @@
1
1
  import { Cursor } from "@cursor/sdk";
2
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import { homedir } from "node:os";
4
- import { join } from "node:path";
5
- import { handleChatCompletion } from "./bridge.js";
2
+ import { errorResponse, handleChatCompletion } from "./bridge.js";
3
+ import { currentModels } from "./models.js";
4
+ export { parseContext } from "./models.js";
6
5
  const BASE_URL = "http://cursor.local/v1";
7
- const CACHE_DIR = join(homedir(), ".cache", "opencode-cursor-auth");
8
- const MODELS_CACHE_PATH = join(CACHE_DIR, "models.json");
9
- const MODELS_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
10
- function loadCachedModels() {
11
- try {
12
- const raw = JSON.parse(readFileSync(MODELS_CACHE_PATH, "utf8"));
13
- if (raw?.version === 1 && raw.models && Object.keys(raw.models).length > 0)
14
- return raw;
15
- }
16
- catch {
17
- // no cache yet, or unreadable — fall back
18
- }
6
+ // Cursor publishes no output limit and the bridge never passes max_tokens to Cursor, so output is only an accounting reserve for opencode.
7
+ const DEFAULT_CONTEXT = 200_000;
8
+ const DEFAULT_OUTPUT = 32_000;
9
+ export function keyProblem(status, nowMs) {
10
+ if (status.status !== "logged-in")
11
+ return "Cursor SDK key missing — run `opencode auth login` and pick Cursor";
12
+ if (typeof status.apiKeyExpiresAtMs === "number" && status.apiKeyExpiresAtMs <= nowMs)
13
+ return `Cursor SDK key expired on ${new Date(status.apiKeyExpiresAtMs).toISOString()} — run \`opencode auth login\` and pick Cursor`;
19
14
  return undefined;
20
15
  }
21
- // Live model list from Cursor, cached 24h to keep opencode startup fast.
22
- // Offline/logged-out: stale cache if present, else just "auto" (Cursor's universal alias).
23
- async function currentModels() {
24
- const cached = loadCachedModels();
25
- if (cached && Date.now() - cached.fetchedAt <= MODELS_CACHE_TTL_MS)
26
- return cached.models;
27
- try {
28
- const list = await Cursor.models.list();
29
- const models = {};
30
- for (const m of list)
31
- models[m.id === "default" ? "auto" : m.id] = m.displayName ?? m.id;
32
- mkdirSync(CACHE_DIR, { recursive: true });
33
- writeFileSync(MODELS_CACHE_PATH, JSON.stringify({ version: 1, fetchedAt: Date.now(), models }));
34
- return models;
16
+ const KEY_CHECK_TTL_MS = 60_000;
17
+ const EXPIRY_WARN_MS = 7 * 24 * 60 * 60 * 1000;
18
+ let keyStatus;
19
+ let warnedExpiry = false;
20
+ async function checkKey() {
21
+ if (pastedApiKey || process.env.CURSOR_API_KEY)
22
+ return undefined;
23
+ const now = Date.now();
24
+ if (!keyStatus || now - keyStatus.at > KEY_CHECK_TTL_MS) {
25
+ keyStatus = { at: now, value: Cursor.auth.status().catch(() => undefined) };
35
26
  }
36
- catch {
37
- return cached?.models ?? { auto: "Auto" };
27
+ const status = await keyStatus.value;
28
+ if (!status)
29
+ return undefined;
30
+ const exp = status.status === "logged-in" ? status.apiKeyExpiresAtMs : undefined;
31
+ if (!warnedExpiry && typeof exp === "number" && exp > now && exp - now <= EXPIRY_WARN_MS) {
32
+ warnedExpiry = true;
33
+ const days = Math.ceil((exp - now) / (24 * 60 * 60 * 1000));
34
+ console.error(`[opencode-cursor-auth] Cursor SDK key expires in ${days} days — run \`opencode auth login\` to renew`);
38
35
  }
36
+ return keyProblem(status, now);
39
37
  }
40
38
  async function bodyText(input, init) {
41
39
  const b = init?.body;
@@ -51,8 +49,15 @@ let pastedApiKey;
51
49
  async function cursorFetch(input, init) {
52
50
  const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
53
51
  if (url.includes("/chat/completions")) {
52
+ const problem = await checkKey();
53
+ if (problem)
54
+ return errorResponse(problem, 401);
54
55
  const body = JSON.parse(await bodyText(input, init));
55
- return handleChatCompletion(body, pastedApiKey);
56
+ const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
57
+ const scope = new Headers(init?.headers).get("x-opencode-session") ??
58
+ (input instanceof Request ? input.headers.get("x-opencode-session") : null) ??
59
+ undefined;
60
+ return handleChatCompletion(body, pastedApiKey, signal, scope);
56
61
  }
57
62
  if (url.includes("/models")) {
58
63
  return Response.json({
@@ -71,12 +76,17 @@ export const CursorAuthPlugin = async () => {
71
76
  cursor.name ??= "Cursor";
72
77
  cursor.options = { ...cursor.options, baseURL: BASE_URL };
73
78
  cursor.models ??= {};
74
- for (const [id, displayName] of Object.entries(await currentModels())) {
75
- cursor.models[id] ??= { name: displayName };
79
+ for (const [id, { name, context }] of Object.entries(await currentModels())) {
80
+ cursor.models[id] ??= { name };
76
81
  const model = cursor.models[id];
77
82
  model.cost = { input: 0, output: 0, cache_read: 0, cache_write: 0 };
83
+ model.limit ??= { context: context ?? DEFAULT_CONTEXT, output: DEFAULT_OUTPUT };
78
84
  }
79
85
  },
86
+ "chat.headers": async (input, output) => {
87
+ if (input.model.providerID === "cursor")
88
+ output.headers["x-opencode-session"] = input.sessionID;
89
+ },
80
90
  auth: {
81
91
  provider: "cursor",
82
92
  loader: async (getAuth) => {
@@ -0,0 +1,13 @@
1
+ import { type ModelListItem, type ModelParameterValue, type ModelSelection } from "@cursor/sdk";
2
+ export type ModelInfo = {
3
+ name: string;
4
+ context?: number;
5
+ base?: string;
6
+ params?: ModelParameterValue[];
7
+ blocked?: true;
8
+ };
9
+ export declare function parseContext(m: ModelListItem): number | undefined;
10
+ export declare function buildCatalog(list: ModelListItem[]): Record<string, ModelInfo>;
11
+ export declare function currentModels(): Promise<Record<string, ModelInfo>>;
12
+ export declare function resolveModel(opencodeId: string): ModelSelection | undefined;
13
+ export declare function markBlocked(opencodeId: string): void;
package/dist/models.js ADDED
@@ -0,0 +1,115 @@
1
+ import { Cursor } from "@cursor/sdk";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ const CACHE_DIR = join(homedir(), ".cache", "opencode-cursor-auth");
6
+ const MODELS_CACHE_PATH = join(CACHE_DIR, "models.json");
7
+ const MODELS_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
8
+ let catalog = {};
9
+ let fetchedAt = 0;
10
+ function saveCatalog() {
11
+ mkdirSync(CACHE_DIR, { recursive: true });
12
+ writeFileSync(MODELS_CACHE_PATH, JSON.stringify({ version: 3, fetchedAt, models: catalog }));
13
+ }
14
+ function contextValue(raw) {
15
+ const match = raw?.trim().match(/^(\d+)([km]?)$/i);
16
+ if (!match)
17
+ return undefined;
18
+ const unit = match[2].toLowerCase();
19
+ return Number(match[1]) * (unit === "k" ? 1_000 : unit === "m" ? 1_000_000 : 1);
20
+ }
21
+ export function parseContext(m) {
22
+ return contextValue(m.parameters?.find((p) => p.id === "context")?.values[0]?.value);
23
+ }
24
+ export function buildCatalog(list) {
25
+ const models = {};
26
+ const rename = (id) => (id === "default" ? "auto" : id);
27
+ for (const m of list) {
28
+ const context = parseContext(m);
29
+ models[rename(m.id)] = { name: m.displayName ?? m.id, ...(context ? { context } : {}) };
30
+ }
31
+ for (const m of list) {
32
+ const base = rename(m.id);
33
+ for (const v of m.variants ?? []) {
34
+ // id = base + non-default param values (first listed value is the default); "true" flags use the param id, e.g. gpt-5.5-low-fast
35
+ const parts = v.params
36
+ .filter((p) => m.parameters?.find((d) => d.id === p.id)?.values[0]?.value !== p.value)
37
+ .map((p) => (p.value === "true" ? p.id : p.value));
38
+ const id = [base, ...parts].join("-");
39
+ if (models[id])
40
+ continue;
41
+ const context = contextValue(v.params.find((p) => p.id === "context")?.value) ?? models[base].context;
42
+ const label = v.displayName && v.displayName !== m.displayName ? v.displayName : parts.join(" ");
43
+ models[id] = { name: `${models[base].name} (${label})`, ...(context ? { context } : {}), base, params: v.params };
44
+ }
45
+ }
46
+ return models;
47
+ }
48
+ function loadCachedModels() {
49
+ try {
50
+ const raw = JSON.parse(readFileSync(MODELS_CACHE_PATH, "utf8"));
51
+ if (raw?.version === 3 && raw.models && Object.keys(raw.models).length > 0)
52
+ return raw;
53
+ }
54
+ catch {
55
+ // no cache yet, or unreadable — fall back
56
+ }
57
+ return undefined;
58
+ }
59
+ // Live model list from Cursor, cached 24h to keep opencode startup fast.
60
+ // Offline/logged-out: stale cache if present, else just "auto" (Cursor's universal alias).
61
+ // Models Cursor has rejected with "Model Blocked" for this account are hidden
62
+ // until the cache file is deleted (the catalog itself carries no entitlement info).
63
+ export async function currentModels() {
64
+ const cached = loadCachedModels();
65
+ if (cached && Date.now() - cached.fetchedAt <= MODELS_CACHE_TTL_MS) {
66
+ catalog = cached.models;
67
+ fetchedAt = cached.fetchedAt;
68
+ }
69
+ else {
70
+ try {
71
+ catalog = buildCatalog(await Cursor.models.list());
72
+ for (const [id, m] of Object.entries(cached?.models ?? {}))
73
+ if (m.blocked && catalog[id])
74
+ catalog[id].blocked = true;
75
+ fetchedAt = Date.now();
76
+ saveCatalog();
77
+ }
78
+ catch {
79
+ catalog = cached?.models ?? { auto: { name: "Auto" } };
80
+ fetchedAt = cached?.fetchedAt ?? 0;
81
+ }
82
+ }
83
+ return Object.fromEntries(Object.entries(catalog).filter(([, m]) => !m.blocked));
84
+ }
85
+ function loadCatalog() {
86
+ if (Object.keys(catalog).length)
87
+ return;
88
+ const cached = loadCachedModels();
89
+ catalog = cached?.models ?? {};
90
+ fetchedAt = cached?.fetchedAt ?? 0;
91
+ }
92
+ export function resolveModel(opencodeId) {
93
+ loadCatalog();
94
+ const m = catalog[opencodeId];
95
+ if (!m)
96
+ return undefined;
97
+ return m.base && m.params ? { id: m.base, params: m.params } : { id: opencodeId };
98
+ }
99
+ export function markBlocked(opencodeId) {
100
+ loadCatalog();
101
+ const base = catalog[opencodeId]?.base ?? opencodeId;
102
+ let changed = false;
103
+ for (const [id, m] of Object.entries(catalog)) {
104
+ if ((id === base || m.base === base) && !m.blocked)
105
+ m.blocked = changed = true;
106
+ }
107
+ if (changed) {
108
+ try {
109
+ saveCatalog();
110
+ }
111
+ catch {
112
+ // cache dir unwritable — the in-memory flag still hides it for this process
113
+ }
114
+ }
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vidit19sharma/opencode-cursor-auth",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "OpenCode plugin that uses your Cursor subscription via the official @cursor/sdk \u2014 no proxy server, no separate login.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -8,6 +8,7 @@
8
8
  "files": [
9
9
  "dist",
10
10
  "README.md",
11
+ "CHANGELOG.md",
11
12
  "LICENSE"
12
13
  ],
13
14
  "author": "Vidit Sharma <vidit19sharma@gmail.com> (https://github.com/vidit19sharma)",